Linux高级IO

目录

一、五个模型

[1. 阻塞 I/O](#1. 阻塞 I/O)

[2. 非阻塞 I/O](#2. 非阻塞 I/O)

[3. 信号驱动 I/O](#3. 信号驱动 I/O)

[4. I/O 多路转接/复用](#4. I/O 多路转接/复用)

[5. 异步 I/O](#5. 异步 I/O)

[6. 同步通信 vs 异步通信(⭐⭐⭐)](#6. 同步通信 vs 异步通信(⭐⭐⭐))

[6.1 同步通信](#6.1 同步通信)

[6.2 异步通信](#6.2 异步通信)

二、fcntl实现SetNoBlock

三、多路转接select

[1. select函数参数](#1. select函数参数)

[1.1 FD 集合参数 (readfds, writefds, exceptfds)](#1.1 FD 集合参数 (readfds, writefds, exceptfds))

[1.2 超时参数 (timeout)](#1.2 超时参数 (timeout))

[1.3 fd_set 结构体](#1.3 fd_set 结构体)

[1.4 timeval 结构体](#1.4 timeval 结构体)

[2. select的就绪条件](#2. select的就绪条件)

[2.1 读就绪](#2.1 读就绪)

[2.2 写就绪](#2.2 写就绪)

[2.3 异常就绪](#2.3 异常就绪)

[3. select服务器的简单建立(⭐⭐⭐)](#3. select服务器的简单建立(⭐⭐⭐))

[3.1 引入 fd_array的必要性](#3.1 引入 fd_array的必要性)

[3.2 SelectServer 核心运作流程](#3.2 SelectServer 核心运作流程)

[3.2.1 初始化花名册与监听套接字](#3.2.1 初始化花名册与监听套接字)

[3.2.2 重置位图与寻找最大 FD(第一次遍历)](#3.2.2 重置位图与寻找最大 FD(第一次遍历))

[3.2.3 陷入阻塞,等待内核通知](#3.2.3 陷入阻塞,等待内核通知)

[3.2.4 事件分发与处理(第二次遍历)](#3.2.4 事件分发与处理(第二次遍历))

4.select的优缺点

[4.1 优点](#4.1 优点)

[4.2 缺点](#4.2 缺点)

[4.2.1 连接数存在硬性上限](#4.2.1 连接数存在硬性上限)

[4.2.2 繁琐的"参数重置"开销](#4.2.2 繁琐的“参数重置”开销)

[4.2.3 高昂的"用户态 ⇌ 内核态"内存拷贝成本](#4.2.3 高昂的“用户态 ⇌ 内核态”内存拷贝成本)

[4.2.4 盲目的 O(N) 线性遍历(最大的性能瓶颈)](#4.2.4 盲目的 O(N) 线性遍历(最大的性能瓶颈))

四、多路转接poll

[1. poll函数参数](#1. poll函数参数)

[2. struct pollfd](#2. struct pollfd)

[3. 核心事件宏 (Events)](#3. 核心事件宏 (Events))

[4. poll 的运转逻辑(对比 select)](#4. poll 的运转逻辑(对比 select))

[4.1 初始化阶段:结构体数组](#4.1 初始化阶段:结构体数组)

[4.2 等待阶段:告别重置](#4.2 等待阶段:告别重置)

[4.3 事件分发阶段:位运算](#4.3 事件分发阶段:位运算)

[4.4 连接的动态增删](#4.4 连接的动态增删)

[5. poll 的优缺点](#5. poll 的优缺点)

[5.1 优点](#5.1 优点)

[5.2 缺点](#5.2 缺点)

五、多路转接epoll (⭐⭐⭐)

[1. epoll 的三大核心接口](#1. epoll 的三大核心接口)

[1.1 epoll_create](#1.1 epoll_create)

[1.2 epoll_ctl](#1.2 epoll_ctl)

[1.3 epoll_wait](#1.3 epoll_wait)

[2. 数据结构](#2. 数据结构)

[2.1 struct epitem 结构体](#2.1 struct epitem 结构体)

[2.1.1 rbn 的作用:O(log N) 的增删改查](#2.1.1 rbn 的作用:O(log N) 的增删改查)

[2.1.2 rdllink 的作用:O(1) 的就绪转移](#2.1.2 rdllink 的作用:O(1) 的就绪转移)

[2.1.3 event 的作用:原封不动的物归原主](#2.1.3 event 的作用:原封不动的物归原主)

[2.2 epoll_event 结构体](#2.2 epoll_event 结构体)

[2.2.1 events:事件掩码(情报类型)](#2.2.1 events:事件掩码(情报类型))

[2.2.2 data:联合体](#2.2.2 data:联合体)

[3. 工作原理(⭐⭐⭐)](#3. 工作原理(⭐⭐⭐))

[3.1 epoll_create 的内核布局](#3.1 epoll_create 的内核布局)

[3.2 epoll_ctl 的暗箱操作](#3.2 epoll_ctl 的暗箱操作)

[3.3 epoll_wait 的守株待兔](#3.3 epoll_wait 的守株待兔)

[3.4 网卡发力与回调爆发](#3.4 网卡发力与回调爆发)

[3.5 唤醒收割](#3.5 唤醒收割)

[4. epoll简单使用](#4. epoll简单使用)

六、对比LT和ET(⭐⭐⭐)

[1. 核心触发机制](#1. 核心触发机制)

[2. 对Socket 属性的硬性要求](#2. 对Socket 属性的硬性要求)

[3. 代码编写范式的区别](#3. 代码编写范式的区别)

[4. 性能与系统调用的较量](#4. 性能与系统调用的较量)


一、五个模型

我们必须先牢记一个核心前提。一次完整的网络 I/O 读取操作(如 recv 或 read),在底层其实分为两个完全不同的阶段:

  1. 阶段一:等待数据准备就绪(等网卡收到报文,并由内核放入接收缓冲区)。

  2. 阶段二:将数据从内核拷贝到用户态 (把内核缓冲区的数据,强行搬运到你代码里的 char buffer[] 中)。

1. 阻塞 I/O

这是最原始、最简单的模型,也是所有套接字的默认状态。

  • 运转逻辑: 进程调用 recv,如果缓冲区没数据,进程就一直死等(休眠)。等数据来了(阶段一完成),进程醒来,内核开始把数据拷贝到用户态(阶段二),拷贝完后 recv 才返回。

  • 特点: 两个阶段全程阻塞。对 CPU 非常不友好,只能处理单并发。

2. 非阻塞 I/O

  • 运转逻辑: 你把套接字设置为非阻塞(O_NONBLOCK)。调用 recv 时,如果没数据,内核不让你休眠,而是立刻返回一个错误码(EAGAIN / EWOULDBLOCK)。你只能写个 while 循环不断去问。当终于问到数据准备好了,在执行阶段二(拷贝数据)时,进程依然是阻塞的。

  • 特点: 阶段一不阻塞,阶段二阻塞。不停地轮询会导致 CPU 占用率极高(Busy Wait),极少单独使用。

3. 信号驱动 I/O

  • 运转逻辑: 给套接字注册一个信号处理函数(比如 SIGIO)。然后进程去干别的事,完全不阻塞。当内核发现数据准备好了,会发送一个信号给你。你收到信号后,再调用 recv 把数据拷贝出来。

  • 特点: 阶段一完全不阻塞,靠异步信号通知;阶段二依然是阻塞的。在实际的 TCP 网络编程中极少使用,因为 TCP 产生信号的条件太复杂了,很难区分到底是因为读就绪还是写就绪。

4. I/O 多路转接/复用

  • 运转逻辑: 进程阻塞在 select 或 epoll_wait 上,而不是阻塞在真实的 I/O 系统调用(如 recv)上。虽然也是阻塞,但它的好处在于它能同时监视成千上万个套接字 。只要有一个数据好了,它就返回,你再针对那个就绪的套接字调用 recv 取数据。

  • 特点: 两个阶段依然全是阻塞的。但它通过一次阻塞管理了海量连接,是当前高并发服务器(Nginx, Redis)的绝对中流砥柱。

5. 异步 I/O

  • 运转逻辑: 进程调用异步读取接口(如 aio_read),告诉内核:"这里有 1024 字节的空间,如果有数据,你帮我读出来,并且帮我填到这个空间里,全搞定后直接发个通知或执行个回调告诉我。" 进程调用完立刻返回,继续干别的事。内核全自动完成阶段一(等数据)和阶段二(拷贝数据),完事后才通知用户。

  • 特点: 阶段一和阶段二全部非阻塞。这是最完美的模型。

6. 同步通信 vs 异步通信(⭐⭐⭐)

6.1 同步通信

  • 转逻辑: 发送方发出请求后,必须停下来原地等待,直到接收方处理完毕并返回结果,发送方才能继续执行下一步。

  • 技术场景:

    • 传统的 HTTP 请求(浏览器转圈圈等服务器返回)。

    • 绝大多数的 RPC(远程过程调用)调用,如 Dubbo、gRPC 的默认模式。

    • 数据库的增删改查(执行 SQL 后等结果返回)。

  • 优点: 逻辑简单直观: 代码是从上往下写的,符合人类的线性思维。

强一致性: 这一步做完才做下一步,绝对不会乱。

  • 致命缺点: 阻塞等待,浪费资源: 如果接收方处理很慢(或者网络卡顿),发送方的线程就会一直被挂起,白白消耗系统资源。

    • 级联雪崩: A 调 B,B 调 C。如果 C 挂了,B 会卡死,接着 A 也会卡死,最后整个系统崩溃。

6.2 异步通信

  • 运转逻辑: 发送方发出请求后,立刻返回,继续去干别的事 。接收方收到请求后慢慢处理,处理完之后,再通过状态、通知或回调机制来告诉发送方结果。

  • 技术场景:

    • 各种消息队列 (Message Queue) 架构,如 Kafka、RabbitMQ、RocketMQ。

    • 前端 AJAX 的异步回调、Node.js 的事件驱动机制。

    • 发送短信验证码、后台音视频转码、生成大量报表等极其耗时的任务。

  • 优点

    • 极高的吞吐量: 发送方无需等待,可以疯狂发送请求,极大提升了并发能力。

    • 解耦与削峰: 通过引入消息队列,前端的洪峰流量可以被缓存下来,后端的服务按照自己的节奏慢慢消费,系统极其稳定。

  • 致命缺点:

    • 编程复杂度飙升: 逻辑被割裂了。发送请求的代码和处理结果的代码不在同一个地方(回调地狱)。

    • 最终一致性: 无法立刻知道结果,不适合需要严格实时强一致性的金融级核心交易场景。

二、fcntl实现SetNoBlock

cpp 复制代码
#include <iostream>
#include <fcntl.h>   // fcntl 所在的头文件
#include <unistd.h>

// 将指定的文件描述符设置为非阻塞模式
// 成功返回 true,失败返回 false
bool SetNoBlock(int fd) {
    // 第一步:获取该文件描述符当前的所有的状态标志(File Status Flags)
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0) {
        std::cerr << "fcntl F_GETFL failed for fd: " << fd << std::endl;
        return false;
    }

    // 第二步:在原有标志的基础上,按位或(|)加上非阻塞标志 O_NONBLOCK
    // 然后将新的标志集合设置回该文件描述符
    if (fcntl(fd, F_SETFL, fl | O_NONBLOCK) < 0) {
        std::cerr << "fcntl F_SETFL failed for fd: " << fd << std::endl;
        return false;
    }

    return true;
}
  • 先用 F_GETFL 拿到当前的属性清单存入变量 fl 中。

  • 利用位运算 fl | O_NONBLOCK,意思是:"保留之前的全部属性,只是在这个基础上追加一个非阻塞的标签",然后再用 F_SETFL 塞回给内核。

三、多路转接select

1. select函数参数

它的核心作用是让进程监视多个文件描述符(FD),直到其中一个或多个变为"就绪"状态。

cpp 复制代码
函数原型:int select(int nfds, fd_set *readfds, fd_set *writefds, 
                            fd_set *exceptfds, struct timeval *timeout);
  • nfds(输入):监视的文件描述符范围。通常设置为所有集合中最大 FD 值加 1。
  • readfds(输入/输出 ):监视可读事件的集合(如:缓冲区有数据、连接关闭)。
  • writefds(输入/输出 ):监视可写事件的集合(如:发送缓冲区有空间)。
  • exceptfds(输入/输出 ):监视异常条件的集合(如:带外数据 OOB)。
  • timeout(输入/输出):输入输出型参数,调用时由用户设置select的等待时间,返回时表示timeout的剩余时间。

1.1 FD 集合参数 (readfds, writefds, exceptfds)

  • 作为输入: 你通过这些集合告诉内核:"请帮我关注这些文件描述符"。

  • 作为输出: 当函数返回时,内核会修改这些集合,只保留那些真正发生了事件的描述符。

    注意: 正因为集合会被修改,所以每次在循环中调用 select 之前,你必须重新初始化(重新设置)这些集合。

1.2 超时参数 (timeout)

  • 作为输入: 告诉内核最长等多久。

  • 作为输出: 在某些 Linux 系统上,函数返回时会修改此结构体,反映剩余的等待时间。为了跨平台兼容,建议每次循环都重新给它赋值。

1.3 fd_set 结构体

fd_set 本质上是一个位图(Bitmap),每一位(bit)对应一个文件描述符(FD)。

cpp 复制代码
typedef struct {
    long fds_bits[FD_SETSIZE / (8 * sizeof(long))];
} fd_set;
  • FD_SETSIZE :这是一个系统定义的宏,通常是 1024 。这意味着 select 默认最多只能监视 1024 个文件描述符。

  • 位操作 :如果你把文件描述符 5 加入集合,fd_set 内部数组的第 5 个 bit 就会被置为 1。

FD_ZERO(&set):清空所有位(全部设为 0)。

FD_SET(fd, &set):把特定位设为 1(添加监视)。

FD_CLR(fd, &set):把特定位设为 0(取消监视)。

FD_ISSET(fd, &set):检查特定位是否为 1(判断是否就绪)。

1.4 timeval 结构体

struct timeval 用于指定 select 等待的截止时间。

cpp 复制代码
struct timeval {
    long tv_sec;  // 秒 (seconds)
    long tv_usec; // 微秒 (microseconds)
};

通过设置 timeout 参数的不同取值,select 会表现出截然不同的行为:

  1. 永久阻塞 (timeout = NULL): select 会一直等下去,直到至少有一个 FD 就绪或被信号中断。

  2. 完全非阻塞 (tv_sec = 0, tv_usec = 0): select 立即检查所有 FD 状态并返回,不管有没有就绪的,都不会等待。

  3. 限时等待 (tv_sec > 0 或 tv_usec > 0): 在指定时间内等待。如果在 5 秒内有数据,立即返回;如果 5 秒到了还没数据,返回 0。

2. select的就绪条件

"读就绪就是有数据,写就绪就是能发数据",但实际上,"就绪"的真正含义是:此时对该文件描述符(FD)调用对应的 I/O 函数(如 read、write、accept),一定不会被阻塞。 select 告诉你"就绪",只是承诺你去操作不会卡住(阻塞),但不承诺操作一定成功,所以配套的返回值检查是必不可少的。

2.1 读就绪

当一个Socket被标记为"读就绪"并从 select 返回时,意味着它满足以下 四个条件之一:

  • 接收缓冲区有数据: 套接字接收缓冲区中的数据字节数,大于等于接收缓冲区的低水位标记 (SO_RCVLOWAT)。对于 TCP 和 UDP 套接字,默认的读低水位标记通常是 1 字节 。此时调用 read/recv 会返回大于 0 的值。

  • 对端关闭连接(收到 FIN): 如果客户端正常断开连接(或者调用了 close/shutdown),内核会收到一个 FIN 包。此时套接字处于读就绪状态,调用 read/recv 不会阻塞,而是立即返回 0。

  • 监听套接字有新连接: 对于作为服务器的监听套接字(Listening Socket),如果底层已经完成了 TCP 三次握手,有一个新的连接待处理,它就会被标记为读就绪。此时调用 accept 不会阻塞。

  • 套接字发生错误: 如果套接字上发生了一个挂起的错误(例如收到了 RST 报文),它也会被标记为读就绪。此时调用 read/recv 会立即返回 -1 ,并且系统会设置 errno(例如 ECONNRESET)。

2.2 写就绪

写就绪通常是最容易忽视的,因为大多数时候 TCP 发送缓冲区都是有空间的。写就绪意味着满足以下 四个条件之一:

  • 发送缓冲区有空间: 套接字发送缓冲区的可用空间字节数,大于等于发送缓冲区的低水位标记 (SO_SNDLOWAT)。对于 TCP 套接字,这个默认值通常是 2048 字节 。这意味着只要你的缓冲区没被塞满,select 就会一直告诉你"可以写"。

  • 连接的写半部被关闭: 如果你主动调用 shutdown(fd, SHUT_WR) 关闭了写的方向,或者收到了某些致命错误。此时套接字也是"写就绪"的,但如果你真的去调用 write,非但写不进去,还会触发 SIGPIPE 信号(这会导致进程崩溃,除非你忽略了该信号)。

  • 非阻塞 connect 完成: 在进行异步网络编程时,如果你对一个设置为非阻塞 的套接字调用 connect,它会立即返回 EINPROGRESS。当底层的三次握手成功,或者因为超时/被拒绝而失败时,该套接字会变为写就绪。

  • 套接字发生错误: 与读就绪类似,发生挂起错误时,套接字也会变为写就绪。此时调用 write/send 会立即返回 -1 ,并设置 errno。

避坑指南: 绝不要像监视读事件那样,一开始就把所有 FD 都加入 writefds 中。因为绝大多数情况下发送缓冲区都是不满的,这会导致 select 每次都立即返回(形成 CPU 空转/Busy Loop)。正确做法是:只有当你想发数据,但调用 write 返回 EAGAIN(表示缓冲区满了)时,才将该 FD 加入 writefds,等 select 通知你有空间了,再去继续发。

2.3 异常就绪

在 Linux/Unix 的网络编程中,exceptfds 中的异常绝大多数情况下指的并不是普通的 TCP 错误(如断网、超时)。普通的网络错误都是通过"读/写就绪 + read/write 返回 -1"来体现的。满足异常就绪通常只有以下几种极其罕见的情况:

  • 收到带外数据 (Out-of-Band Data, OOB): 这是 TCP 协议中的一个特殊标志位(URG)。发送方可以发送带有紧急指针的数据。当网络层收到这种"带外数据"时,对应的套接字会在 select 中被标记为异常就绪。

  • 伪终端控制状态改变: 在某些特定的伪终端(pty)主设备状态发生改变时。这与常规的 Socket 网络编程无关。

对于绝大多数的 Web 服务器和网络应用开发,几乎永远不需要把套接字加入到 exceptfds 参数中,直接传 NULL 或 nullptr 即可。

3. select服务器的简单建立(⭐⭐⭐)

3.1 引入 fd_array的必要性

fd_set 每次循环都会被内核"破坏",就必须在循环之外找一个安全的地方 ,把我们到底想监视哪些 FD 给永久记录下来。这就是引入第三方数组的根本原因。我们可以把 fd_array 形象地比喻为服务器的"客户花名册"。

  • 入会(新连接到来): 调用 accept 获取新 FD,找个空位塞进 fd_array。

  • 退会(客户端断开): 调用 close 关闭 FD,并把 fd_array 对应位置设为无效值(通常是 -1)。

  • 点名(select 轮询): 每次调用 select 前,照着 fd_array 这个花名册,重新画一遍 fd_set 签到表。

没有这个花名册,服务器在一轮循环后就会彻底"失忆"。

3.2 SelectServer 核心运作流程

3.2.1 初始化花名册与监听套接字

在服务器启动前,我们需要将 fd_array 全部初始化为无效值 -1。随后,创建服务器的核心------监听套接字 (listensock) ,并将其作为我们的一号 VIP 客户,放入 fd_array[0] 中。

cpp 复制代码
// 初始化阶段
for (int i = 0; i < fd_num_max; i++) {
    fd_array[i] = defaultfd; // 全部设为 -1
}
fd_array[0] = listensock_.Fd(); // 将监听套接字加入花名册
3.2.2 重置位图与寻找最大 FD(第一次遍历)

进入死循环。在调用 select 之前,我们必须根据花名册重新生成位图。 同时,select 函数的第一个参数要求传入最大文件描述符的值加 1 (为了内核底层遍历位图时的边界判断)。因此,我们顺便在这次遍历中求出 maxfd。

cpp 复制代码
fd_set rfds;
FD_ZERO(&rfds); // 绝对不能忘:清空被内核污染的位图
int maxfd = fd_array[0];

for (int i = 0; i < fd_num_max; i++) {
    if (fd_array[i] == defaultfd) continue;
    
    FD_SET(fd_array[i], &rfds); // 照着花名册,重新设置位图
    
    if (maxfd < fd_array[i]) {
        maxfd = fd_array[i]; // 动态更新 maxfd
    }
}
3.2.3 陷入阻塞,等待内核通知

万事俱备,调用 select。此时当前线程交出 CPU 执行权,进入休眠,直到我们监视的 FD 中有事件发生(或者超时)。

cpp 复制代码
// 这里的 timeout 也是输入输出型参数,为了跨平台稳定,每次循环也要重新赋值!
struct timeval timeout = {3, 0}; 
int n = select(maxfd + 1, &rfds, nullptr, nullptr, &timeout);
3.2.4 事件分发与处理(第二次遍历)

当 select 返回大于 0 的值时,说明有事件发生了。此时的 rfds 已经被内核修改,里面位值为 1 的就是"就绪"的套接字。 再次遍历花名册,拿着每个 FD 去测试 rfds!

cpp 复制代码
for (int i = 0; i < fd_num_max; i++) {
    int fd = fd_array[i];
    if (fd == defaultfd) continue;

    // 使用 FD_ISSET 检查内核是否保留了该 FD 的标记
    if (FD_ISSET(fd, &rfds)) {
        if (fd == listensock_.Fd()) {
            // 情况 A:监听套接字就绪,说明有新客户端连接!
            Accepter(); 
        } else {
            // 情况 B:普通套接字就绪,说明客户端发数据来了,或者退出了!
            Recver(fd, i); 
        }
    }
}
  • Accepter(增加监视): 当获取到新的 sock 时,遍历 fd_array 找到一个值为 -1 的位置填进去。这就意味着,在下一次大循环开始时,这个新的套接字就会被 FD_SET 自动加进位图中!

  • Recver(取消监视): 当 read 返回 0(对端关闭)或发生错误时,调用 close(fd),同时执行 fd_array[i] = -1。这就意味着,该客户端从花名册中被除名,下一次循环不再被监视。

4.select的优缺点

4.1 优点

  • 极致的跨平台通用性 select 几乎被所有的主流操作系统所支持,包括所有的 Unix/Linux 分支、macOS,甚至 Windows(Winsock 提供了高度兼容的 select 实现)。如果你需要写一个在任何平台上都能直接编译运行的网络模块,select 通常是唯一的保底选择。

  • 微秒级的超时精度 select 的 timeout 参数使用的是 struct timeval,理论上可以支持微秒(us)级别的精细控制(尽管实际精度受限于系统时钟)。相比之下,后来的 poll 函数只支持毫秒(ms)级。

  • 概念相对简单明了 对于处理少量固定连接(比如十几个、几十个以内的终端或本地进程间通信),select 的位图机制和逻辑非常直接,不需要像 epoll 那样维护复杂的红黑树和事件队列。

4.2 缺点

在面对现代互联网动辄"C10K"(单机万级并发)的挑战时,select 暴露出以下四个致命缺陷:

4.2.1 连接数存在硬性上限

原理 :select 使用 fd_set 位图来保存文件描述符。在大多数 Linux 系统中,FD_SETSIZE 宏被硬编码为 1024。

痛点:这意味着一个进程默认最多只能监视 1024 个文件描述符(还要扣除标准输入、输出、错误等)。要想突破这个限制,必须修改内核头文件并重新编译系统内核,这在生产环境中是极其不现实的。

4.2.2 繁琐的"参数重置"开销

原理 :正如在博客中反复强调的,fd_set 是输入输出型参数。

痛点 :每次调用 select 之前,开发者都必须手动清空位图,并遍历自己的 fd_array 把感兴趣的 FD 重新"画"回位图中。这不仅让代码变得繁琐,也增加了 CPU 的无谓开销。

4.2.3 高昂的"用户态 ⇌ 内核态"内存拷贝成本

原理 :每次调用 select,都需要将完整的 fd_set 集合从用户态拷贝到内核态;当 select 返回时,内核又需要将修改后的 fd_set 集合从内核态拷贝回用户态。

痛点:即使这 1024 个连接中只有一个连接发来了数据,这来回两次的完整位图拷贝也是不可避免的。并发量越大,拷贝开销越恐怖。

4.2.4 盲目的 O(N) 线性遍历(最大的性能瓶颈)

内核底层的遍历 :内核在拿到位图后,不知道到底哪个 FD 有事件,只能从 0 一直遍历到 maxfd 去挨个检查底层硬件状态。

用户代码的遍历 :select 返回后,只告诉你"有 n 个事件就绪了",但不告诉你是哪几个。开发者必须再次写一个 for 循环,遍历整个 fd_array,用 FD_ISSET 挨个去试探。

痛点 :假设你有 1000 个活跃连接,但某一个瞬间只有 1 个客户端发了消息。为了找出这 1 个连接,内核和你的代码都要各自空转循环 1000 次。这种 O(N) 的复杂度导致 select 的性能随着连接数的增加呈断崖式下跌。

四、多路转接poll

1. poll函数参数

它的核心作用是让进程监视多个文件描述符(FD),直到其中一个或多个变为"就绪"状态。

cpp 复制代码
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds:这是一个数组的首地址 。你可以根据需要 new 或 malloc 任意大小的数组。
  • nfds:告诉内核,这个数组里当前有效 的元素有多少个(不需要像 select 那样算 maxfd + 1 了)。
  • timeout:超时时间,单位是毫秒 (ms)。

-1:永久阻塞等待;0:非阻塞,查完立刻返回;>0:限时等待。

2. struct pollfd

cpp 复制代码
struct pollfd {
    int   fd;         // 你要监视的文件描述符
    short events;     // 你希望内核监视的事件(输入型参数)
    short revents;    // 内核实际返回的就绪事件(输出型参数)
};

在 select 中,我们提过最大的痛点是 fd_set 作为输入输出型参数,每次调用都会被内核"破坏",导致必须引入第三方数组 fd_array 并每次重新循环赋值。 而 pollfd 巧妙地将"提问(events)"和"回答(revents)"分开了!

  • 每次调用 poll 时,内核只读取 events,不管 revents。

  • 当 poll 返回时,内核把结果写在 revents 里,绝对不会去动你的 events。

  • 结论:你再也不需要在 while 循环里每次重新设置关心的事件了!

3. 核心事件宏 (Events)

events 和 revents 的值是通过位掩码(按位或 |)来组合的。最常用的事件有:

  • POLLIN :读就绪(有数据可读、或者有新连接请求 accept)。相当于 select 的 readfds。

  • POLLOUT :写就绪(发送缓冲区有空间)。相当于 select 的 writefds。

  • POLLERR :发生错误(仅用于 revents 输出)。

  • POLLHUP :对端挂断(如对端关闭连接,仅用于 revents 输出)。

cpp 复制代码
if (fds[i].revents & POLLIN) {
    // 读事件发生了!
}

4. poll 的运转逻辑(对比 select)

4.1 初始化阶段:结构体数组

cpp 复制代码
_event_fds[0].fd = listensock;
_event_fds[0].events = POLLIN; // 告诉内核:盯着它的读事件!
  • select 的痛点 :因为 fd_set 会被内核破坏,必须额外维护一个 int fd_array[] 来记住历史连接。

  • poll 的解法 :在 PoolServer 构造函数和 Start 中,直接使用了结构体数组 struct pollfd _event_fds[fd_num_max]。

    • 这个结构体把"要监视的 FD (fd)"、"关心的事件 (events)"和"内核返回的事件 (revents)"全部绑定在了一起。

    • 初始化时,只需将 _event_fds[0] 设为监听套接字,并贴上 POLLIN(读就绪)的标签。

4.2 等待阶段:告别重置

  • select 的痛点 :每次 while(true) 循环开头,都要写一个 for 循环去 FD_ZERO 清空位图,然后再用 FD_SET 重新把数组里的 FD 挂载一遍,最后还要苦哈哈地计算 maxfd。

  • poll 的解法 :现在繁琐的准备工作**全部消失了。**直接一行代码调内核。

cpp 复制代码
for (;;) {
    // 不需要清空集合,不需要重新挂载,不需要算 maxfd!
    int n = poll(_event_fds, fd_num_max, timeout);
    // ... switch 处理返回值
}

4.3 事件分发阶段:位运算

  • select 的痛点 :必须遍历 fd_array,然后用 FD_ISSET(fd, &rfds) 去宏里查状态。

  • poll 的解法 :在你的 Dispatcher() 中,直接遍历 _event_fds 数组,通过位运算 & 来检查 revents 是否被内核打上了特定标记。

cpp 复制代码
for (int i = 0; i < fd_num_max; i++) {
    int fd = _event_fds[i].fd;
    if (fd == defaultfd) continue;

    // 直接按位与判断,POLLIN 表示读事件就绪
    if (_event_fds[i].revents & POLLIN) {
        // 处理新连接或新数据...
    }
}

4.4 连接的动态增删

新增连接 (Accepter) :当新客户端连入时,你遍历寻找 fd == -1 的空位。只需把新 sock 放进去,并必须配上 events = POLLIN ,下一次 poll 就会自动监视它。

cpp 复制代码
_event_fds[pos].fd = sock;
_event_fds[pos].events = POLLIN; // 【修复】一定要告诉内核,我们需要监视它的读事件!
PrintFd();

5. poll 的优缺点

5.1 优点

  1. 突破 1024 限制: poll 使用的是动态数组,连接数上限取决于系统能打开的文件描述符总数(通常可以配置到 10万 甚至更高),不再有 1024 的硬性编译限制。

  2. 告别参数重置噩梦: 读写事件分离(events 和 revents),代码结构更清晰,不需要每次调用前重置状态。

  3. 告别 maxfd + 1: 只需要告诉内核当前数组用了多少个位置即可,不用再维护最大的 FD 值。

5.2 缺点

poll 治标不治本,它仅仅优化了开发者的"接口体验",但底层的性能瓶颈一点都没解决。

  1. 依然是 O(N) 的线性轮询: 内核拿到你的 pollfd 数组后,依然要从头到尾挨个检查底层硬件状态;poll 返回后,你依然要写个 for 循环从头到尾挨个判断 revents。并发连接数一多(比如 5 万个连接),即使只有 1 个活跃,依然要空转循环 5 万次。

  2. 依然有巨大的内存拷贝开销: 每次调用 poll,整个 pollfd 数组都要在用户态和内核态之间来回拷贝一遍,数组越大,拷贝越慢。

五、多路转接epoll (⭐⭐⭐)

理解 epoll,我们先从一个比喻开始:

  • select/poll 的工作方式(大妈扫楼): 宿管大妈(内核)拿着 10 万个宿舍的名单,为了找出哪个宿舍漏水了,她必须从 1 楼爬到 100 楼,挨个敲门问(O(N) 遍历)。

  • epoll 的工作方式(智能报修): 宿管大妈在一楼大厅喝茶。哪个宿舍漏水了,那个宿舍的智能水表就会自动给大妈发个微信(基于硬件中断的回调机制)。大妈手机上收到几条微信,就直接去处理那几个宿舍,完全不用爬楼(O(1) 获取就绪事件)。

1. epoll 的三大核心接口

1.1 epoll_create

cpp 复制代码
int epoll_create(int size);
  • 作用: 在内核中创建一个 epoll 实例(大本营)。

  • 底层原理: 内核会为你创建一颗红黑树(Red-Black Tree) (用来高效存储和查找你要监视的 FD)和一个双向链表组成的就绪队列(Ready List)(用来存放已经发生事件的 FD)。

  • 返回值: 返回一个专门指向这个 epoll 实例的文件描述符(epfd)。这意味着,epoll 本身也是一个文件。

1.2 epoll_ctl

cpp 复制代码
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • 作用: 向 epoll 的红黑树 中添加、修改或删除你要监视的套接字。

  • 参数解析:

    • epfd:刚才 epoll_create 返回的句柄。

    • op:EPOLL_CTL_ADD (新增)、EPOLL_CTL_MOD (修改)、EPOLL_CTL_DEL(删除)。

    • fd:你要监视的具体网络套接字(比如 listensock)。

    • event:你要监视它的什么事件(如 EPOLLIN 读、EPOLLOUT 写)。

  • 核心: 在 select/poll 中,我们每次循环都要把整个数组传给内核(巨大的内存拷贝)。而在 epoll 中,一个套接字只需在刚连接时调用一次 EPOLL_CTL_ADD 添加进内核即可,之后内核会一直记着它,彻底消灭了毫无意义的重复拷贝!

1.3 epoll_wait

cpp 复制代码
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);
  • 作用: 阻塞等待事件发生。这是放在 while(true) 死循环里的唯一函数。

  • 参数解析:

    • events:这是一个纯输出型参数(用户态分配的一个空数组)。

    • maxevents:这个数组有多大。

  • 核心: 内核如果发现就绪队列里有东西,就会把就绪的事件按顺序 填入你的 events 数组中,并返回就绪的总个数 n。

  • 颠覆性的改变: 此时你的代码只需要遍历 0 到 n-1 即可。**返回的数组里,每一个绝对都是有真实事件发生的,绝没有滥竽充数的空位!,**彻底消灭了 O(N) 的空转遍历。

2. 数据结构

2.1 struct epitem 结构体

如果说 struct eventpoll 是 epoll 的"大本营",那么 struct epitem 就是大本营里为每一个 Socket 建立的"专属档案袋" 。每次调用 epoll_ctl(..., EPOLL_CTL_ADD, ...) 时,内核本质上就是 kmalloc 申请了一块内存,创建了一个 epitem 结构体,并把它挂到了红黑树上。

cpp 复制代码
struct epitem {
    // 1. 红黑树节点:靠它挂在 eventpoll 的红黑树上
    struct rb_node rbn;
    
    // 2. 双向链表节点:当事件发生时,靠它链接进 eventpoll 的就绪链表 (rdllist)
    struct list_head rdllink;
    
    // 3. 指向所属的 epoll 大本营
    struct eventpoll *ep;
    
    // 4. 封装了被监视的 FD 和底层 file 结构体指针
    struct epoll_filefd ffd;
    
    // 5. 等待队列里的回调钩子(极其关键)
    struct eppoll_entry *pwqlist;
    
    // 6. 保存用户关心什么事件 (EPOLLIN/EPOLLOUT等),以及绑定的用户数据 (data.fd)
    struct epoll_event event;
};
2.1.1 rbn 的作用:O(log N) 的增删改查

当调用 epoll_ctl(EPOLL_CTL_MOD) 想修改某个 FD 的事件,或者 EPOLL_CTL_DEL 想删除某个 FD 时,内核通过epitem 里的 rbn快速找到它。内核顺着 eventpoll 的红黑树根节点向下比较,以 O(log N) 的极快速度就能精准揪出这个 epitem。

当网卡数据到来,触发了 ep_poll_callback 回调函数时,回调函数手里拿到的就是这个特定 Socket 的 epitem 指针。

回调函数不需要去动红黑树,它只需要极其简单地执行一步链表操作:把当前 epitem 里的 rdllink 这个节点,通过指针操作插入到 eventpoll 的 rdllist(就绪链表)的尾部!

2.1.3 event 的作用:原封不动的物归原主

当 epoll_wait 被唤醒,准备向用户态拷贝数据时,它就是遍历 rdllist 链表上的这些 epitem。拷贝就是把 epitem->event 里的内容(你当初传给内核的关心的事件和 data.fd),原封不动地 copy_to_user 拷贝回你代码里的 events 数组中。

2.2 epoll_event 结构体

struct epoll_event 是用户态和内核态之间传递情报的"专属信封" 。在 epoll 的编程模型中,这个结构体的出场率是 100%。无论是你告诉内核"我要监视谁"(通过 epoll_ctl),还是内核告诉你"谁就绪了"(通过 epoll_wait),用的都是这个结构体。

cpp 复制代码
struct epoll_event {
    uint32_t     events;      // 你关心的 epoll 事件(如 EPOLLIN, EPOLLOUT, EPOLLET)
    epoll_data_t data;        // 用户数据(极其核心!内核不管里面是什么,原样返回)
} __attribute__ ((__packed__)); // 保证在不同平台上的内存对齐

// data 是一个联合体(Union)
typedef union epoll_data {
    void        *ptr;         // 指针(高阶用法,指向你的自定义对象)
    int          fd;          // 文件描述符(最基础、最常用的用法)
    uint32_t     u32;         // 32位整数
    uint64_t     u64;         // 64位整数
} epoll_data_t;
2.2.1 events:事件掩码(情报类型)

这是一个 32 位的无符号整数,通过按位或(|)来组合你要监视的事件。最常用的有:

  • EPOLLIN:表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭)。

  • EPOLLOUT:表示对应的文件描述符可以写。

  • EPOLLET :(极其重要) 将 epoll 的工作模式设置为边缘触发(Edge Triggered)。默认是水平触发(LT)。

  • EPOLLERR / EPOLLHUP :发生错误或挂断(内核会自动为你监视这些,通常不需要你在 epoll_ctl 时显式添加)。

2.2.2 data:联合体

这是 epoll 设计中最灵活的地方!内核对 data 里面的内容完全不关心,也不做任何解析 。它只负责一件事:你用 epoll_ctl 传进来什么,它在 epoll_wait 返回时就原封不动地还给你。

3. 工作原理(⭐⭐⭐)

3.1 epoll_create 的内核布局

当我们在用户态调用 epoll_create 时,内核(如图中上方所示)会创建一个核心结构体 struct eventpoll。你可以把它看作是 epoll 的"大本营"。它包含三个至关重要的组件:

  1. rbr (红黑树根节点, rb_root): 用来高效管理所有被监视的 socket 节点。保证增删改查的时间复杂度都是 O(log )。

  2. rdllist (就绪双向链表, list_head): 一个纯粹的就绪队列。这里面只存放真正发生了事件的 socket。

  3. wq (等待队列, wait_queue_head_t): 当没有事件发生时,调用 epoll_wait 的进程就会在这个队列上挂起休眠。

注意: epoll 实例本身也是一个文件,所以图中左侧的 fd_array 中,有一个专属的位置(比如 FD 3)是指向这个 eventpoll 对象的。

3.2 epoll_ctl 的暗箱操作

当我们调用 epoll_ctl(EPOLL_CTL_ADD) 将一个新的 Socket (比如 FD 4) 添加到 epoll 中时,内核会做两件极其关键的事:

  1. 上树: 将这个 Socket 包装成一个节点,挂载到大本营的 红黑树 (rbr) 上。

  2. 注册回调函数: 这是 epoll 碾压 select 的终极奥义!内核会在这个特定 Socket 的等待队列(图中的 sk_wq)上,悄悄注册一个回调函数 ------ ep_poll_callback。这就像是给这个 Socket 绑定了一个触发器,只要网卡一收到属于它的数据,这个触发器就会立刻爆炸!

3.3 epoll_wait 的守株待兔

用户态代码执行到 while(true) 循环里的 epoll_wait 时: 内核会直接去检查大本营里的 就绪链表 (rdllist)。

  • 如果链表里有节点,直接拿走返回。

  • 如果链表是空的(大部分情况),当前进程就会交出 CPU 控制权,挂载到大本营的 等待队列 (wq) 上陷入休眠,静静等待被唤醒。

3.4 网卡发力与回调爆发

如图中右下角所示,真正的关键在这个瞬间发生:

  1. 网络事件到达: 数据包通过网线到达网卡,网卡通过硬件中断通知 CPU。

  2. 数据入列: 内核将数据解析后,放入对应 Socket 的接收缓冲区中。

  3. 触发回调: Socket 的状态发生变化(变成读就绪),内核顺藤摸瓜找到之前"埋好的雷",直接触发了 ep_poll_callback 回调函数!

  4. 移入就绪队列(核心动作): 这个回调函数非常干脆,它顺着指针找到红黑树上的对应节点,一把将它拽下来,直接插入到就绪链表 (rdllist) 中!

3.5 唤醒收割

当 ep_poll_callback 把节点塞进就绪链表后,它立刻执行最后一步(图中最上方的蓝线箭头):调用 wake_up() 唤醒等待队列 (wq) 上休眠的进程 。进程醒来后,发现原本空空如也的 rdllist 里已经有东西了。

  • 内核不再需要像 select 那样去遍历整棵红黑树,而是直接把 rdllist 里的节点按顺序拷贝到用户态传入的 events 数组中。

  • 有几个节点,就拷贝几个,最后返回就绪的总数 n。

  • 时间复杂度:真正的 O(1) 获取! 没有一次空转,没有一点性能浪费。

4. epoll简单使用

cpp 复制代码
#pragma once

#include <iostream>
#include <sys/epoll.h> // 引入 epoll 专属头文件
#include <unistd.h>
#include "Socket.hpp"
using namespace std;

static const uint16_t defaultport = 8888;
static const int max_events = 100; // epoll_wait 一次最多拿回多少个就绪事件

class EpollServer
{
public:
    EpollServer(uint16_t port = defaultport) : port_(port), epfd_(-1) {}

    ~EpollServer() {
        if (epfd_ >= 0) close(epfd_); // epoll 句柄本身也是个文件,记得关
        listensock_.Close();
    }

public:
    bool Init() {
        listensock_.Socket();
        listensock_.Bind(port_);
        listensock_.Listen();

        // 1. 建立大本营:创建一个 epoll 实例
        // 参数 size 现代 Linux 中已被忽略,但必须大于 0
        epfd_ = epoll_create(256); 
        if (epfd_ < 0) {
            cerr << "epoll_create error" << endl;
            return false;
        }
        return true;
    }

    void Accepter() {
        std::string clientip;
        uint16_t clientport = 0;
        int sock = listensock_.Accept(&clientip, &clientport); 
        if (sock < 0) return;
        
        cout << "accept success, " << clientip << ":" << clientport << ", sock fd: " << sock << endl;

        // 【极其关键】:将新连接直接扔给内核的红黑树!
        // 不需要再去手写 for 循环找 fd_array 的空位了!
        struct epoll_event ev;
        ev.events = EPOLLIN; // 关心读事件
        ev.data.fd = sock;   // 绑定用户数据,等它就绪时,内核会把这个 fd 原封不动还给我

        epoll_ctl(epfd_, EPOLL_CTL_ADD, sock, &ev); 
    }

    void Recver(int fd) {
        char buffer[1024];
        ssize_t n = read(fd, buffer, sizeof(buffer) - 1);
        if (n > 0) {
            buffer[n] = 0;
            cout << "get a message on fd " << fd << ": " << buffer << endl;
        } else {
            if (n == 0) cout << "client quit, close fd: " << fd << endl;
            else cerr << "recv error on fd: " << fd << endl;

            // 【清理工作】:从内核红黑树中删掉它
            epoll_ctl(epfd_, EPOLL_CTL_DEL, fd, nullptr); 
            close(fd); 
        }
    }

    void Start() {
        // 先把监听套接字加进大本营
        int listensock = listensock_.Fd();
        struct epoll_event ev;
        ev.events = EPOLLIN;
        ev.data.fd = listensock;
        epoll_ctl(epfd_, EPOLL_CTL_ADD, listensock, &ev);

        // 准备一个空数组,专门用来接收内核弹出的"就绪事件"
        struct epoll_event revs[max_events]; 

        for (;;) {
            // 3. 坐等通知:只取有事件的 FD
            int timeout = 1000;
            int n = epoll_wait(epfd_, revs, max_events, timeout);

            switch (n) {
            case 0:
                // cout << "time out" << endl;
                break;
            case -1:
                cerr << "epoll_wait error" << endl;
                break;
            default:
                // 有事件就绪了!n 代表有几个就绪
                // 这里的循环只遍历 0 到 n-1,绝对没有空转!
                for (int i = 0; i < n; i++) {
                    int ready_fd = revs[i].data.fd;      // 拿回刚才绑定的 fd
                    uint32_t ready_events = revs[i].events; // 拿回具体的事件类型

                    if (ready_events & EPOLLIN) {
                        if (ready_fd == listensock_.Fd()) {
                            Accepter(); 
                        } else {
                            Recver(ready_fd); 
                        }
                    }
                }
                break;
            }
        }
    }

private:
    Sock listensock_;
    uint16_t port_;
    int epfd_; // epoll 的专属文件描述符
};

六、对比LT和ET(⭐⭐⭐)

1. 核心触发机制

这是两者最本质的区别,也就是我们之前提到的"啰嗦快递员"与"高冷快递员"的区别。

LT (Level Triggered - 水平触发):关注"状态"

  • 原理: 只要底层套接字的读/写缓冲区处于"就绪状态"(例如接收缓冲区里有数据,或者发送缓冲区有空间),epoll_wait 就会一直不断地唤醒并通知你。

  • 行为: 假设缓冲区里有 10KB 数据,你只读了 2KB,剩下的 8KB 只要还在里面,你下一次调用 epoll_wait,它还是会立刻返回告诉你"有数据可读"。

ET (Edge Triggered - 边缘触发):关注"变化"

  • 原理: epoll_wait 只有在底层套接字的状态发生"跳变"(Edge)时,才会触发一次通知。

  • 行为: 比如接收缓冲区从"空"变成了"有数据",或者对端又发送了"新的数据包"过来。如果缓冲区里有 10KB 数据,你只读了 2KB,只要没有新 的数据到达,你下次再调用 epoll_wait,它绝对不会再通知你,剩下的 8KB 数据就会永远沉睡在内核里。

2. 对Socket 属性的硬性要求

LT 模式:

  • 要求: 非常宽容。你可以使用默认的阻塞套接字(Blocking Socket),也可以使用非阻塞套接字。

  • 原因: 因为就算你这次没读完,下次还能接着读,不要求你一次性榨干缓冲区,所以不用担心被阻塞挂起。

ET 模式:

  • 要求: 必须配合非阻塞套接字(Non-blocking Socket)使用!

  • 原因: 因为 ET 模式"只通知一次",这倒逼你必须写一个死循环不断去 read,直到把缓冲区彻底榨干。如果在这个死循环中使用了阻塞套接字,当读完最后一点数据时,再调用一次 read,当前线程就会直接死锁卡住!

3. 代码编写范式的区别

LT 模式的代码范式(简单直白):

cpp 复制代码
// 收到通知后,随缘读一次,读到多少是多少
ssize_t n = read(fd, buffer, sizeof(buffer));
// 完事!没读完的交给下一次 epoll_wait 通知。

ET 模式的代码范式(严谨的榨干循环):

cpp 复制代码
// 收到通知后,必须死循环读,直到内核说"一滴都不剩了" (EAGAIN)
while (true) {
    ssize_t n = read(fd, buffer, sizeof(buffer));
    if (n < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            break; // 完美榨干,可以安心退出循环
        }
        // 处理其他真错误...
    }
    // 处理读到的数据...
}

4. 性能与系统调用的较量

LT 模式的性能隐患(惊群与频繁切换):

  • 如果数据包很大,开发者为了不阻塞其他连接,可能会限制每次 read 的长度。在 LT 模式下,这会导致 epoll_wait 被极其频繁地触发。每一次触发都伴随着昂贵的用户态到内核态的上下文切换,极大地浪费 CPU 资源。

  • 在写数据时更致命。因为发送缓冲区绝大多数时间都是有空间的,如果你把一个 Socket 的 EPOLLOUT 事件注册为 LT 模式,epoll_wait 会疯狂触发,形成可怕的 CPU 空转。

ET 模式的高性能奥秘:

  • 最大程度地减少了 epoll_wait 的调用次数。

  • 将多次系统调用合并为在用户态的一个 while 循环,极大地降低了内核态切换的开销。

  • Nginx、Redis、Node.js 等追求极致高并发的中间件,底层清一色采用 ET 模式 + 非阻塞 I/O 的架构。

相关推荐
律宏阔6 小时前
WSL Docker 端口明明空闲,但就是绑不上端口
linux·windows
律宏阔6 小时前
WSL 突然断网,无法 ping 内网或外网
linux·windows
穷人小水滴6 小时前
用容器编译 VirtualBox 虚拟机软件 (ArchLinux, podman)
linux·容器·virtualbox
乱码三千6 小时前
如何优雅地直连无公网 IP 的远程 GPU 服务器
linux·人工智能·程序员
yunwei376 小时前
使用 eBPF 跟踪 Nginx 请求
linux·后端·性能优化
yunwei376 小时前
使用 eBPF 跟踪 MySQL 查询
linux·后端·性能优化
yunwei376 小时前
eBPF 示例教程:使用 XDP 捕获 TCP 信息
linux·后端·性能优化
judezh6 小时前
api 容器一直 unhealthy?我把一个 Agent 运行时的健康检查逐条拆了,查出四个问题
运维·docker
GeW6 小时前
制造业高端化,为什么必须重估Linux和数据库?
linux
百万蹄蹄向前冲6 小时前
双端同步!云服务器装最新Node.js v26.10全过程追踪
服务器·人工智能·node.js