Linux I/O 多路复用:poll、epoll、Reactor 与 eventfd(C++ 面试复习版)
学习主线:
select → poll → epoll → Reactor → One Thread One Loop → eventfd。适用:C++ / Linux 软件开发、网络编程、后端开发岗位的笔试和面试。
先记住:
poll/epoll负责等待文件描述符就绪 ,read/recv/accept/write/send等负责实际 I/O 。就绪不等于 I/O 已经完成。
目录
- [Linux I/O 多路复用:poll、epoll、Reactor 与 eventfd(C++ 面试复习版)](#Linux I/O 多路复用:poll、epoll、Reactor 与 eventfd(C++ 面试复习版))
-
- [0. 先建立整体认识](#0. 先建立整体认识)
-
- [0.1 为什么需要 I/O 多路复用?](#0.1 为什么需要 I/O 多路复用?)
- [0.2 三个容易混淆的概念](#0.2 三个容易混淆的概念)
- [1. poll:I/O 多路复用接口](#1. poll:I/O 多路复用接口)
-
- [1.1 `poll()` 的概念与函数原型](#1.1
poll()的概念与函数原型) - [1.2 `poll()` 返回值与错误处理](#1.2
poll()返回值与错误处理) - [1.3 常用事件标志](#1.3 常用事件标志)
- [1.4 socket 什么时候算"就绪"?](#1.4 socket 什么时候算“就绪”?)
- [1.5 `poll()` 的执行过程](#1.5
poll()的执行过程) - [1.6 `poll` 相比 `select` 的优势与局限](#1.6
poll相比select的优势与局限) -
- [1.7 代码示例:使用 `poll` 监控标准输入](#1.7 代码示例:使用
poll监控标准输入)
- [1.7 代码示例:使用 `poll` 监控标准输入](#1.7 代码示例:使用
- [1.1 `poll()` 的概念与函数原型](#1.1
- [2. epoll:Linux 高并发 I/O 多路复用](#2. epoll:Linux 高并发 I/O 多路复用)
-
- [2.1 epoll 的概念](#2.1 epoll 的概念)
- [2.2 epoll 的三个核心系统调用](#2.2 epoll 的三个核心系统调用)
-
- [(1)创建 epoll 实例:`epoll_create1()`](#(1)创建 epoll 实例:
epoll_create1()) - (2)注册/修改/删除:`epoll_ctl()`
- (3)等待就绪:`epoll_wait()`
- [2.3 常见 epoll 事件](#2.3 常见 epoll 事件)
- [(1)创建 epoll 实例:`epoll_create1()`](#(1)创建 epoll 实例:
- [2.4 epoll 的内部工作原理](#2.4 epoll 的内部工作原理)
- [2.5 LT(水平触发)与 ET(边缘触发)](#2.5 LT(水平触发)与 ET(边缘触发))
-
- [(1)LT:Level Triggered,水平触发(默认)](#(1)LT:Level Triggered,水平触发(默认))
- [(2)ET:Edge Triggered,边缘触发](#(2)ET:Edge Triggered,边缘触发)
- [(3)LT 与 ET 对比](#(3)LT 与 ET 对比)
- [2.6 为什么 ET 通常必须搭配非阻塞?](#2.6 为什么 ET 通常必须搭配非阻塞?)
- [2.7 一个容易忽略的写事件问题](#2.7 一个容易忽略的写事件问题)
- [2.8 epoll 示例:单线程 TCP 服务器(只接收并打印)](#2.8 epoll 示例:单线程 TCP 服务器(只接收并打印))
- [3. select / poll / epoll 怎么选?](#3. select / poll / epoll 怎么选?)
- [4. Reactor:把 I/O 就绪通知组织成服务器架构](#4. Reactor:把 I/O 就绪通知组织成服务器架构)
-
- [4.1 什么是 Reactor?](#4.1 什么是 Reactor?)
-
- [4.2 Reactor 的常见组件](#4.2 Reactor 的常见组件)
- [4.3 单 Reactor 的基本流程](#4.3 单 Reactor 的基本流程)
- [5. One Thread One Loop(OTOL)及多核扩展](#5. One Thread One Loop(OTOL)及多核扩展)
-
- [5.1 什么是 One Thread One Loop?](#5.1 什么是 One Thread One Loop?)
- [5.2 多进程 Reactor:每个 Worker 一个 EventLoop](#5.2 多进程 Reactor:每个 Worker 一个 EventLoop)
-
- [5.3 多线程 Reactor:主线程接入,工作线程处理](#5.3 多线程 Reactor:主线程接入,工作线程处理)
- [5.4 单线程、多线程、多进程怎么选?](#5.4 单线程、多线程、多进程怎么选?)
- [6. eventfd:在线程间唤醒事件循环](#6. eventfd:在线程间唤醒事件循环)
-
- [6.1 什么是 eventfd?](#6.1 什么是 eventfd?)
- [6.2 函数与参数](#6.2 函数与参数)
-
- [6.3 `eventfd` 的两种模式](#6.3
eventfd的两种模式) - [6.4 最小示例:写通知并读取计数](#6.4 最小示例:写通知并读取计数)
- [6.5 eventfd、管道和 Unix 域套接字的区别](#6.5 eventfd、管道和 Unix 域套接字的区别)
- [6.3 `eventfd` 的两种模式](#6.3
- [7. Reactor 网络计算器案例:保留关键设计思路](#7. Reactor 网络计算器案例:保留关键设计思路)
-
- [7.1 核心对象职责](#7.1 核心对象职责)
- [7.2 程序启动:`main()`](#7.2 程序启动:
main()) - [7.3 添加连接:`AddConnection()`](#7.3 添加连接:
AddConnection()) - [7.4 事件循环:`Loop()` / `LoopOnce()` / `Dispatcher()`](#7.4 事件循环:
Loop()/LoopOnce()/Dispatcher()) - [7.5 生命周期与常见陷阱](#7.5 生命周期与常见陷阱)
- [8. 面试常见问题与参考答案](#8. 面试常见问题与参考答案)
-
-
- [Q1. `poll` 与 `select` 是同一种 I/O 模型吗?](#Q1.
poll与select是同一种 I/O 模型吗?) - [Q2. `pollfd.events` 和 `pollfd.revents` 有什么区别?](#Q2.
pollfd.events和pollfd.revents有什么区别?) - [Q3. 为什么判断事件用 `revents & POLLIN` 而不是 `revents == POLLIN`?](#Q3. 为什么判断事件用
revents & POLLIN而不是revents == POLLIN?) - [Q4. `poll` 是否像 `select` 一样最多只能监听 1024 个 fd?](#Q4.
poll是否像select一样最多只能监听 1024 个 fd?) - [Q5. `poll` 为什么不适合海量低活跃连接?](#Q5.
poll为什么不适合海量低活跃连接?) - [Q6. `epoll_create1()` 返回的是什么?](#Q6.
epoll_create1()返回的是什么?) - [Q7. epoll 三个核心接口分别干什么?](#Q7. epoll 三个核心接口分别干什么?)
- [Q8. `epoll_wait()` 的 `maxevents` 是监听连接数的上限吗?](#Q8.
epoll_wait()的maxevents是监听连接数的上限吗?) - [Q9. epoll 为什么通常比 poll 更适合海量低活跃连接?](#Q9. epoll 为什么通常比 poll 更适合海量低活跃连接?)
- [Q10. epoll 有没有使用 mmap 来共享就绪链表?](#Q10. epoll 有没有使用 mmap 来共享就绪链表?)
- [Q11. LT 和 ET 的区别是什么?](#Q11. LT 和 ET 的区别是什么?)
- [Q12. 为什么 ET 配合非阻塞?](#Q12. 为什么 ET 配合非阻塞?)
- [Q13. `EPOLLIN` 表示一定收到正常数据吗?](#Q13.
EPOLLIN表示一定收到正常数据吗?) - [Q14. 为什么不是一直监听 `EPOLLOUT`?](#Q14. 为什么不是一直监听
EPOLLOUT?) - [Q15. `epoll` 和 Reactor 有什么区别?](#Q15.
epoll和 Reactor 有什么区别?) - [Q16. Reactor 模式属于同步还是异步 I/O?](#Q16. Reactor 模式属于同步还是异步 I/O?)
- [Q17. 多进程能通过管道传一个整数 `fd` 就共享 socket 吗?](#Q17. 多进程能通过管道传一个整数
fd就共享 socket 吗?) - [Q18. 主线程怎样唤醒正在 epoll_wait 的工作线程?](#Q18. 主线程怎样唤醒正在 epoll_wait 的工作线程?)
- [Q19. eventfd 和管道有什么区别?](#Q19. eventfd 和管道有什么区别?)
- [Q20. TCP 粘包/拆包和 epoll 有关系吗?](#Q20. TCP 粘包/拆包和 epoll 有关系吗?)
- [Q1. `poll` 与 `select` 是同一种 I/O 模型吗?](#Q1.
-
- [9. 考前速记](#9. 考前速记)
0. 先建立整体认识
0.1 为什么需要 I/O 多路复用?
一个 TCP 服务器可能同时连接很多客户端,但某一时刻通常只有部分连接有数据可读或可写。若一个线程阻塞等待一个连接,其他连接可能无法及时处理。
I/O 多路复用(I/O Multiplexing,也叫 I/O 多路转接) :允许一个线程等待多个文件描述符的就绪事件 ,哪个就绪就先处理哪个。Linux 常用 select、poll、epoll。
📌 大白话: 一个服务员管理很多桌客人,不必守着一桌等点菜;谁举手(I/O 就绪),就去服务谁(实际读写)。
0.2 三个容易混淆的概念
| 概念 | 准确含义 | 典型接口 |
|---|---|---|
| I/O 多路复用 | 同时等待多个 fd 的读/写等就绪事件 | select、poll、epoll |
| 非阻塞 I/O | 暂时无法完成操作时,调用及时返回,而不是一直等待 | fcntl(..., O_NONBLOCK) |
| 异步 I/O | 提交操作后,后续通过完成通知等方式得知 I/O 已完成 | Linux AIO、io_uring(支持多种使用方式) |
poll/epoll 属于就绪通知 ,应用程序拿到事件后通常仍然需要自己调用 recv() 等;它们不是"由内核自动替应用程序把消息读取到指定用户缓冲区"的完成式异步 I/O。
1. poll:I/O 多路复用接口
1.1 poll() 的概念与函数原型
poll() 是 Linux/POSIX 提供的 I/O 多路复用接口,通过一个 pollfd 数组同时等待多个 fd 的可读、可写和异常状态。
cpp
#include <poll.h>
// 函数原型:由 <poll.h> 提供,不需要自己实现
int poll(struct pollfd* fds, nfds_t nfds, int timeout);
// pollfd 的成员示意:类型也已在头文件中定义,不要重复声明
/*
struct pollfd {
int fd; // 待监视的文件描述符
short events; // 输入:希望监视的事件
short revents; // 输出:实际发生的事件
};
*/
| 参数 | 含义 | 大白话 |
|---|---|---|
fds |
pollfd 数组的首地址 |
"给内核一张需要关注的 fd 清单" |
nfds |
fds 数组的元素个数,类型为 nfds_t |
"清单上有多少项" |
timeout |
等待时间,单位毫秒 | "最多等多久" |
timeout 有三种常用取值:-1 为一直等待(也可能被信号打断);0 为立即检查并返回;正整数为最多等待指定毫秒数。在 Linux 中,负数一般表示无限等待 ,常用 -1。
pollfd 三个成员重点区分:
fd:需要观察哪个文件描述符;fd < 0时该数组项被忽略 ,其revents清零。events:调用者填入希望关注的事件,可以按位或组合,例如POLLIN | POLLOUT。revents:poll()返回时内核填写的实际事件,每轮调用都会更新。调用者不要自行当作输入事件设置。
📌 大白话: events 是你交给内核的"待办事项",revents 是内核返回的"检查结果"。
1.2 poll() 返回值与错误处理
| 返回值 | 含义 |
|---|---|
> 0 |
有 revents 非 0 的数组项,返回这些数组项的个数(不是事件标志位的个数) |
0 |
等待超时,且没有报告就绪事件 |
-1 |
调用失败,检查 errno |
常见错误:
EINTR:等待时被信号中断,通常根据需要重新调用。EINVAL:参数不合法,例如 Linux 下nfds超过适用限制。ENOMEM:内核无法分配必要的资源。
注意: poll() 把某个 fd 标记为就绪,不表示读写必然成功,也不保证一次能读完/写完所有数据。
1.3 常用事件标志
| 标志 | 主要用途 | 备注 |
|---|---|---|
POLLIN |
可读 | 普通数据可读;监听 socket 有连接可接收等 |
POLLOUT |
可写 | 通常表示发送缓冲区有可用空间,不代表能一次写完 |
POLLPRI |
高优先级数据 | 例如 TCP 带外/紧急数据相关情况,了解即可 |
POLLERR |
出错 | 无需在 events 中指定,也可能在 revents 中出现 |
POLLHUP |
挂断 | 可能与 POLLIN 同时出现,注意处理残留数据 |
POLLNVAL |
fd 无效 | 例如已经关闭的 fd;仅 poll 提供该标志 |
POLLRDHUP |
对端关闭写方向(Linux 扩展) | TCP 半关闭检测;使用时注意头文件特性宏 |
查看原文事件标志图:


事件判断应使用按位与,因为多个标志可能同时出现:
cpp
if (pfd.revents & POLLIN) { /* 可读 */ }
if (pfd.revents & POLLERR) { /* 出错 */ }
不要只写 pfd.revents == POLLIN:当 POLLIN | POLLHUP 同时出现时,这种写法会漏掉可读事件。
1.4 socket 什么时候算"就绪"?
(1)读就绪(POLLIN)
常见情况:
- 接收缓冲区有足够可读数据(通常至少 1 字节;具体还受接收低水位等影响)。
- 监听 socket 的已完成连接队列中有可获取的连接,适合调用
accept()。 - TCP 对端正常关闭发送方向 且已无剩余数据,
recv()可以返回0(EOF)。 - 某些错误或关闭状态也可能使读操作立即返回结果,需要检查返回值及错误标志。
📌 大白话: "可读"不是"必然读到正常业务数据",也可能是"对方关闭了,我一读就知道"。
(2)写就绪(POLLOUT)
通常表示发送缓冲区具有一定可用空间,或非阻塞 connect() 已完成(成功或失败都可能)。
- 非阻塞连接完成后,需通过
getsockopt(fd, SOL_SOCKET, SO_ERROR, ...)判断连接结果。 - 可写不代表一次
send()能发送全部内容;需要考虑部分发送 和EAGAIN。 - 对端异常断开时,写操作可能出错甚至产生
SIGPIPE;不能把可写等同于连接健康。
(3)错误/挂断就绪
POLLERR、POLLHUP、POLLNVAL 需要结合实际读写返回值处理:特别是 POLLHUP 并不必然表示缓冲区已空。先处理可能仍可读的残留数据,再决定关闭连接。
1.5 poll() 的执行过程
text
① 创建 pollfd 数组,填入 fd 和 events
↓
② poll(fds, nfds, timeout)
↓
③ 内核等待/检查这些 fd 的就绪状态
↓
④ 返回就绪数组项数量,同时填写各项 revents
↓
⑤ 程序遍历数组,根据 revents 调用 accept/recv/send 等
↓
⑥ 更新需要监视的 fd,进入下一轮
📌 大白话: 每一轮把整张清单交给内核,内核把每项的检查结果写在 revents,你再按结果办事。
1.6 poll 相比 select 的优势与局限
| 对比项 | select |
poll |
|---|---|---|
| 监听数据结构 | fd_set 位图 |
pollfd 数组 |
| 监听事件与返回事件 | 集合被修改,每轮需重建 | events 与 revents 分离 |
| fd 编号范围 | 常受 FD_SETSIZE 限制 |
不受 FD_SETSIZE 限制 |
| 每轮提交监听信息 | 是 | 是 |
| 程序获取具体就绪 fd | 检查集合 | 遍历 pollfd 数组 |
| 适用情况 | fd 数量较少、兼容性要求 | fd 数量较少到中等、接口易用性要求 |
poll 没有 select 常见的位图上限 ,但受系统资源限制(例如进程打开 fd 数量限制、内存和 Linux 对 nfds 的检查)。并不意味着可以无限监听。
性能瓶颈: poll() 每次调用都传递整组监听信息,内核和应用侧都可能进行与监听项数量有关的检查/遍历。若有上万个连接、只有少量活跃,通常考虑 epoll。
1.7 代码示例:使用 poll 监控标准输入
cpp
#include <poll.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
int main() {
pollfd pfd{};
pfd.fd = STDIN_FILENO;
pfd.events = POLLIN;
char buf[1024];
while (true) {
int n = poll(&pfd, 1, 1000); // 最多等 1000 毫秒
if (n == -1) {
if (errno == EINTR) continue;
perror("poll");
break;
}
if (n == 0) {
puts("poll timeout");
continue;
}
if (pfd.revents & (POLLERR | POLLNVAL)) {
puts("stdin error");
break;
}
if (pfd.revents & (POLLIN | POLLHUP)) {
ssize_t r = read(STDIN_FILENO, buf, sizeof(buf));
if (r > 0) {
// 按长度输出,避免把原始字节当作 NUL 结尾字符串
fwrite(buf, 1, static_cast<size_t>(r), stdout);
fflush(stdout);
} else if (r == 0) {
puts("stdin EOF");
break; // 文件/管道关闭后不要陷入反复就绪循环
} else if (errno != EINTR && errno != EAGAIN) {
perror("read");
break;
}
}
}
return 0;
}
编译:g++ -std=c++17 -Wall -Wextra poll_stdin.cpp -o poll_stdin
面试记忆: poll 通过 pollfd 数组同时等待多个 fd,就绪结果写入 revents;相比 select,接口更直观且无 FD_SETSIZE 位图限制,但大规模连接下仍有全量提交/遍历成本。
2. epoll:Linux 高并发 I/O 多路复用
2.1 epoll 的概念
epoll 是 Linux 提供的、面向大量文件描述符的I/O 就绪事件通知机制。
相比 poll 每次都传入完整数组,epoll 把注册监听事件 和等待就绪事件分开:提前向内核注册,随后每轮只取回就绪事件。
📌 大白话: poll 像每次查岗都把"所有员工名单"重新交一遍;epoll 像先登记员工,之后管理员只通知"今天哪些人有事"。
特别注意:epoll 依旧是I/O 就绪通知,不是 Linux 完成式异步 I/O 的同义词。
2.2 epoll 的三个核心系统调用
(1)创建 epoll 实例:epoll_create1()
cpp
#include <sys/epoll.h>
int epoll_create(int size); // 历史接口
int epoll_create1(int flags); // 推荐
| 函数/参数 | 含义 |
|---|---|
epoll_create(size) |
创建实例;现代 Linux 中 size 不再表示容量,但必须大于 0 |
epoll_create1(flags) |
创建实例;常用 EPOLL_CLOEXEC |
EPOLL_CLOEXEC |
进程执行 exec 时关闭该 epoll fd,避免不必要的继承 |
| 返回值 | 成功为非负 epoll fd,失败为 -1 并设置 errno |
用完应 close(epfd)。监听容量不由旧接口 size 决定,而受打开 fd 数、内存、内核监视资源限制等影响。
(2)注册/修改/删除:epoll_ctl()
cpp
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
| 参数 | 含义 |
|---|---|
epfd |
epoll 实例的 fd |
op |
操作类型:EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL |
fd |
被监视的文件描述符 |
event |
关心的事件和附加数据;DEL 在 Linux 上可以传 nullptr |
返回:成功 0,失败 -1 并设置 errno。
epoll_event 核心成员可理解为:
cpp
// 仅用于说明字段含义,结构体已在 <sys/epoll.h> 中定义
/*
struct epoll_event {
uint32_t events; // EPOLLIN / EPOLLOUT / EPOLLET 等位掩码
epoll_data_t data; // 用户自定义数据,例如 data.fd 或 data.ptr
};
*/
data.fd 常用于取回 fd;data.ptr 可以关联自己的 Connection 对象,但要特别管理对象生命周期,避免出现悬空指针。
(3)等待就绪:epoll_wait()
cpp
int epoll_wait(int epfd, struct epoll_event* events,
int maxevents, int timeout);
| 参数 | 含义 |
|---|---|
epfd |
epoll 实例 fd |
events |
调用者提前分配的输出数组,接收就绪事件 |
maxevents |
输出数组最多能放多少个事件,必须 > 0 |
timeout |
-1 一直等;0 立即返回;正数按毫秒等待 |
返回:正数为本次返回的就绪事件条目数 ,0 为超时,-1 为错误(例如 EINTR)。
关键点: maxevents 是这一次最多取回多少条就绪事件 ,与旧版 epoll_create(size) 的 size 无关,也不等于 epoll 可管理连接的总数。
2.3 常见 epoll 事件
| 事件 | 作用 |
|---|---|
EPOLLIN |
可读;对监听 socket 表示可能有新连接 |
EPOLLOUT |
可写 |
EPOLLPRI |
高优先级事件,了解即可 |
EPOLLERR |
出错,无须主动订阅也会返回 |
EPOLLHUP |
挂断,无须主动订阅也会返回 |
EPOLLRDHUP |
TCP 对端关闭写方向,有助于识别半关闭 |
EPOLLET |
启用边缘触发 ET;不设置则默认水平触发 LT |
EPOLLONESHOT |
一次通知后暂时禁用,需要 MOD 重新激活 |
EPOLLEXCLUSIVE |
部分多等待者场景中减少惊群;有使用限制,属于进阶内容 |
常见实践: 客户端 fd 刚加入 epoll 时通常先关注 EPOLLIN。只有输出缓冲区存在尚未发送的数据时再关注 EPOLLOUT,否则 socket 经常处于可写状态,可能使事件循环频繁被唤醒。
2.4 epoll 的内部工作原理
典型 Linux 内核实现可以概括为:
- 监听集合:保存已注册的 fd 及事件(常用"红黑树"理解其组织形式)。
- 就绪队列/链表:维护等待用户处理的就绪事件。
- 事件回调/唤醒机制:底层对象就绪时,把相应事件记录为待处理,并唤醒等待者。
epoll_wait():从就绪队列取得事件,复制到用户提供的数组。
text
应用程序 Linux 内核
epoll_ctl(ADD/MOD/DEL) ──────────────→ 监听集合
│
fd 状态变化/就绪通知
↓
就绪事件队列
│
epoll_wait() ←──────── 返回就绪事件 ───────┘
↓
recv()/send()/accept() 等实际 I/O
📌 大白话: 内核平时记着"谁需要关注";哪个连接有事,就把它放进"待处理列表";程序醒来只处理这份列表。
准确理解性能: epoll_wait() 不需要每轮线性扫描全部被监听 fd,但依然需要处理和复制本次返回的就绪事件 。若返回 k 个事件,处理这些事件的成本不可能是恒定的 O(1)。不要机械背诵"epoll 永远 O(1)、一定比 poll 快"。
也不要把 epoll 与 mmap 混为一谈: 通常 epoll_wait() 返回的就绪事件仍会从内核复制到用户提供的 epoll_event 数组,并不是靠 mmap() 直接把就绪队列映射给用户。
2.5 LT(水平触发)与 ET(边缘触发)
(1)LT:Level Triggered,水平触发(默认)
概念: 只要 fd 仍满足就绪条件,下次 epoll_wait() 仍可能继续报告该事件。
例如缓冲区有 2 KB,程序只读取 1 KB,剩余 1 KB 仍可读,那么 LT 会继续提示可读。
📌 大白话: 门口有快递,快递员会持续提醒:"你还有快递没拿!"
(2)ET:Edge Triggered,边缘触发
概念: 主要在 fd 出现新的就绪通知/事件变化时报告,而不会仅因为旧数据一直没读完,就像 LT 那样持续重复提醒。
例如缓冲区有 2 KB,程序只读取 1 KB,剩余 1 KB 不一定再触发提醒。后续新数据到达等情况仍可能触发新通知,所以"ET 永远只在未就绪变为就绪那一瞬间触发一次"只是过度简化的记忆法。
📌 大白话: 快递到了通知你一次;你没把东西拿完,快递员不会因为旧快递仍在就一直提醒,但新快递到来可能会再通知。
(3)LT 与 ET 对比
| 对比 | LT | ET |
|---|---|---|
| 就绪事件通知 | 只要仍就绪,可以重复报告 | 不会因旧的就绪条件持续重复报告 |
| 编程复杂度 | 较低、容错更好 | 较高,需要更细致处理数据 |
| 非阻塞配合 | 推荐;某些场景也可阻塞 | 强烈建议非阻塞 fd |
| 典型读处理 | 可以多轮读取 | 通常循环读到 EAGAIN |
| 性能 | 足以适应大量场景 | 可能减少重复通知,不保证始终更快 |
原文相关图(帮助理解为什么 ET 要尽量读尽缓冲区):

2.6 为什么 ET 通常必须搭配非阻塞?
原因 1:只读一部分就返回事件循环,剩余数据可能没有新的提醒。
原因 2:若循环读取到没有数据为止,阻塞 fd 可能在缓冲区读空后一直卡住,无法处理其他连接。
正确的常用写法:
cpp
#include <unistd.h>
#include <cerrno>
void drain_readable_fd(int fd) {
char buf[4096];
for (;;) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
// 处理 n 个字节,必要时放入应用层接收缓冲区
continue;
}
if (n == 0) {
// TCP 对端关闭发送方向,或文件到达 EOF
break;
}
if (errno == EINTR) continue;
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
// 其他错误,按连接/设备类型处理
break;
}
}
注意: 代码仅演示"读到 EAGAIN"的思想,业务程序还需要处理 EOF、异常、连接关闭和接收数据的保存;read() 返回正数并不代表已经读到一个完整应用层消息。
推荐使用 fcntl(fd, F_GETFL) 与 fcntl(fd, F_SETFL, old | O_NONBLOCK) 设置非阻塞,并检查返回值。LT 模式也建议在高并发服务器中配合非阻塞 fd :例如就绪后被其他线程抢先读空,阻塞式 recv() 仍可能卡住。
2.7 一个容易忽略的写事件问题
非阻塞 send() 可能只发送部分内容,剩余部分需要保存在应用层发送缓冲区 里,并关注 EPOLLOUT,等下次就绪继续发送。
如果暂时没有需要发送的数据,应取消 EPOLLOUT 关注,以免 socket 持续可写导致无意义唤醒。
📌 大白话: 快递车一次装不下全部货,没装完的要先放仓库;车有空位时再继续运。不能认为"车能装货"就代表"所有货已经送出"。
2.8 epoll 示例:单线程 TCP 服务器(只接收并打印)
以下示例为完整可编译的教学程序 :监听 8080,管理多个客户端,使用非阻塞 socket + ET,接收数据并打印。为集中演示 epoll,它不实现业务回包、协议解码或线程池。
cpp
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
int main() {
int listenfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
if (listenfd < 0) { perror("socket"); return 1; }
int one = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listenfd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0 ||
listen(listenfd, 128) < 0) {
perror("bind/listen"); close(listenfd); return 1;
}
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd < 0) { perror("epoll_create1"); close(listenfd); return 1; }
epoll_event ev{};
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listenfd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev) < 0) {
perror("epoll_ctl listen"); close(epfd); close(listenfd); return 1;
}
epoll_event ready[64];
puts("listening on 0.0.0.0:8080");
for (;;) {
int n = epoll_wait(epfd, ready, 64, -1);
if (n < 0) {
if (errno == EINTR) continue;
perror("epoll_wait"); break;
}
for (int i = 0; i < n; ++i) {
int fd = ready[i].data.fd;
if (fd == listenfd) {
// ET:把当前已完成队列中的连接尽量 accept 完
for (;;) {
int cfd = accept4(listenfd, nullptr, nullptr,
SOCK_NONBLOCK | SOCK_CLOEXEC);
if (cfd < 0) {
if (errno == EINTR) continue;
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
perror("accept4"); break;
}
epoll_event cev{};
cev.events = EPOLLIN | EPOLLRDHUP | EPOLLET;
cev.data.fd = cfd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &cev) < 0) {
perror("epoll_ctl client"); close(cfd);
}
}
} else {
bool closed = false;
char buf[4096];
for (;;) {
ssize_t r = recv(fd, buf, sizeof(buf), 0);
if (r > 0) {
fwrite(buf, 1, static_cast<size_t>(r), stdout);
fflush(stdout);
} else if (r == 0) {
closed = true; break;
} else if (errno == EINTR) {
continue;
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
} else {
perror("recv"); closed = true; break;
}
}
if (ready[i].events & (EPOLLERR | EPOLLHUP)) closed = true;
if (closed) {
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
}
}
}
}
close(epfd);
close(listenfd);
return 0;
}
编译:g++ -std=c++17 -Wall -Wextra epoll_server.cpp -o epoll_server
测试:终端 A 运行 ./epoll_server,终端 B 运行 nc 127.0.0.1 8080 后输入内容(需要安装 netcat)。
理解重点: listenfd 上的 EPOLLIN 对应 accept4();客户端 fd 上的 EPOLLIN 对应 recv();ET 下两处都循环直到 EAGAIN。
3. select / poll / epoll 怎么选?
| 维度 | select |
poll |
epoll |
|---|---|---|---|
| 核心形式 | 位图集合 | pollfd 数组 |
内核注册 + 就绪事件输出 |
| 每次等待是否传入整组监听 fd | 是 | 是 | 否;先通过 epoll_ctl 注册 |
| 获取就绪信息 | fd_set |
revents |
epoll_wait 输出数组 |
FD_SETSIZE 限制 |
常见实现有 | 无 | 无 |
| 支持 ET | 否 | 否 | 是 |
| 平台兼容性 | 广泛 | 较广泛 | Linux 专属 |
| 常见使用场景 | 少量 fd、兼容性需求 | 中小规模、易维护 | 大量连接、就绪连接占比小 |
如何选: 少量 fd 时选择 poll 很合理;大量连接且只有部分活跃时 epoll 通常更有优势。实际性能还取决于 fd 数量、活跃比例、负载分布、事件处理逻辑和系统资源。性能不能只背"大 O"。
4. Reactor:把 I/O 就绪通知组织成服务器架构
4.1 什么是 Reactor?
Reactor(反应堆模式) 是一种事件驱动的服务器设计模式 :统一等待事件,识别事件类型,然后调用对应的处理函数。epoll 是实现 Reactor 的常用底层机制,而 Reactor 是上层软件架构,两者不是同一回事。
text
epoll_wait() 等待事件
↓
Dispatcher(事件分发)
/ \
监听 fd 可读 客户端 fd 就绪
↓ / \
Accepter Recver Sender
↓ ↓ ↓
添加新 Connection 读取/解码 发送输出缓冲数据
\ | /
继续下一轮事件循环
📌 大白话: epoll 是"值班室报警器",Reactor 是"报警以后该由谁处理的值班制度"。
原文 Reactor 示意图:

4.2 Reactor 的常见组件
| 组件 | 作用 |
|---|---|
EventLoop |
事件循环,反复等待并处理就绪事件 |
Poller |
封装 epoll/poll/select 的事件监听功能 |
Connection / Channel |
管理 fd、感兴趣事件、输入/输出缓冲区、回调等 |
Dispatcher |
根据事件类型选择相应处理器 |
Accepter |
从监听 socket 获取新连接,交给 Reactor 管理 |
Recver / Sender |
处理读写及业务数据缓冲 |
| 业务回调 | 解码请求、执行具体业务、生成响应 |
关键点: 通过 Reactor 的回调执行 recv()/send(),本质上仍通常是同步非阻塞 I/O + 就绪通知,不能因为"有回调"就认定是内核完成式异步 I/O。
4.3 单 Reactor 的基本流程
- 创建监听 socket,绑定端口并
listen()。 - 创建
EventLoop/Poller,注册监听 fd 的EPOLLIN。 epoll_wait()等待事件。- 监听 fd 就绪:调用
accept()/accept4(),创建并注册Connection。 - 客户端 fd 读就绪:读取数据,放入输入缓冲区,按应用层协议解析完整消息。
- 执行业务回调,生成响应,放入输出缓冲区。
- 尝试发送;若没发完,暂时订阅
EPOLLOUT,以后继续发送。 - 对端退出、出错或业务完成时移除 fd、释放
Connection。
为什么要输入/输出缓冲区? TCP 是字节流,一次 recv() 可能只读到半个请求,或者读到多个请求;一次 send() 也可能只发送部分响应。
📌 大白话: 收到半张订单先存起来,拼完整再处理;送货车一次运不完,就把剩余货物留下等下次。
5. One Thread One Loop(OTOL)及多核扩展
5.1 什么是 One Thread One Loop?
One Thread One Loop :通常指每个 I/O 线程拥有一个独立的事件循环,由该线程负责其所管理的连接的读写事件。
- 线程内部事件处理通常串行执行;线程之间可以并行。
- 通过"一个连接主要归一个 EventLoop/线程管理"简化每条连接上的并发控制。
- 并不意味着不同线程完全没有共享数据,也不意味着整个系统不需要任何同步。
📌 大白话: 每个服务员负责自己的几桌客人,一桌尽量只让一个服务员主办,减少相互抢活;多个服务员可以同时工作。
5.2 多进程 Reactor:每个 Worker 一个 EventLoop
常见做法:主进程启动多个 Worker 子进程,每个 Worker 用自己的 epoll 实例处理连接。这样可以利用多核,也具有进程级隔离能力。
两种连接接收方式:
- 共享监听 socket :父进程在
fork()前创建监听 fd,子进程继承这个 fd;多个 Worker 可以分别accept()。需要合理处理多进程竞争和惊群;某些模式可配合EPOLLEXCLUSIVE或SO_REUSEPORT等机制。 - 主进程接收连接并分发 :主进程
accept()后通过 Unix 域套接字 +sendmsg/recvmsg+SCM_RIGHTS把已连接 socket fd 传给 Worker。
重要区别: 进程有各自的文件描述符表 ,只通过普通管道传一个 int fd 数字,不能保证接收进程里的同号 fd 指向同一个 socket。进程间传递打开的 fd 通常要用 SCM_RIGHTS;fork() 继承 fd 是另一回事。
更准确地说: 通过 fork() 继承的监听 fd 在不同进程各自的描述符表中存在,但可能引用相同的底层打开文件对象,因此不能说这些进程"完全不共享任何 fd"。
原文多进程结构图:

5.3 多线程 Reactor:主线程接入,工作线程处理
典型方式:
text
Main Reactor(主线程)
│
accept() 新连接
│
选择一个 Worker
│
┌────────────┼────────────┐
↓ ↓ ↓
EventLoop A EventLoop B EventLoop C
管理一组连接 管理一组连接 管理一组连接
由于同一进程的线程共享文件描述符表 ,主线程 accept() 得到的客户端 fd,可以通过线程安全任务队列交给 Worker;通常再使用 eventfd 或管道唤醒 Worker 的事件循环 ,由 Worker 自己完成 epoll_ctl(ADD, ...)。
注意: 跨线程传递 fd 数字是可行的,但需要约定连接所有权、何时关闭 fd、队列同步;不能让另一个线程在使用 fd 时被提前 close()。
原文多线程参考图:

原文另一种多线程结构:

5.4 单线程、多线程、多进程怎么选?
| 模式 | 优点 | 主要注意事项 |
|---|---|---|
| 单线程单 Reactor | 简单、连接状态管理清晰 | 长耗时业务回调会阻塞所有连接 |
| 多线程多 EventLoop | 利用多核,线程间共享数据方便 | 需要线程安全队列、唤醒、连接归属管理 |
| 多进程多 EventLoop | 故障隔离更强,适合多 Worker | IPC 成本、共享监听竞争、进程间状态管理 |
如果业务计算较耗时,还可让 I/O 线程只做网络读写,把 CPU 密集型任务交给工作线程池,避免卡住事件循环。
6. eventfd:在线程间唤醒事件循环
6.1 什么是 eventfd?
eventfd 是 Linux 提供的一种基于 64 位计数器的事件通知机制 ,对外表现为一个 fd,可以加入 epoll/poll/select 进行监听。
典型用途:Worker 线程正在 epoll_wait(),主线程给它的任务队列放入新任务后,向 eventfd 写入一个数字,Worker 被唤醒,再从任务队列取任务处理。
📌 大白话: 任务队列是"放订单的盒子",eventfd 是"按门铃"。门铃只提醒"有事了",订单内容还在盒子里。
6.2 函数与参数
cpp
#include <sys/eventfd.h>
#include <stdint.h>
#include <unistd.h>
int eventfd(unsigned int initval, int flags);
initval:计数器初始值。flags:通常使用EFD_NONBLOCK | EFD_CLOEXEC;需要信号量语义时加EFD_SEMAPHORE。- 返回值:成功返回 fd,失败返回
-1;使用完毕需close()。
6.3 eventfd 的两种模式
| 模式 | 写入行为 | 读取行为 |
|---|---|---|
| 默认模式 | 将写入数值累加到计数器 | 一次 read() 取出当前计数值,并把计数器清零 |
EFD_SEMAPHORE |
同样累加 | 计数器非零时,一次读取返回数值 1,计数器减 1 |
重要细节:
- 使用
read(fd, &value, sizeof(value))和write(fd, &value, sizeof(value)),每次读写的数据长度是 8 字节 。成功时系统调用返回的字节数是8,真正的计数值存在value里。 - 计数器为 0 时不可读;非阻塞读取返回
-1,errno = EAGAIN。 - 写入的数值不能是
UINT64_MAX;超出允许的计数上限时,阻塞写会等待,非阻塞写可能返回EAGAIN。 eventfd保证计数器更新的原子性,但不替代任务队列本身的同步机制。eventfd无法直接传递字符串、连接对象等完整业务消息。
6.4 最小示例:写通知并读取计数
cpp
#include <sys/eventfd.h>
#include <unistd.h>
#include <cstdint>
#include <cstdio>
int main() {
int efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
if (efd == -1) { perror("eventfd"); return 1; }
std::uint64_t inc = 3;
if (write(efd, &inc, sizeof(inc)) != sizeof(inc)) {
perror("eventfd write"); close(efd); return 1;
}
std::uint64_t value = 0;
if (read(efd, &value, sizeof(value)) == sizeof(value)) {
printf("counter = %llu\n", static_cast<unsigned long long>(value));
}
close(efd);
}
输出:counter = 3。这表示读取到的计数值为 3,不是"本次 read 返回了 3 字节"。
6.5 eventfd、管道和 Unix 域套接字的区别
| 机制 | 适合做什么 | 是否能传复杂数据/真正的 fd |
|---|---|---|
eventfd |
轻量级计数/通知、唤醒事件循环 | 不直接传复杂数据;不能跨进程传递已打开 fd |
pipe |
字节流通知/少量消息 | 能传字节,但跨进程传 int 不等于 fd 传递 |
| Unix 域套接字 | 进程间传消息/连接分发 | 可以通过 SCM_RIGHTS 传递打开的 fd |
不要机械认为 eventfd 总比管道快;性能还取决于具体负载及实现。若任务内容需要传输,而不是只做唤醒,管道或 Unix 域套接字更合适。
7. Reactor 网络计算器案例:保留关键设计思路
原文的 Reactor 网络计算器项目按应用层协议 → 连接对象 → 事件循环 组织,适合作为综合练习。其完整工程依赖 Cal、Protocol、Connection、Listener、Channel、Reactor 等类;原文仅给出了部分流程说明,没有附齐全部源码,因此这里保留设计脉络,不把伪代码冒充可直接运行的完整项目。
项目原参考链接:Linux 网络 Reactor 反应堆示例资源
7.1 核心对象职责
| 对象 | 核心职责 |
|---|---|
Cal |
执行加、减、乘、除、取模等业务计算,并处理非法运算 |
Request / Response |
请求与响应的数据结构 |
Protocol |
解决应用层封包、拆包、解码、编码;调度业务处理 |
Listener |
监听端口、获取新连接 |
Channel / Connection |
保存 fd、接收/发送缓冲区、关注的事件、回调等 |
Reactor |
统一管理连接并通过 epoll 等待与分发事件 |
7.2 程序启动:main()
- 初始化日志和监听端口。
- 创建
Cal(计算逻辑)与Protocol(编解码及业务入口)。 - 创建
Listener,绑定收到请求后的处理回调。 - 创建
Reactor,调用AddConnection(listener)注册监听 fd。 - 运行
Loop(),进入事件循环。
7.3 添加连接:AddConnection()
- 检查 fd 是否已经被当前 Reactor 管理,避免重复注册。
- 设置需要关注的事件,比如监听 fd 上的
EPOLLIN。 - 通过
epoll_ctl(ADD, ...)注册到 epoll。 - 把连接对象保存在 Reactor 的连接表里,明确谁负责其生命周期。
7.4 事件循环:Loop() / LoopOnce() / Dispatcher()
text
Loop()
└─ LoopOnce(): epoll_wait() 返回就绪事件
└─ Dispatcher(): 根据事件类型分发
├─ 监听 fd 可读 → Listener/Accepter → 新增 Connection
├─ 普通 fd 可读 → Recver → inbuffer → Decode → Execute
└─ 普通 fd 可写 → Sender → 发送 outbuffer 中剩余数据
处理完整请求时不能依赖单次 recv(): 应把字节持续累积到 inbuffer,只有凑齐一个完整应用层数据包才进行 Decode → Execute;每次读可能收到半包 或多个完整包。
回包也不能依赖单次 send(): 先将结果编码到 outbuffer,发送不完时维护发送进度并订阅写事件,直到全部发送完后取消 EPOLLOUT。
7.5 生命周期与常见陷阱
- 接收到 EOF:根据协议决定是否处理已读完的数据、处理未发送响应,并最终关闭连接。
EPOLLERR/EPOLLHUP:不能简单等价为普通读/写事件;应获取错误并考虑缓冲区尚有数据的情况。- 使用
data.ptr指向连接对象时:删除连接后不能再访问旧事件中的悬空指针;需要设计生命周期/延迟销毁策略。 - Reactor 回调执行时间过长时,其他 fd 就绪也不能及时处理;耗时业务交给工作线程池。
- 同一连接尽量明确归属哪个 I/O 线程,避免多线程重复读写和重复关闭。
📌 大白话: Reactor 负责"接电话、分配业务";Protocol 负责"听懂对方说什么";Cal 负责"算出答案";Connection 负责"保存尚未听完的话和还没说完的回复"。
8. 面试常见问题与参考答案
Q1. poll 与 select 是同一种 I/O 模型吗?
是,都是I/O 多路复用 。select 使用 fd_set,poll 使用 pollfd 数组。
Q2. pollfd.events 和 pollfd.revents 有什么区别?
events 是用户指定的待观察事件;revents 是内核填入的实际发生事件,判断时要用位运算。
Q3. 为什么判断事件用 revents & POLLIN 而不是 revents == POLLIN?
因为同一 fd 可以同时出现多个事件标志,例如可读与挂断。
Q4. poll 是否像 select 一样最多只能监听 1024 个 fd?
不受 FD_SETSIZE 限制,但仍受到文件描述符数和其他系统资源限制。
Q5. poll 为什么不适合海量低活跃连接?
每轮需要处理完整 pollfd 数组,内核和用户侧可能产生随监听数量增长的开销。
Q6. epoll_create1() 返回的是什么?
一个表示 epoll 实例的 fd,要用 close() 释放。
Q7. epoll 三个核心接口分别干什么?
epoll_create1 创建实例,epoll_ctl 维护监听事件,epoll_wait 获取就绪事件。
Q8. epoll_wait() 的 maxevents 是监听连接数的上限吗?
不是,它限制的是本次输出数组最多返回多少条就绪事件。
Q9. epoll 为什么通常比 poll 更适合海量低活跃连接?
因为事件事先注册,等待时不必重复提交完整监听数组,取回的是就绪事件,而不是由用户每轮遍历所有被监视 fd。
Q10. epoll 有没有使用 mmap 来共享就绪链表?
标准 epoll 接口不是依赖 mmap 来让用户直接读取就绪链表,epoll_wait 仍会复制就绪事件到用户数组。
Q11. LT 和 ET 的区别是什么?
LT 只要仍就绪就可继续报告;ET 主要依靠新的就绪通知,不会单凭旧数据未读完就持续提醒。
Q12. 为什么 ET 配合非阻塞?
要循环读写到 EAGAIN;若 fd 为阻塞模式,缓冲区读空后继续调用可能把整个事件循环卡住。
Q13. EPOLLIN 表示一定收到正常数据吗?
不一定,还可能表示对端关闭写方向、监听 socket 有新连接等,要根据 fd 类型和系统调用返回值判断。
Q14. 为什么不是一直监听 EPOLLOUT?
socket 往往长时间可写,没有待发送数据时继续监听会产生大量无意义的就绪通知。
Q15. epoll 和 Reactor 有什么区别?
epoll 是 Linux 提供的 I/O 就绪通知机制;Reactor 是基于事件循环和事件分发组织服务器的软件设计模式。
Q16. Reactor 模式属于同步还是异步 I/O?
典型 Reactor 使用同步非阻塞读写加 I/O 多路复用;"回调式编程"不意味着数据复制本身就是完成式异步 I/O。
Q17. 多进程能通过管道传一个整数 fd 就共享 socket 吗?
不能。不同进程有各自的 fd 表;应通过 fork() 继承打开的 fd,或用 Unix 域套接字的 SCM_RIGHTS 传递 fd。
Q18. 主线程怎样唤醒正在 epoll_wait 的工作线程?
可以向工作线程注册的 eventfd 写入计数,触发读就绪;实际任务通过线程安全队列传递。
Q19. eventfd 和管道有什么区别?
eventfd 传递计数/通知,管道传递字节流;若需进程间传送真正的 fd,应使用支持 SCM_RIGHTS 的 Unix 域套接字。
Q20. TCP 粘包/拆包和 epoll 有关系吗?
TCP 是字节流,消息边界必须由应用层协议约定(长度字段、分隔符等);无论是 poll 还是 epoll,都不能保证一次读到一个完整业务消息。
9. 考前速记
poll:pollfd {fd, events, revents};等多个 fd;返回后遍历数组,检测revents。epoll:epoll_create1 → epoll_ctl → epoll_wait;事件事先注册,按需取得就绪事件。- LT :仍就绪就可能继续提醒。ET :新的就绪通知;通常配非阻塞并读写到
EAGAIN。 EPOLLIN:能尝试读/accept;EPOLLOUT:能尝试写,并不保证完全写入。- Reactor :
EventLoop + Poller + Dispatcher + Connection + 业务回调。 - OTOL:一个 I/O 线程管理一个事件循环,多线程各管一组连接。
eventfd:64 位计数器,适合唤醒事件循环;复杂任务内容在队列里。- 最重要的工程习惯 :检查系统调用返回值、处理
EINTR/EAGAIN、维护收发缓冲区、避免重复关闭 fd。
一句话面试总结:
poll和epoll都是 I/O 多路复用的就绪通知机制;epoll通过事件注册和就绪队列降低大量连接下的重复扫描开销。Reactor 在此基础上组织连接管理和事件分发,One Thread One Loop 负责多核扩展,eventfd常用于线程之间唤醒事件循环。