Select的优化:poll

前言:

在之前关于多路转接Select的讲解中,我们基于Select函数简单的实现了一个多路转接模型的服务器demo,但是我们也在使用过程中发现了Select的一些缺点------select的输入输出参数是一个类型为fd_set的位图,这就导致了两个问题:

  • electfd_set传入传出参数 。内核在返回时会修改它(把没就绪的位抹去),导致你每次 while 循环都要重新 FD_SET 一遍,非常麻烦且低效。参数都需要重新被设置
  • 位图这个类型已经固定死了大小,这个位图的大小被固定了,所以无法容纳更多的等待的fd文件描述符

所以poll就被创造出来了,也就是说,poll函数至少需要解决我们以上说的两个问题。

接下来就让我们带着大家来学习一下poll以及它的使用吧!

poll的函数定义

这是poll函数的定义:

poll的参数

我们先来介绍一下函数的各个参数,首先,第一个参数fds,它是一个指向 struct pollfd 数组的指针。

那么struct pollfd是一个什么结构体呢?

核心结构体:struct pollfd

poll 不再使用 bitmap(位图),而是使用一个结构体数组。每个数组元素代表一个你需要关心的文件描述符:

c++ 复制代码
struct pollfd {
    int   fd;         /* 要监听的文件描述符 */
    short events;     /* 你关心的事件(读?写?还是出错?) */
    short revents;    /* 内核返回的事件(实际发生了什么?) */
};

其中,数组的参数fd表示我们要关心的fd是谁。在调用poll时,events会告诉调用者,你需要关心对应fd的文件描述符上的哪些事件?是读事件,写事件,还是异常事件

在返回之后,我们可以通过这个指针找到这个结构体数组,随后通过revents知道调用者想告诉你的:你要关心的事件,哪些已经就绪了

这达成了一个什么效果呢?

------输入输出信息分离!

在之前的select的位图结构中,我们的readfds等参数,是一个输入输出型参数,也就是说输入输出信息都需要通过改变这个参数的值来传递,而我们这里分别使用了events与revents,分别来履行输入、输出信息的传递,导致我们不需要每次循环调用时,都重新设置输入信息了!!


由于我们的fds是一个指针,如果想用它来访问一个数组的数据,就需要知道这个数组的大小,所以poll的第二个参数nfds就派上用场了。

nfds代表着数组中fd元素的个数。


timeout参数就更直接了,一个int类型,代表着超时时间(注意单位是毫秒 ms)。

当:

  • timeout=-1: 永久阻塞,直到有事件发生。
  • timeout=0: 立即返回(非阻塞)。
  • timeout=>0: 等待指定的毫秒数。

为什么说POLL可以解决等待的fd有上限这个问题呢?

之前Select有上限是因为,fd_set这个类型的大小是固定的。由于是位图,靠的是自己的比特为来存储信息,数据大小固定,所以存储的信息数量是有上限的。

而我们这里的fds是一个指针,指向的是一个数组,我们是可以通过函数来对这个数组进行扩容的!!所以大小问题自然就被解决了。


poll的事件宏

刚刚我们介绍了struct pollfd结构体,说我们可以通过eventsrevents两个参数分别知道我们需要关心的事件以及对应事件是否准备就绪的作用。

那么,我们如何设置,对这些参数进行一个初始化呢?

因为fd参数很明显,就是文件描述符号,那么时间呢?

这就涉及到事件宏了。这些宏用来设置 struct pollfd 中的 events 字段(你关心的事件)以及解释 revents 字段(内核实际返回的事件)。

poll 的事件宏可以分为三类:输入/输出事件(I/O Events)、错误事件(Error Events)、以及其他控制事件

1. 输入/输出(I/O)事件 (Events)

这些是你通常设置在 events 字段中的,用来告诉内核你要监控什么。

宏名称 描述
POLLIN 普通或优先级带数据可读。对于监听 Socket ,这意味着有新的客户端连接请求(accept 可调用)。
POLLRDNORM 普通数据可读(与 POLLIN 相似,但更明确地指代普通数据)。
POLLRDBAND 带外数据(Out-of-band data)可读(较少使用)。
POLLPRI 高优先级带数据可读(如 TCP 紧急数据)。
POLLOUT 普通数据可写。发送缓冲区有空间,可以写入数据。
POLLWRNORM 普通数据可写(与 POLLOUT 相似)。
POLLWRBAND 带外数据可写。

