Linux 高级IO(三)多路转接之poll,poll的原理,poll版本的TCP服务器的实现

目录

[一、什么是 poll](#一、什么是 poll)

poll

poll的原理

二、编码

完整代码:

[三、poll 优缺点](#三、poll 优缺点)

四、总结


一、什么是 poll

上一篇文章我们之前已经清楚 select 存在明显缺陷:

  1. 文件描述符有 1024 的数量上限
  2. 输入输出参数未分离
  3. 内核与用户态都需要全量遍历,开销较大

为了优化这些问题,poll 应运而生,它同样是 Linux 下的多路转接就绪事件通知机制,核心作用和 select 一致,都只负责阻塞等待文件描述符事件就绪。poll 主要解决了 select 中 fd 数量受限、输入输出参数未分离这两个痛点,但没有解决循环遍历带来的性能开销问题,内核依旧需要遍历所有传入的 fd 来检测就绪状态,因此它只是 select 的改良版本,而非根本性优化。

poll

我们先讲 poll 的最后一个参数 timeout:是一个输入型参数,单位为毫秒,用来控制 poll 的阻塞行为:值为 -1 时代表永久阻塞等待,直到有 fd 事件就绪才返回;值为 0 代表非阻塞,立即检测所有 fd 状态并返回;值大于 0 则表示在指定毫秒时间内阻塞等待,超时后直接返回,这一行为和 select 的超时参数作用基本一致。

返回值:

返回值也和 select 一样 : 调用出错返回 -1,错误信息存放在 errno;等待成功则返回就绪的文件描述符总个数,select 会同时监控 所有的 fd,当有事件就绪时,它会统计有多少个 fd 变成了就绪状态,这个数字就是返回值;返回 0 代表等待超时,没有任何事件就绪。

下面我们着重来讲 poll 的前两个参数:第一个参数 fds 是 struct pollfd 结构体指针,第二个参数 nfds 是该结构体数组的元素个数,从传参角度可以把第一个参数看作数组首地址,第二个参数看作数组长度。其中第二个参数是输入型参数,第一个参数是输入输出型参数。

那这个 struct pollfd 结构体是什么?

struct pollfd 结构体包含三个成员:fd 表示要监听的文件描述符;events 是输入型成员变量,由用户设置,告诉内核当前 fd 需要监听哪些事件;revents 是输出型成员变量,由内核填充,返回当前 fd 实际就绪的事件。**当 poll 作为输入参数时,fd 与 events 生效;作为输出参数时,fd 与 revents 生效。**正是依靠这个结构体,poll 将用户关心的监听事件与内核返回的就绪事件分离开,在 fd 不变的前提下实现了输入输出参数解耦,解决了 select 输入输出参数未分离的问题。

那 events 是什么呢?short 又是什么?

short 是 C 语言里16 位短整型,用来存储事件标志位;而 events 就是我们要监听的 IO 事件,比如读、写、异常事件等,这些事件全部用大写宏定义的标志位表示,本质是位图 (比特位)。常见的 events 事件如下图 :

poll 里所有事件都是一个个独立的比特位,存放在 short 类型里 : 每一个比特位代表一种事件,比如 POLLIN 可读、POLLOUT 可写、POLLERR 错误等。我们通过按位或 (|) 把多个事件组合起来赋值给 events 告诉内核:这个 fd 我关心哪些事件;内核检测就绪后,同样用位图把就绪事件放到 revents 里,我们再用按位与 (&) 判断某一位是否为 1,就能知道对应事件是否就绪。

也就是说,poll 没有像 select 那样用位图管理 fd,而是用位图管理事件:用户用 events 按位或传入要监听的事件,内核用 revents 按位或返回就绪事件,我们再通过按位与做判断。日常开发里我们最常用的只有 POLLIN 读事件、POLLOUT 写事件、POLLERR 异常事件,其余事件很少用到。

下面是这些事件所对应的宏值:

举个例子 :

首先我们先明确按位或 (|) 和按位与 (&) 的运算规则:按位或 (|) 是对应二进制位只要有一个为 1,结果位就为 1;按位与 (&)是对应二进制位必须全为 1,结果位才为 1,否则为 0。

假如我们用户现在想要同时监听 4 号文件描述符的读、写事件,就要先定义struct pollfd 结构体,将成员 fd 设为 4,代表要监控的文件描述符;再通过按位或运算,把读事件POLLIN (二进制 0001)和写事件POLLOUT(二进制 0100)组合,赋值给输入型成员变量 events,0001 | 0100 得到二进制 0101,也就是数值 5,这一步仅用到结构体前两个成员,作为输入

传递给内核,明确告知内核需要监听 4 号 fd 的读、写事件。

当内核检测到事件就绪后,会将就绪事件填充到输出型成员 revents 中。假设只有读事件就绪,内核就会把 revents 赋值为 POLLIN,也就是二进制 0001;之后用户程序通过按位与运算,用revents & POLLIN 判断读事件是否就绪,0001 & 0001 结果为 0001,数值非零,就代表读事件已经就绪;如果就绪的是写事件,revents 为 0100,与 POLLIN 做按位与运算会得到 0,就说明读事件未就绪。

poll的原理

poll 的底层原理和 select 是基本一致的,底层同样采用遍历检测 的方式,只是在遍历范围上做了优化。poll 只会遍历用户传入的、真正关心的文件描述符,也就是 pollfd 结构体数组里的元素,而不是像 select 那样从 0 号 fd 一直遍历到 maxfd。

它的执行逻辑也是双循环结构:内循环遍历数组里的每一个 pollfd,调用底层的 poll 方法检测对应的事件是否就绪;外循环则负责在当前没有任何事件就绪时,让进程阻塞休眠,等事件到来被唤醒后,再重新执行内循环进行检测。poll 和 select 最核心的区别,就在于 select 是全量遍历所有可能的 fd,而 poll 只遍历用户明确传入的、有效的 fd,减少了不必要的遍历开销,但本质上仍然无法摆脱 "每次调用都要遍历所有 fd" 的模式,因此在高并发场景下依然存在性能瓶颈。

二、编码

所以我们要编写 poll 的代码时,肯定是要比编写 select 的代码更要简单点,因为 poll 是为了解决 select 的缺点而设计的。

私有成员变量:

相较于之前 Select 依靠整型辅助数组存储所有待监控 fd,Poll 版本在私有成员中新增了struct pollfd _fdsgfdnun 数组作为核心容器。poll 系统调用本身就以 pollfd 结构体数组作为传入载体,每一个 pollfd 单元都会独立存放待监听的 fd、需要关注的输入事件、内核反馈的就绪事件,因此我们直接用该数组统一托管监听套接字与客户端套接字,不再额外单独开辟纯数字的 fd 辅助数组。这里选择定长数组是为了简化开发、方便调试,实际工程场景里也可以通过动态开辟内存实现数组按需扩容,以此规避固定长度带来的上限约束,也刚好体现出 poll 本身不再存在 1024fd 硬性限制的特性。

构造函数 :

下面我们来看 PollServer 的构造函数,它的核心改动都围绕 struct pollfd 数组展开:

首先,构造函数依然会先创建并初始化监听套接字,完成 TCP 服务端的基础配置,这部分和 Select 版本的逻辑完全一致。接着遍历整个 _fds 数组,将每个元素的 fd 初始化为无效值 gdefaultfd,同时把 events 和 revents 都清零,完成数组的初始化,确保一开始没有无效的事件监听设置。初始化完成后,代码直接把监听套接字的 fd 存入 _fds0,并给它的 events 设置为 POLLIN,也就是告诉 poll 要监听这个 fd 的 "可读事件",这样当有新连接到来时,poll 就能感知到事件就绪。这一步相比 Select 版本,省去了后续循环中反复调用 FD_SET、FD_CLR 的操作,poll 的事件管理直接在结构体里完成,逻辑更简洁清晰。

Loop 函数:

Loop 主循环是从 select 版本到 poll 版本改动最核心的地方,也是 poll 优势最直接体现的地方。

poll 的调用替代了 select,参数直接用我们之前定义的 struct pollfd 数组 _fds,第二个参数通过 sizeof(_fds)/sizeof(_fds0) 自动计算出数组元素个数,第三个 timeout 参数设为 -1,表示永久阻塞等待事件就绪,这和 select 的超时参数逻辑一致。poll 调用完成后,返回值 n 的含义和 select 完全相同:0 表示超时,-1 表示调用出错,正数则表示本次有 n 个事件就绪。

和 select 相比,poll 省去了每次循环前都要重新构建 fd_set 位图的操作,因为 pollfd 数组本身就是输入输出参数,内核只会修改其中的 revents 字段,不会影响我们预先设置好的 events,所以不需要每次调用前都重置集合,代码更简洁,也避免了重复构建位图的开销。当 n > 0 时,代码同样会调用 Dispatcher 事件派发器,区分监听套接字和普通客户端套接字,分别执行新连接接收和 IO 读写逻辑,整体的事件处理流程和 select 版本保持一致,只是底层的事件通知机制换成了 poll。

Dispatcher 派发函数:

Dispatcher 事件派发器和 Select 版本的核心逻辑是一致的,但事件判断的方式做了适配。

它依然会遍历整个 _fds 数组,先跳过无效的 gdefaultfd,只处理合法的 fd。对于每个合法 fd,它不再像 Select 那样用 FD_ISSET 判断是否就绪,而是通过 _fdsi.revents & POLLIN 来判断是否有读事件就绪 ------ 这正是 poll 事件模型的核心:内核会把就绪事件直接写在 revents 字段里,我们用按位与就能判断目标事件是否触发。

判断就绪后,它会区分两种情况:如果是监听套接字的 fd 就绪,就调用 Accepter 接收新连接;如果是普通客户端 fd 就绪,就调用 Recver 处理 IO 读写。整体流程和 Select 版本完全一致,只是把 "用位图判断就绪" 换成了 "用 revents 字段 + 按位与判断就绪",代码更简洁,也更贴合 poll 的接口特性。

Accepter 接收监听器:

Poll 版本的 Accepter 连接管理器的核心逻辑和 Select 版本一致,但在 fd 托管方式上做了 Poll 适配:

监听套接字就绪后,代码调用 accept 获取新的客户端 fd,这里完全不会阻塞,因为 poll 已经提前告知我们监听 fd 可读事件就绪了。接着它会遍历 _fds 数组,找到第一个fd为 gdefaultfd 的空闲位置,用来存放新的客户端 fd。

如果遍历完都没找到空闲位置,说明服务器已达到最大连接数,代码会直接关闭新 fd 并打印提示;如果找到位置,就把新 fd 存入 _fdspos.fd ,同时把 events 设为POLLIN(表示要监听该客户端的读事件),并将 revents 清零,完成新连接的托管。和 Select 版本相比,这里不再需要单独维护一个辅助数组,直接在 pollfd 数组里就能完成 fd 和事件的绑定,后续 poll 会自动检测这些客户端的就绪事件,逻辑更简洁,也省去了FD_SET/FD_CLR的操作。

Recver IO 处理器:

Recver IO 处理器和 Select 版本的逻辑基本一致,只是针对 Poll 的 pollfd 数组做了适配。

首先,因为 poll 已经提前通知我们客户端 fd 的读事件就绪,所以这里调用 recv 读取数据时不会阻塞。代码会把读到的数据存入缓冲区,再拼接成回显字符串,再通过 send 发送回客户端。

接着,根据 recv 的返回值做不同处理:

  1. 如果返回值n > 0,表示成功读到数据,执行回显逻辑即可。
  2. 如果返回值n == 0,表示客户端正常断开连接,代码会先调用 close 关闭 fd,再把 _fdsi.fd 重置为 gdefaultfd,同时把 events 和 revents 清零,完成从 poll 监听列表中的移除。
  3. 如果返回值n < 0,表示读取出错,同样会关闭 fd 并重置 pollfd 数组中的对应项,避免无效事件监听。

和 Select 版本相比,这里的改动非常小,主要是直接操作pollfd数组的成员来移除 fd,不再需要额外维护辅助数组,也省去了FD_CLR的调用,代码更简洁。

关于这里的读 recv 逻辑有一点需要强调 : 就是这里我们已经可以正常读数据了,并且第一次读的时候肯定不是阻塞的,那如果一直读的话还会阻塞吗?

先说结论:本次第一次 recv 读绝对不会阻塞,但如果缓冲区数据没一次性读完,下一轮再进入 Recver 执行 recv,依旧存在阻塞风险,这也是这套回显模型隐藏的小隐患。

完整代码:

PollServer.hpp :

cpp 复制代码
#pragma once

#include <iostream>
#include <cstdio>
#include <cstdint>
#include <string>
#include <memory>
#include <poll.h>
#include "Logger.hpp"
#include "Socket.hpp"

using namespace NS_LOG_MODULE;
using namespace NS_SOCKET_MODULE;

static const int gnum = 4096;
static const int gdefaultfd = -1;

class PollServer
{
public:
    PollServer(uint16_t port = 8080) : _port(port), _listensock(std::make_unique<TcpSocket>()), _quit(false)
    {
        _listensock->BuildTcpSocketMethod(port);
        LOG(LogLevel::DEBUG) << "create listensock success, fd : " << _listensock->Sockfd();

        for (int i = 0; i < gnum; i++)
        {
            _fds[i].fd = gdefaultfd;
            _fds[i].events =  _fds[i].revents = 0;
        }

        // 默认直接把刚开始的fd,直接添加到数组中
        _fds[0].fd = _listensock->Sockfd();
        _fds[0].events = POLLIN;
    }

    void Accepter()
    {
        // 监听socket就绪
        LOG(LogLevel::WARNING) << "listensockfd event ready!";
        InetAddr clientaddr;
        int sockfd = _listensock->Accepter(clientaddr); // 这里还会卡主吗??不会了!!!
        LOG(LogLevel::WARNING) << "get a new link, sockfd is :" << sockfd << clientaddr.ToString();

        // 我们能直接读取sockfd吗??不能直接读,而应该托管给select!!如何托管??只要把这个sockfd添加到辅助数组中即可!!
        int pos = 0;
        for (; pos < gnum; pos++)
        {
            if (_fds[pos].fd == gdefaultfd)
                break;
        }
        if (pos == gnum)
        {
            LOG(LogLevel::WARNING) << "server is full!";
            close(sockfd);
        }
        else
        {
            _fds[pos].fd = sockfd;
            _fds[pos].events = POLLIN;
            _fds[pos].revents = 0;
        }
    }

    void Recver(int i)
    {
        // 普通fd就绪了!
        char buffer[1024];
        ssize_t n = recv(_fds[i].fd, buffer, sizeof(buffer), 0); // 这里读取会阻塞吗??不会!!
        if (n > 0)
        {
            buffer[n] = 0;
            LOG(LogLevel::DEBUG) << "buffer : " << buffer;
            std::string echo_string = "echo #";
            echo_string += buffer;
            // 我们可以直接发送数据吗?
            send(_fds[i].fd, echo_string.c_str(), echo_string.size(), 0);
        }
        else if (n == 0)
        {
            LOG(LogLevel::INFO) << "client quit: " << _fds[i].fd;
            // 0. 关闭fd
            close(_fds[i].fd);
            // 1. 从select移除掉fd
            _fds[i].fd = gdefaultfd;
            _fds[i].events =  _fds[i].revents = 0;
        }
        else
        {
            LOG(LogLevel::WARNING) << "recv error: " << _fds[i].fd;
            // 0. 关闭fd
            close(_fds[i].fd);
            // 1. 从select移除掉fd
            _fds[i].fd = gdefaultfd;
            _fds[i].events =  _fds[i].revents = 0;
        }
    }

    void Dispatcher()
    {
        for (int i = 0; i < gnum; i++)
        {
            if (_fds[i].fd == gdefaultfd)
                continue;
            else
            {
                // fd是合法的
                if (_fds[i].revents & POLLIN)
                {
                    if (_fds[i].fd == _listensock->Sockfd())
                    {
                        Accepter(); // 连接管理器
                    }
                    else
                    {
                        Recver(i); // io处理器
                    }
                }
            }
        }
    }
    void PrintFd()
    {
        std::cout << "poll fd list: ";
        for (int i = 0; i < gnum; i++)
        {
            if (_fds[i].fd == gdefaultfd)
                continue;
            std::cout << _fds[i].fd << " ";
        }
        std::cout << std::endl;
    }
    void Loop()
    {
        int timeout = -1;
        while (!_quit)
        {
            PrintFd();
            // 只负责等待
            // 监听事件是否就绪!
            // 监听器
            int n = poll(_fds, sizeof(_fds)/sizeof(_fds[0]), timeout);
            switch (n)
            {
            case 0:
                LOG(LogLevel::INFO) << "timeout";
                break;
            case -1:
                LOG(LogLevel::WARNING) << "select error";
                break;
            default:
                LOG(LogLevel::WARNING) << "event ready! n = " << n;
                // 事件派发器
                Dispatcher(); // 不仅仅处理新链接,还要处理普通的IO事件!!
                break;
            }
        }
    }
    ~PollServer() {}

private:
    uint16_t _port;
    std::unique_ptr<Socket> _listensock;
    bool _quit;

    struct pollfd _fds[gnum];
};

三、poll 优缺点

poll 最核心的改进,就是解决了 select 的两大硬伤:fd_set 的 1024 上限,以及输入输出参数未分离的问题。它不再依赖固定大小的位图,而是通过 struct pollfd 数组来管理事件,用户可以根据需要传入任意大小的数组,理论上没有上限。当然,这也受限于操作系统对单个进程可打开文件描述符的全局限制,并非无限制增长。

但 poll 并没有解决 select 的根本性能瓶颈 ------全量遍历。虽然它只遍历用户传入的 fd 数组,而非从 0 到 maxfd 的所有可能值,但每次调用 poll 时,内核依然要遍历整个 pollfd 数组来检测就绪事件。当并发连接数增多,数组长度越来越大,每次系统调用的遍历开销也会线性增长,导致性能下降,这和 select 的性能问题本质上是一样的。

这种循环遍历的模式,意味着 select 和 poll 都无法做到 "高效区分就绪 / 未就绪连接",随着连接数上升,它们的性能会明显打折。正是为了解决这个问题,Linux 引入了 epoll 机制,通过事件回调、就绪列表等方式,实现了只处理就绪连接,从而解决了遍历带来的性能瓶颈。

四、总结

Poll是Linux下的多路转接就绪事件通知机制,相比select主要解决了两个问题:一是突破了1024文件描述符的数量限制,二是实现了输入输出参数的分离。Poll通过struct pollfd结构体数组管理事件,用户设置events成员指定监听事件,内核填充revents返回就绪事件。虽然Poll优化了select的部分缺陷,但仍需遍历所有传入的文件描述符检测就绪状态,未能解决性能瓶颈问题。Poll适用于中等并发场景,但在高并发环境下仍存在性能限制,这促使了epoll机制的诞生。

谢谢大家的观看!

相关推荐
A小辣椒16 小时前
TShark:Wireshark CLI 功能
linux
A小辣椒19 小时前
TShark:基础知识
linux
AlfredZhao1 天前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao2 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334662 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪2 天前
linux 拷贝文件或目录到指定的位置
linux
大树882 天前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠2 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
bush42 天前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行5203 天前
Linux 11 动态监控指令top
linux