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机制的诞生。

谢谢大家的观看!

相关推荐
梦奇不是胖猫2 小时前
[ 计算机网络 | 第四章 ] 网络层 01 概述
网络·网络协议·计算机网络
小此方2 小时前
Re:Linux系统篇(二十四)进程篇·九:进程从创建到退出的底层机制与写时拷贝全解
linux·运维·驱动开发
春日见2 小时前
5分钟入门强化学习之蒙特卡洛(MC)算法与实现
运维·服务器·人工智能·深度学习·算法·机器学习
dualven_in_csdn2 小时前
cmd切换到powershell (一)
服务器·开发语言·php
蜜蜜不吃糖2 小时前
解决Veeam备份数据到Backup copy服务器报错session log违反了检查约束
运维·服务器
艾莉丝努力练剑2 小时前
【Linux网络】Linux 网络编程:传输层TCP(二)
linux·运维·服务器·网络·tcp/ip·计算机网络
都在酒里2 小时前
Linux字符设备驱动开发(九):内核定时器——实现LED周期性闪烁与轮询驱动原理
linux·运维·驱动开发·交互
都在酒里3 小时前
Linux字符设备驱动开发(十):综合实例——I2C传感器 + LED智能控制与进阶指南
linux·运维·服务器·驱动开发·交互
2301_8090511410 小时前
Linux 网络编程 学习笔记
linux·网络·学习