在实际编程中,你最常用的就是 POLLIN (监听读/连接)和 POLLOUT(监听写)


2. 返回(Return)状态和错误(Error)事件 (Revents)

这些宏主要出现在内核返回的 revents 字段中,表示文件描述符发生的实际状态或错误。

宏名称 描述
POLLERR 发生错误 。例如,getsockopt 发现 Socket 错误。
POLLHUP 挂断(Hang Up)。连接的对端关闭了连接或写入端关闭。这个通常不是一个错误,而是一种通知,表示你可以读取剩余数据直到 EOF,然后关闭连接。
POLLNVAL 无效的 fd (文件描述符)。这个 fdevents 数组中是有效的,但它本身是一个未打开或无效的文件描述符。

poll 返回时,你必须检查 revents 是否包含 POLLERRPOLLHUPPOLLNVAL,以正确处理连接断开或错误情况。


在我们使用poll的过程中,对于 struct pollfd 数组中,可以通过 位或运算 (|) 来组合你感兴趣的事件:

c 复制代码
// 示例:既想知道何时可读,也想知道何时可写
my_fds[i].events = POLLIN | POLLOUT;

在检查 poll 返回的结果时,同样使用位与运算 (&) 来判断发生了什么:

c 复制代码
// 示例:检查是否可读
if (my_fds[i].revents & POLLIN) {
    // 处理可读事件...
}

// 示例:检查是否发生错误
if (my_fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
    // 处理连接断开或错误...
}

实战代码

接下来,我们在上一篇文章中所写的demo代码里,用poll函数来对其进行一个重塑。

这里是之前的服务器代码:

c++ 复制代码
#pragma once

#include <iostream>
#include <string>
#include "log.hpp"
#include "Socket.hpp"
#include <memory>

#define NUM sizeof(fd_set) * 8
const int defaultfd = -1;

class SelectServer
{
public:
    /**
     * @brief 默认构造函数
     * 创建一个未初始化的SelectServer实例
     */
    SelectServer(uint16_t port)
        : _port(port),
          _listen_socket(std::make_unique<TcpSocket>()),
          _isrunning(false)
    {
    }

    /**
     * @brief 初始化服务器
     */
    void Init()
    {
        _listen_socket->BuildTcpSocketMethod(_port);

        for (int i = 0; i < NUM; ++i)
        {
            _fd_array[i] = defaultfd;
        }

        _fd_array[0] = _listen_socket->Fd();
    }

    /**
     * @brief 启动服务器主循环
     */
    void Start()
    {
        fd_set rfds; // 读文件描述符集合
        _isrunning = true;
        while (_isrunning)
        {
            //-----------------------------------------------------------------------------------------
            // 这样写是错误的,可能会出现阻塞的情况
            //  InetAddr client; // 创建客户端地址对象,用于接收连接客户端的地址信息
            //  // 调用监听socket的Accept方法等待并接受新的客户端连接
            //  SockPtr newsockfd=_listen_socket->Accepter(&client);
            //-----------------------------------------------------------------------------------------

            // 根据之前提到的四个宏方法,我们使用这四个宏方法来操作我们的rfds位图
            // 首先就是清零
            FD_ZERO(&rfds);
            int maxfd = defaultfd;
            for (int i = 0; i < NUM; ++i)
            {
                if (_fd_array[i] == defaultfd) // 辅助数组也没要求监管,那就不管
                {
                    continue;
                }
                // 到这里就是要管的
                //  这个步骤我们不再是只将listensocket添加到位图中,还会添加辅助数组里存储的文件描述符(这个文件描述符可能是上次循环获取到的,要求我们这次循环对其进行管理)
                FD_SET(_fd_array[i], &rfds);
                maxfd = std::max(maxfd, _fd_array[i]);
            }

            // 如果不想在调用select的时候阻塞,我们就需要提供一个设定好的timeval变量
            struct timeval timeout = {10, 0};
            // 我们不能让accept来阻塞检测新连接的到来,而应该让select来负责进行对就绪事件的检测
            int n = ::select(maxfd + 1, &rfds, nullptr, nullptr, &timeout);
            // 随后对select的结果进行处理
            switch (n)
            {
            case 0:
                // 在指定的超时时间内,没有任何文件描述符就绪,返回0
                std::cout << "time out......" << std::endl;
                break;
            case -1:
                // 返回-1,调用出错,需要检查errno判断具体错误类型
                perror("select");
                break;
            default:
                // 发生事件就绪
                std::cout << "有事件就绪了......" << "timeout: " << timeout.tv_sec << ":" << timeout.tv_usec << std::endl;
                HandlerEvents(rfds);
                break;
            }
        }
    }

