Skip to content

epoll 与 Reactor 并发模型详解 ​

Linux 高性能服务器绕不开两个主题:IO 多路复用(select/poll/epoll)和基于它的并发模型(Reactor/Proactor)。本文以经典 C++ 网络编程学习项目 TinyWebServer 采用的「单 Reactor 多线程」为例,把这两块串联讲清:模型怎么分工、epoll 为什么快、ET/LT 怎么选、以及 EPOLLONESHOT、线程安全这些常被追问的细节。

单 Reactor 多线程模型怎么运转 ​

以 TinyWebServer 为例,整体结构是「主线程监听 IO 事件 + 线程池做业务处理」:

  1. 主线程用 epoll 监听套接字上的 IO 事件;
  2. 收到新连接请求时,accept 建立连接套接字,创建对应的连接对象与定时器(超时未活动则关闭连接),并把 fd 加入 epoll 管理;
  3. 收到已连接客户的读写请求时,延长该连接的定时器有效期,并按事件类型向线程池任务队列投递任务——EPOLLIN 事件投递 read 任务,EPOLLOUT 事件投递 write 任务;
  4. 主线程同时维护一个小顶堆定时器,删除超时节点,实现应用层保活;
  5. 线程池中的工作线程被唤醒后从任务队列取任务:read 任务读取数据(ET 模式)并做后续业务处理(如用状态机解析 HTTP 报文),write 任务把生成的响应分散写回 socket;
  6. 任务结束前用 epoll_ctl 调整 fd 状态:决定是否关闭连接(Keep-Alive)、继续监听哪个方向的事件。

Reactor 只负责事件分发:连接建立事件交给 Acceptor 处理(accept 获取连接并创建 Handler),读写事件交给该连接对应的 Handler;Handler 不做业务,把收到的数据交给子线程里的 Processor,处理完的结果再经 Handler 发回客户端。

关于线程池大小的经验值:CPU 密集型任务线程数取核数(四核取 4,最多 +1 余量);IO 密集型任务线程数一般多于核数——线程间竞争的不是 CPU 而是 IO,多于核数的线程能在部分线程等 IO 时让 CPU 继续干活。

主从 Reactor 与 Proactor ​

主从 Reactor 模型 ​

MainReactor 只监听连接建立事件,accept 后把新连接分配给某个子线程;子线程的 SubReactor 把连接加入自己的 epoll 继续监听并处理后续事件。

多 Reactor 多线程的方案虽然看起来复杂,但实际实现往往比单 Reactor 多线程更简单:

  • 主线程和子线程分工明确:主线程只接收新连接,子线程完成后续处理;
  • 交互简单:主线程只需要把新连接传给子线程,子线程无须返回数据,处理结果直接发给客户端。

Proactor 模型 ​

两者的本质区别:

  • Reactor 是非阻塞同步网络模式,感知的是「就绪可读写」事件。每次感知到可读就绪后,需要应用进程主动调用 read 把数据从内核缓冲区搬进用户内存,这个过程是同步的,读完才能处理。
  • Proactor 是异步网络模式,感知的是「已完成」的读写事件。发起异步读写时传入数据缓冲区地址,内核自动完成读写并通知应用进程直接处理数据——读写全程由操作系统完成。

Linux 原生的异步 IO(AIO)并不成熟,实际项目里的「Proactor」多是「Reactor + 非阻塞 IO 模拟」出来的。

为什么用 epoll?与 select/poll 的区别 ​

select/poll 的问题 ​

select 把所有已连接的 Socket 放进一个文件描述符集合,调用 select 时把集合拷贝进内核,内核遍历检查每个 fd 有无事件,标记后把整个集合拷贝回用户态,用户态再遍历一遍找到就绪的 fd。于是:

  1. 每次调用要做 2 次遍历(内核态一次、用户态一次)和 2 次拷贝(进出内核);
  2. select 用固定长度的 BitsMap 表示 fd 集合,个数受 FD_SETSIZE 限制(Linux 默认 1024,只能监听 0~1023)。

poll 改用 pollfd 结构数组,突破了个数限制(仍受系统 fd 上限约束),但没有本质区别:仍用线性结构存储 fd 集合,仍然要遍历、要拷贝,时间复杂度 O(n)。并发数上升后,每次调用的遍历与拷贝开销线性叠加,性能急剧下降。

epoll 的做法 ​

epoll 在内核里用红黑树跟踪进程所有待检测的 fd:epoll_ctl 把 socket 加入红黑树(增删改 O(log n))。select/poll 内核不保存 fd 集合,每次都要整份传入;epoll 因为有这棵内核事件表,每次只需要操作单个 fd,省掉了大量拷贝和内存分配。

epoll 用事件驱动:内核维护一个就绪链表,fd 上有事件时通过回调函数把它挂进就绪链表。epoll_wait 只返回有事件的 fd,不需要轮询扫描整个集合。

补充两点:

  • select/poll 只能工作在相对低效的 LT 模式,epoll 同时支持 LT 和 ET;
  • 监听的 fd 数量少且都很活跃时,三者差别不大;fd 多而单位时间只有部分活跃时(典型的高并发服务器),epoll 优势明显。

epoll 为什么选红黑树 ​

