Appearance
网络服务器 I/O:select、poll、epoll 与 io_uring
网络服务器通常要同时维护大量连接,而绝大多数连接在某一时刻并没有数据可处理。核心问题不是“如何创建很多线程”,而是:如何用有限线程高效等待大量 socket,并在事件到来后正确处理状态、缓冲区和背压。
本文侧重服务器工程模型;
select、poll、epoll的操作系统机制概览见 I/O 多路复用。
先区分四个概念
阻塞与非阻塞
- 阻塞 I/O:
recv没数据时当前线程睡眠,直到有数据、连接关闭或发生错误; - 非阻塞 I/O:
recv暂时无法完成时立即返回-1,并设置errno = EAGAIN/EWOULDBLOCK。
非阻塞只说明“调用不会长时间睡眠”,不等于异步。使用 epoll 的事件循环仍然由应用在收到就绪通知后主动调用 accept/recv/send。
同步与异步
- 同步 I/O:应用发起并参与完成 I/O 操作;即使先等就绪,最终仍由应用调用
recv/send; - 异步 I/O:应用提交“执行读写”的请求,内核完成后再通知结果。
就绪通知与完成通知
| 模型 | 典型 API | 内核通知的含义 | 应用下一步 |
|---|---|---|---|
| 就绪(readiness) | select / poll / epoll | “现在执行某类 I/O 很可能不会阻塞” | 应用调用 accept/recv/send,处理部分读写和 EAGAIN |
| 完成(completion) | io_uring、Windows IOCP | “此前提交的 I/O 已完成,结果是 N 字节或错误码” | 应用消费完成项并更新连接状态 |
“socket 可读”不等于一条完整业务消息已经到齐:TCP 是字节流,可能只收到包头的一部分、一个完整消息或多个粘在一起的消息。
select
select 用多个 fd_set 位图表示关心的可读、可写和异常 fd:
- 用户态准备位图和超时时间;
- 每次调用都把集合传入内核;
- 内核检查所有 fd,把不就绪位清零;
- 返回后应用还要从
0扫描到max_fd找出就绪 fd。
关键限制
fd_set可表达的 fd 数量通常受FD_SETSIZE限制,常见为 1024;这是 fd 数值限制,不只是连接数量限制;- 内核和应用都要扫描整个范围,主要成本为 O(n);
select会修改传入的集合和timeval,因此每轮都要从主集合复制工作集合;- 第一个参数必须是
max_fd + 1,不是 fd 数量; - 优点是接口历史久、可移植性好,适合连接少、跨平台或教学场景。
cpp
fd_set master;
FD_ZERO(&master);
FD_SET(listen_fd, &master);
int max_fd = listen_fd;
for (;;) {
fd_set ready = master; // select 会修改集合
int n = select(max_fd + 1, &ready, nullptr, nullptr, nullptr);
if (n < 0) {
if (errno == EINTR) continue;
break;
}
for (int fd = 0; fd <= max_fd && n > 0; ++fd) {
if (!FD_ISSET(fd, &ready)) continue;
--n;
// listen_fd: accept;连接 fd: recv / 解析 / 写入发送队列
}
}poll
poll 用连续的 pollfd 数组替代位图:
cpp
struct pollfd {
int fd;
short events; // 关心的事件
short revents; // 实际发生的事件
};它解决了 fd_set 固定大小和 max_fd 位图扫描问题,但没有解决本质上的全量处理:
- 每次调用仍要把整个数组交给内核;
- 内核逐项检查,返回后应用逐项扫描
revents,仍是 O(n); - fd 数量主要受进程文件描述符上限和内存限制,不再受
FD_SETSIZE固定上限; - 将
fd设为负数可临时忽略该数组项;删除大量连接时要考虑数组压缩成本。
poll 适合连接规模中等、希望接口简单且兼顾 POSIX 可移植性的场景。
epoll
epoll 把“兴趣集合”持久保存在内核中:
epoll_create1创建实例;epoll_ctl(ADD/MOD/DEL)增量维护 fd 及兴趣事件;epoll_wait只返回已经就绪的事件,而不是每轮传入全部 fd。
因此,大量连接长期空闲时,应用处理成本主要与本轮返回的活跃事件数 k 有关。不要把它简化为“所有操作都是 O(1)”:epoll_wait 获取就绪项可近似看作 O(k),而兴趣集合维护、内核唤醒和回调仍有成本,具体内部结构也属于 Linux 实现细节。
LT、ET 与 ONESHOT
| 模式 | 语义 | 使用要求 |
|---|---|---|
| LT(水平触发,默认) | 只要仍可读/可写,就会继续通知 | 更容易写对;单次未读完,下轮还会提醒 |
| ET(边缘触发) | 仅在状态从未就绪变为就绪时通知 | fd 必须非阻塞;循环 accept/recv/send 直到 EAGAIN |
EPOLLONESHOT | 通知一次后自动禁用该 fd,需 MOD 重新武装 | 多线程处理同一连接时避免并发消费,但必须保证重新武装 |
ET 并不天然比 LT 快。它可以减少重复通知,但会增加状态机复杂度;若漏读到 EAGAIN、漏重新武装或错误处理半关闭,连接可能永久“沉默”。
服务器事件循环骨架
cpp
int epfd = epoll_create1(EPOLL_CLOEXEC);
set_nonblocking(listen_fd);
struct epoll_event ev {};
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
std::array<epoll_event, 1024> events {};
for (;;) {
int n = epoll_wait(epfd, events.data(), events.size(), -1);
if (n < 0) {
if (errno == EINTR) continue;
break;
}
for (int i = 0; i < n; ++i) {
const auto flags = events[i].events;
const int fd = events[i].data.fd;
if (fd == listen_fd) {
for (;;) {
int conn = accept4(listen_fd, nullptr, nullptr,
SOCK_NONBLOCK | SOCK_CLOEXEC);
if (conn < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
if (errno == EINTR) continue;
break;
}
add_connection(epfd, conn);
}
continue;
}
if (flags & EPOLLERR) {
handle_socket_error_and_close(fd);
continue;
}
if (flags & (EPOLLIN | EPOLLRDHUP | EPOLLHUP)) {
// 读到 EAGAIN;recv == 0 表示对端关闭写方向。
if (!read_until_eagain(fd)) {
close_connection(fd);
continue;
}
}
if (flags & EPOLLOUT) flush_until_eagain(fd);
}
}代码只是结构示意,真实服务器还要维护连接对象、输入/输出缓冲区、协议解析器、定时器、发送队列和关闭状态。
事件循环辅助函数:职责速查
| 函数 | 通常要做什么 | 关键边界 |
|---|---|---|
set_nonblocking(fd) | 用 fcntl(F_GETFL) 读取旧状态,再以 `F_SETFL(old | O_NONBLOCK)` 设置非阻塞 |
add_connection(epfd, conn) | 创建 Connection 状态,初始化收/发缓冲和超时器,以 EPOLL_CTL_ADD 注册 `EPOLLIN | EPOLLRDHUP` |
read_until_eagain(fd) | 循环 recv;读到的字节追加输入缓冲并增量拆包;EINTR 重试,EAGAIN 表示本轮读空 | recv == 0 是对端关闭写方向,不一定立刻关闭本端;若还需发送响应,应标记半关闭,发完再关。限制输入缓冲,避免慢客户端耗尽内存 |
flush_until_eagain(fd) | 循环 send 待发送队列,记录部分写偏移;写空时取消 EPOLLOUT | EPOLLOUT 不保证一次发完;遇到 EAGAIN 保留剩余数据并继续监听。Linux 可用 MSG_NOSIGNAL 防止 SIGPIPE 结束进程 |
close_connection(fd) | 取消定时器/任务,EPOLL_CTL_DEL,清理连接状态和缓冲,最后 close(fd) | 保持幂等;避免当前事件批次中关闭 fd 后又被系统复用,导致旧事件误作用于新连接 |
更严谨的实现不宜让 read_until_eagain 只返回 bool,至少应区分“暂时读空”“对端半关闭”和“致命错误”。同样,flush_until_eagain 需要区分“暂时写满”和“连接错误”。业务处理、磁盘 I/O、数据库等待等耗时工作应投递给有界工作队列,不能直接阻塞事件循环。
epoll 高频易错点
- ET 没有读到
EAGAIN:剩余数据可能不再触发新边缘; - 始终监听
EPOLLOUT:socket 大部分时间可写,会造成事件循环空转;只有发送队列非空时才开启,写空后关闭; - 假设一次
send发完:非阻塞发送可能只写部分字节,剩余数据必须进入连接发送队列; - 只处理
EPOLLIN:还要处理EPOLLERR、EPOLLHUP、EPOLLRDHUP、半关闭和recv == 0; - 连接对象提前释放:
event.data.ptr可能变成悬空指针。可使用 fd + generation token,并把销毁推迟到当前事件批次结束; - fd 复用:旧 fd 关闭后数值可能很快分配给新连接,不能仅靠整数 fd 判断事件归属;
- 惊群:多线程/多进程共同等待监听 socket 时,可评估
EPOLLEXCLUSIVE或每 worker 使用SO_REUSEPORT; - 事件循环执行耗时任务:数据库慢查询、磁盘同步 I/O、压缩和复杂计算会阻塞所有连接,应提交给有界工作队列。
io_uring
io_uring 是 Linux 的异步 I/O 接口,主要通过用户态与内核共享的两个环通信:
- SQ(Submission Queue):应用放入 SQE,描述要执行的操作;
- CQ(Completion Queue):内核放入 CQE,
res是结果字节数或负错误码,user_data用于关联请求上下文; - 可一次提交多条 SQE,批量减少系统调用;可选 SQPOLL 由内核线程轮询 SQ,但会消耗额外 CPU,不应默认启用;
- 请求可能乱序完成,必须通过
user_data区分;需要顺序时使用连接状态机或IOSQE_IO_LINK等机制; - SQE 引用的缓冲区、地址结构和用户上下文必须至少存活到 CQE 到来,不能提交后立即复用或释放。
io_uring 的网络能力
| 能力 | 作用 |
|---|---|
ACCEPT / multishot accept | 一次提交可产生多次连接完成通知,减少重复提交 |
RECV / multishot recv | 连续接收并产生多个 CQE;常与 provided buffer ring 配合 |
| provided buffers / registered buffers | 预先提供缓冲区,减少分配和注册开销 |
| fixed files | 预注册 fd,降低每次查找和引用成本 |
| linked requests / linked timeout | 把操作与超时、依赖顺序组合提交 |
SEND_ZC | 在满足条件时减少发送路径的数据复制;仍要等待通知 CQE 后才能安全复用缓冲区 |
这些能力依赖内核和 liburing 版本。上线前应通过 feature probing 检测 opcode/flag 支持,并保留降级路径,不能仅凭“内核版本看起来够新”就假定全部可用。
io_uring 不是“默认零拷贝”
共享 SQ/CQ 减少的是提交和完成元数据在用户态/内核态之间的复制与系统调用次数。普通 recv/send 的网络载荷仍可能复制;只有使用 SEND_ZC、注册缓冲区或特定内核路径时,才可能进一步减少载荷复制,而且仍有缓冲区生命周期和回退语义。
io_uring 高频易错点
- CQ 消费不及时:完成队列积压甚至溢出,必须把 CQ 当作有界资源并持续回收;
- 默认完成有序:多个在途请求可能乱序完成。对同一 TCP socket,一般不要无约束地并发提交多个 send 或多个 recv;
- 缓冲区过早复用:内核尚未完成操作时修改/释放缓冲区会造成数据错误或生命周期问题;
- 取消等于立即停止:取消请求也可能与原请求完成竞态,必须同时处理“原操作已完成”和“取消完成”;
- 所有操作都是真异步:部分操作或文件类型可能回退到内核 worker 线程,仍可能受阻塞和线程池拥塞影响;
- 一个 ring 被所有线程共享:SQ/CQ 竞争和 cache line 抖动可能抵消收益。常见设计是每个 I/O worker 一个 ring,连接固定归属;
- 只看吞吐不看尾延迟:批量越大并不一定越好,过度 batching 可能增加排队延迟。
Reactor 与完成式模型
epoll 的优势是成熟、语义清晰、生态广;io_uring 的优势是批量提交、统一文件和网络异步操作、减少系统调用,并能使用更丰富的网络 opcode。两者不是简单的新旧替代关系。
对比速查
| 维度 | select | poll | epoll | io_uring |
|---|---|---|---|---|
| 模型 | 就绪 | 就绪 | 就绪 | 完成(也支持 poll 类操作) |
| 集合维护 | 每轮传入位图 | 每轮传入数组 | 内核持久维护兴趣集合 | 应用提交具体 I/O 请求 |
| 返回结果 | 修改位图,应用全扫描 | 设置 revents,应用全扫描 | 返回活跃事件 | 返回已完成操作的 CQE |
| 典型处理成本 | O(n) | O(n) | 约 O(k),k 为活跃数 | 与完成数相关,可批量 |
| FD 限制 | 常见 FD_SETSIZE | 资源限制 | 资源限制 | 资源和 ring 深度限制 |
| 可移植性 | 较好 | POSIX | Linux | Linux,且依赖较新内核 |
| 工程复杂度 | 低 | 低 | 中 | 高 |
| 推荐场景 | 小规模/教学 | 中小规模/兼容 | Linux 高并发网络默认选择 | 高 IOPS、批处理、磁盘与网络统一异步,并经压测证明确有收益 |
服务器工程结构
连接分片
常见模型是“监听/分发 + N 个 I/O worker”:
text
accept / SO_REUSEPORT
↓
按连接哈希或轮询分配
↓
I/O worker 0 I/O worker 1 ... I/O worker N-1
(event loop) (event loop) (event loop)
↓
同一连接始终由固定 worker 维护状态固定归属可避免每条消息都跨线程加锁。若同一玩家或房间还要求业务有序,可再按 player_id/room_id 哈希到固定业务队列。
每个连接至少维护什么
cpp
struct Connection {
int fd;
ReadBuffer input;
WriteQueue output;
ProtocolParser parser;
ConnectionState state;
TimePoint last_active;
std::uint64_t generation;
};- 输入缓冲区负责累计 TCP 字节并处理半包/粘包;
- 输出队列保存未发送完的数据,支持部分写;
- 状态机处理 TLS 握手、认证、正常通信、半关闭和最终关闭;
- generation 防止 fd 复用后旧事件误操作新连接;
- 活跃时间用于空闲超时和慢客户端清理。
背压与过载保护
高并发服务器不能只追求“尽可能多读”:
- 连接级高水位:发送队列超过阈值时暂停该连接读取、拒绝非关键消息或断开慢客户端;
- 全局有界队列:I/O 线程到业务线程的队列必须有上限,满时阻塞、拒绝或降级,不能无限堆积;
- 仅按需监听可写事件:输出队列为空就取消
EPOLLOUT; - 读写预算:单轮给每个连接限制最大字节/消息数,避免一个热点连接饿死其他连接;
- CPU 任务隔离:压缩、序列化、寻路、脚本执行等提交到有界工作池,完成后把结果投递回原 I/O worker;
- 过载指标:事件循环延迟、队列深度、发送积压、丢弃数、连接数、P95/P99、CQ 深度和
EAGAIN比例。
定时器
事件循环还需要处理握手超时、心跳、重传、空闲关闭等定时任务。连接多时可用时间轮降低大量定时器更新成本;数量较少或精度要求高时可用最小堆。不要为每个连接单独创建系统线程或频繁系统定时器。
如何选择
- 默认 Linux 网络服务器:先用成熟的非阻塞 socket + epoll,把连接状态机、背压和线程模型写正确;
- 大量文件/磁盘 I/O 与网络 I/O 混合、高 syscall 密度、可批量提交:评估 io_uring;
- 连接数很少或重视简单可移植:select/poll 足够;
- 跨平台:考虑 Asio、libuv 等抽象;底层可能分别使用 epoll、kqueue、IOCP;
- 不要为了“新”而使用 io_uring:必须在目标内核、真实连接数、包大小和 CPU 亲和性下比较吞吐、P99、CPU、内存与故障恢复。
面试速答
select使用 fd 位图,每轮在用户态和内核态之间传递并全量扫描,且通常受 FD_SETSIZE 限制;poll改成 pollfd 数组,取消固定上限但仍 O(n) 扫描。epoll在内核持久维护兴趣集合,epoll_wait返回活跃事件,适合大量空闲连接;ET 必须配合非阻塞并读写到 EAGAIN。io_uring是完成式异步接口,通过共享 SQ/CQ 批量提交和回收 I/O,可减少系统调用并支持 multishot、注册缓冲区和 SEND_ZC,但完成可能乱序、缓冲区生命周期复杂、特性依赖内核版本。工程上先保证状态机、部分读写、背压和连接分片正确,再用压测决定 epoll 还是 io_uring。