    void HandlerEvents(fd_set &who)
    {
        for (int i = 0; i < NUM; ++i)
        {

            if (_fd_array[i] == defaultfd)
            {
                continue;
            }

            if (_fd_array[i] == _listen_socket->Fd())
            {

                // 我们这个函数可能不只是处理listenSocket,还可能处理其他的文件描述符,所以我们其实在里面也是要分情况的
                // 比如说我现在就是处理listensocket,我就if判断一下
                // 如何判断呢?:还是那四个宏方法
                if (FD_ISSET(_fd_array[i], &who))
                {
                    // 只要判断在fd_set位图中的listensocket的位置是1,表示就绪中,就代表我们需要对其进行处理
                    InetAddr client;
                    SockPtr newsockfd = _listen_socket->Accepter(&client); // 我们这里进行accept的时候不会阻塞,因为我们已经确认了,一定是就绪的状态
                    if (newsockfd)                                         // 不为空指针
                    {
                        std::cout << "获取了一个新连接:" << newsockfd->Fd() << "  client info:" << client.Addr() << std::endl;

                        int pos = -1;
                        for (int j = 0; j < NUM; ++j)
                        {
                            if (_fd_array[j] == defaultfd)
                            {
                                // 之前没监控,现在要监控了
                                pos = j;
                                break;
                            }
                        }
                        if (pos == -1)
                        {
                            LOG(LogLevel::ERROR) << "服务器已经满了......";
                            ::close(newsockfd->Fd());
                            // newsockfd智能指针离开作用域时会自动释放
                            return;
                        }
                        else
                        {
                            _fd_array[pos] = newsockfd->Fd();
                        }
                    }
                    else
                    {
                        return;
                    }
                }
            }
            else
            {
                if (FD_ISSET(_fd_array[i], &who))
                {
                    // 此时如果走到这里,就是合法的,已经准备就绪的,普通的fd,那么我们可以直接调用recv等函数了
                    // 此时并不会发生阻塞,因为我们的fd已经准备好了
                    char buffer[1024];
                    ssize_t n = ::recv(_fd_array[i], buffer, sizeof(buffer) - 1, 0);

                    // 接下来就是公式化处理n的情况并打印了
                    if (n > 0)
                    {
                        buffer[n] = 0;
                        std::cout << "client# " << buffer << std::endl;

                        // 添加回显信息
                        std::string message = "echo#";
                        message += buffer;
                        ::send(_fd_array[i], message.c_str(), message.size(), 0);
                    }
                    else if (n == 0)
                    {
                        std::cout << "客户端退出" << " : " << _fd_array[i] << std::endl;
                        ::close(_fd_array[i]);
                        _fd_array[i] = defaultfd;
                        return;
                    }
                    else
                    {
                        LOG(LogLevel::ERROR) << "客户端读取出错" << _fd_array[i];
                        ::close(_fd_array[i]);
                        _fd_array[i] = defaultfd;
                        return;
                        // 理论上这个错误应该进行处理异常,但是我们这里就不考虑了
                    }
                }
            }
        }
    }
    ~SelectServer() {}

private:
    uint16_t _port;                         // 服务器监听的端口号
    std::unique_ptr<Socket> _listen_socket; // 监听socket的智能指针,使用unique_ptr实现独占所有权
    // 采用智能指针管理Socket资源,确保异常安全性和自动资源释放

    bool _isrunning; // 服务器运行的状态

