IO多路复用 epoll & IOCP
从 select 到 IOCP 的四代 I/O 多路复用机制完整对比,涵盖原理、数据结构、复杂度分析和跨平台实践,适合后端工程师理解高性能网络编程的基础。
摘要:本文从 “一个服务怎么同时处理成千上万个 TCP 连接” 这个问题出发,依次讲解 select、poll、epoll、IOCP 四代 I/O 多路复用机制的演进脉络,对比它们的数据结构、时间复杂度和适用场景,并解释 Reactor 与 Proactor 两种模式的本质区别。
一、问题起源:一个服务怎么 “同时” 处理成千上万个 TCP 连接?
一个 TCP 连接就是一个 socket fd(文件描述符)。传统做法有两种,但都有致命缺陷:
方法 A:一个连接一个线程(BIO 阻塞 I/O)
1
2
3
4
socket = accept() // 阻塞,等新连接
read(socket, buf) // 阻塞,等数据到达
process(buf)
write(socket, resp) // 阻塞,等数据发出
问题:1 万个连接需要 1 万个线程。每线程栈占用 ~8MB → 80GB 内存,且大量 CPU 时间消耗在线程上下文切换而非实际工作。
方法 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(1983 年,POSIX 标准,全平台)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
fd_set readfds; // fd_set 是一个位图,默认最大 1024 位
FD_ZERO(&readfds);
FD_SET(sock1, &readfds); // 把要监视的 fd 加入位图
FD_SET(sock2, &readfds);
int select(maxfd+1, &readfds, NULL, NULL, &timeout);
// 内核遍历 0~maxfd 所有 fd,检查每个是否有事件
// 返回后就绪 fd 在位图中对应位被置 1
// 四个核心问题:
// ① fd_set 大小固定(默认 1024),突破需改内核宏重编译
// ② 每次调用都要把整个 fd_set 从用户态拷贝到内核态 O(n)
// ③ 内核通过遍历全部 fd 来查找就绪事件 O(n)
// ④ 返回后用户还需要遍历全部 fd 找出哪些就绪 O(n)
核心缺陷:O(n) 的 “全量传入 + 全量遍历 + 全量传出” 模式,n 越大性能越差。
三、第二代:poll(1997 年,POSIX 标准,全平台)
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 poll(fds, nfds, timeout);
改进:用 pollfd 数组代替 fd_set 位图,突破 1024 个 fd 的限制。
仍存在的问题:每次调用仍然要 O(n) 拷贝全部 fd 到内核、O(n) 内核遍历全部 fd、O(n) 用户遍历全部 fd 找就绪事件。poll 只是解除了数量上限,没有改变算法复杂度。
四、第三代:epoll(2002 年,Linux 2.6+)
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
// epoll 创建:
int epfd = epoll_create(1); // 创建 epoll 实例,内核分配事件表
// 函数返回该对象的文件描述符 epfd
struct epoll_event ev;
ev.events = EPOLLIN; // 关心可读事件
ev.data.fd = sock1; // 把监听套接字注册进 epoll,监听可读事件
epoll_ctl(epfd, EPOLL_CTL_ADD, sock1, &ev); // 注册到内核(只做一次!)
ev.data.fd = sock2; // 注册 sock2 前,先更新 data.fd
ev.data.ptr = my_conn_struct; // data 中还可以存指针,这里指向一个自定义的连接结构体
epoll_ctl(epfd, EPOLL_CTL_ADD, sock2, &ev);
// 阻塞等待事件就绪:
struct epoll_event events[1024]; // 用户态分配的数组,用于接收内核返回的就绪事件
int n = epoll_wait(epfd, events, 1024, timeout); // 核心等待函数,获取已就绪事件列表
// events[0..n-1] 只包含就绪的 fd —— 不需要遍历全部 fd
epoll_wait 等待的 “事件” 本质上是对 socket 内核缓冲区 的状态通知。每个 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
// epoll_wait 返回后:
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN)
recv(events[i].data.fd, buf, size, 0); // 从内核接收缓冲区读数据
if (events[i].events & EPOLLOUT)
send(events[i].data.fd, buf, size, 0); // 往内核发送缓冲区写数据
}
关键事件类型:
| 事件 | 含义 |
|---|---|
EPOLLIN |
接收缓冲区有数据。对监听 fd 意味着有新连接可以 accept(),对连接 fd 意味着可以 recv() |
EPOLLOUT |
发送缓冲区有空位,可以 send() |
EPOLLHUP / EPOLLERR |
对端关闭或连接出错,需要清理连接 |
资源回收 同样需要显式处理,先取消 epoll 注册,再关闭 fd:
1
2
3
4
5
// 连接断开时,移除监听并关闭套接字:
epoll_ctl(epfd, EPOLL_CTL_DEL, sock1, NULL); // 从 epoll 红黑树中移除
close(sock1); // 关闭 socket
// 服务退出时,关闭 epoll 实例,释放内核事件表:
close(epfd);
4.2 epoll 为什么快?核心数据结构
1
2
3
4
5
6
7
8
9
10
select/poll:
每次调用 → 拷贝全部fd到内核 → 遍历全部fd检查状态 → 拷贝就绪位图回用户态 → 遍历全部fd找就绪
复杂度:O(n),n=监视的fd总数,每次调用都重来一遍
epoll:
初始化: epoll_create 创建红黑树(存储所有注册fd) + 就绪链表
注册: epoll_ctl 将fd插入红黑树 O(log n),为fd注册内核回调
当fd就绪时,回调函数自动把fd放入就绪链表 O(1)
等待: epoll_wait 直接返回就绪链表内容 O(k),k=就绪fd数量,k << n
不需要拷贝全部fd,不需要遍历全部fd
epoll 的三大关键改进:
| 改进点 | select/poll | epoll |
|---|---|---|
| fd 状态存储 | 每次调用传入(用户态维护) | 内核红黑树持久维护 |
| 就绪事件发现 | 遍历全部 fd 逐个检查 | 回调驱动,就绪时自动入链表 |
| 返回结果 | 返回全部 fd,用户遍历查找就绪 | 只返回就绪 fd 列表 |
4.3 两种触发模式
理解了 epoll 为什么快之后,下一个问题是:epoll_wait 返回就绪事件时,通知的规则是什么?epoll 提供两种选择:
1
2
3
4
5
6
7
8
水平触发 (Level Triggered, LT) — 默认模式,编程简单
"只要缓冲区还有数据没读完,就一直通知你"
类比:水龙头没关就一直提醒
边缘触发 (Edge Triggered, ET) — 效率更高,编程复杂
"只在缓冲区从空变为非空的那一刻通知一次"
类比:只在水龙头刚打开时说一次,后面自己处理完
ET 必须配合非阻塞 socket + 循环 read 直到 EAGAIN
4.4 epoll 的演进
从 2002 年至今,epoll 的核心 API 和数据结构一直保持稳定,但陆续增加了几个补丁:
| 年份 | 内核版本 | 新增 | 作用 |
|---|---|---|---|
| 2006 | 2.6.17 | EPOLLRDHUP |
检测对端半关闭(只关写不关读),之前需要靠 read()==0 来判断 |
| 2006 | 2.6.19 | epoll_pwait() |
带 sigmask 参数的等待,避免信号打断事件循环 |
| 2008 | 2.6.27 | epoll_create1(EPOLL_CLOEXEC) |
创建时直接设 close-on-exec 标志,避免 fork 后的 fd 泄漏 |
| 2016 | 4.5 | EPOLLEXCLUSIVE |
多个进程/线程 epoll 同一 fd 时只唤醒一个,解决惊群问题 |
4.5 Reactor 模式
Reactor(反应器) 是一种 同步事件处理模式:一个线程阻塞等待多个事件源的就绪通知,然后将就绪事件分发给对应的处理器回调。
整个过程是同步的,handler 在调用者的线程中执行,I/O 操作(read / write)由 handler 自己完成。
Doug Schmidt 在《Pattern-Oriented Software Architecture》中正式命名了这一模式,其核心是一个事件循环:
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 就是 Reactor 模式在 Linux 上的典型实现——demultiplexer.wait() 对应 epoll_wait()。
下一章要讲的 IOCP 则是另一种模式:Proactor。
五、第四代:IOCP(Windows,真正的异步 I/O — Proactor 模式)
5.1 工作机制与对比
IOCP (I/O Completion Port) 的设计思路和 epoll 有 根本性不同。
IOCP 中的 GetQueuedCompletionStatus 等待的是之前投递的异步 I/O 操作完成——WSARecv 完成(数据已在用户 buffer)、WSASend 完成(数据已发走)、AcceptEx 完成(新连接已接受)。epoll 通知你 “可以读了”,IOCP 通知你 “已经读好了”:
1
2
3
4
5
6
7
8
9
epoll 是 "就绪通知"(Reactor 模式):
内核: "fd 上有数据了,你来读吧"
用户: 自己调用 read() 去读取 → read() 可能还是阻塞的(数据在内核缓冲区)
IOCP 是 "完成通知"(Proactor 模式):
用户: ReadFile/WSARecv 投递一个 IO 请求 → 立即返回(不阻塞)
内核: 后台完成实际的数据传输(DMA 直接把数据拷到用户 buffer)
内核: IO 完成后,把一个"完成包"放入 IOCP 队列
用户: GetQueuedCompletionStatus 从队列取完成包 → 数据已经在用户 buffer 里了
Reactor 与 Proactor 的本质区别:
| 维度 | Reactor(epoll) | Proactor(IOCP) |
|---|---|---|
| 谁负责 IO 传输 | 用户线程调用 read/write | 内核异步完成 DMA 传输 |
| 通知什么 | “数据就绪,可以读了” | “数据已经读好了” |
| 用户线程阻塞点 | read/write 系统调用 | 仅等待完成通知 |
| 零拷贝能力 | 需配合 sendfile/splice | 天然支持 DMA 零拷贝 |
5.2 “零拷贝” 到底免了什么
先解释 DMA(Direct Memory Access):主板上的一块独立硬件,能在 CPU 不参与的情况下完成设备(网卡、磁盘)和内存之间的数据传输。CPU 只需下指令 “从网卡搬 N 个字节到地址 0x…” ,DMA 控制器自己完成搬运,CPU 可以同时执行其他指令。
epoll 模式下,recv(fd, user_buf, n) 这一步涉及一次 CPU 拷贝:内核 TCP 缓冲区 → 用户态 user_buf。数据路径如下:
1
2
3
网卡 → DMA → 内核 TCP 缓冲区 → CPU(memcpy) → 用户缓冲区
↑
recv() 的开销
IOCP 模式下,WSARecv 调用时指定的用户 buffer 直接注册为 DMA 目标,数据到达网卡后直接写入用户内存,绕过了内核到用户态的 CPU 拷贝:
1
网卡 → DMA → 用户缓冲区(WSARecv 预注册的目标地址)
但注意:TCP 协议栈本身仍有校验和、重排序等 CPU 处理,”零拷贝” 仅指 应用层能看到的 recv/send 拷贝被省掉了,不是整个网络栈零开销。
对实际应用影响多大? 每次 recv() 拷贝的体积是你的消息体大小。一个任务系统处理的消息通常几 KB,一次 memcpy 几 KB 耗时微秒级。与之对比,一次 Redis 查询 1ms,一次规则计算 5-20ms。IOCP 省下的微秒级开销在实际业务中可以忽略。DMA 零拷贝真正有意义的场景是大块数据传输:文件服务器、视频流、CDN 边缘节点。
六、四代机制对比总表
| 维度 | select | poll | epoll | IOCP |
|---|---|---|---|---|
| 出生年份 | 1983 | 1997 | 2002 | 1994(Windows NT 3.5) |
| 最大 fd 数 | 1024(可改) | 无限制 | 无限制 | 无限制 |
| 每次调用复杂度 | O(n) 全遍历 | O(n) 全遍历 | O(k) 仅就绪 | O(k) 仅完成 |
| fd 状态维护 | 每次传入 | 每次传入 | 内核维护(红黑树) | 内核维护 |
| 触发方式 | 水平触发 | 水平触发 | LT + ET | Proactor |
| 平台 | 全平台 | 全平台 | Linux only | Windows only |
| 编程模型 | 同步 | 同步 | 同步(Reactor) | 异步(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”。编译时预处理器只编译对应的分支,Linux 编译产物不包含一行 Windows 代码,反之亦然。这套代码当前只部署 Linux 和 Windows,两种写法无实质区别。如果将来需要支持第三种平台(如 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 底层)就是同时适配了三家:#ifdef _WIN32 走 IOCP,#elif __APPLE__ 或 __FreeBSD__ 走 kqueue,其余走 epoll。不管你开发跨平台框架还是只部署 Linux,知道这三家的存在就够了。
八、从原理到实践
前面讲的都是操作系统提供的基础机制。这一章展示一个生产级 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
void AccepterImpl::AcceptClient() {
while (!m_bStop) {
Sockfd sfd = accept(m_listenFd, ...); // ① 阻塞等待新连接
SetNoBlock(sfd); // ② 设为非阻塞
SockConn *pSockConn = m_pSockServer->GetSockConn(); // ③ 从 ObjectPool 取
pSockConn->SetFd(sfd, ip, port); // ④ 写入 fd 和地址
m_pSockServer->OnAccept(pSockConn); // ⑤ 分发给负载最低的 Connecter
}
}
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
void ConnecterImpl::Loop() {
while (!m_bStop) {
int n = epoll_wait(m_EpollFd, m_events, m_nMaxSize, 1000);
for (int i = 0; i < n; i++) {
SockConn *conn = (SockConn *)m_events[i].data.ptr;
if (m_events[i].events & EPOLLIN)
conn->RecvNetData(); // recv() + 解析 + Handle(pMsg, conn)
if (m_events[i].events & EPOLLOUT)
conn->SendNetData(); // 从 m_SendMsgList 取数据 send()
if (m_events[i].events & (EPOLLHUP | EPOLLERR | EPOLLRDHUP)) {
conn->Close(); // epoll_ctl DEL + close(fd)
m_pSockServer->DelConn(conn); // SockConn 归还 ObjectPool
}
}
}
close(m_EpollFd); // 线程退出时关闭 epoll 实例
}
注册 fd 时 epoll_event.data.ptr 存的是 SockConn 指针,事件触发时零查找开销直接拿到连接对象。
8.4 SockConn:连接对象
一个 SockConn 对象包含连接的全部上下文:
| 成员 | 用途 |
|---|---|
m_fd |
socket fd,标识这条 TCP 连接 |
m_pRecvBuf |
接收缓冲区,recv() 读到这儿 |
m_SendMsgList |
发送消息链表,SendMsg() 把数据塞到这儿 |
发送数据时,SendMsg(pSockConn, &header) 内部调用 AddMsg()——把消息 new 到堆上、拷一份进 m_SendMsgList,然后 EpollMod(EPOLLOUT) 通知 epoll 有数据要发。epoll 线程下一轮 epoll_wait 返回 EPOLLOUT 时调 SendNetData() → send(m_fd, ...) 真正发出。
注意 SendMsg 中的 new + memcpy 是异步消息队列的固有代价——业务线程不能阻塞在 send() 上,必须先把数据拷到连接自己的发送队列里,由 epoll 线程统一发送。这个拷贝跟 epoll/IOCP 无关,任何异步模型都绕不开。
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);
}
};
注意归还前调用方必须先完成资源清理——epoll_ctl(DEL) 取消注册 + close(fd)。ObjectPool 只管 C++ 对象的复用,不管 fd 和 epoll。连接断开的完整链路:epoll_wait 返回 EPOLLHUP → epoll_ctl DEL → close(fd) → ReleaseObject() 归还池中。
新连接 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) → ObjectPool.ReleaseObject()
整个过程业务代码只接触 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 之间完全并行。这是一种用空间换正确性的设计——不需要在处理逻辑中加细粒度锁。
8.7.2 双路径设计
不同消息对响应时机的需求不同,通常分为两条路径:
异步路径(fire-and-forget):适用于通知类消息(如数据变更推送)。epoll 线程收到后立即回复 ACK 确认送达,然后投递到业务队列异步处理,处理完不再回复上游。快速 ACK 使得整条链路不被最慢的业务操作拖累。
同步路径(request-response):适用于查询类消息(如客户端拉取数据)。epoll 线程投递到队列后立即释放去服务其他连接,队列线程完成业务计算后通过原连接的 SendMsg 回包(SendMsg 只是把数据入队到 SockConn 的发送队列,真正的 send() 仍由 epoll 线程执行)。
8.7.3 反压保护
每个业务队列设置最大等待数,当队列积压超过上限时丢弃新请求,防止流量突增导致内存无限增长(OOM)。这是一个简单的生产者-消费者流控——队列满了生产者必须退让。
8.7.4 核心纪律
无论哪种路径,epoll 线程绝不执行耗时操作。收到请求后要么立即回 ACK,要么投递到异步队列,epoll 线程必须立刻回到 epoll_wait 继续服务其他连接。这条纪律是 Reactor 模式能支撑高并发的根本原因。
8.8 SockServer 如何组合这些组件
SockServer 是框架的总控类,将前面四个组件串联起来。SockServer::Start() 的启动顺序:
1
2
3
4
5
int SockServer::Start() {
for (auto& c : m_pConnecterVec)
c->Start(); // ① 先启动所有 Connecter 的事件循环线程
m_pAccepter->Start(); // ② 再启动 Accepter,开始 accept
}
先启动 Connecter 再启动 Accepter 是关键——确保新连接到达时 Connecter 已经就绪能接收。SockServer 还提供了三个供组件间调用的接口:
1
2
3
4
5
6
7
8
9
10
11
// 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()
→ EPOLLHUP → Close() → PushSockConn() 归还
↓
ObjectPool
AcquireObject() ← GetSockConn()
ReleaseObject() ← PushSockConn()
这就是从原理到框架的完整闭环。
8.9 总结
选型结论:操作系统决定了可用的 I/O 机制:Linux 用 epoll,Windows 用 IOCP,macOS/BSD 用 kqueue。框架通过 #ifndef WIN32 条件编译屏蔽平台差异,业务代码不感知底层实现。
性能结论:epoll 的 recv() CPU 拷贝和 IOCP 的 DMA 直写之间的差异是微秒级的,在毫秒级的业务处理耗时面前可以忽略。I/O 模型之间没有绝对的优劣,只有与部署平台的适配。
开发结论:本章展示的框架骨架(SockServer + Accepter + ConnecterImpl + SockConn + ObjectPool)约 1500 行代码,其中平台相关代码仅占约 25%,其余为跨平台 C++ 通用逻辑。
九、延伸阅读
- 高性能C++服务架构分析 —— 本系列主文档,涵盖 epoll 多 Reactor 在实际项目中的落地架构
- 《Unix Network Programming》第 6 章 —— W. Richard Stevens 的 I/O 多路复用经典讲解
- The C10K Problem —— Dan Kegel 关于单机万并发的经典文章