2.18 备选 I/O:select/poll/epoll/io_uring
为什么 select 有 1024 个 fd 的限制?poll 与 epoll 的复杂度差在哪?水平触发(LT)与边缘触发(ET)到底该怎么写?epoll 的「就绪链表」是什么?io_uring 又改变了什么?
用 poll/epoll 写出单线程多连接 echo 服务端;正确实现 LT 与 ET 两版读循环;解释 ET 为什么必须非阻塞并读到 EAGAIN;用 eventfd/signalfd/timerfd 统一事件源;写出一个 liburing 的最小异步读程序。
2.17 的服务端一次只服务一条连接:accept 之后就在 read/write 上阻塞,直到这条连接结束。要同时服务一万条连接,就得一万个线程/进程——内存与调度开销都不可接受。I/O 多路复用换了个思路:让一个线程同时等多个 fd,谁就绪就处理谁。
四种接口是同一需求的不同代际:select(1983)用位图,受 FD_SETSIZE 限制;poll(1997)用数组,去掉数量限制但仍要线性扫描;epoll(2002)把「注册」与「等待」分开,内核维护兴趣集合与就绪链表;io_uring(2019)进一步把「提交」与「收割」做成两个环形队列,系统调用可以批量、可以异步完成。
这一章的重点不是 API 列表,而是两种触发语义与事件循环的状态机。LT/ET 写错,症状是「压测时连接卡住」或「CPU 100% 空转」;事件循环里少处理一个 EPOLLERR,症状是「fd 泄漏」。这些都不是靠背函数签名能避免的。
2.18.1 五种 I/O 模型与「就绪」的含义
阻塞 I/O 非阻塞 I/O I/O 多路复用 信号驱动 I/O 异步 I/O
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ read() │ │ read() │ │ select │ │ sigaction│ │ io_uring│
│ 阻塞 │ │ EAGAIN │ │ poll │ │ SIGIO │ │ submit │
│ 等待 │ │ 轮询 │ │ epoll │ │ 等待信号 │ │ 立即返回│
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
数据拷贝 数据拷贝 通知「就绪」 通知「就绪」 通知「完成」
后返回 后返回 再 read 再 read 数据已在缓冲区
读图:前四种模型都是同步的——「就绪」只表示「现在读不会阻塞」,真正的数据拷贝仍由 read 完成,仍可能阻塞一小段时间。只有异步 I/O 的「完成」表示数据已经被内核放进你提供的缓冲区。io_uring 同时支持「就绪」与「完成」两种用法,但它的目标是后者。
区分两个词:
- 就绪(readiness):fd 上有数据可读/可写,调用
read/write不会阻塞(至少能读到一个字节); - 完成(completion):内核已经把数据拷贝到用户缓冲区,或已经把用户缓冲区写完。
select/poll/epoll 都是就绪通知。它们不减少数据拷贝次数,只减少「等待」的次数。io_uring 的 IORING_OP_READ 才是完成通知:提交后不用再调 read。
2.18.2 select:位图、1024 与「入参被修改」
#include <sys/select.h>
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(fd, &rfds);
struct timeval tv = { .tv_sec = 5, .tv_usec = 0 };
int n = select(fd + 1, &rfds, NULL, NULL, &tv);
if (n == -1) {
if (errno == EINTR) { /* 被信号打断 */ }
} else if (n == 0) {
/* 超时 */
} else if (FD_ISSET(fd, &rfds)) {
/* fd 可读 */
}
四个必须记住的细节:
nfds是「最大 fd + 1」,不是 fd 个数。内核要遍历0..nfds-1。fd_set是入参兼出参。select返回后它只保留「就绪的 fd」,下一轮必须重新FD_ZERO/FD_SET。这是最常见的 bug:循环里忘了重建,第二轮起就永远等不到。FD_SETSIZE默认 1024。fd 号大于等于 1024 时FD_SET越界写栈,行为未定义。改FD_SETSIZE要重新编译整个程序(包括 libc 内部使用它的地方),不现实。timeout在 Linux 上会被修改。返回后tv是「剩余时间」,循环里复用必须重新赋值。
select 的优点是可移植:POSIX、Windows、所有 Unix 都有。缺点是 fd 多时每次调用都要把整个位图从用户态拷到内核、再线性扫描,复杂度 O(n);1024 的上限让它不适合现代服务端。
2.18.3 poll:数组、无上限、仍要线性扫描
#include <poll.h>
struct pollfd fds[2] = {
{ .fd = fd0, .events = POLLIN },
{ .fd = fd1, .events = POLLIN },
};
int n = poll(fds, 2, 1000); /* 超时 1000 ms;-1 表示永久 */
if (n == -1) {
/* EINTR 或错误 */
} else {
for (int i = 0; i < 2; i++) {
if (fds[i].revents & POLLIN) { /* 可读 */ }
if (fds[i].revents & POLLOUT) { /* 可写 */ }
if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
/* 错误、挂断、fd 无效:无论 events 是否请求都会返回 */
}
}
}
与 select 相比:
- 没有
FD_SETSIZE限制,fd 数量只受内存与RLIMIT_NOFILE限制; events是入参,revents是出参,不需要每轮重建(但每次调用前仍要把revents清零,或依赖内核覆盖);- 仍然是 O(n):内核要遍历整个数组,用户态也要遍历找就绪项;
- 超时单位是毫秒(
int),比timeval简单。
POLLERR、POLLHUP、POLLNVAL 是总是报告的,不受 events 过滤。很多代码只检查 POLLIN,结果对端关闭(POLLHUP)时死循环——poll 一直返回,read 一直返回 0,CPU 打满。
poll 适合「fd 数量几百、跨平台要求不高」的场景。上万连接时,每次 poll 的线性扫描成为瓶颈。
2.18.4 epoll:兴趣集合与就绪链表
epoll 把「注册」与「等待」拆成两个系统调用:
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd == -1) { perror("epoll_create1"); }
struct epoll_event ev = {
.events = EPOLLIN | EPOLLRDHUP, /* 关心可读与对端半关闭 */
.data.fd = fd, /* 或 data.ptr,随你 */
};
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1) { perror("epoll_ctl ADD"); }
struct epoll_event events[64];
int n = epoll_wait(epfd, events, 64, -1); /* -1:永久等待 */
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) { /* 可读 */ }
}
epitem,挂在红黑树上;socket 的等待队列注册 ep_poll_callback。数据到达时回调把 epitem 挂到就绪链表并唤醒 epoll_wait。因此 epoll 的复杂度与「就绪 fd 数」相关,而不是「监听 fd 总数」——这是它能撑百万连接的核心。读图:epoll_ctl(ADD) 把一个 epitem 挂进内核的红黑树(兴趣集合),同时向目标 socket 的等待队列注册一个回调 ep_poll_callback。数据到达时,回调把 epitem 挂到就绪链表 rdllist,并唤醒 epoll_wait。epoll_wait 只取就绪链表,不扫描全部 fd——这是它相对 poll 的复杂度优势:注册 O(log n),等待 O(就绪数)。
epoll 的三个关键语义:
epoll_wait返回的是「当前就绪」的 fd,不是「自上次以来变化」的 fd。LT 下只要还有数据,下一次epoll_wait仍会返回它。close(fd)会自动从 epoll 移除,但如果 fd 被dup过,要等所有副本都关闭。epoll_ctl(DEL)不是必须的,但显式删除能避免「fd 号被复用后仍挂在旧事件上」的困惑。data是唯一能带回用户态的东西。用data.fd最简单;用data.ptr可以直接带回连接对象指针,省一次查找。注意对象生命周期:epoll_wait返回的事件数组里可能包含已经关闭的 fd,处理前要确认对象还有效。
LT 与 ET
- 水平触发(level-triggered, LT):只要 fd 处于「就绪」状态,每次
epoll_wait都会返回它。默认模式。 - 边缘触发(edge-triggered, ET):只在状态变化时返回一次(从「未就绪」变为「就绪」)。之后即使还有数据没读完,也不会再通知,直到下一次新数据到达。
| 维度 | LT | ET |
|---|---|---|
| 返回时机 | 只要就绪就返回 | 状态变化时返回一次 |
| 读循环 | 可以只读一次 | 必须循环读到 EAGAIN |
| 阻塞 fd | 可以工作(但会阻塞循环) | 必须非阻塞,否则最后一次 read 永久阻塞 |
| 编程难度 | 低 | 高,容易漏读/漏写 |
| 通知开销 | 数据多时重复通知 | 通知少,适合高并发 |
| 典型用户 | 大多数应用 | Nginx、Envoy、Redis |
ET 的纪律:
- fd 必须非阻塞。ET 通知后你要一直读到
EAGAIN;如果 fd 阻塞,最后一次read会卡住整个事件循环。 - 必须读到
EAGAIN。只读一次会留下未读数据,而 ET 不会再通知,连接就「饿死」了。 - 写也要处理
EAGAIN。ET 下写缓冲区满时write返回EAGAIN,必须把剩余数据存进用户缓冲区,等EPOLLOUT再写。 accept也要循环到EAGAIN。监听 fd 设为 ET 后,一次事件可能对应多个已完成连接。
EPOLLONESHOT 让 fd 在一次事件后自动从兴趣集合里禁用,处理完必须 epoll_ctl(MOD) 重新 armed。它适合「同一个连接可能被多个线程处理」的场景,避免两个线程同时读同一条连接。
EPOLLEXCLUSIVE(Linux 4.5+)用于 accept:多个进程/线程监听同一个 fd 时,内核只唤醒一个,避免惊群。它只能用于 EPOLL_CTL_ADD,不能与 EPOLLONESHOT 同用。
epoll 把注册的兴趣集合与就绪链表分开;epoll_wait 只遍历就绪链表。开启 JavaScript 可播放。
2.18.5 LT/ET 两版 echo 服务端
下面的程序用一个 mode 参数切换 LT/ET:ET 模式加 EPOLLET,读循环一直读到 EAGAIN;LT 模式每次事件只读一轮。两种模式都使用非阻塞 fd 与用户态输出缓冲区,因为 ET 下写也可能 EAGAIN。
/* epoll_echo.c:epoll 版 echo 服务端,支持 lt/et 两种模式 */
#include <errno.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#define PORT 12348
#define MAX_EVENTS 64
#define IN_BUF 4096
#define OUT_BUF 16384
struct conn {
int fd;
size_t out_len;
char out[OUT_BUF];
};
static void conn_destroy(struct conn *c)
{
if (c->fd >= 0)
close(c->fd);
free(c);
}
/* 把输出缓冲区尽量写出去;返回 -1 表示连接应关闭 */
static int conn_flush(struct conn *c)
{
size_t off = 0;
while (off < c->out_len) {
ssize_t n = write(c->fd, c->out + off, c->out_len - off);
if (n == -1) {
if (errno == EINTR)
continue;
if (errno == EAGAIN || errno == EWOULDBLOCK)
break; /* 内核缓冲区满,等 EPOLLOUT */
return -1;
}
off += (size_t)n;
}
if (off > 0) {
memmove(c->out, c->out + off, c->out_len - off);
c->out_len -= off;
}
return 0;
}
static int conn_append(struct conn *c, const char *data, size_t len)
{
if (c->out_len + len > sizeof c->out)
return -1; /* 输出积压过多:断开,避免内存无限增长 */
memcpy(c->out + c->out_len, data, len);
c->out_len += len;
return 0;
}
int main(int argc, char **argv)
{
int et = (argc > 1 && strcmp(argv[1], "et") == 0);
uint32_t base_events = EPOLLIN | EPOLLRDHUP | (et ? EPOLLET : 0);
int lfd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
if (lfd == -1) {
perror("socket");
return EXIT_FAILURE;
}
int one = 1;
if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) == -1) {
perror("setsockopt");
return EXIT_FAILURE;
}
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) == -1) {
perror("bind");
return EXIT_FAILURE;
}
if (listen(lfd, 128) == -1) {
perror("listen");
return EXIT_FAILURE;
}
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd == -1) {
perror("epoll_create1");
return EXIT_FAILURE;
}
struct epoll_event ev = { .events = base_events, .data.ptr = NULL };
if (epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev) == -1) {
perror("epoll_ctl listen");
return EXIT_FAILURE;
}
printf("epoll echo (%s) on %d\n", et ? "ET" : "LT", PORT);
fflush(stdout);
struct epoll_event events[MAX_EVENTS];
for (;;) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (n == -1) {
if (errno == EINTR)
continue;
perror("epoll_wait");
break;
}
for (int i = 0; i < n; i++) {
struct conn *c = events[i].data.ptr;
if (c == NULL) { /* 监听 fd */
for (;;) {
int cfd = accept4(lfd, NULL, NULL,
SOCK_NONBLOCK | SOCK_CLOEXEC);
if (cfd == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK)
break; /* ET 下必须取空 accept 队列 */
if (errno == EINTR)
continue;
perror("accept4");
break;
}
c = calloc(1, sizeof *c);
if (c == NULL) {
close(cfd);
continue;
}
c->fd = cfd;
struct epoll_event cev = {
.events = base_events,
.data.ptr = c,
};
if (epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &cev) == -1) {
perror("epoll_ctl conn");
conn_destroy(c);
}
}
continue;
}
if (events[i].events & (EPOLLERR | EPOLLHUP)) {
conn_destroy(c);
continue;
}
if (events[i].events & EPOLLIN) {
char buf[IN_BUF];
for (;;) {
ssize_t r = read(c->fd, buf, sizeof buf);
if (r > 0) {
if (conn_append(c, buf, (size_t)r) == -1) {
conn_destroy(c);
c = NULL;
break;
}
} else if (r == 0) {
conn_destroy(c);
c = NULL;
break;
} else {
if (errno == EINTR)
continue;
if (errno == EAGAIN || errno == EWOULDBLOCK)
break; /* 读空了 */
conn_destroy(c);
c = NULL;
break;
}
if (!et)
break; /* LT:留一点给别的连接,下次还会通知 */
}
if (c == NULL)
continue;
}
if (c->out_len > 0) {
if (conn_flush(c) == -1) {
conn_destroy(c);
continue;
}
}
/* 有积压就订阅 EPOLLOUT,清空后取消订阅 */
struct epoll_event cev = {
.events = base_events | (c->out_len > 0 ? EPOLLOUT : 0),
.data.ptr = c,
};
if (epoll_ctl(epfd, EPOLL_CTL_MOD, c->fd, &cev) == -1) {
perror("epoll_ctl MOD");
conn_destroy(c);
}
if (events[i].events & EPOLLOUT) {
/* 下一轮 EPOLLOUT 会再次进入上面的 flush 逻辑 */
}
}
}
close(epfd);
close(lfd);
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g epoll_echo.c -o epoll_echo
./epoll_echo lt # 水平触发
./epoll_echo et # 边缘触发
# 另一个终端:
printf 'hello\n' | nc 127.0.0.1 12348
hello
两种模式都能 echo,但内部路径不同:
- LT:每次
epoll_wait都告诉你「可读」,读一次就返回,剩下的数据下一轮再读。实现简单,但数据多时通知次数多。 - ET:只在「有新数据」时通知一次,必须循环读到
EAGAIN;写方向也要在EPOLLOUT时继续 flush。
程序里有两个容易写错的点:
accept循环到EAGAIN。即使 LT,accept一次也可能取不完 accept 队列;ET 下则是必须。conn_destroy后立刻continue。epoll_wait返回的事件数组里,同一个 fd 只会出现一次,但后续事件可能指向已释放的对象——所以关闭连接后不能继续访问c。
2.18.6 eventfd、timerfd、signalfd:把一切变成 fd
事件循环最舒服的形态是「所有事件源都是 fd」。Linux 提供了三个把非 fd 事件变成 fd 的接口:
| 接口 | 把什么变成 fd | 读出来的内容 |
|---|---|---|
eventfd | 计数器/唤醒 | 8 字节 uint64_t,读后清零(默认) |
timerfd | 定时器 | 8 字节 uint64_t,到期次数 |
signalfd | 信号 | struct signalfd_siginfo |
eventfd 的典型用途是跨线程唤醒:一个线程往 eventfd 写 1,epoll_wait 立刻返回,事件循环处理队列里的任务。
int efd = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK);
uint64_t one = 1;
if (write(efd, &one, sizeof one) != sizeof one) { /* 错误 */ }
/* 事件循环里: */
uint64_t val;
if (read(efd, &val, sizeof val) == sizeof val) {
/* val 是累计计数 */
}
timerfd 在 2.11 讲过,signalfd 在 2.10 讲过。三者与 epoll 组合,就不需要 self-pipe、不需要信号处理器、不需要单独的定时器线程。
io_uring_register(ring, IORING_REGISTER_EVENTFD, &efd, 1) 让完成事件也写到 eventfd,这样 io_uring 可以和 epoll 共存。但更好的做法是直接用 io_uring 的完成队列,不必绕回 epoll。
2.18.7 io_uring:提交队列与完成队列
io_uring 的核心是两个环形缓冲区,位于用户态与内核共享的内存里:
用户态 内核态
┌─────────────────────────┐ ┌─────────────────────────┐
│ SQ ring(提交队列) │ mmap │ 内核读取 SQ entry │
│ tail ──► [SQE][SQE]... │ ───────► │ 执行 read/write/accept │
└─────────────────────────┘ └────────────┬────────────┘
│ 完成
┌─────────────────────────┐ ┌────────────▼────────────┐
│ CQ ring(完成队列) │ mmap │ 内核写入 CQE(res) │
│ [CQE][CQE]... ◄── head │ ◄─────── │ │
└─────────────────────────┘ └─────────────────────────┘
一次 io_uring_enter 可以提交多个 SQE、收割多个 CQE,系统调用次数从「每个 I/O 一次」降到「一批一次」。配合 IORING_SETUP_SQPOLL(内核线程轮询 SQ),甚至可以不进内核就提交。
最小使用分四步:
io_uring_setup(entries, ¶ms)创建 ring,返回 fd;mmapSQ ring、CQ ring、SQE 数组;- 填 SQE、更新 SQ tail、
io_uring_enter提交; - 从 CQ head 收割 CQE,更新 head。
直接用系统调用需要处理内存屏障与环形队列索引,容易出错。生产代码用 liburing,它把这些封装成 io_uring_get_sqe/io_uring_submit/io_uring_wait_cqe。
下面的程序用 liburing 批量读一个文件:一次提交 4 个读请求,再按顺序把结果写到 stdout。
/* uring_cat.c:用 io_uring 批量读文件(需要 liburing) */
#include <fcntl.h>
#include <liburing.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#define QD 4
#define BUF_SIZE 4096
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(int argc, char **argv)
{
if (argc != 2) {
fprintf(stderr, "usage: %s file\n", argv[0]);
return EXIT_FAILURE;
}
int fd = open(argv[1], O_RDONLY);
if (fd == -1)
die("open");
struct io_uring ring;
int rc = io_uring_queue_init(QD, &ring, 0);
if (rc < 0) {
fprintf(stderr, "io_uring_queue_init: %s\n", strerror(-rc));
return EXIT_FAILURE;
}
char *bufs[QD];
for (int i = 0; i < QD; i++) {
bufs[i] = malloc(BUF_SIZE);
if (bufs[i] == NULL)
die("malloc");
}
off_t offset = 0;
int eof = 0;
while (!eof) {
int submitted = 0;
for (int i = 0; i < QD; i++) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (sqe == NULL)
die("io_uring_get_sqe");
io_uring_prep_read(sqe, fd, bufs[i], BUF_SIZE, offset + (off_t)i * BUF_SIZE);
sqe->user_data = (unsigned long)i;
submitted++;
}
rc = io_uring_submit(&ring);
if (rc < 0) {
fprintf(stderr, "io_uring_submit: %s\n", strerror(-rc));
break;
}
/* 按 user_data 收集结果,再按偏移顺序输出,保证输出顺序 */
ssize_t got[QD];
memset(got, 0, sizeof got);
for (int i = 0; i < submitted; i++) {
struct io_uring_cqe *cqe;
rc = io_uring_wait_cqe(&ring, &cqe);
if (rc < 0) {
fprintf(stderr, "io_uring_wait_cqe: %s\n", strerror(-rc));
goto out;
}
int idx = (int)cqe->user_data;
got[idx] = cqe->res; /* 负数是 -errno */
io_uring_cqe_seen(&ring, cqe);
}
for (int i = 0; i < submitted; i++) {
if (got[i] < 0) {
fprintf(stderr, "read: %s\n", strerror((int)-got[i]));
goto out;
}
if (got[i] == 0) {
eof = 1;
break;
}
if (write(STDOUT_FILENO, bufs[i], (size_t)got[i]) != got[i]) {
perror("write");
goto out;
}
if (got[i] < BUF_SIZE) {
eof = 1; /* 短读:文件结束 */
break;
}
}
offset += (off_t)QD * BUF_SIZE;
}
out:
for (int i = 0; i < QD; i++)
free(bufs[i]);
io_uring_queue_exit(&ring);
close(fd);
return EXIT_SUCCESS;
}
# Debian/Ubuntu:sudo apt install liburing-dev
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g uring_cat.c -o uring_cat -luring
./uring_cat /etc/hosts
这个例子故意保持「一次提交 QD 个、收齐再写」,所以比同步 read 快不了多少;它的意义是展示批量提交与乱序完成:CQE 可能以任意顺序到达,程序用 user_data 把结果对回缓冲区,再按文件偏移输出。
生产中的 io_uring 用法:
- 网络服务:
IORING_OP_ACCEPT、IORING_OP_RECV、IORING_OP_SEND全部异步,一个线程可以管理上万连接; IORING_OP_READV/WRITEV:散列/聚集 I/O;IOSQE_IO_LINK:把多个操作串成链,前一个成功才执行下一个;IORING_REGISTER_FILES/BUFFERS:预注册 fd 与缓冲区,减少每次提交的开销。
io_uring 的限制:内核 5.1+(网络操作在后续版本才补齐),部分发行版默认禁用(担心安全面),容器里的 seccomp 可能拦截 io_uring_setup。用之前先检查 io_uring_setup 是否返回 ENOSYS/EPERM。
2.18.8 选型判据
| 场景 | 推荐 | 理由 |
|---|---|---|
| 少量 fd、要跨平台 | poll | 无 1024 限制,接口简单 |
已有 select 代码 | 保持或迁移 poll | FD_SETSIZE 与入参修改是长期负担 |
| 上万连接、Linux | epoll LT | 编程简单,吞吐足够 |
| 高并发 + 极致通知开销 | epoll ET | 通知少,但要处理完整状态机 |
| 需要异步完成、批量 I/O | io_uring | 减少系统调用,支持真正异步 |
| 每个连接一个线程 | 线程 + 阻塞 I/O | 连接数少、逻辑复杂时最简单 |
三个常见误区:
- 「
epoll一定比poll快」——连接少、活跃度高时,poll的常数更小;epoll的优势在「连接多、活跃少」。 - 「ET 一定比 LT 快」——ET 减少通知次数,但要求应用自己维护状态机;写不好(漏读、漏 flush)反而更慢、更容易卡死。
- 「
io_uring能替代epoll」——它可以,但生态、调试工具、内核版本支持都还不统一;多数服务端仍以epoll为主。
- 「
select的nfds是 fd 个数」——是最大 fd + 1。传小了会漏掉大 fd。 - 「
select的fd_set可以直接复用」——不能。返回后只剩就绪位,下一轮必须重建。 - 「
poll不会返回没请求的事件」——POLLERR/POLLHUP/POLLNVAL总是返回,必须处理。 - 「ET 下读一次就够了」——不够。必须读到
EAGAIN,否则剩余数据永远不会再触发通知。 - 「
epoll_wait返回的事件里 fd 一定还有效」——不一定。同一批事件里前面的处理可能已经关闭了后面的 fd;用data.ptr时要自己保证生命周期。
2.18.9 多路复用 API 全表:事件位、flags 与 errno
这一节回答:epoll_event.events 的每一位是什么?EPOLLERR/EPOLLHUP 为什么总会出现?epoll_ctl 的 EEXIST/ENOENT/ENOSPC 分别对应什么现场?
select / poll / epoll 的系统调用
| API | 签名 | 成功 | 失败 errno | 说明 |
|---|---|---|---|---|
select | int select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *timeout) | 就绪 fd 数 | EBADF、EINTR、EINVAL、ENOMEM | nfds 是最大 fd + 1;三个集合都是入参兼出参 |
pselect | int pselect(int nfds, fd_set *r, fd_set *w, fd_set *e, const struct timespec *ts, const sigset_t *mask) | 就绪 fd 数 | 同上 | 原子地换信号掩码;ts 是 timespec |
poll | int poll(struct pollfd *fds, nfds_t nfds, int timeout) | 就绪 fd 数 | EFAULT、EINTR、EINVAL、ENOMEM | timeout 毫秒;-1 永久 |
ppoll | int ppoll(struct pollfd *fds, nfds_t nfds, const struct timespec *ts, const sigset_t *mask) | 就绪 fd 数 | 同上 | 纳秒超时 + 原子信号掩码 |
epoll_create1 | int epoll_create1(int flags) | epoll fd | EINVAL、EMFILE、ENFILE、ENOMEM | flags 只有 EPOLL_CLOEXEC |
epoll_ctl | int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev) | 0 | EBADF、EEXIST、EINVAL、ENOENT、ENOMEM、ENOSPC、EPERM | ENOSPC:watch 上限;EPERM:fd 不支持 epoll |
epoll_wait | int epoll_wait(int epfd, struct epoll_event *ev, int maxevents, int timeout) | 就绪事件数 | EBADF、EINTR、EINVAL、EFAULT | maxevents > 0;timeout 毫秒 |
epoll_pwait | int epoll_pwait(int epfd, struct epoll_event *ev, int maxevents, int timeout, const sigset_t *mask) | 就绪事件数 | 同上 | 原子换信号掩码 |
epoll_pwait2 | int epoll_pwait2(int epfd, struct epoll_event *ev, int maxevents, const struct timespec *ts, const sigset_t *mask) | 就绪事件数 | 同上 | Linux 5.11;纳秒超时 |
select 的 EINVAL 包括 nfds < 0 或 nfds > FD_SETSIZE;poll 的 EINVAL 是 nfds > RLIMIT_NOFILE;epoll_wait 的 EINVAL 是 maxevents <= 0。epoll_ctl 的 ENOSPC 不是「内存不足」,而是「注册的 fd 数超过 /proc/sys/fs/epoll/max_user_watches」,容器里经常遇到。
epoll_event.events 位表
| 位 | 值 | 含义 | 备注 |
|---|---|---|---|
EPOLLIN | 0x001 | 可读 | 流式 socket 有数据/EOF;监听 fd 有新连接 |
EPOLLPRI | 0x002 | 带外数据可读 | TCP 紧急模式;少用 |
EPOLLOUT | 0x004 | 可写 | 发送缓冲区有空位 |
EPOLLERR | 0x008 | 错误 | 总是报告,不需要在 events 里请求 |
EPOLLHUP | 0x010 | 挂断 | 总是报告;对端完全关闭 |
EPOLLRDNORM | 0x040 | 普通数据可读 | 与 EPOLLIN 多数场景等价 |
EPOLLRDBAND | 0x080 | 优先数据可读 | 很少用 |
EPOLLWRNORM | 0x100 | 普通数据可写 | 与 EPOLLOUT 多数场景等价 |
EPOLLWRBAND | 0x200 | 优先数据可写 | 很少用 |
EPOLLMSG | 0x400 | 消息可用 | 未使用 |
EPOLLRDHUP | 0x2000 | 对端关闭写方向 | 用于检测半关闭;需要 _GNU_SOURCE |
EPOLLEXCLUSIVE | 1<<28 | 独占唤醒 | 只用于 EPOLL_CTL_ADD;不能与 ONESHOT 同用 |
EPOLLWAKEUP | 1<<29 | 阻止系统休眠 | 需要 CAP_BLOCK_SUSPEND;少用 |
EPOLLONESHOT | 1<<30 | 一次触发后禁用 | 处理完要 EPOLL_CTL_MOD 重新 arm |
EPOLLET | 1<<31 | 边缘触发 | 必须非阻塞 + 读到 EAGAIN |
EPOLLERR/EPOLLHUP 是输出位:即使 events 没请求,只要 fd 出错/挂断,epoll_wait 也会返回它们。只检查 EPOLLIN 的代码会在对端关闭后死循环:epoll_wait 一直返回 EPOLLHUP,read 一直返回 0。
struct epoll_event 在 x86-64 上是 __attribute__((packed)) 的 12 字节(events 4 字节 + data 8 字节)。不要自己定义结构体或按 16 字节传,内核按 ABI 校验大小。
struct pollfd 与 fd_set
| 结构 | 字段 | 方向 | 说明 |
|---|---|---|---|
struct pollfd | int fd | 入 | 要监视的 fd;负数表示忽略 |
short events | 入 | 请求的事件位 | |
short revents | 出 | 实际发生的事件位;内核覆盖 | |
fd_set | 位图 | 入/出 | FD_SETSIZE 位;select 返回后只剩就绪位 |
poll 的 revents 每次调用都会被内核覆盖,不需要手动清零;但如果你在循环里跳过某些 fd,最好显式清零,避免读到上一轮的残留。
eventfd / signalfd / timerfd
| API | 签名 | 成功 | 失败 errno | 说明 |
|---|---|---|---|---|
eventfd | int eventfd(unsigned int initval, int flags) | fd | EINVAL、EMFILE、ENFILE、ENOMEM | flags:EFD_CLOEXEC、EFD_NONBLOCK、EFD_SEMAPHORE |
signalfd | int signalfd(int fd, const sigset_t *mask, int flags) | fd | EINVAL、EBADF、EMFILE、ENOMEM | fd=-1 新建;SFD_CLOEXEC/SFD_NONBLOCK |
timerfd_create | int timerfd_create(clockid_t clk, int flags) | fd | EINVAL、EMFILE、ENFILE、ENOMEM | 见 2.11.6 |
timerfd_settime | int timerfd_settime(int fd, int flags, const struct itimerspec *new, struct itimerspec *old) | 0 | EINVAL、EFAULT、EBADF | TFD_TIMER_ABSTIME/CANCEL_ON_SET |
read(eventfd) | ssize_t read(int fd, uint64_t *v, size_t 8) | 8 | EAGAIN、EINTR、EINVAL | 默认读后清零;SEMAPHORE 模式减一 |
write(eventfd) | ssize_t write(int fd, const uint64_t *v, size_t 8) | 8 | EAGAIN、EINVAL | 累加;超过 ULLONG_MAX-1 返回 EAGAIN |
read(signalfd) | ssize_t read(int fd, struct signalfd_siginfo *si, size_t sizeof) | sizeof | EAGAIN、EINTR、EINVAL | 缓冲区至少 sizeof(struct signalfd_siginfo)(128 字节) |
eventfd 的 read 长度必须是 8,否则 EINVAL;signalfd 的 read 长度小于 sizeof(struct signalfd_siginfo) 也返回 EINVAL。这两个约束和 timerfd 一样,是内核强制的。
signalfd_siginfo 字段
| 字段 | 含义 | 与 siginfo_t 的对应 |
|---|---|---|
ssi_signo | 信号编号 | si_signo |
ssi_errno | 错误号 | si_errno |
ssi_code | 来源码(SI_USER/SI_KERNEL/SI_QUEUE…) | si_code |
ssi_pid / ssi_uid | 发送者 PID/UID | si_pid/si_uid |
ssi_fd | SIGIO 相关的 fd | si_fd |
ssi_tid | 发送线程 ID | si_tid |
ssi_band | SIGPOLL 事件 | si_band |
ssi_overrun | 实时信号溢出计数 | si_overrun |
ssi_status | SIGCHLD 子进程状态 | si_status |
ssi_int / ssi_ptr | sigqueue 携带的值 | si_int/si_ptr |
ssi_addr | 硬件故障地址 | si_addr |
ssi_syscall / ssi_call_addr / ssi_arch | SIGSYS 信息 | 对应字段 |
signalfd 只对被阻塞的信号有效。如果信号没有 sigprocmask(SIG_BLOCK),它会走默认处置(可能终止进程),signalfd 读不到。正确顺序:先阻塞信号,再创建 signalfd,再进事件循环。
io_uring 系统调用
| API | 签名 | 成功 | 失败 errno | 说明 |
|---|---|---|---|---|
io_uring_setup | int io_uring_setup(unsigned entries, struct io_uring_params *p) | ring fd | EINVAL、EMFILE、ENFILE、ENOMEM、EPERM | entries 会向上取整到 2 的幂 |
io_uring_enter | int io_uring_enter(int fd, unsigned to_submit, unsigned min_complete, unsigned flags, const void *sig, size_t sz) | 提交/完成数 | EAGAIN、EBADF、EINTR、EINVAL、ENOSYS | IORING_ENTER_GETEVENTS 等 |
io_uring_register | int io_uring_register(int fd, unsigned opcode, void *arg, unsigned nr_args) | 0 | EBADF、EINVAL、ENOMEM、EPERM | 注册缓冲区/fd/eventfd 等 |
io_uring_setup 在容器里可能返回 EPERM(seccomp 拦截)或 ENOSYS(内核未开 CONFIG_IO_URING)。生产代码必须把 io_uring 当作「可选加速」,失败时回退到 epoll + 线程池。
2.18.10 内核路径:eventpoll、ep_poll_callback 与惊群
这一节回答:epoll_ctl(ADD) 在内核里存了什么?数据到达时谁把 fd 挂到就绪链表?EPOLLEXCLUSIVE 如何避免惊群?
eventpoll 与 epitem
epoll_create1 创建一个 struct eventpoll,通过匿名 inode 暴露成 fd:
| 字段 | 作用 |
|---|---|
mtx | 保护 rbr/rdllist 的互斥锁 |
wq | epoll_wait 的等待队列 |
rbr | 红黑树,键是 (file, fd),存放所有注册的 epitem |
rdllist | 就绪链表,epoll_wait 直接取它 |
ovflist | 溢出链表,回调在中断上下文无法加锁时临时挂这里 |
refs | 引用计数;fork/dup 共享同一个 eventpoll |
user | 创建者,用于 max_user_watches 统计 |
每个注册的 fd 对应一个 struct epitem:
| 字段 | 作用 |
|---|---|
rbn | 红黑树节点 |
rdllink | 就绪链表节点 |
ffd | struct epoll_filefd(file + fd) |
nwait | 等待队列项数量 |
pwqlist | 挂在目标 fd 等待队列上的 eppoll_entry 链表 |
event | 用户传入的 epoll_event 副本 |
epoll_ctl(ADD) 调 ep_insert:分配 epitem,插入红黑树,然后调用 ep_item_poll → vfs_poll → 目标文件的 ->poll(),把 ep_ptable_queue_proc 注册到目标 fd 的等待队列。之后数据到达,目标 fd 的等待队列回调 ep_poll_callback 被调用:
/* 按 Linux 6.8 fs/eventpoll.c 的结构简化 */
static int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode,
int sync, void *key)
{
struct epitem *epi = ep_item_from_wait(wait);
struct eventpoll *ep = epi->ep;
unsigned long flags;
if (!(epi->event.events & ~EP_PRIVATE_BITS))
return 1; /* 不关心这个事件 */
spin_lock_irqsave(&ep->lock, flags);
if (!(epi->event.events & EPOLLEXCLUSIVE))
; /* 普通模式:所有等待者都唤醒 */
if (!ep_is_linked(epi)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
ep_pm_stay_awake(epi);
}
spin_unlock_irqrestore(&ep->lock, flags);
wake_up(&ep->wq); /* 唤醒 epoll_wait */
return 1;
}
这段伪代码省略了 EPOLLEXCLUSIVE 的关键分支:独占模式下内核只唤醒一个等待者(wake_up 的 exclusive 语义),而不是 wake_up_all。具体实现会检查 epi->event.events & EPOLLEXCLUSIVE,决定唤醒一个还是全部。效果是:
- 多个进程/线程在同一个监听 fd 上
epoll_wait,没有EPOLLEXCLUSIVE:每个新连接唤醒所有等待者,只有一个能accept成功,其余白跑(惊群); - 加了
EPOLLEXCLUSIVE:内核只唤醒一个,失败再唤醒下一个。
EPOLLEXCLUSIVE 只能用于 EPOLL_CTL_ADD,且不能与 EPOLLONESHOT 同用;它解决的是「多个消费者等同一个 fd」的唤醒放大,不解决「一个连接被多个线程读」的问题(那需要 EPOLLONESHOT 或应用层分片)。
LT 与 ET 的内核差异
epoll_wait 走 ep_poll → ep_scan_ready_list → ep_send_events。对每个就绪的 epitem:
- LT:
ep_send_events_proc调用ep_item_poll重新检查;如果仍然就绪,把epitem放回rdllist。所以下一次epoll_wait还会返回它。 - ET:只在
ep_poll_callback被调用时挂rdllist一次;ep_send_events取走后不再重查。如果应用没读空,内核不会再通知,直到下一次状态变化。
EPOLLONESHOT 在 ep_send_events 后把 epitem 从兴趣集合里「禁用」(清 EPOLLIN 等),需要 EPOLL_CTL_MOD 重新设置。它常用于多线程:一个连接同一时刻只允许一个线程处理,处理完再重新 arm,避免两个线程同时读同一条连接。
惊群与 SO_REUSEPORT
| 场景 | 现象 | 解决 |
|---|---|---|
多进程 accept 同一个监听 fd | 新连接唤醒所有进程,只有一个成功 | EPOLLEXCLUSIVE(4.5+) |
多进程各自 bind 同一端口 | 内核按四元组哈希分流,天然无惊群 | SO_REUSEPORT(3.9+) |
| 多线程共享同一个连接 fd | 多个线程同时 read,数据被随机分走 | EPOLLONESHOT + 重新 arm,或每连接一个线程 |
accept 队列满 | 新连接被丢弃/RST,客户端超时 | 调大 backlog、加快 accept、加 SO_REUSEPORT |
SO_REUSEPORT 的分流是按四元组哈希,不是轮询;同一客户端的重连可能落到同一个 worker。它适合无状态服务,不适合需要连接粘性的场景(如 WebSocket 会话)。
cat /proc/$PID/fdinfo/$EPFD看tfd与events(内核 5.x 起有epoll信息)。cat /proc/sys/fs/epoll/max_user_watches看注册上限。strace -e trace=epoll_create1,epoll_ctl,epoll_wait看注册与等待。bpftrace -e 'kprobe:ep_poll_callback { @[comm] = count(); }'看回调频率。perf trace -e epoll_wait看每次等待的就绪数。
2.18.11 eventfd、signalfd、timerfd:把事件源统一成 fd
这一节回答:eventfd 的计数器语义是什么?signalfd 为什么必须先阻塞信号?三者如何与 epoll 组合成一个事件循环?
eventfd:8 字节的计数器
eventfd(initval, flags) 创建一个内核计数器,read 取走当前值,write 累加。默认模式下 read 返回累计值并清零;EFD_SEMAPHORE 模式下每次 read 只减 1。
| 操作 | 默认模式 | EFD_SEMAPHORE |
|---|---|---|
write(v) | 计数 += v | 计数 += v |
read | 返回计数并清零 | 返回 1,计数 -= 1;计数为 0 时阻塞/EAGAIN |
| 溢出 | 计数到 ULLONG_MAX-1 后 write 返回 EAGAIN | 同上 |
eventfd 的典型用途:
- 线程唤醒:工作线程往
eventfd写 1,事件循环epoll_wait立刻返回,处理任务队列; - 信号处理器:
write(eventfd, &one, 8)是 async-signal-safe 的,可以在信号处理器里唤醒事件循环(比 self-pipe 少一个 fd); - 计数器:多个生产者
write,消费者一次read拿到总量,避免多次唤醒。
eventfd 的 read 必须正好 8 字节;缓冲区小于 8 返回 EINVAL。write 的值不能为 ULLONG_MAX(保留为错误),否则 EINVAL。
signalfd:把信号变成可读事件
signalfd 把「被阻塞的信号」变成 fd 上的可读事件。使用顺序固定:
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
sigprocmask(SIG_BLOCK, &mask, NULL); /* 必须先阻塞 */
int sfd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
/* epoll_ctl(ADD, sfd, EPOLLIN) */
/* 事件循环里 read(sfd, &si, sizeof si) */
读出的 struct signalfd_siginfo 有 128 字节,包含 ssi_signo、ssi_pid、ssi_uid、ssi_code 等。read 一次返回一个信号;多个相同实时信号会排队,标准信号会合并。
signalfd 的常见错误:
- 忘了
sigprocmask:信号走默认处置,进程可能直接退出,signalfd永远读不到; read缓冲区太小:返回EINVAL,不是短读;- 多线程:信号可能被任意一个未阻塞该信号的线程处理;要统一到事件循环,所有线程都要阻塞,只有事件循环线程读
signalfd。
timerfd:到期变成可读
timerfd 在 2.11.6 已详述。它在事件循环里的用法是:timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK|TFD_CLOEXEC),timerfd_settime 设置周期,epoll_ctl(ADD, EPOLLIN),read 8 字节计数。它比 epoll_wait 的毫秒超时更精确,也能同时管理多个不同周期的定时器。
三者组合的事件循环
┌──────────────┐
│ epoll_wait │
└──────┬───────┘
┌─────────┼──────────┬─────────────┐
▼ ▼ ▼ ▼
listen fd signalfd timerfd eventfd
accept SIGINT tick wakeup
│ │ │ │
└─────────┴──────────┴─────────────┘
统一处理
读图:所有事件源都是 fd,事件循环不需要信号处理器、不需要 self-pipe、不需要定时线程。signalfd 负责「退出/重载」,timerfd 负责「周期任务」,eventfd 负责「其他线程投递任务」,listen fd 负责「新连接」。
- 「
eventfd的read可以读 4 字节」——必须 8 字节,否则EINVAL。 - 「
eventfd的计数不会溢出」——到ULLONG_MAX-1后write返回EAGAIN,计数不变。 - 「
signalfd可以直接收未阻塞的信号」——不行。必须先sigprocmask(SIG_BLOCK)。 - 「
signalfd的read会返回多个信号」——一次一个;要循环读到EAGAIN。 - 「
EFD_SEMAPHORE与默认模式只是返回值不同」——语义不同:默认清零,SEMAPHORE 每次减一;用错会导致计数丢失或永久阻塞。
2.18.12 io_uring:SQE/CQE、注册与 liburing
这一节回答:SQE 和 CQE 各有哪些字段?io_uring_enter 的 min_complete 是什么意思?注册缓冲区/fd 省了什么?
三个系统调用与两个环
io_uring 的共享内存布局:
| 区域 | mmap 偏移 | 内容 |
|---|---|---|
| SQ ring | IORING_OFF_SQ_RING(0) | head/tail/ring_mask/ring_entries/flags/dropped/array |
| CQ ring | IORING_OFF_CQ_RING(0x8000000) | head/tail/ring_mask/ring_entries/overflow/cqes |
| SQE 数组 | IORING_OFF_SQES(0x10000000) | sq_entries 个 struct io_uring_sqe |
io_uring_setup(entries, ¶ms) 返回 ring fd,并回填 params.sq_off/params.cq_off(各字段在 ring 内的字节偏移)。如果 params.features & IORING_FEAT_SINGLE_MMAP,SQ 与 CQ 可以映射同一块内存,只需一次 mmap。
io_uring_enter(fd, to_submit, min_complete, flags, sig, sz):
to_submit:本次提交多少个 SQE;min_complete:至少等多少个 CQE 完成再返回;0 表示不等;flags:IORING_ENTER_GETEVENTS表示「提交后等完成」;sig/sz:信号掩码(可NULL)。
一次调用可以「提交 N 个 + 等 M 个完成」,系统调用次数从「每个 I/O 一次」降到「一批一次」。
SQE 字段
| 字段 | 类型 | 含义 |
|---|---|---|
opcode | __u8 | 操作码,如 IORING_OP_READ/WRITE/ACCEPT/RECV |
flags | __u8 | IOSQE_*,如 IO_LINK/IO_DRAIN/FIXED_FILE |
ioprio | __u16 | 优先级;某些 op 用作特殊 flags |
fd | __s32 | 目标 fd(或注册 fd 的索引) |
off/addr2 | __u64 | 文件偏移或第二地址 |
addr | __u64 | 缓冲区地址或 iovec 数组地址 |
len | __u32 | 缓冲区长度或 iovec 数量 |
rw_flags | __u32 | 操作相关 flags(如 RWF_*) |
user_data | __u64 | 原样带回 CQE,用于关联请求 |
buf_index/buf_group | __u16 | 注册缓冲区索引或缓冲组 |
personality | __u16 | 凭证 personality |
splice_fd_in | __s32 | splice 的输入 fd |
IOSQE_* 常用位:
| flag | 语义 |
|---|---|
IOSQE_FIXED_FILE | fd 是注册 fd 表的索引,不是真实 fd |
IOSQE_IO_DRAIN | 等之前所有请求完成再执行 |
IOSQE_IO_LINK | 与下一个 SQE 链接,前一个成功才执行下一个 |
IOSQE_IO_HARDLINK | 链接,前一个失败也继续 |
IOSQE_ASYNC | 强制走异步 worker |
IOSQE_BUFFER_SELECT | 从缓冲组选择缓冲区 |
IOSQE_CQE_SKIP_SUCCESS | 成功时不产生 CQE(省带宽) |
CQE 字段
| 字段 | 类型 | 含义 |
|---|---|---|
user_data | __u64 | 对应 SQE 的 user_data |
res | __s32 | 结果:成功为字节数/0,失败为负 -errno |
flags | __u32 | IORING_CQE_F_BUFFER/MORE/NOTIF 等 |
CQE 按完成顺序返回,不保证与提交顺序一致;用 user_data 关联。res < 0 时是 -errno,不是 -1;要打印错误用 strerror(-res)。
注册:省掉每次提交的开销
| 注册 op | 作用 | 收益 |
|---|---|---|
IORING_REGISTER_FILES | 预注册 fd 表 | 提交时用索引,省去 fdget/fput 与文件表查找 |
IORING_REGISTER_BUFFERS | 预注册缓冲区 | 内核提前 pin 页,省去每次 get_user_pages |
IORING_REGISTER_EVENTFD | 完成事件写 eventfd | 与 epoll 共存 |
IORING_REGISTER_PERSONALITY | 注册凭证 | 替代每次 setfsuid |
IORING_REGISTER_RESTRICTIONS | 限制可用 op | 安全沙箱 |
注册缓冲区后,SQE 用 buf_index 指定索引,addr 可以是偏移;注册 fd 后,SQE 设 IOSQE_FIXED_FILE,fd 是索引。注意注册缓冲区必须保持有效且对齐,io_uring_register 会 pin 住这些页。
liburing 的封装
生产代码通常用 liburing:
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, offset);
sqe->user_data = id;
io_uring_submit(&ring);
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int res = cqe->res;
io_uring_cqe_seen(&ring, cqe);
io_uring_queue_exit(&ring);
io_uring_prep_* 负责填 SQE 字段,io_uring_submit 负责更新 tail 并调 io_uring_enter,io_uring_wait_cqe 负责等 CQE。自己写裸系统调用的价值在于理解 ring 的内存布局;生产代码不要重复造轮子。
限制与安全
- 内核 5.1+;网络 op(
ACCEPT/RECV/SEND)在 5.5+ 才逐步补齐; IORING_SETUP_SQPOLL需要CAP_SYS_ADMIN或IORING_SETUP_SQ_AFF+ 特权;- 容器 seccomp 可能拦截
io_uring_setup,返回EPERM; - 部分发行版通过
/proc/sys/kernel/io_uring_disabled禁用(0=允许,1=仅特权,2=禁止); io_uring的漏洞历史较多,安全敏感环境要评估内核版本。
2.18.13 程序一:eventfd + signalfd + timerfd 的统一事件循环
这个程序把三类事件源挂到同一个 epoll:signalfd 处理 SIGINT/SIGTERM,timerfd 每 100 ms 打一次 tick,eventfd 由后台线程在 250 ms 后唤醒。
/* event_loop.c — signalfd + timerfd + eventfd + epoll */
#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/eventfd.h>
#include <sys/signalfd.h>
#include <sys/timerfd.h>
#include <time.h>
#include <unistd.h>
#define MAX_EVENTS 8
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
struct wake_arg {
int efd;
useconds_t delay_us;
};
static void *waker(void *p)
{
struct wake_arg *a = p;
usleep(a->delay_us);
uint64_t one = 1;
ssize_t n = write(a->efd, &one, sizeof one);
if (n != (ssize_t)sizeof one)
perror("write eventfd");
return NULL;
}
static void add_fd(int epfd, int fd, uint32_t events)
{
struct epoll_event ev = { .events = events, .data.fd = fd };
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1)
die("epoll_ctl");
}
int main(void)
{
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1)
die("sigprocmask");
int sfd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
if (sfd == -1) die("signalfd");
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK);
if (tfd == -1) die("timerfd_create");
struct itimerspec its = {0};
its.it_value.tv_nsec = 100 * 1000000L;
its.it_interval.tv_nsec = 100 * 1000000L;
if (timerfd_settime(tfd, 0, &its, NULL) == -1)
die("timerfd_settime");
int efd = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK);
if (efd == -1) die("eventfd");
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd == -1) die("epoll_create1");
add_fd(epfd, sfd, EPOLLIN);
add_fd(epfd, tfd, EPOLLIN);
add_fd(epfd, efd, EPOLLIN);
struct wake_arg arg = { .efd = efd, .delay_us = 250000 };
pthread_t th;
int rc = pthread_create(&th, NULL, waker, &arg);
if (rc != 0) {
fprintf(stderr, "pthread_create: %s\n", strerror(rc));
return EXIT_FAILURE;
}
int ticks = 0;
int running = 1;
while (running) {
struct epoll_event evs[MAX_EVENTS];
int n = epoll_wait(epfd, evs, MAX_EVENTS, -1);
if (n == -1) {
if (errno == EINTR) continue;
perror("epoll_wait");
break;
}
for (int i = 0; i < n; i++) {
int fd = evs[i].data.fd;
if (fd == sfd) {
struct signalfd_siginfo si;
ssize_t r = read(sfd, &si, sizeof si);
if (r == (ssize_t)sizeof si) {
printf("signal %u from pid %u -> exit\n",
si.ssi_signo, si.ssi_pid);
running = 0;
}
} else if (fd == tfd) {
uint64_t exp;
ssize_t r = read(tfd, &exp, sizeof exp);
if (r == (ssize_t)sizeof exp) {
ticks += (int)exp;
printf("tick exp=%llu total=%d\n",
(unsigned long long)exp, ticks);
}
} else if (fd == efd) {
uint64_t val;
ssize_t r = read(efd, &val, sizeof val);
if (r == (ssize_t)sizeof val)
printf("eventfd wakeup value=%llu\n",
(unsigned long long)val);
}
}
}
pthread_join(th, NULL);
close(epfd);
close(efd);
close(tfd);
close(sfd);
return EXIT_SUCCESS;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g -pthread \
event_loop.c -o event_loop
./event_loop
# tick exp=1 total=1
# tick exp=1 total=2
# eventfd wakeup value=1
# ...
# 按 Ctrl-C:
# signal 2 from pid ... -> exit
关键点:
signalfd之前必须sigprocmask(SIG_BLOCK);timerfd的read返回累计到期次数,可能大于 1;eventfd的read返回累计写值并清零;- 所有 fd 都是非阻塞的,
epoll_wait是唯一阻塞点; - 后台线程只负责
write(eventfd),不直接操作事件循环的状态。
strace -f -e trace=epoll_wait,read 会看到一次 epoll_wait 返回多个 fd,read 分别消费。把 signalfd 换成 signal() 处理器,程序会失去「统一事件源」的优势:信号处理器里不能安全地 printf,也不能调用 epoll_ctl。
2.18.14 程序二:EPOLLEXCLUSIVE 与惊群
这个程序让两个子进程各自在同一个监听 fd 上 epoll_wait,父进程发起 20 个连接。shared 模式下两个子进程都被唤醒;exclusive 模式下内核只唤醒一个。子进程统计 epoll_wait 返回监听事件的次数,用来对比。
/* epoll_exclusive.c — 对比 EPOLLEXCLUSIVE 的唤醒次数 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#ifndef EPOLLEXCLUSIVE
#define EPOLLEXCLUSIVE (1u << 28)
#endif
static volatile sig_atomic_t stop;
static void on_alarm(int sig)
{
(void)sig;
stop = 1;
}
static void child_loop(int lfd, int exclusive, int idx)
{
struct sigaction sa = {0};
sa.sa_handler = on_alarm;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGALRM, &sa, NULL);
alarm(2); /* 2 秒后退出 */
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd == -1) _exit(1);
struct epoll_event ev = {
.events = EPOLLIN | (exclusive ? EPOLLEXCLUSIVE : 0),
.data.fd = lfd,
};
if (epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev) == -1) _exit(1);
int wakeups = 0, accepted = 0;
while (!stop) {
struct epoll_event out;
int n = epoll_wait(epfd, &out, 1, -1);
if (n == -1) {
if (errno == EINTR) continue;
break;
}
if (n == 1 && (out.events & EPOLLIN)) {
wakeups++;
for (;;) {
int cfd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
if (cfd == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
if (errno == EINTR) continue;
break;
}
accepted++;
close(cfd);
}
}
}
printf("child %d exclusive=%d wakeups=%d accepted=%d\n",
idx, exclusive, wakeups, accepted);
fflush(stdout);
close(epfd);
_exit(0);
}
int main(int argc, char **argv)
{
int exclusive = (argc > 1 && strcmp(argv[1], "exclusive") == 0);
int lfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
if (lfd == -1) { perror("socket"); return EXIT_FAILURE; }
int one = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_addr.s_addr = htonl(INADDR_LOOPBACK),
.sin_port = 0,
};
if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) == -1) {
perror("bind");
return EXIT_FAILURE;
}
if (listen(lfd, 64) == -1) { perror("listen"); return EXIT_FAILURE; }
socklen_t alen = sizeof addr;
if (getsockname(lfd, (struct sockaddr *)&addr, &alen) == -1) {
perror("getsockname");
return EXIT_FAILURE;
}
for (int i = 0; i < 2; i++) {
pid_t pid = fork();
if (pid == -1) { perror("fork"); return EXIT_FAILURE; }
if (pid == 0) {
child_loop(lfd, exclusive, i);
}
}
/* 20 个客户端连接;每个连接只发一个字节就关 */
for (int i = 0; i < 20; i++) {
int fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd == -1) { perror("socket client"); break; }
if (connect(fd, (struct sockaddr *)&addr, sizeof addr) == -1) {
perror("connect");
close(fd);
continue;
}
if (write(fd, "x", 1) != 1) perror("write");
close(fd);
usleep(20000); /* 20ms,让子进程有时间 accept */
}
for (int i = 0; i < 2; i++) {
int status;
if (wait(&status) == -1) perror("wait");
}
close(lfd);
return EXIT_SUCCESS;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g \
epoll_exclusive.c -o epoll_exclusive
./epoll_exclusive shared # 两个子进程都被唤醒
./epoll_exclusive exclusive # 内核只唤醒一个
预期(数值会抖动):
# shared
child 0 exclusive=0 wakeups=20 accepted=10
child 1 exclusive=0 wakeups=20 accepted=10
# exclusive
child 0 exclusive=1 wakeups=10 accepted=10
child 1 exclusive=1 wakeups=10 accepted=10
shared 模式下每个连接唤醒两个子进程,总 wakeups 接近 40;exclusive 模式下总 wakeups 接近 20。两个子进程的 accepted 之和应等于 20(可能有连接在 2 秒后才被 accept,取决于调度)。
注意:EPOLLEXCLUSIVE 只减少唤醒次数,不保证负载均衡;某个子进程可能抢到更多连接。要按连接分流,用 SO_REUSEPORT 让每个 worker 各自 bind。
2.18.15 程序三:裸 io_uring 读取文件
这个程序不依赖 liburing,直接用 io_uring_setup/io_uring_enter 与 mmap 完成一次 IORING_OP_READ,用来验证 SQ/CQ 的内存布局与 user_data 关联。
/* uring_read.c — 裸系统调用版 io_uring 读文件 */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <linux/io_uring.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <unistd.h>
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(int argc, char **argv)
{
if (argc != 2) {
fprintf(stderr, "usage: %s file\n", argv[0]);
return EXIT_FAILURE;
}
int fd = open(argv[1], O_RDONLY | O_CLOEXEC);
if (fd == -1) die("open");
struct io_uring_params p;
memset(&p, 0, sizeof p);
int ring_fd = (int)syscall(__NR_io_uring_setup, 8, &p);
if (ring_fd == -1) die("io_uring_setup");
size_t sq_ring_sz = p.sq_off.array + p.sq_entries * sizeof(unsigned);
size_t cq_ring_sz = p.cq_off.cqes + p.cq_entries * sizeof(struct io_uring_cqe);
size_t ring_sz = sq_ring_sz > cq_ring_sz ? sq_ring_sz : cq_ring_sz;
char *sq_ptr, *cq_ptr;
if (p.features & IORING_FEAT_SINGLE_MMAP) {
sq_ptr = mmap(NULL, ring_sz, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, ring_fd, IORING_OFF_SQ_RING);
if (sq_ptr == MAP_FAILED) die("mmap sq");
cq_ptr = sq_ptr;
} else {
sq_ptr = mmap(NULL, sq_ring_sz, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, ring_fd, IORING_OFF_SQ_RING);
if (sq_ptr == MAP_FAILED) die("mmap sq");
cq_ptr = mmap(NULL, cq_ring_sz, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, ring_fd, IORING_OFF_CQ_RING);
if (cq_ptr == MAP_FAILED) die("mmap cq");
}
struct io_uring_sqe *sqes = mmap(NULL,
p.sq_entries * sizeof(struct io_uring_sqe),
PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, ring_fd,
IORING_OFF_SQES);
if (sqes == MAP_FAILED) die("mmap sqes");
unsigned *sq_head = (unsigned *)(sq_ptr + p.sq_off.head);
unsigned *sq_tail = (unsigned *)(sq_ptr + p.sq_off.tail);
unsigned *sq_mask = (unsigned *)(sq_ptr + p.sq_off.ring_mask);
unsigned *sq_array = (unsigned *)(sq_ptr + p.sq_off.array);
if (*sq_tail - *sq_head >= p.sq_entries) {
fprintf(stderr, "SQ ring is full\n");
return EXIT_FAILURE;
}
unsigned *cq_head = (unsigned *)(cq_ptr + p.cq_off.head);
unsigned *cq_tail = (unsigned *)(cq_ptr + p.cq_off.tail);
unsigned *cq_mask = (unsigned *)(cq_ptr + p.cq_off.ring_mask);
struct io_uring_cqe *cqes =
(struct io_uring_cqe *)(cq_ptr + p.cq_off.cqes);
char buf[4096];
struct io_uring_sqe *sqe = &sqes[0];
memset(sqe, 0, sizeof *sqe);
sqe->opcode = IORING_OP_READ;
sqe->fd = fd;
sqe->addr = (uint64_t)(uintptr_t)buf;
sqe->len = sizeof buf;
sqe->off = 0; /* 从文件偏移 0 读 */
sqe->user_data = 0x1234;
unsigned tail = *sq_tail;
sq_array[tail & *sq_mask] = 0; /* SQE 下标 */
__atomic_store_n(sq_tail, tail + 1, __ATOMIC_RELEASE);
int rc = (int)syscall(__NR_io_uring_enter, ring_fd, 1, 1,
IORING_ENTER_GETEVENTS, NULL, 0);
if (rc < 0) die("io_uring_enter");
for (;;) {
if (__atomic_load_n(cq_head, __ATOMIC_ACQUIRE) !=
__atomic_load_n(cq_tail, __ATOMIC_ACQUIRE))
break;
/* 理论上 enter 已经等到,这里只是保险 */
}
unsigned head = *cq_head;
struct io_uring_cqe *cqe = &cqes[head & *cq_mask];
printf("cqe user_data=0x%llx res=%d\n",
(unsigned long long)cqe->user_data, cqe->res);
if (cqe->res < 0) {
fprintf(stderr, "read failed: %s\n", strerror(-cqe->res));
} else if (cqe->res > 0) {
ssize_t w = write(STDOUT_FILENO, buf, (size_t)cqe->res);
(void)w;
}
__atomic_store_n(cq_head, head + 1, __ATOMIC_RELEASE);
munmap(sqes, p.sq_entries * sizeof(struct io_uring_sqe));
if (p.features & IORING_FEAT_SINGLE_MMAP)
munmap(sq_ptr, ring_sz);
else {
munmap(sq_ptr, sq_ring_sz);
munmap(cq_ptr, cq_ring_sz);
}
close(ring_fd);
close(fd);
return 0;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g \
uring_read.c -o uring_read
./uring_read /etc/hosts | head
# cqe user_data=0x1234 res=...
关键点:
sq_tail用 release 存储,确保 SQE 内容先可见;内核读 tail 后才会读 SQE。cq_head用 acquire 读取,确保 CQE 内容可见后再访问。sq_array[tail & mask]是「SQE 下标」,不是 SQE 地址;这是最容易写错的一步。cqe->res是ssize_t语义:成功为字节数,失败为-errno。IORING_FEAT_SINGLE_MMAP时 SQ/CQ 共用一块映射,不能重复munmap。
这个程序只做一次读,没有注册缓冲区/fd,也没有批量提交。生产代码应该用 liburing,并考虑 IORING_REGISTER_BUFFERS 与 IOSQE_FIXED_FILE 降低每次提交的开销。
- 「
io_uring一定比epoll快」——小 I/O、低并发时,SQE/CQE 的内存操作与io_uring_enter开销可能超过epoll+read。 - 「CQE 按提交顺序返回」——按完成顺序;用
user_data关联请求。 - 「
cqe->res是-1表示失败」——是-errno;用strerror(-res)。 - 「
io_uring在所有内核上都有」——5.1+,且可能被CONFIG_IO_URING、seccomp、io_uring_disabled禁用。 - 「注册缓冲区后可以随意释放」——注册期间内核 pin 住页,释放/改写会导致未定义行为。
2.18.16 实验:epoll 的 fdinfo、惊群与 io_uring 系统调用
实验一:epoll 的注册与唤醒
# 1. 看 epoll 系统调用
strace -f -e trace=epoll_create1,epoll_ctl,epoll_wait ./event_loop 2>&1 | head -20
# 预期:epoll_ctl ADD 三次;epoll_wait 返回 1..3
# 2. 看 epoll 实例的 fdinfo
./event_loop & pid=$!
ls -l /proc/$pid/fd | grep eventpoll
cat /proc/$pid/fdinfo/3 2>/dev/null
# 预期:tfd: 后面列出注册的 fd 与 events
# 3. 看 watch 上限
cat /proc/sys/fs/epoll/max_user_watches
# 4. 对比 LT/ET 的通知次数(用 2.18.5 的 epoll_echo)
./epoll_echo lt & pid=$!
# 另一个终端发 1 MB 数据,观察 strace 的 epoll_wait 次数
strace -p $pid -e trace=epoll_wait -c
怎么读:fdinfo 里的 tfd 行是「注册的 fd + 事件掩码」,可以直接看到 EPOLLIN/EPOLLOUT/EPOLLET 的数值。max_user_watches 是每用户上限,不是每进程;容器里多个服务共享同一个 UID 时会互相挤占。
实验二:惊群与 io_uring
# 1. 对比 shared/exclusive
./epoll_exclusive shared
./epoll_exclusive exclusive
# 预期:shared 的 wakeups 总数约为 exclusive 的两倍
# 2. 看 io_uring 的系统调用
strace -e trace=io_uring_setup,io_uring_enter,io_uring_register \
./uring_read /etc/hosts 2>&1 | head
# 预期:io_uring_setup(8, ...) = 3;io_uring_enter(3, 1, 1, ...) = 1
# 3. 看内核是否允许 io_uring
cat /proc/sys/kernel/io_uring_disabled 2>/dev/null || echo "sysctl not present"
grep -i io_uring /boot/config-$(uname -r) 2>/dev/null || true
# 4. 用 liburing 的示例做对照(如果已安装)
# sudo apt install liburing-dev
# cc uring_cat.c -o uring_cat -luring && ./uring_cat /etc/hosts
怎么读:io_uring_setup 返回的 fd 是 ring fd,不是普通文件;io_uring_enter 的第二个参数是提交数,第三个是等待完成数。/proc/sys/kernel/io_uring_disabled 不存在不代表启用(老内核没有这个 sysctl),要看 io_uring_setup 的返回值。
- 连接数 < 1000、活跃比例高:
poll足够;不要为了「先进」上epoll。 - 连接数 > 1 万、活跃比例低:
epollLT;先保证正确,再考虑 ET。 - ET 卡住:检查 fd 是否非阻塞、是否读到
EAGAIN、accept是否循环。 - CPU 100%:检查是否漏处理
EPOLLHUP、是否在 ET 下忙等、是否epoll_wait超时为 0。 - 延迟抖动:检查
epoll_wait的maxevents是否太小、是否有惊群。 io_uring失败:查内核版本、CONFIG_IO_URING、seccomp、io_uring_disabled。
2.18.17 陷阱清单:多路复用的十六个现场
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | select 第二轮永远等不到 | fd_set 是入参兼出参,没重建 | 每轮 FD_ZERO + FD_SET |
| 2 | select 在大 fd 上崩溃 | FD_SETSIZE 1024,FD_SET 越界 | 改用 poll/epoll |
| 3 | poll 对端关闭后 CPU 100% | 只检查 POLLIN,漏了 POLLHUP | 检查 POLLERR/POLLHUP/POLLNVAL,read 返回 0 就关闭 |
| 4 | ET 下连接「饿死」 | 只读一次,没读到 EAGAIN | 循环读到 EAGAIN;fd 必须非阻塞 |
| 5 | ET 下 accept 漏连接 | 只 accept 一次 | 循环 accept4 到 EAGAIN |
| 6 | ET 下写阻塞 | 没处理 EPOLLOUT/EAGAIN | 用户态输出缓冲区 + EPOLLOUT |
| 7 | EPOLLONESHOT 后不再触发 | 处理完没重新 arm | epoll_ctl(MOD) 恢复事件 |
| 8 | EPOLLEXCLUSIVE 注册失败 | 用在 MOD 或与 ONESHOT 同用 | 只用于 ADD,不与 ONESHOT 混用 |
| 9 | epoll_ctl 返回 ENOSPC | 超过 max_user_watches | 减少注册/调大 sysctl/分实例 |
| 10 | epoll_ctl 返回 EPERM | fd 是普通文件/目录 | epoll 不支持普通文件;用 inotify/io_uring |
| 11 | 事件里的 fd 已失效 | 同一批事件中前面的处理关闭了后面的 fd | 用 data.ptr 管理生命周期,关闭后不再访问 |
| 12 | eventfd read 返回 EINVAL | 缓冲区不是 8 字节 | 用 uint64_t,长度 8 |
| 13 | signalfd 收不到信号 | 信号没阻塞 | 先 sigprocmask(SIG_BLOCK),再 signalfd |
| 14 | signalfd read 返回 EINVAL | 缓冲区小于 signalfd_siginfo | 用 sizeof(struct signalfd_siginfo) |
| 15 | io_uring_setup 返回 EPERM | seccomp/io_uring_disabled | 回退到 epoll;检查容器安全策略 |
| 16 | io_uring CQE 对不上请求 | 没用 user_data 或 SQE 被覆盖 | 每个 SQE 设唯一 user_data;不要在提交前复用 SQE |
| 17 | io_uring 提交后内核看不到 | 没更新 SQ tail 或缺少 release 屏障 | 用 __atomic_store_n(..., RELEASE) 或 liburing |
| 18 | 惊群导致上下文切换暴涨 | 多进程等同一 listen fd,没 EPOLLEXCLUSIVE | 加 EPOLLEXCLUSIVE 或 SO_REUSEPORT |
epoll_create1是 Linux 2.6.27 起;更老只有epoll_create(size),size参数已忽略。EPOLLEXCLUSIVE是 Linux 4.5 起;老内核会EINVAL。EPOLLRDHUP需要_GNU_SOURCE;不定义时编译失败。epoll_pwait2是 Linux 5.11 起;老内核只有毫秒精度的epoll_wait/epoll_pwait。eventfd是 Linux 2.6.22 起;EFD_SEMAPHORE2.6.30。signalfd是 Linux 2.6.22 起;SFD_CLOEXEC/SFD_NONBLOCK2.6.27。io_uring是 Linux 5.1 起;网络 op、注册缓冲区等特性在后续版本逐步补齐;容器 seccomp 经常拦截。
2.18.18 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出五种 I/O 模型的差别 | 就绪 vs 完成 | 2.18.1 |
解释 select 的 1024 限制 | FD_SETSIZE 与越界 | 2.18.2 |
解释 poll 的 O(n) | 每次调用遍历整个数组 | 2.18.3 |
| 画出 epoll 的 rbtree + rdllist | 能说出回调挂就绪链表 | 2.18.4 |
| 正确实现 ET 读循环 | 非阻塞 + 读到 EAGAIN | 2.18.5 |
处理 EPOLLERR/EPOLLHUP | 不把它当可读事件 | 2.18.5 |
用 eventfd 唤醒事件循环 | 知道读后清零 | 2.18.6 |
说出 io_uring 的 SQ/CQ | 能说出批量提交与乱序完成 | 2.18.7 |
Q1 select(fd + 1, ...) 里的 fd + 1 如果写成 fd,会怎样?如果写成 1024,而 fd 是 2000,会怎样?
Q2 一个 poll 循环只检查 POLLIN,对端关闭后会发生什么?怎么修?
Q3 ET 模式下,监听 fd 收到一次 EPOLLIN,你只 accept 了一个连接就返回。剩下的已完成连接会怎样?
Q4 epoll_wait 返回了 EPOLLIN,但 read 返回 EAGAIN。可能的原因是什么?
Q5 io_uring 的 CQE 一定按提交顺序返回吗?程序应该怎么处理?
答案
A1 写 fd 会漏掉 fd 本身(内核只检查 0..fd-1),表现为「永远等不到这个 fd」。写 1024 且 fd=2000 时,fd_set 只有 1024 位,FD_SET(2000, ...) 越界写栈,行为未定义;即使没崩,内核也不会检查 1024 以上的位。
A2 对端关闭时 poll 返回 POLLHUP(可能还带 POLLIN),只检查 POLLIN 会漏掉关闭事件;read 返回 0 后如果继续 poll,会立即再次返回,形成忙循环,CPU 打满。修复:检查 POLLERR|POLLHUP|POLLNVAL,并在 read 返回 0 时关闭连接。
A3 它们留在 accept 队列里,但 ET 不会再通知(没有新的「未就绪 → 就绪」变化),直到又有新连接到达才可能再次触发。结果是连接「连上了但永远不被处理」。必须在 ET 的 EPOLLIN 里循环 accept4 直到 EAGAIN。
A4 ① 另一个线程/进程已经把数据读走(同一 fd 被多个事件循环共享);② fd 被设置成 ET,但事件是「上一次留下的」;③ EPOLLIN 是 EPOLLRDHUP 之类的组合,实际只是对端半关闭;④ 数据被内核丢弃(比如校验和错误);⑤ 你用 data.ptr 指向的对象已经被复用,读的不是同一个 fd。最常见的是①与⑤。
A5 不一定。CQE 按完成顺序返回,多个并发读的完成顺序与提交顺序无关。程序必须用 sqe->user_data 标记请求,在 CQE 里读回 user_data 做对应;需要按序输出时,自己缓存结果再排序(如上面的 got[] 数组)。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| K | 第 63 章 其他备选的 I/O 模型(p.1088–1129,PDF p.1133–1174) | 2.18.1–2.18.11 | 完整:63.1 概览与 LT/ET、63.2 select/poll、63.3 信号驱动 I/O、63.4 epoll(create/ctl/wait/性能对比/ET)、63.5 pselect 与 self-pipe |
| K | 第 63 章 63.4.1–63.4.6(p.1113–1123) | 2.18.4、2.18.9–2.18.10 | 完整:epoll_create1、epoll_ctl(EEXIST/ENOENT/ENOSPC)、epoll_wait、max_user_watches、LT/ET 语义 |
| K | 第 63 章 63.3 信号驱动 I/O(p.1096–1100) | 2.2.9、2.18.1 | 背景:O_ASYNC/SIGIO/F_SETOWN 见 2.2.9;signalfd 见 2.18.11 |
| L | eventfd(2)、signalfd(2)、timerfd(2)(Linux man-pages) | 2.18.11 | 书外扩展:Kerrisk 第 63 章未覆盖这三个「事件源 fd」,按 Linux 6.8 手册与源码补 |
| L | io_uring(7)、io_uring_setup(2)(Linux man-pages 6.x) | 2.18.12、2.18.15 | 书外扩展:Kerrisk 第 63 章成书时 io_uring 尚未出现;按 Linux 6.8 io_uring 头文件与 man-pages 补 |
| C | 第 12 章 12.2 基于 I/O 多路复用的并发编程 | 2.18.2–2.18.5 | 完整(事件循环状态机) |
| C | 第 12 章 12.1、12.3–12.8(进程/线程并发) | 2.12–2.13(他章)、2.18.1 | 背景(并发模型对照) |
| N | 第 3 章 内核如何与用户进程协作 | 2.18.4 | 完整(等待队列、epoll 回调) |
| N | 第 9 章 网络性能优化建议 | 2.18.5、2.18.8 | 背景(多路复用与性能) |
| O | 第 33 章 基于事件的并发(进阶) | 2.18.1、2.18.8 | 背景(事件循环的取舍) |
| R | 第 5 章 中断管理 | 2.18.4 | 背景(软中断、tasklet 与唤醒路径) |
下一站:2.19 终端、pty、作业控制与守护进程 回到「进程与用户」的交界:
Ctrl-C如何变成信号、伪终端如何让 ssh 里跑交互程序、守护进程为什么要setsid与二次 fork。