    int _fd_array[NUM]; // 辅助数组,从当我们每次设置位图的锚点
};

首先,对于Init函数:

从for循环开始的代码就没必要存在了,我们直接删除。

c++ 复制代码
 	void Init()
    {
        _listen_socket->BuildTcpSocketMethod(_port);
    }

start中的读写文件描述符,以及辅助数组相关也可以直接删除,与之对应的就是我们需要创建我们的struct pollfd数组

由于我们要多次使用fds变量,所以我建议把这个数组定义到类变量中。

c++ 复制代码
private:
    uint16_t _port;                         // 服务器监听的端口号
    std::unique_ptr<Socket> _listen_socket; // 监听socket的智能指针,使用unique_ptr实现独占所有权
    // 采用智能指针管理Socket资源,确保异常安全性和自动资源释放
    bool _isrunning; // 服务器运行的状态

    struct pollfd _fds[MAX];//我们就先开一个4096大小的数组

这里为了方便,我们可以定义一个宏或者全局变量来控制数组的大小:

复制代码
#define MAX 4096

接下来在Init中对这个数组进行一个初始化:

c++ 复制代码
    void Init()
    {
        _listen_socket->BuildTcpSocketMethod(_port);
        for (int i = 0; i < 4096; ++i)
        {
            _fds[i].fd = defaultfd;
            _fds[i].events = 0;
            _fds[i].revents = 0;
        }
    }

并且还需要先对数组的0号下标位置进行一个赋值,因为我们需要它来监察我们的监听文件描述符:

c++ 复制代码
    void Init()
    {
        _listen_socket->BuildTcpSocketMethod(_port);
        for (int i = 0; i < MAX; ++i)
        {
            _fds[i].fd = defaultfd;
            _fds[i].events = 0;
            _fds[i].revents = 0;
        }

        _fds[0].fd = _listen_socket->Fd();
        _fds[0].events |= POLLIN;
    }

由于我们唯一存储输出信息的参数已经是_fds,他是一个类成员变量,所以我们的HandlerEvents的参数就不需要了,我们对此进行一下微调:

c++ 复制代码
 void HandlerEvents()
    {
        for (int i = 0; i < MAX; ++i)
        {

            if (_fds[i].fd == defaultfd)
                continue;

            if (_fds[i].fd == _listen_socket->Fd())
            {

                // 我们这个函数可能不只是处理listenSocket,还可能处理其他的文件描述符,所以我们其实在里面也是要分情况的
                // 比如说我现在就是处理listensocket,我就if判断一下
                // 如何判断呢?:还是那四个宏方法
                if (_fds[i].revents & POLLIN)
                {
                    // 只要判断在fd_set位图中的listensocket的位置是1,表示就绪中,就代表我们需要对其进行处理
                    InetAddr client;
                    SockPtr newsockfd = _listen_socket->Accepter(&client); // 我们这里进行accept的时候不会阻塞,因为我们已经确认了,一定是就绪的状态
                    if (newsockfd)                                         // 不为空指针
                    {
                        std::cout << "获取了一个新连接:" << newsockfd->Fd() << "  client info:" << client.Addr() << std::endl;

                        int pos = -1;
                        for (int j = 0; j < MAX; ++j)
                        {
                            if (_fds[j].fd == defaultfd)
                            {
                                // 之前没监控,现在要监控了
                                pos = j;
                                break;
                            }
                        }
                        if (pos == -1)
                        {
                            // 使用poll的时候可以进行一个扩容操作,这里我们就不演示了
                            return;
                        }
                        else
                        {
                            _fds[pos].fd = newsockfd->Fd();
                            _fds[pos].events = POLLIN;
                        }
                    }
                    else
                    {
                        return;
                    }
                }
            }
            else
            {
                if (_fds[i].revents & POLLIN)
                {
                    // 此时如果走到这里,就是合法的,已经准备就绪的,普通的fd,那么我们可以直接调用recv等函数了
                    // 此时并不会发生阻塞,因为我们的fd已经准备好了
                    char buffer[1024];
                    ssize_t n = ::recv(_fds[i].fd, buffer, sizeof(buffer) - 1, 0);

                    // 接下来就是公式化处理n的情况并打印了
                    if (n > 0)
                    {
                        buffer[n] = 0;
                        std::cout << "client# " << buffer << std::endl;

                        // 添加回显信息
                        std::string message = "echo#";
                        message += buffer;
                        ::send(_fds[i].fd, message.c_str(), message.size(), 0);
                    }
                    else if (n == 0)
                    {
                        std::cout << "客户端退出" << " : " << _fds[i].fd << std::endl;
                        ::close(_fds[i].fd);
                        _fds[i].fd = defaultfd;
                        return;
                    }
                    else
                    {
                        LOG(LogLevel::ERROR) << "客户端读取出错" << _fds[i].fd;
                        ::close(_fds[i].fd);
                        _fds[i].fd = defaultfd;
                        return;
                        // 理论上这个错误应该进行处理异常,但是我们这里就不考虑了
                    }
                }
            }
        }
    }

