IO多路复用 epoll & IOCP
select、poll、epoll 与 IOCP 四种 I/O 事件机制对比,涵盖原理、数据结构、复杂度分析和跨平台实践,适合后端工程师理解高性能网络编程的基础。
摘要:本文从 “一个服务怎么同时处理成千上万个 TCP 连接” 这个问题出发,依次讲解 select、poll、epoll 的就绪通知机制,以及 IOCP 的完成通知机制,对比它们的数据结构、时间复杂度和适用场景,并解释 Reactor 与 Proactor 两种模式的本质区别。
一、问题起源:一个服务怎么 “同时” 处理成千上万个 TCP 连接?
在 Linux 中,应用通过 socket fd(文件描述符)操作 TCP 连接;Windows 中对应的是 SOCKET 句柄。先看两种直观做法,以及它们在大量连接场景下的局限:
方法 A:一个连接一个线程(BIO 阻塞 I/O)
1
2
3
4
socket = accept() // 阻塞,等新连接
read(socket, buf) // 阻塞,等数据到达
process(buf)
write(socket, resp) // 可能等待发送缓冲区空间;返回不代表对端已收到
问题:1 万个连接需要 1 万个线程。以常见 Linux/glibc 环境中每线程默认预留约 8MiB 栈空间为例(实际由启动时的资源限制、架构和线程属性决定) → 1 万线程就是 80GB 虚拟地址空间(物理内存只按实际触碰的页算,通常每线程几十 KB,但地址空间、上下文切换、cache/TLB 抖动都是实打实的开销)。
方法 B:非阻塞 I/O + 忙轮询
1
2
3
4
5
6
7
把 socket 设为非阻塞模式
while(true) {
for (每个连接的 socket) {
n = read(socket, buf); // 非阻塞,立刻返回
if (n > 0) process(buf);
}
}
问题:每次循环对全部 1 万个 socket 做 read() 系统调用,其中 9999 个返回 -1(EAGAIN),大量无效系统调用,CPU 空转。
I/O 多路复用 就是为了解决这个问题:让内核告诉你 “哪些 fd 就绪了”,而不是自己去挨个试。
二、select:基于描述符集合的就绪通知
1
2
3
4
5
6
7
8
9
10
11
12
13
14
fd_set readfds; // Linux/glibc 的 fd_set 通常是 1024 位的位图;其他平台实现不同
FD_ZERO(&readfds);
FD_SET(sock1, &readfds); // 把要监视的 fd 加入位图
FD_SET(sock2, &readfds);
int n = select(maxfd+1, &readfds, NULL, NULL, &timeout);
// 内核扫描集合表示的范围,检查被选中的 fd 是否就绪
// 返回后就绪 fd 在位图中对应位被置 1
// 四个核心问题:
// ① Linux/glibc 的常规 fd_set 要求 fd 数值 < FD_SETSIZE(1024),并非只限制数量
// ② 每次调用都要把整个 fd_set 从用户态拷贝到内核态 O(n)
// ③ 内核通过遍历全部 fd 来查找就绪事件 O(n)
// ④ 返回后用户还需要遍历全部 fd 找出哪些就绪 O(n)
核心局限:每次重新传入监听集合并扫描,成本随监视范围增大。select 的扫描范围与最大 fd 值有关,常写为 O(maxfd),不能一概把 n 当成实际监听数量。返回后的集合会被改写,下一轮调用前需要重新初始化;Linux 还可能修改 timeout。
Linux 内核的 select 系统调用本身没有 glibc fd_set 的固定 1024 位限制。常规程序需要监视 fd ≥ 1024 时,应改用 poll/epoll,而不是修改内核重编译。Windows 的 select 仅支持套接字,fd_set 也不是 Linux 这种位图,不能直接套用上述限制与实现细节。
三、poll:用数组描述监视集合
1
2
3
4
5
struct pollfd fds[10000];
fds[0].fd = sock1; fds[0].events = POLLIN; // 用数组代替位图
fds[1].fd = sock2; fds[1].events = POLLIN;
int n = poll(fds, nfds, timeout);
改进:用 pollfd 数组代替 fd_set 位图,突破 1024 个 fd 的限制。
poll 是 POSIX 接口,但不能因此视为所有平台行为一致。Windows 提供的是 WSAPoll,需要注意与 POSIX 接口及错误语义的区别。
仍存在的问题:每次调用仍然要 O(n) 拷贝全部 fd 到内核、O(n) 内核遍历全部 fd、O(n) 用户遍历全部 fd 找就绪事件。poll 没有 fd_set 的固定容量和 fd 数值限制,但仍受进程资源限制;它没有改变逐项扫描的基本复杂度。
四、epoll:持久注册与就绪事件集合(Linux)
4.1 基本用法
创建套接字:
1
2
3
4
5
// 套接字创建:
int sock1 = socket(AF_INET, SOCK_STREAM, 0); // 创建TCP监听socket
bind(sock1, (struct sockaddr*)&addr, sizeof(addr));
listen(sock1, 128); // socket 绑定地址+开启监听(省略地址结构体配置)
epoll 创建与监听:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// epoll 创建:
int epfd = epoll_create1(EPOLL_CLOEXEC); // 创建 epoll 实例,内核分配事件表
// 函数返回该对象的文件描述符 epfd
struct epoll_event ev = {0};
ev.events = EPOLLIN; // 关心可读事件
ev.data.fd = sock1; // 把监听套接字注册进 epoll,监听可读事件
epoll_ctl(epfd, EPOLL_CTL_ADD, sock1, &ev); // 注册到内核(只做一次!)
// data 是一个 union,fd / ptr / u32 / u64 只能选其中一个:
// union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; };
// 存了 ptr 就把 fd 覆盖掉了,事件回来时只能按 ptr 取。
ev.data.fd = sock2; // 本示例统一使用 fd
epoll_ctl(epfd, EPOLL_CTL_ADD, sock2, &ev); // 注册已连接的 sock2
// 另一种方案是所有连接统一存 data.ptr,并由连接对象保存 fd;不要与本示例混读。
// 阻塞等待事件就绪:
struct epoll_event events[1024]; // 用户态分配的数组,用于接收内核返回的就绪事件
int n = epoll_wait(epfd, events, 1024, timeout); // 核心等待函数,获取已就绪事件列表
// events[0..n-1] 只包含就绪事件 —— 不需要遍历全部 fd
epoll_wait 返回的是 fd 的就绪或状态变化通知。对 TCP 数据连接,可以借助内核收发队列来理解读写就绪;但监听连接、EOF、错误等也能产生事件,不能把全部语义归结为“缓冲区有数据或有空位”。下面是简化示意,实际内核使用队列、内存记账和动态分配,并非创建 socket 时就分配两个固定大小的连续数组:
1
2
3
4
5
6
7
8
9
10
11
一个 socket 的内核结构:
对端发来数据 ──→ ┌─────────────────┐
│ 接收缓冲区 │ 有数据可读 → EPOLLIN
│ (内核管理) │
└─────────────────┘
写入数据 ──→ ┌─────────────────┐
│ 发送缓冲区 │ 有空位可写 → EPOLLOUT
│ (内核管理) │
└─────────────────┘
事件触发后的实际操作——从 接收缓冲区 读数据用 recv(),往 发送缓冲区 写数据用 send()(和第一章用到的 read()/write() 功能相同,只是特定于 socket 的版本):
1
2
3
4
5
6
7
8
9
10
11
12
// 示意分发:所有 fd 均设为非阻塞;下列 Handle 方法封装完整错误处理。
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == sock1) {
if (events[i].events & EPOLLIN)
HandleAccept(fd); // 接受连接,并注册新的连接 fd
continue; // 监听 socket 不调用 recv
}
HandleConnectionEvent(fd, events[i].events);
// 内部按需调用 recv/send,处理 EOF、EAGAIN、部分发送及错误。
}
// 若统一存 data.ptr,则必须统一按 ptr 读取,并保证对象在使用期间有效。
关键事件类型:
| 事件 | 含义与处理要点 |
|---|---|
EPOLLIN |
可尝试读。监听 socket 对应 accept();数据 socket 对应 recv(),结果也可能是 EOF 或错误 |
EPOLLOUT |
可尝试写,不保证一次发完任意长度的数据;非阻塞连接建立也可借此获知进展,再检查 SO_ERROR |
EPOLLHUP |
挂断状态。仍可能有未读数据,不能直接视为“数据已经读完” |
EPOLLRDHUP |
流式 socket 的对端关闭连接或关闭写方向。应用通常应读完剩余数据,并根据协议决定是否继续发送响应 |
EPOLLERR |
fd 出现错误,可通过 getsockopt(SO_ERROR) 等检查原因,再执行相应清理 |
EPOLLHUP 和 EPOLLERR 无须显式订阅也会上报;EPOLLRDHUP 需要加入监听掩码。就绪是一次状态观察,之后可能被其他线程消费或发生变化,事件循环通常使用非阻塞 socket,并检查每次系统调用的结果。
资源回收:显式 EPOLL_CTL_DEL 后再 close(fd) 可以让注销与关闭的意图更明确:
1
2
3
4
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
// 服务退出时,还需关闭所有连接,再关闭 epoll 实例。
close(epfd);
不显式 DEL 时,只有指向底层同一个 open file description 的所有 fd 都关闭后,相关监听才会自动移除;dup()、fork() 等可能使其他引用继续存活。
另外,DEL 无法撤回已经返回到用户态 events[] 中的事件。关闭连接后,不应仅凭“已经 DEL”就立即销毁或复用事件指向的对象;需要结合连接代次、引用计数或延迟回收等机制管理生命周期。
Linux 2.6.9 之前,DEL 的 event 参数需要传合法指针;现代代码可以传空指针。
4.2 epoll 为什么快?核心数据结构
1
2
3
4
5
6
7
8
9
10
11
select/poll:
每次调用 → 拷贝全部fd到内核 → 遍历全部fd检查状态 → 拷贝结果集合或 revents 回用户态 → 遍历全部fd找就绪
复杂度:poll 为 O(n),n=数组项数;select 与最大 fd 值对应的范围有关
epoll:
初始化: epoll_create 创建红黑树(存储所有注册fd) + 就绪链表
注册: epoll_ctl 将fd插入红黑树 O(log n),为fd注册内核回调
当fd就绪时,回调函数自动把fd放入就绪链表 O(1)
等待: epoll_wait 处理就绪候选并返回事件,常简化为 O(k)
k 表示本轮处理的就绪候选规模;大量连接空闲时通常 k << n
不需要拷贝全部fd,不需要遍历全部fd
红黑树用于管理监听项,优势在于动态增删查找;每轮等待避免扫描全部监听项,主要依靠就绪集合和唤醒回调。epoll_wait 仍可能重新检查候选项的就绪状态,O(k) 是教学层面的概括,不是所有内核路径的严格保证。活跃连接比例很高或注册频繁变化时,也不能仅凭复杂度断言 epoll 一定更快。
epoll 的三大关键改进:
| 改进点 | select/poll | epoll |
|---|---|---|
| fd 状态存储 | 每次调用传入(用户态维护) | 内核红黑树持久维护 |
| 就绪事件发现 | 遍历全部 fd 逐个检查 | 回调驱动,就绪时自动入链表 |
| 返回结果 | 返回全部 fd,用户遍历查找就绪 | 只返回就绪 fd 列表 |
4.3 两种触发模式
理解了 epoll 为什么快之后,下一个问题是:epoll_wait 返回就绪事件时,通知的规则是什么?epoll 提供两种选择:
1
2
3
4
5
6
7
8
9
水平触发 (Level Triggered, LT) — 默认模式,编程简单
"只要缓冲区还有数据没读完,就一直通知你"
类比:水龙头没关就一直提醒
边缘触发 (Edge Triggered, ET) — 可能减少重复通知,处理规则更严格
"不保证为持续存在的就绪状态反复提醒"
新数据到达等变化可能再次通知,事件也可能合并,并非严格的空→非空检测
通常配合非阻塞 socket,循环读到 EAGAIN;EOF 和真实错误另行处理
发送时处理到队列清空或 EAGAIN;若为公平性提前停止,需自行安排继续处理
4.4 epoll 的演进
从 2002 年至今,epoll 的核心 API 和数据结构一直保持稳定,但陆续增加了几个补丁:
| 年份 | 内核版本 | 新增 | 作用 |
|---|---|---|---|
| 2006 | 2.6.17 | EPOLLRDHUP |
检测对端半关闭(只关写不关读),之前需要靠 read()==0 来判断 |
| 2006 | 2.6.19 | epoll_pwait() |
原子地替换信号掩码并等待,避免检查信号与进入等待之间的竞态;仍可能被信号中断 |
| 2008 | 2.6.27 | epoll_create1(EPOLL_CLOEXEC) |
创建时直接设 close-on-exec 标志,成功 exec 时自动关闭,避免 fd 泄漏到新程序;fork 仍继承 fd |
| 2016 | 4.5 | EPOLLEXCLUSIVE |
多个 epoll 实例监听同一目标时,减少同时唤醒;不保证严格只唤醒一个 |
4.5 Reactor 模式
Reactor(反应器) 是一种 同步事件处理模式:一个线程阻塞等待多个事件源的就绪通知,然后将就绪事件分发给对应的处理器回调。
这里的“同步”指应用在收到就绪通知后,仍需主动执行 read / write,不等于调用必须阻塞。典型实现使用非阻塞 I/O;回调通常在事件循环线程执行,耗时业务可以投递给其他线程。
Reactor 是 Douglas C. Schmidt 等人系统阐述的事件处理模式,其核心可以用一个事件循环说明:
1
2
3
4
5
6
while (running) {
events = demultiplexer.wait(); // ① 阻塞等待事件(epoll_wait)
for (each ready event) {
dispatch(event, handler); // ② 把就绪事件分发给对应的 handler 回调
}
}
核心思想是 “等事件” 和 “处理事件” 的分离:epoll(或 select / poll)负责等待,业务回调负责处理。
epoll 是 Linux 上实现 Reactor 常用的事件分离器——demultiplexer.wait() 对应 epoll_wait()。
下一章要讲的 IOCP 则是另一种模式:Proactor。
五、IOCP:异步操作的完成通知(Windows)
5.1 工作机制与对比
IOCP(I/O Completion Port,I/O 完成端口)通常与 overlapped I/O 配合使用。应用先通过 WSARecv、WSASend、AcceptEx 等提交操作,操作完成后从完成端口获取结果。提交时需要正确设置套接字、OVERLAPPED、缓冲区和完成端口关联关系;仅调用函数名并不自动构成异步模型。
1
2
3
4
5
6
7
epoll:就绪通知
应用注册关注的事件 → epoll_wait 等待就绪
应用调用非阻塞 recv/send → 根据返回值继续或等待
overlapped I/O + IOCP:完成通知
应用提交操作及其缓冲区 → 操作立即完成,或返回 WSA_IO_PENDING
系统完成操作 → 应用从完成端口取得结果 → 检查状态与实际字节数
WSA_IO_PENDING 表示操作成功提交、尚未完成,不是提交失败。异步操作也可能立即完成;使用 IOCP 时,默认情况下立即成功的操作仍会投递完成通知,除非配置了相应的跳过通知机制,不能因此重复处理或提前释放对象。
完成通知也不等于业务成功:
WSARecv成功完成:本次接收的数据已写入指定缓冲区,按实际字节数处理;TCP 流仍需应用协议拆包。WSASend成功完成:系统已经消费本次发送缓冲区,可以按接口约定复用;不代表对端已接收或处理。- 操作也可能以失败或取消状态完成。缓冲区和 OVERLAPPED 在操作结束前必须保持有效。
5.2 Reactor 与 Proactor:差别在通知语义
| 维度 | epoll Reactor | overlapped I/O + IOCP Proactor |
|---|---|---|
| 应用先做什么 | 注册感兴趣的 fd 与事件 | 提交具体读写操作及缓冲区 |
| 通知什么 | 可以尝试执行 I/O | 已提交操作完成,结果可查询 |
| 谁提交读写 | 应用在就绪后调用 recv/send | 应用提前调用 WSARecv/WSASend 等 |
| 线程通常等在哪里 | epoll_wait;非阻塞读写无需等待数据到达 | GetQueuedCompletionStatus 等完成接口 |
| 是否需要每连接一个等待线程 | 不需要 | 不需要 |
| 是否保证零拷贝 | 不保证 | 不保证 |
两者都可以用少量线程管理大量连接。epoll 将就绪检测与实际读写分开;IOCP 汇集异步操作的完成结果,并提供并发线程调度机制。使用原生 IOCP 时,应用仍需创建和管理工作线程;Windows 的线程池 I/O API 可进一步代管线程生命周期。
不能把 IOCP 理解成“内核为每个请求安排一个阻塞工作线程”。操作可能在提交上下文立即完成,也可能由协议栈、驱动和后续 I/O 完成路径推进;具体执行上下文不是完成端口 API 保证的固定模型。
5.3 IOCP 与拷贝、DMA 的关系
DMA 允许设备与内存之间传输数据,而不由 CPU 逐字节搬运。设备 DMA、协议栈缓冲、用户缓冲区复制和完成通知是不同层面的问题。
对普通缓冲式 socket 接收,可以先用下面的路径理解:
1
数据到达 → 系统网络缓冲 → 应用接收缓冲区
但不能把它写成所有 overlapped 接收的固定路径。微软文档说明:提前提交接收缓冲区时,系统可能在数据到达时直接将其放入应用缓冲区,避免原本在调用 recv 时发生的额外缓冲区拷贝。这不等于所有环境下都保证网卡 DMA 直写用户内存,也不等于 IOCP 本身实现了零拷贝。
Winsock 还有 RIORegisterBuffer 等 Registered I/O 接口,注册时可锁定缓冲区所在内存页;这类机制需要单独讨论,不能仅凭“注册缓冲区”推断实际硬件数据路径。
Linux 的 sendfile / splice 等可以减少某些文件与网络路径中经过用户态的数据复制;直接 I/O 也可能绕过页缓存。这些优化都有适用条件,与选择就绪通知还是完成通知并非一一对应。
5.4 如何比较性能
不能用“epoll 和 IOCP 都固定拷贝一次,因此只差等待开销”来推导性能,更不能把这种差异统一说成“微秒级、可以忽略”。
实际表现取决于活跃连接比例、消息大小、吞吐目标、系统调用频率、线程唤醒、队列争用、内存分配和业务耗时。比较时应观察吞吐量、CPU 使用率、延迟分位数和资源占用,并保持测试负载与环境可比。
六、四种机制对比总表
IOCP 早于 epoll 出现,它们属于不同平台和不同通知模型,不能理解为依次替代的“四代技术”。
| 维度 | select | poll | epoll | IOCP |
|---|---|---|---|---|
| 监视规模限制 | Linux/glibc 常规接口要求 fd < 1024;其他平台不同 | 无 fd_set 固定上限,受系统资源限制 | 无 fd_set 固定上限,受 fd、监听项和内存等限制 | 无固定 1024 上限,受句柄、未完成操作和内存等限制 |
| 集合维护 | 每次传入 fd_set | 每次传入 pollfd 数组 | 内核持久维护监听集合 | 关联句柄,管理已提交操作的完成队列 |
| 等待与取结果 | 扫描成本与最大 fd 范围有关 | O(n) 扫描数组 | 主要处理就绪集合,常简化为 O(k) | 获取已完成操作;GetQueuedCompletionStatus 每次取一个包,Ex 版本可批量取 |
| 通知语义 | 就绪 | 就绪 | 就绪,支持 LT/ET | 完成 |
| 常见平台 | POSIX 系统、Windows 等,接口细节有差异 | POSIX 系统;Windows 有 WSAPoll | Linux | Windows |
| 常见使用模式 | Reactor | Reactor | Reactor | 配合 overlapped I/O 实现 Proactor |
七、跨平台生态
7.1 条件编译:把差异锁在一个类里
在实际项目中,框架层通过条件编译屏蔽平台差异,业务代码不需要知道底层是 epoll 还是 IOCP:
1
2
3
4
5
6
7
8
9
10
11
class ConnecterImpl {
#ifndef _WIN32
int m_EpollFd; // epoll fd
struct epoll_event *m_events; // 事件数组
Sockfd m_fdPair[2]; // 自管道,用于跨线程唤醒 epoll_wait
#else
HANDLE m_hIOCP; // IOCP 句柄
int m_nThreadCnt; // 工作线程数
HANDLE *m_workThreads; // IOCP 工作者线程句柄
#endif
};
#ifndef _WIN32 的含义是 “默认走 Unix 路径(epoll),Windows 单独走 IOCP”。这里用 _WIN32 而不是 WIN32:前者由编译器保证定义(MSVC、MinGW、Clang-cl 都有),后者通常靠工程配置手动添加,换个构建系统就可能失效。编译时预处理器只编译对应的分支,当前源文件中,未选中的分支不参与编译。这套代码当前只部署 Linux 和 Windows,只有工程也定义了 WIN32 时,两种写法才可能在现有构建中表现一致;跨构建系统时应使用正确的平台预定义宏。如果将来需要支持第三种平台(如 macOS),需要把宏改成三路分支。
7.2 其他平台:kqueue 与 Event Ports
除了 epoll 和 IOCP,还有两个值得了解的机制。
kqueue(macOS / FreeBSD / OpenBSD,2000 年):
1
2
3
4
5
6
7
int kq = kqueue();
struct kevent ev;
EV_SET(&ev, fd, EVFILT_READ, EV_ADD, 0, 0, NULL);
kevent(kq, &ev, 1, NULL, 0, NULL); // 注册监听
struct kevent events[64];
int n = kevent(kq, NULL, 0, events, 64, NULL); // 等待事件(等价 epoll_wait)
思路和 epoll 一样:注册 fd → 等事件 → 处理。但 API 更通用:同一个 kevent 调用还能监听文件变化、进程信号、定时器,不需要 Linux 的 signalfd、timerfd 等辅助机制。资源回收也和 epoll 类似:EV_SET(&ev, fd, EVFILT_READ, EV_DELETE, ...) 取消监听,close(kq) 关闭 kqueue 实例。
Event Ports(Solaris / illumos):和 epoll/kqueue 类似的就绪通知机制,API 略复杂,用户量少。
7.3 三大家族
1
2
3
epoll → Linux
IOCP → Windows
kqueue → macOS / FreeBSD / OpenBSD
libuv(Node.js 底层)在网络 I/O 上适配了 Windows IOCP、Linux epoll、BSD/macOS kqueue 等机制。不能把其余所有平台一概归为 epoll;文件系统等操作还可能使用线程池或其他平台机制。跨平台接口相同,不代表底层操作路径完全相同。
八、从原理到实践
前面讲的都是操作系统提供的基础机制。这一章展示一套 C++ 框架如何在这些机制之上搭出服务骨架,以及业务代码如何使用。以下代码基于框架结构简化,旨在说明组件职责,不是可直接运行的完整实现;具体线程安全与生命周期保证需对照实际源码确认。
8.1 框架骨架
框架的四块核心组件:
1
2
3
4
SockServer ← 总控:持有 Accepter、Connecter、ObjectPool
Accepter ← accept 新连接,从 ObjectPool 取 SockConn,分配给 Connecter
ConnecterImpl ← 事件循环线程:epoll_wait → RecvNetData → Handle → SendNetData
ObjectPool ← 按块分配 SockConn(默认每块 10 个),空了按需扩容,不等待其他连接归还;分配本身仍有成本
下面按数据流入顺序逐个展开。
8.2 Accepter:连接入口
Accepter 是一个独立线程,核心逻辑在 AcceptClient():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void AccepterImpl::AcceptClient() {
while (!m_bStop) {
Sockfd sfd = accept(m_listenFd, ...); // ① 阻塞等待新连接
if (sfd < 0) {
// EINTR 可重试;其他错误需分类处理,避免忙循环。
HandleAcceptError();
continue;
}
SetNoBlock(sfd); // ② 设为非阻塞
SockConn *pSockConn = m_pSockServer->GetSockConn(); // ③ 从 ObjectPool 取
pSockConn->SetFd(sfd, ip, port); // ④ 写入 fd 和地址
m_pSockServer->OnAccept(pSockConn); // ⑤ 分发给负载最低的 Connecter
}
}
上述代码还省略了停止协调和失败回收:跨线程停止标志需使用原子变量或同步保护,阻塞 accept 需要可唤醒的退出方案;非阻塞设置、对象获取和注册失败时,应正确回收已接受的 fd。
OnAccept 遍历所有 Connecter,找到当前连接数最少的那个,把 SockConn 交给它,Connecter 内部调 epoll_ctl(ADD) 将 fd 注册到自己的 epoll 实例中。从此刻起,这个连接的事件由该 Connecter 的 epoll 线程接管。
8.3 ConnecterImpl:事件循环
ConnecterImpl::Init() 中完成 epoll_create 和 epoll_ctl 注册。启动后每个 Connecter 线程跑事件循环:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 示意接口:事件使用稳定的连接 ID/代次标识;管理器负责安全获取对象。
void ConnecterImpl::Loop() {
while (!m_bStop) {
int n = epoll_wait(m_EpollFd, m_events, m_nMaxSize, 1000);
if (n < 0) {
if (errno == EINTR) continue;
HandleWaitError();
break;
}
for (int i = 0; i < n; ++i) {
auto conn = AcquireLiveConn(m_events[i].data.u64);
if (!conn) continue; // 旧代次事件,忽略
auto flags = m_events[i].events;
if (flags & (EPOLLIN | EPOLLRDHUP | EPOLLHUP))
conn->RecvNetData(); // 读取剩余数据,分别处理 EAGAIN、EOF 和错误
if (!conn->IsClosed() && (flags & EPOLLERR))
conn->HandleSocketError();
if (!conn->IsClosed() && (flags & EPOLLOUT))
conn->SendNetData(); // 处理部分发送;队列清空后取消写事件
conn->AdvanceCloseState(); // 按协议决定半关闭后的响应、排空或关闭
if (conn->IsClosed())
RetireConn(conn); // 注销、关闭;满足生命周期条件后再回池
}
}
ShutdownConnections(); // 清理连接并协调未完成任务
close(m_EpollFd);
}
这些辅助方法表示需要实现的职责,不是 epoll 自带的接口。RecvNetData() 应区分:正数为收到数据;EAGAIN/EWOULDBLOCK 为暂时读完;EINTR 通常重试;recv(..., size > 0, ...) == 0 为对端发送方向结束;其他错误分类处理。读到 EOF 不代表必须丢弃尚未发送的响应。
关闭必须幂等,但 closed 标志还不够。同一个事件可能同时带有多个标志;旧事件、业务任务和发送队列也可能仍引用连接。需要保证:
- 注销、关闭和归还对象池分别只执行一次。
- 先处理剩余数据,再按协议决定关闭时机;半关闭场景可继续发送响应。
- 已返回的事件通过稳定标识和代次校验,或有效的生命周期保护,避免访问被复用的对象。
- 业务异步任务持有安全连接引用或连接 ID/代次;旧任务不能向同一内存地址复用后的新连接回包。
存储 data.ptr 可以直接取得对象指针,但不能自动保证指针有效。若选择指针方案,需要相应的引用管理或延迟回收策略;本示例选择 data.u64 标识方案,不应与指针读取混用。
8.4 SockConn:连接对象
一个 SockConn 对象包含连接的全部上下文:
| 成员 | 用途 |
|---|---|
m_fd |
socket fd,标识这条 TCP 连接 |
m_pRecvBuf |
接收缓冲区,recv() 读到这儿 |
m_SendMsgList |
发送消息链表,SendMsg() 把数据塞到这儿 |
发送数据时,SendMsg(pSockConn, &header) 内部调用 AddMsg()——把消息 new 到堆上、拷一份进 m_SendMsgList,然后启用 EPOLLOUT 监听(修改掩码时保留仍需关注的读事件等标志),让 socket 可写时推进发送。epoll 线程下一轮 epoll_wait 返回 EPOLLOUT 时调 SendNetData() → send(m_fd, ...) 真正发出。
这套实现若使用 new + memcpy,就会产生分配和复制成本,但它不是所有异步模型的必然代价。真正必须保证的是:发送完成前,数据一直有效。可以通过转移缓冲区所有权、共享不可变缓冲区、内存池或分散/聚集 I/O 等方式减少分配与复制。发送队列还需保证线程安全,正确处理部分发送、EAGAIN 和发送失败;队列排空后取消写事件,避免 LT 下持续空转。
8.5 ObjectPool:对象生命周期
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<class T>
class ObjectPool {
std::queue<T*> m_freeQueue;
T* AcquireObject() {
if (m_freeQueue.empty()) Alloc(); // 不等待对象归还;内存分配仍可能耗时或失败
T* obj = m_freeQueue.front();
m_freeQueue.pop();
return obj;
}
void ReleaseObject(T* obj) {
m_freeQueue.push(obj); // 归还,不是 delete
}
~ObjectPool() {
// 析构时批量 delete[] 所有已分配的块
for_each(m_allocObject.begin(), m_allocObject.end(), DeleteObject);
}
};
以上省略了同步逻辑:Accepter 获取对象、多个 Connecter 归还对象时,会并发访问同一个池,std::queue 必须由互斥锁或其他正确的同步机制保护;Alloc 和内存分配失败也应处理。
归还前需完成注销与 fd 清理,并确认没有仍可访问该对象的事件、业务任务或发送操作。ObjectPool 只负责存储复用,不能代替连接生命周期管理。关闭不只来自 HUP,也可能来自 EOF、错误、超时、主动关闭或服务退出。
新连接 accept 时调 AcquireObject() 取一个空白 SockConn,写入 fd 后注册到 epoll。连接断开时调 ReleaseObject() 归还,下次连接复用。按块分配(new T[10]),分批向堆申请内存,减少分配频率。
8.6 业务代码如何使用
业务开发者只需继承 SockServer 并实现 MessageHandler。启动入口:
1
2
3
4
CTKxxxStateMachineHandler handler;
CTKxxxStateMachineService service(&handler); // 继承 SockServer
service.Init();
service.SockServer::Start(); // 启动 Accepter + N 个 Connecter
MessageHandler::Handle(pMsg, pSockConn) 是业务代码的唯一入口——消息已经解好包,连接已经分配好,只要写业务逻辑:
1
2
3
4
5
int Handler::OnReqPushData(PTKHEADER pMsg, SockConn *pSockConn) {
g_service->m_asynPushData.AddWorkData(pMsg, ...); // 投递到异步队列
SendMsg(pSockConn, &ack); // 回复 ACK
return 0;
}
完整链路:
1
2
3
4
accept → ObjectPool.AcquireObject() → 写入 fd → epoll_ctl 注册
→ epoll_wait 返回 EPOLLIN → RecvNetData → 解析 TKHEADER
→ Handle(pMsg, pSockConn) → 业务逻辑 → AddWorkData / SendMsg
→ 关闭条件满足 → epoll_ctl 删除 → close(fd) → 生命周期安全后回池
整个过程业务代码只接触 Handle() 和 SendMsg() 两个接口——fd 管理、epoll 注册、对象池分配回收全由框架完成。
8.7 四项工程实践
从原理到框架之后,还有几项工程实践是几乎所有高并发服务都会遇到的。
8.7.1 泳道隔离
epoll 线程收到消息后需要投递给业务线程处理,但多个业务线程并发处理同一用户的数据会引发竞争。解决方式是对用户 ID 做哈希,将同一用户的请求固定路由到同一个队列:
1
2
3
4
5
6
7
8
9
epoll 线程投递消息
│
┌─────▼─────┐
│ Hash(UID) │ → 同 UID 消息固定落同一队列
└─────┬─────┘
│
Queue-0 Queue-1 ... Queue-N
│ │ │
Thread0 Thread1 ThreadN
在每个队列只有一个消费者、同 UID 的相关状态都由这条路径访问、处理完成前不异步逃逸的前提下,同 UID 请求可串行执行,减少该状态上的锁竞争。不同 UID 若哈希到同一个队列,也会串行;跨 UID 共享资源、跨进程并发和其他访问入口仍需要同步。多生产者入队还需明确消息顺序要求。
8.7.2 双路径设计
不同消息对响应时机的需求不同,通常分为两条路径:
受理后异步处理路径:适用于通知类消息。先校验并尝试接纳请求,再按协议回复“已受理”ACK,随后执行业务;这里的 ACK 不代表业务已经完成。若协议要求进程崩溃后也不能丢消息,应在满足持久化等可靠性条件后确认,并配合重试、去重和幂等。不能先确认成功,再因队列已满丢弃请求。
请求—响应路径(request-response,业务语义):适用于查询类消息(如客户端拉取数据)。epoll 线程投递到队列后立即释放去服务其他连接,队列线程完成业务计算后通过原连接的 SendMsg 回包(SendMsg 只是把数据入队到 SockConn 的发送队列,真正的 send() 仍由 epoll 线程执行)。
8.7.3 反压保护
业务队列设置条目数及必要的字节数上限,超限时按协议拒绝、返回繁忙、暂停读取或触发限流。单纯静默丢弃属于过载丢弃策略,不一定形成有效反压;若上游无感知地持续重试,可能进一步放大负载。发送队列、单连接缓冲和未完成异步任务也需限制,防止慢客户端或大请求耗尽内存。
8.7.4 核心纪律
无论哪种路径,epoll 线程绝不执行耗时操作。收到请求后要么立即回 ACK,要么投递到异步队列,事件循环应及时处理其他连接,避免一个连接长期独占线程。解析、批量收发等也可设置处理预算;ET 下若未处理到 EAGAIN 就让出,必须保留可继续执行的调度状态。事件驱动减少空闲连接的等待成本,合理的处理预算和队列设计才能把这种优势保留下来。
8.8 SockServer 如何组合这些组件
SockServer 是框架的总控类,将前面四个组件串联起来。SockServer::Start() 的启动顺序:
1
2
3
4
5
6
int SockServer::Start() {
for (auto& c : m_pConnecterVec)
c->Start(); // ① 先启动所有 Connecter 的事件循环线程
m_pAccepter->Start(); // ② 再启动 Accepter,开始 accept
return 0; // 实际实现还需检查各组件启动结果
}
可以先完成 Connecter 初始化与启动,再开启 Accepter。仅按顺序调用 Start 不必然保证工作线程已经就绪;若存在异步初始化,还需就绪握手,或保证连接可安全排队。SockServer 还提供了三个供组件间调用的接口:
1
2
3
4
5
6
7
8
9
10
11
12
// Accepter 调:从池中取 SockConn
SockConn* GetSockConn() { return m_SockConnPool.AcquireObject(); }
// Accepter 调:将新连接分配给负载最轻的 Connecter
void OnAccept(SockConn *pSockConn) { ... }
// Connecter 调:连接断开后归还 SockConn
void PushSockConn(SockConn *pConn) {
// 前提:已注销、关闭,并确认事件与异步任务不再引用该对象。
pConn->Reset();
m_SockConnPool.ReleaseObject(pConn);
}
回看 8.1 的骨架图,现在可以理解它们之间的数据流:
1
2
3
4
5
6
7
8
9
10
11
12
13
Accepter::accept()
→ GetSockConn() 取空白 SockConn
→ SetFd(fd, ip, port)
→ OnAccept() 分配 Connecter → epoll_ctl(ADD)
↓
ConnecterImpl::Loop()
→ epoll_wait → RecvNetData → Handle(pMsg, conn)
→ SendNetData / SendMsg → AddMsg → epoll 线程 send()
→ 关闭条件满足 → 注销并关闭 fd → 生命周期安全后回池
↓
ObjectPool
AcquireObject() ← GetSockConn()
ReleaseObject() ← PushSockConn()
这就是从原理到框架的完整闭环。
8.9 总结
选型结论:常见选择是 Linux 上的 epoll、Windows 上配合 overlapped I/O 的 IOCP,以及 macOS/BSD 上的 kqueue;这些是常见方案,不是操作系统唯一可用的接口。框架通过 #ifndef _WIN32 条件编译屏蔽平台差异,业务代码不感知底层实现。
性能结论:epoll 的就绪通知与 IOCP 的完成通知都能用少量线程管理大量连接。通知模型不直接决定拷贝次数,也不能单独决定吞吐和延迟;应结合平台、负载、线程模型、队列和缓冲区策略进行测量。
开发结论:SockServer、Accepter、ConnecterImpl、SockConn 和 ObjectPool 可以分离连接接入、事件处理、业务分发与对象复用。跨平台封装之外,生产实现仍需落实错误处理、半关闭、队列上限、线程安全及连接生命周期;代码行数和平台代码占比应以实际源码统计为准。
九、延伸阅读
- 高性能C++服务架构分析 —— 本系列主文档,涵盖 epoll 多 Reactor 在实际项目中的落地架构
- 《Unix Network Programming》第 6 章 —— W. Richard Stevens 的 I/O 多路复用经典讲解
- The C10K Problem —— Dan Kegel 关于单机万并发的经典文章
官方资料与接口语义
- Linux select(2):fd_set 限制、就绪语义与参数修改。
- Linux epoll(7):LT/ET、重复 fd 与事件缓存生命周期。
- Linux epoll_ctl(2):事件标志、HUP/RDHUP 与 EPOLLEXCLUSIVE。
- Linux epoll_wait(2):等待、信号与 epoll_pwait。
- Linux epoll_create(2):CLOEXEC 与实例管理。
- Microsoft:I/O Completion Ports:完成队列与线程调度。
- Microsoft:Overlapped I/O and Event Objects:提前投递缓冲区与完成语义。
- Microsoft:WSASend:发送完成、缓冲区生命周期与返回值。
- Microsoft:RIORegisterBuffer:缓冲区注册与锁页。