epoll 需要在内核中长期维护一个存放所有待检测 fd 的数据结构,插入(EPOLL_CTL_ADD)、查找、删除操作频繁,需要一种三者效率都不错的结构,红黑树(增删查 O(log n))是合适的选择。也因为有这棵内核事件表,epoll_wait 不必每次重新拷贝一遍所有 fd 进内核态。

边缘触发如何判断数据读完了 ​

两种做法:

  1. 针对 TCP:调用 recv,若返回值小于设定的 recv buffer 大小,可认为本次接收完毕(对端可能还有后续数据,不严格);
  2. TCP/UDP 通用(推荐):先用 fcntl 把 socket 设为非阻塞,可读时循环 read/recv,直到返回 -1 且 errno 为 EAGAIN 或 EWOULDBLOCK——缓冲区已空,本次数据读完。

客户端断开时 epoll 监听到什么 ​

客户端正常断开(调用 close())时,服务器端会触发 epoll 事件:

  • 早期内核:事件是 EPOLLIN(0x1),连接可读。上层在对端已 close() 的连接上读请求只能读到 EOF,靠 EOF 判断对端关闭;
  • 后期内核增加了 EPOLLRDHUP(0x2000),代表对端断开连接或半关闭。此时触发的事件是 EPOLLIN | EPOLLRDHUP(0x2001),对端断开的处理可以在底层直接进行,不用再移交上层。

epoll 的线程安全 ​

简要结论:epoll 通过锁保证线程安全。粒度最小的**自旋锁 ep->lock(spinlock)**保护就绪队列,**互斥锁 ep->mtx(mutex)**保护红黑树等核心数据结构:

  • epoll_ctl():ep_insert() / ep_remove() 等操作自身数据结构前先持有 ep->mtx;
  • epoll_wait():持有自旋锁 ep->lock 保护就绪队列。

水平触发(LT)与边缘触发(ET) ​

水平触发(LT) ​

  1. 只要 socket 缓冲区还有数据,epoll_wait 就会一直触发,直到缓冲区读空;
  2. LT 是 epoll 的默认工作模式;
  3. 优缺点:优点是保证数据完全读取;缺点是数据量大时用户态/内核态切换频繁,消耗资源;
  4. 适用:连接请求少、客户端单次数据量小的场景——一次性收完所有数据,此时若用边缘触发反而多一次「读到 EAGAIN」的判断调用。

边缘触发(ET) ​

  1. 只有套接字事件状态改变(新事件到来)时才触发;
  2. ET 必须配合非阻塞 IO:每次 epoll_wait 触发后,在 while 循环里非阻塞地读到缓冲区为空;
  3. 优缺点:优点是一次触发就能读完缓冲区所有数据,效率高;缺点是数据量很小时至少要调用两次 IO 函数(最后一次读到 EAGAIN),而 LT 只需一次。

怎么选 ​

一种常见的取舍是:任何情况都优先选「ET + 非阻塞 IO」。理由:连接多、数据量大时,ET 一次触发即可读空缓冲区,效率优势明显;连接少、数据量小时,服务本身性能压力不大,ET 多出的那次「读到 EAGAIN」开销可以忽略——同一个选择同时覆盖两种负载。

LT 也需要非阻塞 IO ​

无论 LT 还是 ET,都应该用非阻塞 IO。反证:假设 LT 用阻塞 read 且要求一次读满 20 字节,客户端只发了 10 字节,线程就会阻塞在 read 里等剩余 10 字节;但线程阻塞后无法回到 epoll_wait 监听该连接的下一次就绪——客户端后续数据再也无法接收,read 永远凑不够 20 字节,逻辑上死锁。

ET 的道理相同且更直接:ET 是无限循环读直到 EAGAIN;阻塞 IO 在缓冲区读空时会把整个线程阻塞在 read 上,其他监听事件全部无法处理。

EPOLLONESHOT ​

即使使用 ET 模式,一个 socket 上的事件仍可能被触发多次:一个线程读某 socket 的数据并处理期间,socket 上又有新数据可读(EPOLLIN 再次触发),另一个线程被唤醒来读——两个线程同时操作一个 socket。

「一个连接在任意时刻只被一个线程处理」可以用 EPOLLONESHOT 实现:注册了 EPOLLONESHOT 的 fd,操作系统最多触发其注册的一个可读、可写或异常事件,且只触发一次,除非用 epoll_ctl 重置。这样处理期间其他线程没有机会碰这个 socket。注意配套动作:该线程处理完后应立即重置 EPOLLONESHOT,否则这个 socket 的后续事件再也不会被触发。

要点 ​

  • 单 Reactor 多线程:主线程 epoll_wait + 事件分发,线程池做业务;主从 Reactor 用「MainReactor 只管 accept、SubReactor 管已有连接」简化交互
  • Reactor 感知「就绪」、应用自己读;Proactor 感知「完成」、内核代读——Linux 上多为模拟实现
  • epoll 的两个关键设计:内核红黑树长期持有 fd 集合(免重复拷贝)+ 就绪链表回调(免全量遍历)
  • ET/LT 都要配非阻塞 IO;负载不确定时「ET + 非阻塞」是通用选择
  • 多线程操作同一连接用 EPOLLONESHOT 串行化,处理完记得重置
最近更新

基于 VitePress 构建