改动并不是很大,主要的区别就是我们判断事件的方法不再是使用宏了,而是使用_fds[i].revents & POLLIN的方法来进行条件语句。


结语

通过本篇对 poll 函数的深入学习和实战代码重构,我们清晰地看到了 I/O 多路复用技术的演进:

Poll 解决了 Select 的两大痛点

  1. 文件描述符数量的突破poll 采用 struct pollfd 数组 而非固定大小的位图,从根本上解决了 select 时代硬编码的 1024 个文件描述符限制,使服务器具备了处理更多并发连接的能力。
  2. 输入输出信息的解耦 :通过分离的 events (输入,关心的事件)和 revents(输出,就绪的事件),我们不再需要在每次循环中重新设置监听的描述符集合,大大简化了编程模型,提高了效率。

思考一下:

虽然 poll 在解决上限问题上取得了巨大进步,但如同我们所讨论的,它的效率瓶颈仍然存在:

无论是 select 还是 poll,当文件描述符数量 NNN 变得非常大时(例如数万个连接),每次调用内核返回后,用户态程序仍然需要对整个 struct pollfd 数组进行线性遍历(O(N))来找出是哪一个描述符就绪了。同时,在内核内部,检查所有描述符状态也需要进行类似的 O(N) 操作。

这种线性查找的低效性正是阻碍其迈向"超高并发"服务器的关键。

所以为了彻底解决 O(N) 的性能瓶颈,Linux 系统又引入了更高效、事件驱动的 I/O 多路复用模型------epoll

epoll 采用了回调机制红黑树双向链表 等数据结构,实现了只通知活跃 的文件描述符,将性能复杂度优化到了近乎 O(1)O(1)O(1)。

你已经掌握了从 selectpoll 的技术飞跃,为接下来的学习打下了坚实的基础。如果你对构建真正的高性能、可伸缩的服务器应用感兴趣,那么深入理解 epoll 的原理和使用方法,将是你下一步最重要的目标!

相关推荐
冷yan~2 小时前
OpenAI Codex CLI 完全指南:AI 编程助手的终端革命
人工智能·ai·ai编程
菜鸟‍2 小时前
【论文学习】通过编辑习得分数函数实现扩散模型中的图像隐藏
人工智能·学习·机器学习
知识分享小能手2 小时前
CentOS Stream 9入门学习教程,从入门到精通,CentOS Stream 9 配置网络功能 —语法详解与实战案例(10)
网络·学习·centos
AKAMAI2 小时前
无服务器计算架构的优势
人工智能·云计算
阿星AI工作室2 小时前
gemini3手势互动圣诞树保姆级教程来了!附提示词
前端·人工智能
刘一说2 小时前
时空大数据与AI融合:重塑物理世界的智能中枢
大数据·人工智能·gis
月亮月亮要去太阳2 小时前
基于机器学习的糖尿病预测
人工智能·机器学习
Oflycomm2 小时前
LitePoint 2025:以 Wi-Fi 8 与光通信测试推动下一代无线创新
人工智能·wifi模块·wifi7模块
专业开发者2 小时前
Wi-Fi®:可持续的优选连接方案
网络·物联网
机器之心2 小时前
「豆包手机」为何能靠超级Agent火遍全网,我们听听AI学者们怎么说
人工智能·openai