《Linux 网络编程》深入理解 IO 多路复用:select服务器完善、poll 接口详解与服务端改造实战

🔥小叶-duck:个人主页

❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》

《Linux系统从入门到实践》《Linux网络从入门到实践》

《Qt 方寸极境》 《MySQL》

✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游


目录

前言

[一、完善 select 服务器:事件模块化处理](#一、完善 select 服务器:事件模块化处理)

[1.1 事件拆分的设计思想](#1.1 事件拆分的设计思想)

[1.2 各核心模块代码解析](#1.2 各核心模块代码解析)

[1.2.1 事件派发器 Dispatcher](#1.2.1 事件派发器 Dispatcher)

[1.2.2 连接管理器 HandlerAccept](#1.2.2 连接管理器 HandlerAccept)

[1.2.3 IO 读处理器 HandlerRead](#1.2.3 IO 读处理器 HandlerRead)

[1.3 辅助数组的核心作用](#1.3 辅助数组的核心作用)

[1.4 完整代码实现](#1.4 完整代码实现)

[1.5 select 的编程特点与缺陷复盘](#1.5 select 的编程特点与缺陷复盘)

[1.5.1 select 编程的特点](#1.5.1 select 编程的特点)

[1.5.2 select 四大核心缺陷](#1.5.2 select 四大核心缺陷)

[二、poll:select 改进的多路转接](#二、poll:select 改进的多路转接)

[2.1 poll 核心 API 与参数详解](#2.1 poll 核心 API 与参数详解)

[2.1.1 函数原型](#2.1.1 函数原型)

[2.1.2 poll 参数完整解析](#2.1.2 poll 参数完整解析)

[2.1.3 返回值](#2.1.3 返回值)

[2.2 pollfd 结构体:输入输出分离](#2.2 pollfd 结构体:输入输出分离)

[2.3 常用事件宏(比特位标识)](#2.3 常用事件宏(比特位标识))

[2.4 poll 的定位](#2.4 poll 的定位)

[2.4.1 poll 解决了什么](#2.4.1 poll 解决了什么)

[2.4.2 poll 没有解决什么](#2.4.2 poll 没有解决什么)

[三、实战:将 select 服务端改写为 poll 版本](#三、实战:将 select 服务端改写为 poll 版本)

[3.1 核心结构改动](#3.1 核心结构改动)

[3.2 模块拆解与代码解读](#3.2 模块拆解与代码解读)

[3.2.1 事件派发器 Dispatcher](#3.2.1 事件派发器 Dispatcher)

[3.2.2 连接管理器 HandlerAccept](#3.2.2 连接管理器 HandlerAccept)

[3.2.3 IO 读处理器 HandlerRead](#3.2.3 IO 读处理器 HandlerRead)

[3.3 完整 PollServer 类代码(pollServer.hpp)](#3.3 完整 PollServer 类代码(pollServer.hpp))

[四、select vs poll 对比](#四、select vs poll 对比)

结束语


前言

在上一篇文章中,我们学习了 IO 多路复用中 select 函数的原理,并且从零完成了 Echo 服务端的代码实战,了解了 select 的基础使用方式。但同时我们也会发现 select 存在不少固有的缺陷,例如 fd 数量上限、每次调用都需要重复重置 fd 集合等问题,这些问题会限制服务在高并发场景下的扩展能力。

本篇我们将继续完善 select 服务端代码,把事件处理逻辑做模块化拆分,复盘 select 编程的特点与四大核心缺陷。在此基础上,我们来学习 select 的改进方案 ------poll 多路转接。我们会拆解 poll 的 API、pollfd 结构体与事件宏,理解 poll 解决了哪些问题,又保留了哪些底层短板。最后我们动手将之前的 select 回声服务改造为 poll 版本,对比两套接口的差异,横向总结 select 与 poll 的优缺点,为后续学习 epoll 做好铺垫。

一、完善 select 服务器:事件模块化处理

1.1 事件拆分的设计思想

在上篇文章我们实现的 select 服务代码里,事件处理逻辑全部放在同一个函数中,并且只处理新连接接入。但我们清楚,就绪的不仅仅只有连接事件,还有读事件、写事件,如果这些处理逻辑全部都写到一个函数中无疑代码是非常臃肿的。

select 监听的文件描述符分为两大类,不同 fd 就绪代表的事件含义完全不同:

  • 监听套接字: fd 就绪代表有新客户端连接到达,需要调用accept获取连接
  • 普通客户端套接字: fd 就绪代表客户端发来数据,需要调用recv读取数据

所以,前面我们所写的代码是违背了高内聚、低耦合 的软件工程原则。因此我们对事件处理逻辑就需要做模块拆分 ,引入事件驱动的核心模块:

  • Dispatcher(事件派发器) :服务器主循环,执行 select 阻塞等待事件,检测到 fd 就绪后,分发事件到对应处理函数。
  • HandlerAccept(连接管理器) :专门处理监听套接字就绪事件,负责接收新客户端连接。
  • HandlerRead(IO 读处理器) :专门处理普通客户端套接字的读就绪事件,完成数据读写、连接关闭与异常处理(由于我们还没有实现客户端,所以写事件的处理我们等后续讲解 epoll 的时候再全部整体进行完善)

这套**「事件就绪 -> 事件派发 -> 事件处理」**的编程范式,就是事件驱动编程,也是高并发网络服务器最核心的设计思路。

1.2 各核心模块代码解析

1.2.1 事件派发器 Dispatcher

Dispatcher是整个 select 服务的主循环与入口,持续循环调用 select 等待 fd 事件。

cpp 复制代码
    // 事件派发器:将就绪的事件派发到指定的模块进行处理
    void Dispatcher(fd_set &rfds)
    {
        // 我们如何清楚就绪的这个或者这些事件,哪个是新连接到来,哪些是读事件就绪呢??FD_ISSET
        for (int i = 0; i < nums; i++)
        {
            if (fd_array[i] == defaultfd)
            {
                // 当前位置没有被fd占用,无效
                continue;
            }
            else
            {
                // 有效fd,但是需要判断是否就绪
                if (FD_ISSET(fd_array[i], &rfds))
                {
                    // 说明当前位置fd是就绪状态
                    // 那就绪的事件是新连接到来呢,还是读事件就绪,两种最终处理的方式是不一样的
                    if (fd_array[i] == _listensock->Fd())
                    {
                        // 说明是新连接到来的事件就绪了
                        HandlerAccept();
                    }
                    else
                    {
                        // 说明是读事件就绪了
                        HandlerRead(i);
                    }
                }
                // 说明当前位置fd没有就绪,继续判断后面的fd
            }
        }
    }

有两个关键细节需要重点理解:

  1. fd_set 属于输入输出型参数 ,内核在 select 返回后,会清除集合内未就绪 fd 的标记。每一轮循环,都必须重新构建fd_set位图。
  2. maxfd 需要动态计算 ,不能写死为监听 fd。随着客户端不断接入,新增的客户端 fd 的数值会大于监听套接字 fd,需要实时更新最大 fd。

Dispatcher遍历全部 fd,区分 fd 类型:如果是监听套接字就绪,交给连接管理器;普通客户端 fd 就绪,则交给 IO 读处理器。

重要原则: 辅助数组中保存的 fd 只是我们关心的合法 fd,合法 fd 不等于就绪 fd ,必须通过FD_ISSET判断是否真正产生事件。

1.2.2 连接管理器 HandlerAccept

该模块专门负责处理监听 fd 的就绪事件,接收新连接。

cpp 复制代码
    // 连接管理器:处理新连接到来的事件就绪
    void HandlerAccept()
    {
        InetAddr client;
        int sockfd = _listensock->Accept(&client); // 此时accept还会阻塞吗?不会,等的操作已经在上面的select处理了
        if (sockfd >= 0)
        {
            LOG(LogLevel::INFO) << "get a new link, sockfd: " << sockfd << ", client: " << client.StringAddress();
            // 此时我们就成功获取到了新连接,问题在于:我们此时能直接调用read/recv吗??不行!
            // 我们根本不清楚此时这个新的连接fd对应的读事件有没有就绪!
            // 如果我们直接调用read/recv,和阻塞IO没有任何区别!等和拷贝的过程就变成一起的了
            // 那我们如何清楚新的连接fd对应的读事件是否就绪呢?select
            // 所以我们需要将获取到的新连接fd存放到我们的辅助数组中进行记录

            int pos = 0;
            for (pos = 0; pos < nums; pos++)
            {
                if (fd_array[pos] == defaultfd)
                {
                    // 说明当前pos位置是空闲的,不管是之前就没有使用还是之前的fd断开连接,我们都可以使用这个位置
                    break;
                }
            }
            if (pos == nums)
            {
                // 说明整个辅助数组都被占满了,说明当前服务器已经达到了连接的上限不能再进行连接了,直接放弃连接请求
                LOG(LogLevel::WARNING) << "select server full...";
                close(sockfd);
            }
            else
            {
                fd_array[pos] = sockfd;
            }
        }
        else
        {
            LOG(LogLevel::WARNING) << "accept error..";
        }
    }

当 select 检测监听套接字就绪,代表全连接队列中已经存在完成三次握手的连接,此时调用accept不会阻塞 。

拿到新客户端 fd 之后,不能立刻读写数据,因为此时对方用户不一定会立即发送数据,那么直接读写操作依然会进行阻塞等待,那么就和我们设计初衷不符合。所以我们将这个 fd 存入辅助数组,交给下一轮 select 去监听它的读事件。

如果辅助数组空间已满,代表服务器连接数达到上限,需要直接关闭新获取的客户端 fd,拒绝连接。

1.2.3 IO 读处理器 HandlerRead

负责处理普通客户端套接字的读就绪事件,实现 echo 回显逻辑,同时处理连接断开与 IO 错误。

cpp 复制代码
    // IO处理器:处理读事件就绪
    void HandlerRead(int pos)
    {
        // 到这里,读取数据的时候会不会阻塞?不会!
        // 因为如果接收缓冲区没有数据,那么读事件就不会就绪,也就不会调用这个函数
        // 而只有接收缓冲区有数据就绪了,才会调用这个函数进行处理,read/recv就不会阻塞了!
        std::cout << "处理读取操作..." << std::endl;
        char buffer[1024];
        int n = read(fd_array[pos], buffer, sizeof(buffer));
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "client say# " << buffer << std::endl;
        }
        else if (n == 0)
        {
            LOG(LogLevel::INFO) << "client quit...";
            // 1.断开连接,关闭文件描述符
            close(fd_array[pos]);
            // 2.不要让select后续再关系这个sockfd了
            fd_array[pos] = defaultfd;
        }
        else
        {
            LOG(LogLevel::ERROR) << "read error";
            // 1.断开连接,关闭文件描述符
            close(fd_array[pos]);
            // 2.不要让select后续再关系这个sockfd了
            fd_array[pos] = defaultfd;
        }
    }

两个核心要点:

  1. recv/read返回 0 是 TCP 连接正常关闭的标志。一旦检测到返回 0,必须立刻关闭 fd,并且从辅助数组清除该 fd。如果不清理,下一轮 select 会持续监听已经失效的 fd,引发程序异常。
  2. 本案例中send发送操作没有交给 select 监听写事件。因为 TCP 发送缓冲区在绝大多数场景下都是空闲状态,写事件默认就绪;只有发送缓冲区被填满时,才需要注册写事件交给 select 等待可写。

1.3 辅助数组的核心作用

辅助数组**fd_array**是 select 服务里非常关键的数据结构,承担三项核心职责:

  1. 持久存储待监听的全部 fd: fd_set每次调用 select 都会被内核修改覆盖,辅助数组用来完整保存所有需要监听的 fd 列表,每一轮循环都依靠它重建 fd 集合。
  2. **管理 fd 完整生命周期:**新连接接入时添加 fd,连接关闭时移除 fd,所有 fd 的增删操作都在这个数组维护。
  3. **辅助计算 maxfd:**每一轮构造 fd_set 的时候,遍历数组同步算出当前最大 fd,作为 select 的入参。

1.4 完整代码实现

完整代码参考SelectServer类,包含服务器初始化、事件主循环、Dispatcher、HandlerAccept、HandlerRead 全套模块,实现基于 select 的事件驱动 Echo 服务。

cpp 复制代码
#pragma once
#include <iostream>
#include <memory>
#include "Socket.hpp"

using namespace SocketModule;

class SelectServer
{
    const static int nums = sizeof(fd_set) * 8;
    const static int defaultfd = -1; // 表示当前位置没有连接fd占用

public:
    SelectServer(int port) : _listensock(std::make_unique<TcpSocket>()), _isrunning(false) // 派生类对象传给基类指针
    {
        _listensock->BuildTcpSocketMethod(port);

        // 初始化辅助数组fd_array(所有位置初始化为-1,表示所有位置没有连接fd占用)
        // 后续借助这个defaultfd我们就可以重复利用这些位置对连接fd进行存放
        for (int i = 0; i < nums; i++)
        {
            fd_array[i] = defaultfd;
        }
        fd_array[0] = _listensock->Fd();
    }

    ~SelectServer()
    {
    }

    void Start()
    {
        _isrunning = true;
        while (_isrunning)
        {
            // 关键问题:当select返回处理完就绪事件后,下一次再次调用select,
            // 你怎么清楚历史上有哪些fd是被你设置添加到rfds中,让我们的select继续关心??
            // 问题的产生原因:每次select返回的时候,rfds绝大多数情况都会被修改,
            // 修改成只剩"就绪的fd",当时还没有就绪的fd就会在位图中"丢失"

            // 所以,也就是说如果我们只依靠rfds来存放历史所有连接的文件描述符,是做不到的!
            // 所以,只靠rfds做不到,我们就需要额外借助一个辅助数组来帮我们完整记录服务器历史上所有获取到的连接文件描述符!
            fd_set rfds;
            FD_ZERO(&rfds);
            int maxfd = defaultfd; // 获取最大文件描述符的值,用于select
            // 1.每次select之前,都需要对rfds进行重置!
            for (int i = 0; i < nums; i++)
            {
                if (fd_array[i] == defaultfd)
                {
                    // 两种情况:1.当前位置还没有被占用;2.当前位置的连接被断开重新置成-1
                    // 不管是哪种情况,都不需要设置到位图中
                    continue;
                }
                FD_SET(fd_array[i], &rfds);
                // 2.最大文件描述符fd时刻是变化的(有旧的连接会断开也会出现新的连接)
                //  如果当前的fd_array[i]大于maxfd,则更新maxfd
                if (maxfd < fd_array[i])
                {
                    maxfd = fd_array[i];
                }
            }

            // int n = select(_listensock->Fd() + 1, &rfds, nullptr, nullptr, nullptr);
            Testprintfd();
            int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
            if (n == -1)
            {
                // 说明select失败
                LOG(LogLevel::ERROR) << "select error";
            }
            else if (n == 0)
            {
                // 说明超时了,如果最后一个参数为nullptr阻塞式select,则返回值不会为0
                LOG(LogLevel::INFO) << "time out...";
            }
            else
            {
                // 其他情况返回值则表示事件已经就绪的个数
                LOG(LogLevel::INFO) << "有事件就绪了... , n = " << n;
                // 如果是有一个新连接到来了那么select返回值就是1
                // 此时如果没有及时调用accept将连接从全连接队列取出,结果就是死循环打印信息。为什么?
                // 因为针对fd的类型是 监听fd,那么其读事件就绪的条件就是:全连接队列非空(有新连接完成握手)
                // 如果此时没有及时调用accept将连接从全连接队列取出,那么这个就绪条件就是恒成立的,select就会一直返回1进行打印信息

                // 问题:就绪的事件难道只是新连接到来吗??不是!还有读事件就绪,还有写事件就绪(当前暂时不管)
                Dispatcher(rfds);
            }
        }
        _isrunning = false;
    }

    // 事件派发器:将就绪的事件派发到指定的模块进行处理
    void Dispatcher(fd_set &rfds)
    {
        // 我们如何清楚就绪的这个或者这些事件,哪个是新连接到来,哪些是读事件就绪呢??FD_ISSET
        for (int i = 0; i < nums; i++)
        {
            if (fd_array[i] == defaultfd)
            {
                // 当前位置没有被fd占用,无效
                continue;
            }
            else
            {
                // 有效fd,但是需要判断是否就绪
                if (FD_ISSET(fd_array[i], &rfds))
                {
                    // 说明当前位置fd是就绪状态
                    // 那就绪的事件是新连接到来呢,还是读事件就绪,两种最终处理的方式是不一样的
                    if (fd_array[i] == _listensock->Fd())
                    {
                        // 说明是新连接到来的事件就绪了
                        HandlerAccept();
                    }
                    else
                    {
                        // 说明是读事件就绪了
                        HandlerRead(i);
                    }
                }
                // 说明当前位置fd没有就绪,继续判断后面的fd
            }
        }
    }

    // 连接管理器:处理新连接到来的事件就绪
    void HandlerAccept()
    {
        InetAddr client;
        int sockfd = _listensock->Accept(&client); // 此时accept还会阻塞吗?不会,等的操作已经在上面的select处理了
        if (sockfd >= 0)
        {
            LOG(LogLevel::INFO) << "get a new link, sockfd: " << sockfd << ", client: " << client.StringAddress();
            // 此时我们就成功获取到了新连接,问题在于:我们此时能直接调用read/recv吗??不行!
            // 我们根本不清楚此时这个新的连接fd对应的读事件有没有就绪!
            // 如果我们直接调用read/recv,和阻塞IO没有任何区别!等和拷贝的过程就变成一起的了
            // 那我们如何清楚新的连接fd对应的读事件是否就绪呢?select
            // 所以我们需要将获取到的新连接fd存放到我们的辅助数组中进行记录

            int pos = 0;
            for (pos = 0; pos < nums; pos++)
            {
                if (fd_array[pos] == defaultfd)
                {
                    // 说明当前pos位置是空闲的,不管是之前就没有使用还是之前的fd断开连接,我们都可以使用这个位置
                    break;
                }
            }
            if (pos == nums)
            {
                // 说明整个辅助数组都被占满了,说明当前服务器已经达到了连接的上限不能再进行连接了,直接放弃连接请求
                LOG(LogLevel::WARNING) << "select server full...";
                close(sockfd);
            }
            else
            {
                fd_array[pos] = sockfd;
            }
        }
        else
        {
            LOG(LogLevel::WARNING) << "accept error..";
        }
    }

    // IO处理器:处理读事件就绪
    void HandlerRead(int pos)
    {
        // 到这里,读取数据的时候会不会阻塞?不会!
        // 因为如果接收缓冲区没有数据,那么读事件就不会就绪,也就不会调用这个函数
        // 而只有接收缓冲区有数据就绪了,才会调用这个函数进行处理,read/recv就不会阻塞了!
        std::cout << "处理读取操作..." << std::endl;
        char buffer[1024];
        int n = read(fd_array[pos], buffer, sizeof(buffer));
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "client say# " << buffer << std::endl;
        }
        else if (n == 0)
        {
            LOG(LogLevel::INFO) << "client quit...";
            // 1.断开连接,关闭文件描述符
            close(fd_array[pos]);
            // 2.不要让select后续再关系这个sockfd了
            fd_array[pos] = defaultfd;
        }
        else
        {
            LOG(LogLevel::ERROR) << "read error";
            // 1.断开连接,关闭文件描述符
            close(fd_array[pos]);
            // 2.不要让select后续再关系这个sockfd了
            fd_array[pos] = defaultfd;
        }
    }

    void Testprintfd()
    {
        std::cout << "fd_array[]: ";
        for (int i = 0; i < nums; i++)
        {
            if (fd_array[i] == defaultfd)
                continue;
            std::cout << fd_array[i] << " ";
        }
        std::cout << "\r\n";
    }

private:
    std::unique_ptr<Socket> _listensock; // 监听套接字
    bool _isrunning;                     // 服务器状态

    int fd_array[nums]; // 构建一个用于记录服务器历史上所有连接的fd的辅助数组
                        // 这里我们直接使用定长数组即可,其他容器也可以,但是定长数组本身就可以约束服务器的连接数量
};

运行结果展示:

1.5 select 的编程特点与缺陷复盘

到此我们就把基于 select 的 Echo 服务器完整实现了,通过上面我们所实现的 Echo 服务器代码,我们其实间接就知道 select 的天然缺陷所在了,这里我们进行总结。

1.5.1 select 编程的特点

  • 文件描述符监控数量受fd_set大小限制: fd_set本质是位图,每一个 bit 标记一个文件描述符。例如服务器上sizeof(fd_set)=512,则总共可以监控512*8=4096个 fd。原生默认上限常见为 1024。
  • 需要额外维护辅助数组保存全部 fd: select 调用返回后,内核会修改fd_set,清空没有事件发生的 fd 对应的 bit 标记。因此我们必须单独用数组保存所有待监控的 fd。
    • select 返回之后,遍历辅助数组,结合FD_ISSET判断哪些 fd 发生就绪事件;
    • 每一轮 select 调用前,先执行FD_ZERO清空 fd 集合,再从辅助数组里逐个取出 fd,重新添加进fd_set;
    • 在遍历辅助数组的同时,计算当前最大 fd(maxfd),作为 select 函数第一个参数。
  • fd_set属于输入输出参数,每轮循环需要重置: 每次调用 select,内核都会修改fd_set位图,所以每一轮事件循环都要先清空集合,再重新填充待监听的 fd。
  • **需要手动维护 maxfd:**当新增、删除客户端 fd 时,要实时更新当前最大文件描述符,传给 select 作为第一个参数。

1.5.2 select 四大核心缺陷

  1. 连接数量存在硬上限: 受fd_set位图大小限制,可监控 fd 数量有上限,修改上限需要重新编译内核,跨平台兼容性差。
  2. 用户态到内核态拷贝开销大: 每次调用 select,需要把完整的fd_set位图从用户态拷贝至内核态。待监控 fd 越多,拷贝带来的资源开销越大。
  3. **内核线性遍历所有 fd,时间复杂度 O (n):**内核收到 fd 集合后,需要遍历全部传入的文件描述符,逐个检查是否有就绪事件。连接数量越多性能衰减越明显,大量空闲连接场景下效率很低。
  4. **接口使用繁琐,开发成本高:**每一轮循环都要手动重置 fd 集合、维护辅助数组、计算 maxfd,重复代码多,编程体验较差。

二、poll:select 改进的多路转接

在前面我们分析了 select 存在的一系列缺陷,为了解决 select 接口的诸多痛点,POSIX 标准推出了 poll 作为改进方案。

2.1 poll 核心 API 与参数详解

2.1.1 函数原型

cpp 复制代码
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • 头文件:<poll.h>,属于标准 C 库 (libc),编译时无需额外链接库。
  • 扩展函数ppoll: 功能与 poll 一致,增加信号屏蔽字支持;日常网络编程优先使用基础poll。

2.1.2 poll 参数完整解析

  • **struct pollfd *fds:**指向pollfd结构体数组的首地址,数组中每一个元素对应一个待监听的文件描述符及事件配置,是 poll 实现多 fd 监听的核心载体。数组可以动态申请内存,因此突破了 select 的 fd 数量限制。
  • **nfds_t nfds:**nfds_t是系统自定义无符号整型,代表fds 数组的有效元素个数,即当前需要内核监听的文件描述符总数。
  • int timeout: 超时时间,单位为毫秒 (ms),区分三种工作模式,是控制阻塞行为的核心参数:
    • timeout < 0 (常用值 - 1):永久阻塞。内核持续监听事件,直到至少一个 fd 就绪才返回。
    • timeout = 0:非阻塞轮询。内核仅做一次事件检测,无论是否有就绪事件都立即返回,配合循环可实现轮询逻辑。
    • timeout > 0:限时阻塞。内核阻塞等待指定毫秒数,期间有事件就绪则立即返回;超时无事件则返回 0。

补充: 和 select 的 timeout 不同,select 的 timeout 是输入输出型参数,而 poll 的 timeout 仅为输入参数。

2.1.3 返回值

poll 的返回值为整型,三种结果对应不同场景:

  1. **返回值 > 0:**代表有 N 个文件描述符产生就绪事件,N 为就绪 fd 的总数。
  2. 返回值 = 0:代表监听超时,没有任何 fd 事件就绪。
  3. 返回值 = -1: 代表函数调用出错,可通过errno获取具体错误信息。

2.2 pollfd 结构体:输入输出分离

poll 最大的改进,就是把**【用户关心的事件】** 和**【内核返回的事件】** 拆分成了两个独立字段,解决 select 输入输出参数耦合的问题。

cpp 复制代码
struct pollfd
{
    int fd;         // 待监听的文件描述符
    short events;   // 入参:用户设置,需要内核监听的事件(比特位)
    short revents;  // 出参:内核回填,当前fd实际触发的事件(比特位)
};

字段细则:

  • fd
    • **有效值:**合法的文件描述符(套接字、管道、文件等)。
    • **特殊值-1:**内核会直接忽略该数组元素,不做任何监听,常用于标记数组中闲置位置。
  • events :由应用程序主动赋值,使用比特位组合多个监听事件,调用 poll 时传递给内核。调用后该字段不会被内核修改,无需重复赋值。
  • revents:初始置 0,poll 调用结束后由内核填充当前 fd 实际发生的事件。该字段仅作为结果输出,由应用程序读取后自行处理。

2.3 常用事件宏(比特位标识)

事件宏 含义 输入 / 输出
POLLIN 有数据可读(普通数据 + 优先数据) 输入 + 输出
POLLOUT 可以写入数据 输入 + 输出
POLLERR 发生错误 仅输出
POLLHUP 对端挂起 / 关闭 仅输出
POLLRDHUP TCP 连接被对方关闭写端 输入 + 输出

常用组合规则:

网络编程中三大核心事件:POLLIN (读就绪)、POLLOUT (写就绪)、POLLERR (异常),基本覆盖绝大多数服务端场景。

判断事件格式统一 为:if( fds i .revents & 事件宏)

2.4 poll 的定位

2.4.1 poll 解决了什么

  • **不再有 1024 硬限制:**数组大小由用户自己定义,可以动态扩容。理论上限就是系统允许的最大文件描述符数量。

注意: 这里所说的没有上限,并不是完全没有上限,上限由系统允许的最大 fd 决定 。就算动态开辟数组,系统对进程 fd 总数的限制依然存在,但这并不是 poll 接口本身的设计缺陷,和 select 位图硬限制不是一回事。

  • 输入输出分离: events和revents各司其职。events 用户设置,内核不会修改;revents 由内核填充就绪状态。无需每次调用 poll 之前重建整个事件集合,接口更易用。
  • **事件类型更丰富:**支持更多精细的事件,例如对端关闭事件、TCP 带外数据等。

2.4.2 poll 没有解决什么

  1. **内核依然线性遍历 fd,时间复杂度 O (n):**高并发场景性能会随着连接数量线性下降,大量空闲连接场景效率很低。
  2. **依然存在用户态 - 内核态拷贝:**每次调用 poll,都需要把 pollfd 数组在用户态和内核态之间来回拷贝。fd 数量越多,拷贝开销越大。
  3. **大量空闲连接时效率低:**和 select 一样,无论连接是否活跃,内核都要遍历全部传入的 fd。

总结:poll 只相当于是 select 的接口优化版,改善了 select 接口难用、fd 数量硬上限的问题,但是没有从根本上解决 O (n) 遍历的性能瓶颈。

三、实战:将 select 服务端改写为 poll 版本

理解 poll 的原理之后,我们把前面实现的 select 回声服务端改造为 poll 版本。

整体业务逻辑基本保持不变,仅替换 fd 管理与事件监听的相关代码,最核心的改动就是把 select 的 fd_set 位图替换为 poll 的 struct pollfd 数组。

3.1 核心结构改动

原来 select 使用辅助数组保存所有待监听 fd;poll 直接使用 struct pollfd 数组,数组内每一个元素同时保存文件描述符 fd、用户关心的监听事件events、内核返回的就绪事件revents。

数组初始化时,闲置位置的 fd 设置为-1,poll 检测到 fd 为 - 1 时会自动跳过该节点,不做监听。

3.2 模块拆解与代码解读

3.2.1 事件派发器 Dispatcher

poll 返回就绪事件数量之后,进入事件派发函数。需要遍历整个 pollfd 数组,逐个判断revents,找到发生就绪事件的 fd,再区分是监听套接字的新连接事件,还是普通客户端 fd 的读事件。

和 select 相比,poll 不需要每次调用前重置事件集合 ,events由用户预先设置,内核不会修改,只会修改 revents 中的内容,简化了循环内的逻辑。

cpp 复制代码
    // 事件派发器:将就绪的事件派发到指定的模块进行处理
    void Dispatcher()
    {
        // poll 的劣势体现:依然是 O(N) 的遍历。
        // 即便只有 1 个 fd 就绪,也必须从头到尾遍历整个数组来寻找是谁就绪了。
        for (int i = 0; i < nums; i++)
        {
            if (_fds[i].fd == defaultfd)
            {
                // 当前位置没有被fd占用,无效
                continue;
            }
            else
            {
                // 有效fd,但是需要判断是否就绪
                // revents 是"输出型"变量:内核填写的实际发生事件。
                // 按位与(&)判断内核是否反馈了读事件就绪。
                if (_fds[i].revents & POLLIN)
                {
                    // 说明当前位置fd是就绪状态
                    // 那就绪的事件是新连接到来呢,还是读事件就绪,两种最终处理的方式是不一样的
                    if (_fds[i].fd == _listensock->Fd())
                    {
                        // 说明是新连接到来的事件就绪了
                        HandlerAccept();
                    }
                    else
                    {
                        // 说明是读事件就绪了
                        HandlerRead(i);
                    }
                }
                // 说明当前位置fd没有就绪,继续判断后面的fd
            }
        }
    }

3.2.2 连接管理器 HandlerAccept

负责接收新 TCP 连接,调用 accept 拿到新客户端 fd。在 pollfd 数组中寻找空闲位置,填入 fd,设置需要监听的读事件POLLIN,并清空revents 。

如果数组已经全部占满,则拒绝新连接,关闭新 fd。

cpp 复制代码
    // 连接管理器:处理新连接到来的事件就绪
    void HandlerAccept()
    {
        InetAddr client;
        int sockfd = _listensock->Accept(&client); // 此时accept还会阻塞吗?不会,等的操作已经在上面的select处理了
        if (sockfd >= 0)
        {
            LOG(LogLevel::INFO) << "get a new link, sockfd: " << sockfd << ", client: " << client.StringAddress();

            int pos = 0;
            for (pos = 0; pos < nums; pos++)
            {
                if (_fds[pos].fd == defaultfd)
                {
                    // 说明当前pos位置是空闲的,不管是之前就没有使用还是之前的fd断开连接,我们都可以使用这个位置
                    break;
                }
            }
            if (pos == nums)
            {
                // Poll这里, 扩容
                // 数组满了。如果是真实的生产环境,这里可以通过动态分配(比如 std::vector)来进行扩容,
                // 这是 poll 优于 select(上限写死1024) 的另一大特点。这里暂时做拒绝连接处理。
                LOG(LogLevel::WARNING) << "poll server full...";
                close(sockfd);
            }
            else
            {
                _fds[pos].fd = sockfd;
                _fds[pos].events |= POLLIN;
                _fds[pos].revents = 0;
            }
        }
        else
        {
            LOG(LogLevel::WARNING) << "accept error..";
        }
    }

3.2.3 IO 读处理器 HandlerRead

处理客户端读就绪事件,读取客户端发来的数据。

  • **读到数据:**打印客户端消息;
  • **返回值等于 0:**代表客户端正常关闭连接,关闭 fd,把 pollfd 数组该位置重置为空闲状态;
  • **返回值小于 0:**读取出错,同样关闭 fd,清空数组节点。
cpp 复制代码
    // IO处理器:处理读事件就绪
    void HandlerRead(int pos)
    {
        // 到这里,读取数据的时候会不会阻塞?不会!
        // 因为如果接收缓冲区没有数据,那么读事件就不会就绪,也就不会调用这个函数
        // 而只有接收缓冲区有数据就绪了,才会调用这个函数进行处理,read/recv就不会阻塞了!
        std::cout << "处理读取操作..." << std::endl;
        char buffer[1024];
        int n = read(_fds[pos].fd, buffer, sizeof(buffer));
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "client say# " << buffer << std::endl;
        }
        else if (n == 0)
        {
            LOG(LogLevel::INFO) << "client quit...";
            // 1.断开连接,关闭文件描述符
            close(_fds[pos].fd);
            // 2.不要让select后续再关系这个sockfd了
            _fds[pos].fd = defaultfd;
            _fds[pos].events = 0;
            _fds[pos].revents = 0;
        }
        else
        {
            LOG(LogLevel::ERROR) << "read error";
            // 1.断开连接,关闭文件描述符
            close(_fds[pos].fd);
            // 2.不要让select后续再关系这个sockfd了
            _fds[pos].fd = defaultfd;
            _fds[pos].events = 0;
            _fds[pos].revents = 0;
        }
    }

3.3 完整 PollServer 类代码(pollServer.hpp)

cpp 复制代码
#pragma once
#include <iostream>
#include <memory>
#include <poll.h>
#include "Socket.hpp"

using namespace SocketModule;

class PollServer
{
    const static int nums = sizeof(fd_set) * 8;
    const static int defaultfd = -1; // 表示当前位置没有连接fd占用

public:
    PollServer(int port) : _listensock(std::make_unique<TcpSocket>()), _isrunning(false) // 派生类对象传给基类指针
    {
        _listensock->BuildTcpSocketMethod(port);

        // 初始化
        // [核心注释] 将 pollfd 数组全部置为无效状态。
        // poll_wait 底层遍历时,如果看到 fd 为负数,会自动忽略该节点,不会报错
        for (int i = 0; i < nums; i++)
        {
            _fds[i].fd = defaultfd;
            _fds[i].events = 0;
            _fds[i].revents = 0;
        }
        // events 是"输入型"变量:告诉内核,我只关心这个 fd 的"读事件(POLLIN)"
        _fds[0].fd = _listensock->Fd();
        _fds[0].events = POLLIN;
        // _fds[0].events |= POLLIN; //也可以
    }

    ~PollServer()
    {
    }

    void Start()
    {
        _isrunning = true;
        while (_isrunning)
        {
            // fd_set rfds;
            // FD_ZERO(&rfds);
            // int maxfd = defaultfd; // 获取最大文件描述符的值,用于select
            // // 1.每次select之前,都需要对rfds进行重置!
            // for (int i = 0; i < nums; i++)
            // {
            //     if (fd_array[i] == defaultfd)
            //     {
            //         // 两种情况:1.当前位置还没有被占用;2.当前位置的连接被断开重新置成-1
            //         // 不管是哪种情况,都不需要设置到位图中
            //         continue;
            //     }
            //     FD_SET(fd_array[i], &rfds);
            //     // 2.最大文件描述符fd时刻是变化的(有旧的连接会断开也会出现新的连接)
            //     //  如果当前的fd_array[i]大于maxfd,则更新maxfd
            //     if (maxfd < fd_array[i])
            //     {
            //         maxfd = fd_array[i];
            //     }
            // }

            // 对于poll而言,上面的重置操作完全不需要了
            Testprintfd();

            // poll 相比 select 的一大优势:事件的关心和就绪天然分离。
            // 传入需要关心的 events 和事件就绪内核修改的 revents 并不像 select 一样是同一个位图表示,
            // 而是通过一个结构体里面存放多个成员分别表示不同的情况。
            // 所以每次循环不需要像 select 那样重新设定关心的事件。

            // int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
            int timeout = 10000;
            int n = poll(_fds, nums, timeout);
            if (n == -1)
            {
                // 说明poll失败
                LOG(LogLevel::ERROR) << "poll error";
            }
            else if (n == 0)
            {
                // 说明超时了
                LOG(LogLevel::INFO) << "time out...";
            }
            else
            {
                // 其他情况返回值则表示事件已经就绪的个数
                LOG(LogLevel::INFO) << "有事件就绪了... , n = " << n;

                // 问题:就绪的事件难道只是新连接到来吗??不是!还有读事件就绪,还有写事件就绪(当前暂时不管)
                // 进入事件分发处理逻辑
                Dispatcher();
            }
        }
        _isrunning = false;
    }

    // 事件派发器:将就绪的事件派发到指定的模块进行处理
    void Dispatcher()
    {
        // poll 的劣势体现:依然是 O(N) 的遍历。
        // 即便只有 1 个 fd 就绪,也必须从头到尾遍历整个数组来寻找是谁就绪了。
        for (int i = 0; i < nums; i++)
        {
            if (_fds[i].fd == defaultfd)
            {
                // 当前位置没有被fd占用,无效
                continue;
            }
            else
            {
                // 有效fd,但是需要判断是否就绪
                // revents 是"输出型"变量:内核填写的实际发生事件。
                // 按位与(&)判断内核是否反馈了读事件就绪。
                if (_fds[i].revents & POLLIN)
                {
                    // 说明当前位置fd是就绪状态
                    // 那就绪的事件是新连接到来呢,还是读事件就绪,两种最终处理的方式是不一样的
                    if (_fds[i].fd == _listensock->Fd())
                    {
                        // 说明是新连接到来的事件就绪了
                        HandlerAccept();
                    }
                    else
                    {
                        // 说明是读事件就绪了
                        HandlerRead(i);
                    }
                }
                // 说明当前位置fd没有就绪,继续判断后面的fd
            }
        }
    }

    // 连接管理器:处理新连接到来的事件就绪
    void HandlerAccept()
    {
        InetAddr client;
        int sockfd = _listensock->Accept(&client); // 此时accept还会阻塞吗?不会,等的操作已经在上面的select处理了
        if (sockfd >= 0)
        {
            LOG(LogLevel::INFO) << "get a new link, sockfd: " << sockfd << ", client: " << client.StringAddress();

            int pos = 0;
            for (pos = 0; pos < nums; pos++)
            {
                if (_fds[pos].fd == defaultfd)
                {
                    // 说明当前pos位置是空闲的,不管是之前就没有使用还是之前的fd断开连接,我们都可以使用这个位置
                    break;
                }
            }
            if (pos == nums)
            {
                // Poll这里, 扩容
                // 数组满了。如果是真实的生产环境,这里可以通过动态分配(比如 std::vector)来进行扩容,
                // 这是 poll 优于 select(上限写死1024) 的另一大特点。这里暂时做拒绝连接处理。
                LOG(LogLevel::WARNING) << "poll server full...";
                close(sockfd);
            }
            else
            {
                _fds[pos].fd = sockfd;
                _fds[pos].events |= POLLIN;
                _fds[pos].revents = 0;
            }
        }
        else
        {
            LOG(LogLevel::WARNING) << "accept error..";
        }
    }

    // IO处理器:处理读事件就绪
    void HandlerRead(int pos)
    {
        // 到这里,读取数据的时候会不会阻塞?不会!
        // 因为如果接收缓冲区没有数据,那么读事件就不会就绪,也就不会调用这个函数
        // 而只有接收缓冲区有数据就绪了,才会调用这个函数进行处理,read/recv就不会阻塞了!
        std::cout << "处理读取操作..." << std::endl;
        char buffer[1024];
        int n = read(_fds[pos].fd, buffer, sizeof(buffer));
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "client say# " << buffer << std::endl;
        }
        else if (n == 0)
        {
            LOG(LogLevel::INFO) << "client quit...";
            // 1.断开连接,关闭文件描述符
            close(_fds[pos].fd);
            // 2.不要让select后续再关系这个sockfd了
            _fds[pos].fd = defaultfd;
            _fds[pos].events = 0;
            _fds[pos].revents = 0;
        }
        else
        {
            LOG(LogLevel::ERROR) << "read error";
            // 1.断开连接,关闭文件描述符
            close(_fds[pos].fd);
            // 2.不要让select后续再关系这个sockfd了
            _fds[pos].fd = defaultfd;
            _fds[pos].events = 0;
            _fds[pos].revents = 0;
        }
    }

    void Testprintfd()
    {
        std::cout << "fd_array[]: ";
        for (int i = 0; i < nums; i++)
        {
            if (_fds[i].fd == defaultfd)
                continue;
            std::cout << _fds[i].fd << " ";
        }
        std::cout << "\r\n";
    }

private:
    std::unique_ptr<Socket> _listensock; // 监听套接字
    bool _isrunning;                     // 服务器状态

    // int fd_array[nums];
    struct pollfd _fds[nums];
};

运行结果展示:

四、select vs poll 对比

对比维度 select poll
底层数据结构 位图 fd_set,用比特位标记待监听 fd,存储紧凑但大小固定 struct pollfd 结构体数组,每个元素独立保存 fd、监听事件、就绪事件
fd 数量上限 默认硬上限 1024,修改需重新编译内核,成本高 无固定硬上限,数组可动态扩容,不受 1024 限制
参数特性 输入输出集合耦合,内核会修改传入的 fd 集合,每次循环前需重新构建 events(用户关心)与 revents(内核返回)分离,events 不被内核修改,无需重置
事件类型 仅支持读、写、异常三类基础事件,粒度粗 支持更多细分事件(POLLRDHUP、POLLPRI 等),表达能力更强
内核实现 线性遍历全部 fd,判断就绪 同样线性遍历全部 fd,无本质提升
拷贝开销 每次调用需在用户态 / 内核态拷贝 fd_set 位图 每次调用需拷贝整个 pollfd 数组,fd 多时开销同样上升
跨平台性 POSIX 标准,几乎所有系统支持 主流类 Unix 支持,Windows 对 poll 支持较差
使用复杂度 需手动维护 maxfd,每次重置 fd 集合,易出错 无需维护 maxfd、无需重置事件,接口更简洁,复杂度中等

结论 :poll 是 select 的改良版,解决了 select 默认 1024 的上限与输入输出参数耦合两大痛点,API 更友好。

但两者共同的核心性能缺陷(内核线性遍历、用户态 / 内核态拷贝)依然存在,这也正是 epoll 出现的原因。

结束语

本篇我们完成了 select 服务的模块化重构,深入学习了 poll 多路转接接口,并且亲手将 Echo 服务从 select 改造为 poll 实现。通过横向对比 select 与 poll,我们看到 poll 解决了 1024 文件描述符上限、输入输出参数耦合这些痛点,API 使用更加简洁。

但也要意识到,poll 并没有从根本上消除线性遍历、用户态内核态拷贝的性能瓶颈。当并发连接规模持续增大,二者的性能都会明显下降。这也引出了 Linux 下真正面向高并发场景的多路复用方案:epoll。下一篇,我们就进入 epoll 的原理与实战学习。

相关推荐
wacsii1 小时前
【Linux基础开发工具(二)】Vim编辑器从入门到实战:模式、命令、配置与常见问题全解析
linux
数字供应链安全产品选型1 小时前
智能体安全选型建议:抛开功能清单,用运行时硬指标甄别真实防护能力
网络·安全
꯭自꯭闭꯭1 小时前
达梦DMDSC主备搭建
linux·服务器·数据库
AI备忘录1 小时前
(二十三)华为华三锐捷迈普思科 MLAG 跨设备配置命令(双活网关五厂商对照)
服务器·网络·华为
傲世仙尊2 小时前
HTTP起步-URL域名解析与协议的本质
网络·网络协议·http
智能化咨询2 小时前
(110页PPT)酒店智能化设计方案(附下载方式)
网络
墨家句子2 小时前
AnythingLLM 搭本地知识库:文档问答不准怎么调
linux·后端
高山有多高2 小时前
【Linux笔记】冯诺依曼体系结构
linux·运维·笔记
筑梦之路2 小时前
Alibaba Cloud Linux 4 IPXE全自动安装(直连官方源)——筑梦之路
linux·运维·服务器·国产化·阿里云操作系统