【Linux网络】深入理解Linux IO多路复用:select服务器完善、内核原理与poll实战


🔥草莓熊Lotso: 个人主页
❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!


🎬 博主简介:


文章目录

  • 前言:
  • [一. 完善 select 服务器:事件编程思想](#一. 完善 select 服务器:事件编程思想)
    • [1.1 为什么要拆分事件处理](#1.1 为什么要拆分事件处理)
    • [1.2 核心代码解读](#1.2 核心代码解读)
    • [1.3 辅助数组的核心作用](#1.3 辅助数组的核心作用)
    • [1.4 完整代码展示(selectServer.hpp)](#1.4 完整代码展示(selectServer.hpp))
  • [二. select 核心概念与缺陷复盘](#二. select 核心概念与缺陷复盘)
    • [2.1 fd_set 与操作宏](#2.1 fd_set 与操作宏)
    • [2.2 socket 就绪条件](#2.2 socket 就绪条件)
    • [2.3 select 的四大核心缺陷](#2.3 select 的四大核心缺陷)
  • [三. 深入内核:select 是怎么「同时等多个 fd」的](#三. 深入内核:select 是怎么「同时等多个 fd」的)
    • [3.1 前置知识铺垫](#3.1 前置知识铺垫)
    • [3.2 do_select 执行流程:双循环机制](#3.2 do_select 执行流程:双循环机制)
    • [3.3 本质总结](#3.3 本质总结)
  • [四. poll:select 的改进版](#四. poll:select 的改进版)
    • [4.1 poll 函数原型](#4.1 poll 函数原型)
    • [4.2 pollfd 结构体:输入输出分离](#4.2 pollfd 结构体:输入输出分离)
    • [4.3 常用事件宏](#4.3 常用事件宏)
    • [4.4 poll 解决了什么,没解决什么](#4.4 poll 解决了什么,没解决什么)
  • [五. 实战:把 select 服务器改写成 poll 服务器](#五. 实战:把 select 服务器改写成 poll 服务器)
    • [5.1 核心结构改动](#5.1 核心结构改动)
    • [5.2 核心代码解读](#5.2 核心代码解读)
    • [5.3 完整代码演示(pollServer.hpp)](#5.3 完整代码演示(pollServer.hpp))
    • [5.4 运行效果](#5.4 运行效果)
  • [六. select vs poll 全面对比](#六. select vs poll 全面对比)
  • 结尾:

前言:

上一篇我们从零搭建了 select 服务器的基础框架,实现了新连接的接受与托管。但当时的代码还比较粗糙,所有逻辑都挤在一个处理函数里,也没有处理客户端数据读写与连接关闭。这一篇我们先把 select 服务器完善成一个完整的 echo 服务器,引出事件编程的核心思想;然后深入内核层面,拆解 select 到底是怎么实现「同时等待多个 fd」的;最后我们再认识 poll ------ 作为 select 的改进版,它解决了什么问题,又留下了什么遗憾,并亲手把 select 服务器改写成 poll 版本。


一. 完善 select 服务器:事件编程思想

1.1 为什么要拆分事件处理

在上一版代码中,HandlerEvent 只负责处理新连接。但实际上,select 监听的 fd 分为两类:

  • 监听套接字 :就绪代表有新连接到来,需要 accept
  • 普通套接字 :就绪代表有数据到来,需要 recv 读取

如果都塞在一个函数里,代码会越来越臃肿,也不符合高内聚低耦合的设计思想。因此我们对事件处理逻辑进行拆分,引出四个核心角色:

  • Dispatcher(事件派发器):主循环,负责调用 select 等待事件,然后分发给处理器
  • EventHandler(事件处理器):遍历所有 fd,判断哪些就绪,并派发到对应处理函数
  • Acceptor(连接接收器):专门处理监听套接字的就绪事件,接受新连接
  • IOHandler(IO 处理器):专门处理普通套接字的就绪事件,读写数据

这种基于「事件就绪 → 派发 → 处理」的编程模式,就是事件驱动编程,也是所有高并发服务器的核心思想。

1.2 核心代码解读

事件派发器 Dispatcher

这是服务器的主循环,也是整个 select 机制的入口:

cpp 复制代码
void Dispatcher()
{
    fd_set rfds;
    while(true)
    {
        PrintFds();
        // 1. 每次循环必须重置 fd_set(输入输出型参数会被内核覆盖)
        FD_ZERO(&rfds);
        int max_fd = gdefaultfd;
        for(int i = 0; i < NUM; i++)
        {
            if(arr_fds[i] == gdefaultfd)
                continue;
            FD_SET(arr_fds[i], &rfds);
            if(max_fd < arr_fds[i]) 
                max_fd = arr_fds[i]; // 更新最大文件描述符
        }

        // 2. 调用 select 阻塞等待事件就绪
        int n = select(max_fd + 1, &rfds, nullptr, nullptr, nullptr);
        
        switch (n) 
        {
        case 0:
            LOG(LogLevel::DEBUG) << "time out...";
            break;
        case -1:
            LOG(LogLevel::DEBUG) << "select error...";
            break;
        default:
            LOG(LogLevel::DEBUG) << "事件就绪...: n: " << n;
            // 3. 派发给事件处理器
            EventHandler(rfds);
            break;
        }
    }
}

这里要强调两个细节:

  • fd_set 是输入输出型参数,每次调用 select 后,未就绪的 fd 会被内核清 0,所以每次循环必须重新构建
  • max_fd 也要动态计算,而不是固定用 listenfd,因为后续添加的客户端 fd 数值会更大。

事件处理器 EventHandler

遍历所有 fd,先判断合法性,再判断是否就绪,最后分发到对应处理函数:

cpp 复制代码
void EventHandler(fd_set &rfds)
{
    for(int i = 0; i < NUM; i++)
    {
        if(arr_fds[i] == gdefaultfd) continue;
        // 合法 fd 还需判断是否真的就绪
        if(FD_ISSET(arr_fds[i], &rfds))
        {
            if(arr_fds[i] == _listenfd->Socketfd())
            {
                // 监听套接字就绪 → 新连接
                Acceptor();
            }
            else 
            {
                // 普通套接字就绪 → 有数据
                IOHandler(i);
            }
        }
    }
}

这里体现了一个很重要的原则:合法 ≠ 就绪。辅助数组里的都是我们关心的 fd,但只有被 select 标记过的才是真正有事件的。

连接接收器 Acceptor

和上一版逻辑一致,专门处理新连接:

cpp 复制代码
void Acceptor()
{
    InetAddr clientaddr;
    int fd = _listenfd->Accepter(&clientaddr);
    LOG(LogLevel::INFO) << "get a new link...";

    if(fd >= 0)
    {
        // 在辅助数组中找空闲位置
        int pos = 0;
        for(;pos < NUM; pos++)
        {
            if(arr_fds[pos] == gdefaultfd)
                break;
        }
        if(pos >= NUM)
        {
            LOG(LogLevel::WARNING) << "server is full!";
            close(fd);
        }
        else
        {
            // 新 fd 加入数组,下轮循环自动被 select 监听
            arr_fds[pos] = fd;
        }
    }
    else 
    {
        LOG(LogLevel::ERROR) << "Accept error!"; 
    }
}

注意 :这里的 accept 不会阻塞。因为 select 已经告诉我们监听套接字读就绪了,全连接队列里一定有已经完成三次握手的连接。

IO 处理器 IOHandler

处理普通套接字的读事件,实现 echo 回显逻辑,同时处理连接关闭与错误:

cpp 复制代码
void IOHandler(int i)
{
    char inbuffer[1024];
    // select 已保证就绪,直接读,不会阻塞
    ssize_t n = recv(arr_fds[i], inbuffer, sizeof(inbuffer) - 1, 0);
    if(n > 0)
    {
        inbuffer[n] = 0;
        std::cout << "client say@ " << inbuffer << std::endl;
        // 简单 echo 回写
        std::string send_string = "echo# ";
        send_string += inbuffer;
        send(arr_fds[i], send_string.c_str(), send_string.size(), 0);
    }
    else if(n == 0)
    {
        // 对端正常关闭连接
        LOG(LogLevel::INFO) << "sockfd is close";
        close(arr_fds[i]);
        arr_fds[i] = gdefaultfd; // 从辅助数组移除,停止监听
    }
    else
    {
        // 读取出错
        LOG(LogLevel::ERROR) << "read error";
        close(arr_fds[i]);
        arr_fds[i] = gdefaultfd;
    }
}

这里有两个关键点:

  1. recv 返回 0 是 TCP 连接关闭的标志,必须及时关闭 fd 并从辅助数组中清理,否则下轮 select 会监听一个非法 fd,导致报错。
  2. 写操作我们这里直接调用 send,没有交给 select 监听写事件。因为发送缓冲区默认就是空闲的,写事件默认就绪;只有当发送缓冲区满了,才需要交给 select 等待可写。

1.3 辅助数组的核心作用

到这里我们可以总结一下,辅助数组 arr_fds 承担了三个核心职责:

  1. 持久化保存所有待监听的 fd :因为 fd_set 每次都会被内核覆盖,必须有一个地方保存完整的 fd 列表。
  2. 作为 fd 生命周期的管理者:新连接加入、连接关闭移除,都通过数组维护。
  3. 辅助计算 max_fd :每次重建 fd_set 时,顺便算出当前最大 fd,传给 select 的第一个参数。

1.4 完整代码展示(selectServer.hpp)

cpp 复制代码
#ifndef __SELECTSERVER__HPP
#define __SELECTSERVER__HPP

#include "Logger.hpp"
#include "InetAddr.hpp"
#include "Socket.hpp"
#include <bits/types/struct_timeval.h>
#include <cstdint>
#include <memory>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>

// NUM 定义了当前系统 select 能处理的最大 fd 数量,通常是 1024
#define NUM (sizeof(fd_set) * 8)
using namespace LogModule;

// 定义一个无效的文件描述符常量,用于初始化和清理辅助数组
const int gdefaultfd = -1;

class selectServer 
{
public:
    selectServer(uint16_t port)
        :_port(port)
        ,_listenfd(std::make_unique<TcpSocket>())
    {
        _listenfd->BulidSocketMethod(_port);
        
        // -------------------------------------------------------------
        // [初始化环节]
        // 核心思想:使用一个用户态的数组 arr_fds 来维护我们需要监听的套接字。
        // 因为 select 的 fd_set 是输入输出型参数,每次调用都会被内核修改,
        // 所以我们必须有一个干净的"底稿"来每次重新构建传给内核的 fd_set。
        // -------------------------------------------------------------
        for(int i = 0; i < NUM; i++)
        {
            arr_fds[i] = gdefaultfd;
        }
        // 首先把_listenfd 放入数组里面,服务器启动最开始只能关心新连接的到来
        arr_fds[0] = _listenfd->Socketfd();
    }

    // 事件派发器 (Server 的主循环)
    void Dispatcher()
    {
        fd_set rfds; // read fd set
        while(true)
        {
            PrintFds();
            
            // -------------------------------------------------------------
            // [重置参数环节]
            // 由于 select 会清空没有事件就绪的 fd,因此每次循环必须从头构建 rfds。
            // -------------------------------------------------------------
            FD_ZERO(&rfds);
            int max_fd = gdefaultfd; // 记录集合中最大的 fd,select 的第一个参数需要
            
            for(int i = 0; i < NUM; i++)
            {
                if(arr_fds[i] == gdefaultfd)
                    continue;
                
                // 把新的添加进去(将辅助数组中合法的 fd 添加到内核监听集合中)
                FD_SET(arr_fds[i], &rfds);
                
                // 动态更新最大文件描述符
                if(max_fd < arr_fds[i]) max_fd = arr_fds[i]; 
            }
            
            // struct timeval timeout = {5,0}; // 我们今天这里先不使用这个
            
            // -------------------------------------------------------------
            // [系统调用环节]
            // 将准备好的 rfds 交给内核,进程在此处阻塞,直到有 fd 的事件就绪。
            // 注意:select 返回后,rfds 中只剩下真正发生了读事件的 fd。
            // -------------------------------------------------------------
            // int n = select(max_fd + 1, &rfds, nullptr, nullptr, &timeout);
            int n = select(max_fd + 1, &rfds, nullptr, nullptr, nullptr);
            
            switch (n) 
            {
            case 0:
                LOG(LogLevel::DEBUG) << "time out...";
                break;
            case -1:
                LOG(LogLevel::DEBUG) << "select error...";
                break;
            default:
                // n > 0,说明有 n 个事件就绪,交给专门的事件处理器去路由分配
                LOG(LogLevel::DEBUG) << "事件就绪...: n: " << n;
                EventHandler(rfds);
                break;
            }
        }
    }

    ~selectServer()
    {
        // 不需要 arr_fds[i] = gdefaultfd
        // 因为对象马上就销毁了,数组也没了
        for(int i = 0; i < NUM; i++)
        {
            if(arr_fds[i] != gdefaultfd)
                close(arr_fds[i]);
        }
    }

private:
    // -------------------------------------------------------------
    // [事件分发环节]
    // 负责诊断是被内核修改后的 rfds 中,到底是谁就绪了,并做分类处理。
    // -------------------------------------------------------------
    void EventHandler(fd_set &rfds)
    {
        for(int i = 0; i < NUM; i++)
        {
            if(arr_fds[i] == gdefaultfd) continue;
            
            // 走到这里可以判断是合法的, 但是我们还需要判断是否就绪
            // FD_ISSET 检查当前遍历的 fd 是否在就绪集合 rfds 中
            if(FD_ISSET(arr_fds[i], &rfds))
            {
                // 走到这里就肯定是就绪了
                // 再判断是普通的还是listen:这两种 fd 代表的事件含义完全不同
                
                if(arr_fds[i] == _listenfd->Socketfd())
                {
                    // listensockfd 就绪,意味着底层全连接队列有新的客户端完成了三次握手
                    Acceptor();
                }
                else 
                {
                    // normal sockfd 就绪,意味着客户端发来了数据,或者断开了连接
                    IOHandler(i);
                }
            }
        }
    }

    // 处理新连接的专用模块
    void Acceptor()
    {
        InetAddr clientaddr;
        // 这里的 accept 绝对不会阻塞,因为是被 select 通知就绪后才调用的
        int fd = _listenfd->Accepter(&clientaddr);
        LOG(LogLevel::INFO) << "get a new link...";
        
        // 你得到了一个新的连接,这个连接怎么处理??
        // recv(fd)?? 等 + 拷贝, 不能!!
        // fd -> 托管给select-> 只有select具有"等"的能力!-> 如何托管?? -> 只要把fd添加的辅助数组即可!
        
        if(fd >= 0)
        {
            // 找一个空闲的位置
            int pos = 0;
            for(;pos < NUM; pos++)
            {
                if(arr_fds[pos] == gdefaultfd)
                    break;
            }
            
            if(pos >= NUM)
            {
                // 遍历完了找不到,说明当前服务器并发数量已达到 select 的极限
                LOG(LogLevel::WARNING) << "server is full!";
                close(fd);
            }
            else
            {
                // 把这个新获取到的加进辅助数组即可
                // 下一轮循环的时候, Dispatcher 的重置环节会自动将其纳入 select 监听集合
                arr_fds[pos] = fd;
            }
        }
        else 
        {
            LOG(LogLevel::ERROR) << "Accept error!"; 
        }
    }

    // 处理普通数据 IO 的专用模块
    void IOHandler(int i)
    {
        char inbuffer[1024];
        // 在之前的逻辑中已经select等待过了, 走到这里说明接收缓冲区一定有数据,直接读就行
        // 这里的 recv 绝对不会阻塞
        ssize_t n = recv(arr_fds[i], inbuffer, sizeof(inbuffer) - 1, 0);
        
        if(n > 0)
        {
            // 正常读取到客户端数据
            inbuffer[n] = 0;
            std::cout << "client say@ " << inbuffer << std::endl;

            // 需要写回去, 这里我们需要等待嘛
            // 其实是先不需要的,写缓冲区默认有数据(只要写缓冲区没满,写事件默认就是就绪的)
            std::string send_string = "echo# ";
            send_string += inbuffer;
            send(arr_fds[i], send_string.c_str(), send_string.size(), 0);
        }
        else if(n == 0)
        {
            // recv 返回 0 的核心含义:对端(客户端)正常关闭了 TCP 连接
            LOG(LogLevel::INFO) << "sockfd is close";
            
            // -------------------------------------------------------------
            // [清理环节] - 极其重要,否则会造成资源泄漏和 select 报错
            // -------------------------------------------------------------
            close(arr_fds[i]); // 关闭底层文件描述符
            arr_fds[i] = gdefaultfd; // 辅助数组里面也处理一下,告诉后续的 select 别再监视它了
        }
        else
        {
            // recv 返回小于 0,读取发生异常错误
            LOG(LogLevel::ERROR) << "read error";
            close(arr_fds[i]); // 关闭文件描述符
            arr_fds[i] = gdefaultfd; // 辅助数组里面也处理一下 
        }
    }

    // 调试辅助函数:打印当前正在被监听的合法 fd
    void PrintFds()
    {
        std::cout << "Select Server fds list: ";
        for(int i = 0; i < NUM; i++)
        {
            if(arr_fds[i] == gdefaultfd) continue;
            std::cout << arr_fds[i] << " ";
        }
        std::cout << std::endl;
    }

private:
    uint16_t _port;
    std::unique_ptr<Socket> _listenfd;
    // 需要一个辅助数组来记录用户态需要监听的 fd
    // 它是解决 select 参数会被覆盖修改的核心数据结构
    int arr_fds[NUM];
};
#endif

二. select 核心概念与缺陷复盘

2.1 fd_set 与操作宏

fd_set 本质上是一个位图结构,每一个比特位对应一个文件描述符,比特位为 1 表示关心该 fd 的事件。

c 复制代码
typedef struct {
    long int fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set;

系统提供了四个宏来操作这个位图:

c 复制代码
void FD_ZERO(fd_set *set);     // 清空所有位
void FD_SET(int fd, fd_set *set);  // 将 fd 加入集合
void FD_CLR(int fd, fd_set *set);  // 将 fd 从集合移除
int  FD_ISSET(int fd, fd_set *set);// 测试 fd 是否就绪

默认 __FD_SETSIZE 为 1024,也就是说 select 最多只能同时监听 1024 个文件描述符。

2.2 socket 就绪条件

很多人写 select 代码时不知道什么时候才算就绪,这里再系统梳理一遍:

读就绪条件(满足任意一条)

  1. 内核接收缓冲区字节数 ≥ 低水位标记 SO_RCVLOWAT(默认 1),可无阻塞读取
  2. TCP 对端关闭连接,此时 read 返回 0
  3. 监听套接字上有新的连接请求
  4. socket 上有未处理的错误

写就绪条件(满足任意一条)

  1. 内核发送缓冲区可用字节数 ≥ 低水位标记 SO_SNDLOWAT(默认 1),可无阻塞写入
  2. socket 写操作被关闭,继续写会触发 SIGPIPE
  3. 非阻塞 connect 连接成功或失败之后
  4. socket 上有未读取的错误

异常就绪 收到 TCP 带外数据(紧急数据),实际开发很少使用。

2.3 select 的四大核心缺陷

  1. 连接数有硬上限:默认最多 1024 个 fd,想要修改必须重新编译内核,兼容性差。
  2. 输入输出耦合fd_set 既是输入也是输出,每次调用前都必须手动重置,使用繁琐。
  3. 用户态 - 内核态拷贝开销 :每次调用都要把整个 fd_set 从用户态拷贝到内核态,fd 越多开销越大。
  4. 内核线性遍历:内核需要遍历所有传入的 fd 来检查是否就绪,时间复杂度 O (n)。连接数越多,性能下降越明显,大量空闲连接时效率极低。

三. 深入内核:select 是怎么「同时等多个 fd」的

很多同学学到这里都会有疑问:select 到底在内核里做了什么?为什么一个函数就能同时等几十个、上百个 fd?就绪了又是怎么通知进程的?

我们结合 Linux 内核 do_select 的核心逻辑,用通俗的方式拆解它的工作原理。

3.1 前置知识铺垫

在讲流程之前,先明确两个内核机制:

  1. 等待队列(wait_queue):每个 socket / 文件对象内部都有一个等待队列头。当设备未就绪时,进程可以把自己挂到这个队列上睡眠;当设备就绪时,内核会遍历队列,唤醒上面的进程。
  2. 驱动 poll 方法struct file_operations 中有一个 poll 函数指针,由具体的设备驱动实现。它的作用是检查当前设备是否就绪,同时可以通过参数把进程注册到设备的等待队列上。

3.2 do_select 执行流程:双循环机制

select 的内核实现核心是「外层死循环 + 内层遍历 fd」,大致分为四个阶段:

阶段 1:初始化与状态设置

进入 do_select 后,先初始化 poll 表,设置当前进程状态为 TASK_INTERRUPTIBLE(可中断睡眠)。

阶段 2:内层循环:遍历所有 fd 检查状态

这是第一次遍历:

  • 逐个取出用户传入的 fd,调用对应驱动的 poll 方法
  • 如果该 fd 已经就绪,就把它标记到结果集中,就绪计数 +1
  • 如果还没就绪,就通过 poll_wait 把当前进程挂到这个 fd 的等待队列上

阶段 3:判断是否需要睡眠

第一次遍历完后,检查三种情况:

  • 已经有就绪的 fd → 直接退出循环,返回结果
  • 已经超时 → 退出循环,返回 0
  • 收到信号 → 退出循环,返回 -EINTR

如果以上都不满足,就调用 schedule_timeout() 让出 CPU,当前进程进入睡眠。

阶段 4:被唤醒后二次遍历

当任意一个 fd 就绪时,内核会唤醒它等待队列上的进程。进程被唤醒后,会再次遍历所有 fd,收集所有就绪的事件,然后返回给用户。

3.3 本质总结

select 的本质可以概括为一句话:两次遍历 + 挂等待队列

第一次遍历检查有没有就绪的,没有就把进程挂到所有 fd 的等待队列上睡觉;任何一个 fd 就绪了,就唤醒进程;进程醒了再遍历一次,收集所有就绪的 fd 返回。

这也从底层解释了 select 的性能瓶颈:

  • fd 越多,两次遍历的开销越大
  • 每次调用都要重复「挂队列 → 删队列」的操作
  • 哪怕只有一个活跃连接,也要遍历全部 fd

四. poll:select 的改进版

正因为 select 有这么多缺点,后来的 POSIX 标准又推出了 poll 作为改进方案。

4.1 poll 函数原型

c 复制代码
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

参数说明:

  • fdspollfd 结构体数组的首地址
  • nfds:数组的元素个数
  • timeout:超时时间,单位为毫秒
    • -1:永久阻塞,直到有事件就绪
    • 0:立即返回,非阻塞轮询
    • >0:阻塞指定毫秒数,超时返回 0

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

poll 最大的改进,就是把「用户关心的事件」和「内核返回的事件」拆成了两个字段:

c 复制代码
struct pollfd {
    int fd;         // 文件描述符,-1 表示忽略该位置
    short events;   // 输入:用户告诉内核关心哪些事件
    short revents;  // 输出:内核告诉用户发生了哪些事件
};

events 由用户设置,内核不会修改;revents 由内核填充就绪状态。这就彻底解决了 select「输入输出耦合」的问题 ------不需要每次调用都重置事件集合 ,只需要关注 revents 即可。

4.3 常用事件宏

poll 支持的事件比 select 更丰富,最常用的有三个:

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

使用时通过按位与 判断事件,例如:if (_fdevent[i].revents & POLLIN) 就表示读事件就绪。

4.4 poll 解决了什么,没解决什么

解决的问题

  1. 没有 1024 硬限制:数组大小由用户自己定义,可以动态扩容,理论上限就是系统允许的最大文件描述符数。
  2. 输入输出分离eventsrevents 各司其职,无需每次调用都重建整个集合,接口更易用。
  3. 事件类型更丰富:支持更多精细的事件,比如对端关闭、带外数据等。

没解决的问题

  1. 内核依然是线性遍历:时间复杂度还是 O (n),高并发下性能随连接数线性下降。
  2. 依然存在用户态 - 内核态拷贝:需要把 pollfd 数组在用户态和内核态之间来回拷贝。
  3. 大量空闲连接时效率低:和 select 一样,不管活不活跃,都要全部遍历一遍。

简单说:poll 是 select 的「接口优化版」,但没有从根本上解决性能问题。


五. 实战:把 select 服务器改写成 poll 服务器

理解了原理,我们把上面的 select 服务器改写成 poll 版本,你会发现业务逻辑几乎不用变,只是把 fd 管理的部分替换掉就行。

5.1 核心结构改动

原来的辅助数组 int arr_fds[NUM] 替换成 struct pollfd _fdevent[NUM],每个元素同时保存 fd、关心的事件、返回的事件。

5.2 核心代码解读

构造与初始化

cpp 复制代码
pollServer(uint16_t port)
    :_port(port)
    ,_listenfd(std::make_unique<TcpSocket>())
{
    _listenfd->BulidSocketMethod(_port);
    // 初始化数组,fd=-1 表示该位置空闲
    for(int i = 0; i < NUM; i++)
    {
        _fdevent[i].fd = gdefaultfd;
        _fdevent[i].events = _fdevent[i].revents = 0;
    }
    // 添加监听套接字,关心读事件
    _fdevent[0].fd = _listenfd->Socketfd();
    _fdevent[0].events |= POLLIN;
}

事件派发器

对比 select 版,这里不需要每次重置事件集合了,代码更简洁:

cpp 复制代码
void Dispatcher()
{
    while(true)
    {
        int timeout = 2000; // 毫秒单位
        // 直接调用 poll,无需重置 events
        int n = poll(_fdevent, NUM, timeout);
        
        switch (n) 
        {
        case 0:
            LOG(LogLevel::DEBUG) << "time out...";
            break;
        case -1:
            LOG(LogLevel::DEBUG) << "poll error...";
            break;
        default:
            LOG(LogLevel::DEBUG) << "事件就绪...: n: " << n;
            EventHandler();
            break;
        }
    }
}

事件处理器

通过 revents 判断就绪事件,逻辑和 select 版高度一致:

cpp 复制代码
void EventHandler()
{
    for(int i = 0; i < NUM; i++)
    {
        if(_fdevent[i].fd == gdefaultfd) continue;
        
        if(_fdevent[i].revents & POLLIN)
        {
            if(_fdevent[i].fd == _listenfd->Socketfd())
            {
                Acceptor();
            }
            else 
            {
                IOHandler(i);
            }
        }
    }
}

连接接收器

添加新连接时,除了设置 fd,还要设置 events,并清空 revents

cpp 复制代码
void Acceptor()
{
    InetAddr clientaddr;
    int fd = _listenfd->Accepter(&clientaddr);
    LOG(LogLevel::INFO) << "get a new link...";

    if(fd >= 0)
    {
        int pos = 0;
        for(;pos < NUM; pos++)
        {
            if(_fdevent[pos].fd == gdefaultfd)
                break;
        }
        if(pos >= NUM)
        {
            LOG(LogLevel::WARNING) << "poll is full!";
            close(fd);
        }
        else
        {
            _fdevent[pos].fd = fd;
            _fdevent[pos].events |= POLLIN;
            _fdevent[pos].revents = 0;
        }
    }
}

IO 处理器

读写逻辑完全不变,只是关闭连接时要把 eventsrevents 也清空:

cpp 复制代码
void IOHandler(int i)
{
    char inbuffer[1024];
    ssize_t n = recv(_fdevent[i].fd, inbuffer, sizeof(inbuffer) - 1, 0);
    if(n > 0)
    {
        inbuffer[n] = 0;
        std::cout << "client say@ " << inbuffer << std::endl;
        std::string send_string = "echo# ";
        send_string += inbuffer;
        send(_fdevent[i].fd, send_string.c_str(), send_string.size(), 0);
    }
    else if(n == 0)
    {
        LOG(LogLevel::INFO) << "sockfd is close";
        close(_fdevent[i].fd);
        // 完整清理该位置
        _fdevent[i].fd = gdefaultfd; 
        _fdevent[i].events = _fdevent[i].revents = 0;
    }
    else
    {
        LOG(LogLevel::ERROR) << "read error";
        close(_fdevent[i].fd);
        _fdevent[i].fd = gdefaultfd;
        _fdevent[i].events = _fdevent[i].revents = 0;
    }
}

5.3 完整代码演示(pollServer.hpp)

cpp 复制代码
#include "Logger.hpp"
#include "InetAddr.hpp"
#include "Socket.hpp"
#include <bits/types/struct_timeval.h>
#include <cstdint>
#include <memory>
#include <sys/poll.h>
#include <sys/socket.h>
#include <poll.h>
#include <unistd.h>

// [核心注释] 这里借用了 select 的 fd_set 大小来定义 poll 数组的容量(通常为 1024)
// 虽然 poll 本身没有最大连接数的硬性限制,但这里为了演示设定了一个固定上限
#define NUM (sizeof(fd_set) * 8)
using namespace LogModule;

// [核心注释] 定义一个无效的文件描述符标志,用于初始化和判断 pollfd 数组中的空闲槽位
const int gdefaultfd = -1;

class pollServer 
{
public:
    pollServer(uint16_t port)
        :_port(port)
        ,_listenfd(std::make_unique<TcpSocket>())
    {
        _listenfd->BulidSocketMethod(_port);
        // 初始化
        // [核心注释] 将 pollfd 数组全部置为无效状态。
        // poll_wait 底层遍历时,如果看到 fd 为负数,会自动忽略该节点,不会报错。
        for(int i = 0; i < NUM; i++)
        {
            _fdevent[i].fd = gdefaultfd;
            _fdevent[i].events = _fdevent[i].revents = 0;
        }
        // 首先把_litenfd 放入数组里面
        // [核心注释] 服务器启动的基石:必须先让 poll 帮忙盯着监听套接字 (listen socket)
        _fdevent[0].fd = _listenfd->Socketfd();
        _fdevent[0].events |= POLLIN; // there is a data to read 
        // [核心注释] events 是"输入型"变量:告诉内核,我只关心这个 fd 的"读事件(POLLIN)"
    }
    
    // 事件派发器
    void Dispatcher()
    {
        while(true)
        {
            PrintFds();
            int timeout = 2000;
            // 不需要参数重置了
            // [核心注释] poll 相比 select 的一大优势:接口分离。
            // 传入的 events 和内核修改的 revents 分开了,所以每次循环不需要像 select 那样重新设定关心的事件。
            int n = poll(_fdevent, NUM, timeout);
            switch (n) 
            {
            case 0:
                LOG(LogLevel::DEBUG) << "time out...";
                break;
            case -1:
                LOG(LogLevel::DEBUG) << "poll error...";
                break;
            default:
                LOG(LogLevel::DEBUG) << "事件就绪...: n: " << n;
                // [核心注释] n > 0,说明有 n 个 fd 发生了你关心的事件,进入事件分发处理逻辑
                EventHandler();
                break;
            }
        }
    }
    
    ~pollServer()
    {
        // 不需要 arr_fds[i] = gdefaultfd
        // 因为对象马上就销毁了,数组也没了
        // [核心注释] 析构时统一清理资源,防止文件描述符泄漏
        for(int i = 0; i < NUM; i++)
        {
            if(_fdevent[i].fd != gdefaultfd)
                close(_fdevent[i].fd);
        }
    }
private:
    void EventHandler()
    {
        // [核心注释] poll 的劣势体现:依然是 O(N) 的遍历。
        // 即便只有 1 个 fd 就绪,也必须从头到尾遍历整个数组来寻找是谁就绪了。
        for(int i = 0; i < NUM; i++)
        {
            if(_fdevent[i].fd == gdefaultfd) continue;
            // 走到这里可以判断是合法的, 但是我们还需要判断是否就绪
            
            // [核心注释] revents 是"输出型"变量:内核填写的实际发生事件。
            // 按位与(&)判断内核是否反馈了读事件就绪。
            if(_fdevent[i].revents & POLLIN)
            {
                // 走到这里就肯定是就绪了
                // 再判断是普通的还是listen
                if(_fdevent[i].fd == _listenfd->Socketfd())
                {
                    // listensockfd
                    // [核心注释] 身份确认:如果是 listen_fd 就绪,说明有新的客户端发起了连接请求
                    Acceptor();
                }
                else 
                {
                    // normal sockfd
                    // [核心注释] 身份确认:如果是普通 fd 就绪,说明已连接的客户端发来了数据报文
                    IOHandler(i);
                }
            }
        }
    }
    
    void Acceptor()
    {
        InetAddr clientaddr;
        int fd = _listenfd->Accepter(&clientaddr);
        LOG(LogLevel::INFO) << "get a new link...";
        // 你得到了一个新的连接,这个连接怎么处理??
        // recv(fd)?? 等 + 拷贝, 不能!!
        // fd -> 托管给select-> 只有select具有"等"的能力!-> 如何托管?? -> 只要把fd添加的辅助数组即可!
        
        // [核心注释] 上面的原注释极其重要:获取新连接后绝对不能直接阻塞读!
        // 必须把这个新的 fd 扔给 poll,让 poll 代替进程去"等"它来数据。
        if(fd >= 0)
        {
            // 找一个空闲的位置
            // [核心注释] 线性遍历寻找 pollfd 数组中可用(即 fd 为 gdefaultfd)的槽位
            int pos = 0;
            for(;pos < NUM; pos++)
            {
                if(_fdevent[pos].fd == gdefaultfd)
                    break;
            }
            // 为了健壮性 >= 
            if(pos >= NUM)
            {
                // Poll这里, 扩容
                // [核心注释] 数组满了。如果是真实的生产环境,这里可以通过动态分配(比如 std::vector)来进行扩容,
                // 这是 poll 优于 select(上限写死1024) 的另一大特点。这里暂时做拒绝连接处理。
                LOG(LogLevel::WARNING) << "poll is full!";
                close(fd);
            }
            else
            {
                // 把这个新获取到的加进辅助数组即可
                // 下一轮循环的时候, 会关心上的
                // [核心注释] 注册新任务:把新连接的 fd 填入空白槽位,并告诉内核:"下一次轮询,请帮我也盯紧这个新 fd 的读事件"
                _fdevent[pos].fd = fd;
                _fdevent[pos].events |= POLLIN;
                _fdevent[pos].revents = 0; // 保险起见,清空历史状态
            }
        }
        else 
        {
            LOG(LogLevel::ERROR) << "Accept error!"; 
        }
    }
    
    void IOHandler(int i)
    {
        char inbuffer[1024];
        // 在之前的逻辑中已经select等待过了, 走到这里直接读就行
        
        // [核心注释] 因为是 poll 通知我们就绪的,说明底层接收缓冲区一定有数据。
        // 此时调用 recv 绝对不会被阻塞挂起,保证了单线程事件循环的高效运转。
        ssize_t n = recv(_fdevent[i].fd, inbuffer, sizeof(inbuffer) - 1, 0);
        if(n > 0)
        {
            // [核心注释] 正常读到了数据,业务逻辑处理(这里是简单的 Echo 打印和回写)
            inbuffer[n] = 0;
            std::cout << "client say@ " << inbuffer << std::endl;

            // 需要写回去, 这里我们需要等待嘛
            // 其实是先不需要的,写缓冲区默认有数据
            std::string send_string = "echo# ";
            send_string += inbuffer;
            send(_fdevent[i].fd, send_string.c_str(), send_string.size(), 0);
        }
        else if(n == 0)
        {
            // 对端关闭了连接
            LOG(LogLevel::INFO) << "sockfd is close";
            // [核心注释] 客户端主动断开。清理双重奏:1. 关闭文件描述符释放资源;2. 将当前槽位置回无效态,从 poll 监控列表中"注销"。
            close(_fdevent[i].fd); // 关闭文件描述符
            _fdevent[i].fd = gdefaultfd; 
            _fdevent[i].events = _fdevent[i].revents = 0;
        }
        else
        {
            // [核心注释] 读异常处理。清理逻辑同上。
            LOG(LogLevel::ERROR) << "read error";
            close(_fdevent[i].fd); // 关闭文件描述符
            _fdevent[i].fd = gdefaultfd; 
            _fdevent[i].events = _fdevent[i].revents = 0;
        }
    }
    
    void PrintFds()
    {
        // [核心注释] 调试辅助工具:打印当前 poll 正在监控的所有有效 fd
        std::cout << "Poll Server fds list: ";
        for(int i = 0; i < NUM; i++)
        {
            if(_fdevent[i].fd == gdefaultfd) continue;
            std::cout << _fdevent[i].fd << " ";
        }
        std::cout << std::endl;
    }
private:
    uint16_t _port;
    std::unique_ptr<Socket> _listenfd;
    struct pollfd _fdevent[NUM];
};
#endif

5.4 运行效果

编译运行后,用多个 telnet 客户端连接测试,效果和 select 版完全一致:支持多客户端同时在线、echo 回显正常、客户端退出后服务器能正确清理资源。

  • 下面这种就不测试了,只需要修改timeout就行

六. select vs poll 全面对比

对比维度 select poll
数据结构 位图 fd_set pollfd 结构体数组
fd 数量上限 默认 1024,修改需重编译内核 无硬限制,可动态扩容
参数特性 输入输出耦合,每次调用必须重置集合 eventsrevents 分离,无需重置关心事件
事件类型 仅读、写、异常三类 支持更多精细事件,能力更丰富
内核实现 线性遍历所有 fd 线性遍历所有 fd
拷贝开销 用户态 ↔ 内核态拷贝 fd_set 用户态 ↔ 内核态拷贝 pollfd 数组
跨平台性 全 POSIX 平台支持,通用性最好 多数 Unix-like 支持,Windows 兼容性弱
使用复杂度 高,需维护 maxfd、每次重置 中,接口更简洁

结尾:

html 复制代码
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!

结语:select 和 poll 都是经典的同步 IO 多路复用模型,核心思想都是「统一等待、就绪再处理」。poll 在接口设计和连接数限制上比 select 进步了一大截,但本质上都没有解决「内核遍历所有 fd」的性能瓶颈。当连接数很高但活跃连接很少的时候,它们的效率都会急剧下降。这也是为什么后来 Linux 推出了 epoll ------ 它用红黑树 + 就绪队列的设计,彻底解决了遍历问题,让高并发场景下的性能实现了质的飞跃。下一篇我们就会正式进入 epoll 的世界,拆解它的底层原理,看它是怎么成为高并发服务器标配的。

✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど

相关推荐
Andy43 分钟前
Cpp进阶语法详解
c++
●VON1 小时前
鸿蒙 PC Markdown 编辑器 Alpha 评审:如何用退出条件判断阶段完成
网络·安全·华为·编辑器·harmonyos·鸿蒙
king_linlin1 小时前
算法基础——算法复杂度
c语言·开发语言·数据结构·算法
byte轻骑兵1 小时前
BlueZ源码编译环境配置全指南:Linux桌面原生编译 + 嵌入式ARM交叉编译 + 定制裁剪与调试实战
linux·arm开发·蓝牙·bluez·电脑蓝牙
纪伊路上盛名在1 小时前
NVML ERROR_ RM has detected an NVML_RM version mismatch
linux·数据库·gpu·驱动
醉逍遥_祥1 小时前
Linux进程与NuttX任务(Linux Processes vs NuttX Tasks)
linux·单片机·嵌入式软件
网安老伯1 小时前
网络安全基础要点知识介绍(非常详细),零基础入门到精通,看这一篇就够了
运维·前端·网络协议·web安全·网络安全·职场和发展
迷茫中的自我1 小时前
GitHub Actions自动化运维实战:从CI/CD到云原生部署
运维·自动化·github
XH华1 小时前
Linux系统第二章:常见的Linux指令(上)
linux·运维·服务器