第 63 章 备选 I/O 模型 (Alternative I/O Models)
核心结论
-
单 fd 阻塞 I/O 模型不足以应对多客户端监控:(1) 非阻塞 polling 浪费 CPU;(2) 多线程/进程开销过大;(3) 阻塞单 fd 无法同时等 stdin 和 socket。备选三方案解决「同时监控多 fd、何时 ready」。
-
I/O 多路复用 (select / poll):
select(int nfds, fd_set *readfds/writefds/exceptfds, timeval *timeout)与poll(struct pollfd[], nfds, int timeout);水平触发;可移植;FD_SETSIZE=1024 是 select 的硬上限;fd 集合每次调用前需重新初始化。 -
信号驱动 I/O (signal-driven / SIGIO):fcntl
F_SETOWN设 owner、O_ASYNC启用、edge-triggered;fd 可读即向进程发 SIGIO(Linux 也支持 F_SETSIG 改 realtime signal + siginfo 提供 fd 与事件);handler 只设 flag,主循环 poll 自旋 + 把数据读空到 EAGAIN。 -
Linux 专用 epoll API:
epoll_create创建 fd、epoll_ctl注册 fd 与事件、epoll_wait等事件;既支持水平触发(默认)也支持 edge-triggered;事件就绪由内核直接 push 给 userspace;fd 数量 O(1) 不增长 → C10K 主流方案。 -
Level-triggered vs Edge-triggered:LT = 状态通知(fd 没读完下次还会通知);ET = 状态变化通知(fd 首次就绪时一次通知,消耗到 EAGAIN);ET 必须配 O_NONBLOCK + 读到 EAGAIN。
-
pselect 与 self-pipe trick:
pselect处理「等待信号 + 等 fd」原子化;self-pipe trick 把信号 handler 写 pipe 字节,让主循环也用 select 监控信号——是处理「信号 + 多 fd」的常用模式。
|
本章主旨
本章是单 fd 阻塞模型之外的三大 I/O 模型——select/poll、signal-driven I/O、epoll。读者需建立:(1) 三个模型解决的问题与性能 trade-off;(2) select vs poll 的 API 异同;(3) LT vs ET 的语义差;(4) epoll 在 Linux 高并发服务中的核心地位(NGINX / Envoy / Redis 等);(5) 处理「信号 + 多 fd」时 pselect 或 self-pipe trick。 |
一、核心概念
本章围绕 6 个核心概念:阻塞 I/O vs 备选模型、select/poll 的 fd_set/pollfd、signal-driven I/O (O_ASYNC + SIGIO)、epoll 三大调用、LT vs ET、pselect 与 self-pipe trick。
| 概念 | 定义 + 重要性 | 实现提示 |
|---|---|---|
三种备选 I/O 模型对比 |
(1) select/poll:水平触发 + 系统调用传全部 fd 集;(2) signal-driven:edge-triggered SIGIO,O_ASYNC 启用;(3) epoll:用户态 ↔ 内核态共享兴趣列表,LT/ET 皆可。Linux C10K 默认 epoll,BSD/Solaris 用 kqueue / /dev/poll。 |
§63.1;select/poll 优势:可移植;缺点:随 fd 数线性恶化。epoll 优势:O(1) 监控大量 fd;缺点:仅 Linux。 |
select 系统调用 |
|
§63.2.1;FD_ZERO/FD_SET/FD_CLR/FD_ISSET 4 个宏;值-结果,每次调用前重新初始化;nfds = max_fd + 1;exceptfds = OOB + pty packet mode 状态变化。 |
poll 系统调用 |
|
§63.2.2;POLLIN/POLLRDNORM 等价;POLLRDBAND Linux 未用;POLLRDHUP 2.6.17+ 需 _GNU_SOURCE;跨实现行为有差异。 |
Signal-driven I/O / O_ASYNC |
|
§63.3;默认 SIGIO 是普通信号,多事件会丢失 → 改 realtime signal 排队;F_SETOWN 可指定进程组;demo_sigio.c 是经典示例。 |
epoll API (LT/ET) |
|
§63.4;ET 模式必须 O_NONBLOCK;events 结构内核 ↔ 用户拷贝,避免每次重传全部 fd;EPOLLEXCLUSIVE 用于多进程 listen fd 防 thundering herd;EPOLLONESHOT EPOLLET 防多线程共享 fd 问题。 |
self-pipe trick 与 pselect |
在 async-signal-safe handler 中 |
§63.5;alarm/timeout 配合它实现「无限等待 + 定时唤醒」;Common pattern 见 libev / libuv。 |
二、详细笔记
63.1 三种备选 I/O 模型概述
What:除「进程单 fd 阻塞 I/O」外的三种方式——I/O 多路复用(select/poll)、signal-driven I/O、Linux epoll。
Why:高并发服务器或同时等输入+网络的应用不能用单 fd blocking。fork-per-client 资源消耗大;polling 浪费 CPU。
How:
| 维度 | select/poll | signal-driven I/O | epoll |
|---|---|---|---|
触发 |
水平(state) |
边沿(event) |
LT / ET 均可(默认 LT) |
性能 |
随 fd 数线性恶化 |
与 event 数相关 |
O(1)(事件) |
可移植性 |
SUSv3,跨 UNIX |
历史接口 + Linux 扩展 |
Linux-only |
内核记住 fd 列表 |
❌ 每次重传 |
✓ |
✓ |
适合负载 |
fd < 1000 |
fd 上千 + sparse |
C10K / C10M |
When:(1) 移植要求高、fd 少 → select/poll;(2) 高并发 Linux → epoll;(3) 信号驱动 I/O 较罕见,需配合 realtime signal + siginfo 才有完整 fd 信息。
Example:现代 NGINX 用「preforked 多 worker + 单进程 epoll + worker 间共享 listen fd(EPOLLEXCLUSIVE)」。
63.1.1 Level-Triggered vs Edge-Triggered
What:LT(level)— fd 仍处 ready 状态,每次调用 select/poll/epoll_wait 都会再次通知;可不读空。ET(edge)— 仅在 ready 状态变化时通知;通知一次后必须读到 EAGAIN,否则可能漏数据。
Why:ET 比 LT 更省系统调用(OS 只在状态变化时唤醒),但编程要求严——必须 O_NONBLOCK + 循环 read/write 到 EAGAIN。
How:
| 模型 | LT / ET? |
|---|---|
select |
LT only |
poll |
LT only |
signal-driven I/O |
ET only |
epoll |
LT(默认)/ ET(EPOLLET) |
When:(1) 调试、fuzzing → LT 简单;(2) 高并发减唤醒次数 → ET(但配合 measure 防饿死)。
Example:epoll ET 下 read 必须循环到 read() == -1 && errno == EAGAIN;不然下次不再通知。
63.2.1 select 系统调用
What:int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
Why:可移植、无须新 API;缺点 FD_SETSIZE=1024,密集 fd 集仍有 O(N) 内核扫描。
How:
#include <sys/time.h>
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
/* FD_ZERO/FD_SET/FD_CLR/FD_ISSET 4 个宏 */
struct timeval { time_t tv_sec; suseconds_t tv_usec; };
返回值:-1 = 错误(EBADF / EINTR);0 = 超时;正数 = ready fd 数(同 fd 在多集合中重数)。
// 摘自《The Linux Programming Interface》 第 63 章 Listing 63-1 (片段)
fd_set readfds, writefds;
FD_ZERO(&readfds);
FD_ZERO(&writefds);
int nfds = 0;
for (j = 2; j < argc; j++) {
int fd; char rw[10];
sscanf(argv[j], "%d%2[rw]", &fd, rw);
if (fd >= FD_SETSIZE) errExit("limit");
if (fd >= nfds) nfds = fd + 1;
if (strchr(rw, 'r')) FD_SET(fd, &readfds);
if (strchr(rw, 'w')) FD_SET(fd, &writefds);
}
int ready = select(nfds, &readfds, &writefds, NULL, NULL);
for (int fd = 0; fd < nfds; fd++)
printf("%d: %s%s\n", fd, FD_ISSET(fd, &readfds) ? "r" : "",
FD_ISSET(fd, &writefds) ? "w" : "");
When:(1) 写 portable C 在 UNIX;(2) fd 少(< 100);(3) 教学。
Example:TLPI t_select 演示:监控 fd 0 上输入有超时 read = 1, 0:r,监控 fd 1 上输出立即 ready 1:w。
63.2.2 poll 系统调用
What:int poll(struct pollfd fds[], nfds_t nfds, int timeout);;数组元素 {fd, events, revents};events 为注册 bit、revents 为返回值。
Why:select 之后的「更现代」API;pollfd 数组长度理论上无上限,灵活;fd 重复无需重新初始化(events 与 revents 分开)。
How:
#include <poll.h>
struct pollfd {
int fd;
short events; /* POLLIN / POLLOUT / POLLPRI / POLLRDHUP */
short revents; /* 与 events 同样的 bit + POLLERR/POLLHUP/POLLNVAL */
};
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
/* timeout: -1 永远;0 poll;>0 ms */
关键 bit:
| Bit | 含义 |
|---|---|
POLLIN / POLLRDNORM |
普通数据可读 |
POLLPRI |
高优先级数据可读(TCP OOB) |
POLLRDHUP |
对端关闭写半边(需 _GNU_SOURCE,2.6.17+) |
POLLOUT / POLLWRNORM |
数据可写 |
POLLERR |
错误(仅返在 revents) |
POLLHUP |
hangup(仅返在 revents) |
POLLNVAL |
fd 无效(关闭、revents 专用) |
When:(1) 想明确区分多 fd 状态(不重数)、(2) fd > FD_SETSIZE、(3) 跨 UNIX 但仍是现代 API。
Example:TLPI poll_pipes.c 创建 10 个 pipe,写 3 个随机 pipe,poll 等所有读端 ready。
63.2.3 fd 何时算 ready
What:SUSv3 说「readfds 中 fd 在 read() 时不会阻塞」即 ready;select/poll 只是「能否不阻塞」不保证「一定读到数据」。
Why:理解「何时算 ready」避免误读;要区分 fd 类型(regular file / terminal / pipe / socket)行为差异。
How(关键摘要):
| fd 类型 | select r | poll revents |
|---|---|---|
regular file |
始终 ready(read 立即返) |
始终 POLLIN/POLLOUT |
terminal input ready |
r |
POLLIN |
pipe read end, data + write end open |
r |
POLLIN |
pipe read end, write end closed |
r |
POLLIN |
POLLHUP |
pipe write end, space + read end open |
w |
POLLOUT |
socket listen, 新连接 |
r |
POLLIN |
socket data ready |
r |
POLLIN |
socket peer close (FIN) |
rw |
POLLIN |
POLLOUT |
POLLRDHUP |
socket OOB data |
x |
POLLPRI |
When:(1) pipe read 端 select 检测 close → POLLHUP;(2) 写一个 fd 试图 always write——select 可能 short write,因 socket send buffer 满。
Example:TLPI 表 63-3 ~ 63-6 给出全文对照。
63.2.4 select vs poll 对比
What:三个层次——实现、API、可移植性、性能。
| 维度 | select | poll |
|---|---|---|
上限 |
FD_SETSIZE=1024(glibc) |
理论无上限 |
fd 集合 vs 数组 |
fd_set 位图(重初始化) |
pollfd 数组(events/revents 分开) |
timeout 精度 |
μs |
ms |
关闭 fd 错误返回 |
-1, EBADF |
revents = POLLNVAL 标识哪个 |
性能 |
稀疏 fd 集差(且 2.4 中较明显) |
密集 fd 集也 O(N);2.6 优化收敛差距 |
可移植性 |
SUSv3 |
SUSv3 |
实现 |
都用内核内部同一套 poll routine |
When:(1) FD_SETSIZE 够用 + 需要 μs 超时 → select;(2) fd 多、需要明确哪个 fd 关闭 → poll;(3) Linux fd 大量 → epoll。
63.3 Signal-Driven I/O / SIGIO
What:让内核在 fd 可读/可写时主动发信号给进程,进程不用 poll/wait。Linux 用 O_ASYNC;BSD 用 FASYNC(POSIX.1g 弃)。
Why:(1) 完全异步;(2) 多 fd 时性能优于 select/poll;(3) 缺点:信号编程易错(handler 限制、非 async-signal-safe)。
How:(4 步开启)
/* 1. 装 SIGIO handler(必须先于 O_ASYNC) */
sigaction(SIGIO, &sa, NULL);
/* 2. F_SETOWN 设 owner(pid 或 -pgid) */
fcntl(fd, F_SETOWN, getpid());
/* 3. 加 O_ASYNC + O_NONBLOCK */
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_ASYNC | O_NONBLOCK);
/* 4. handler 仅设 flag,主循环读空到 EAGAIN */
static volatile sig_atomic_t gotSigio = 0;
static void sigioHandler(int sig) { gotSigio = 1; }
for (;;) {
if (gotSigio) {
while (read(fd, &ch, 1) > 0)
/* process ch */ ;
gotSigio = 0;
}
/* 干别的活 */
}
Linux 增强:用 realtime signal (SIGRTMIN+n) 替代 SIGIO——多事件可排队:
fcntl(fd, F_SETSIG, SIGRTMIN+1);
sa.sa_flags = SA_SIGINFO;
sa.sa_sigaction = sigioHandler; /* (int sig, siginfo_t *si, void *ucontext) */
/* si->si_fd = 事件 fd;si->si_band = 类似 revents bit */
When:(1) 高并发 + 单进程 + 不希望 select loop——但 epoll 几乎已取代;(2) 教学价值大于实用价值。
Example:TLPI demo_sigio.c 在终端上启用 O_ASYNC,进入 cbreak 模式,主循环打 cnt 计数,按 x / # 立即响应。
63.4 epoll API
What:Linux 2.6 引入的高性能 I/O 事件通知 API;用户态 ↔ 内核态共享一个「兴趣列表」,内核主动通知 ready 事件。
Why:(1) 解决 select/poll O(N) 性能;(2) fd 集合不每次重传;(3) 支持 ET;(4) 是 NGINX / Envoy / Redis 等高并发服务的事实标准。
How:
| 调用 | 头 | 语义 |
|---|---|---|
epoll_create |
|
建 epoll fd(旧版带 size 提示,新版可填任意正数) |
epoll_create1 |
同上 |
建 + flags(EPOLL_CLOEXEC) |
epoll_ctl |
同上 |
epfd 上 op(ADD/MOD/DEL) fd + event 兴趣 |
epoll_wait |
同上 |
等 ready 事件,events[] 数组填 revents;maxevents 单次最大数 |
epoll_pwait |
同上 |
同上 + 临时 sigmask(类似 pselect) |
epoll_event 结构:
struct epoll_event {
uint32_t events; /* EPOLLIN/OUT/PRI/ERR/HUP/ET/LT/ONESHOT/RDHUP/EXCLUSIVE/WAKEUP/... */
epoll_data_t data; /* union { int fd; uint32_t u32; uint64_t u64; void *ptr; } */
};
EPOLLIN/OUT/HUP/ERR 是「事件」;EPOLLET = edge-triggered;EPOLLONESHOT = 一次性;EPOLLEXCLUSIVE = 多 worker listen 防 thundering herd。
典型 server(ET + nonblocking):
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
for (;;) {
int n = epoll_wait(epfd, events, MAX, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == listen_fd) {
/* accept 所有待连接(ET 模式) */
for (;;) {
int cfd = accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK);
if (cfd < 0) break;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, ...);
}
} else if (events[i].events & EPOLLIN) {
int fd = events[i].data.fd;
for (;;) {
ssize_t nr = read(fd, buf, BUF);
if (nr < 0 && errno == EAGAIN) break;
if (nr <= 0) { close(fd); break; }
/* process buf */
}
}
}
}
When:(1) C10K+ 服务首选;(2) 多线程可结合 EPOLLEXCLUSIVE / EPOLLONESHOT 防 race;(3) signal 配合 epoll_pwait。
Example:accept4(fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC) 是 Linux 3.9+ 一步完成 accept + 设 nonblocking + close-on-exec。
63.4.5 epoll vs select/poll 性能
What:select/poll 在内核中需要遍历所有 fd 列表;epoll 在 fd 集大 + active 数小时显著优于 select/poll。
Why:理解「为什么 epoll 适合 C10K」——O(1) 关注列表 + O(K) ready 数(K = 本轮 ready)。
How:(benchmark 数字 TLPI 给出):
| 监控 fd 数 | select/poll | epoll |
|---|---|---|
10-100 |
快(或近) |
略增 setup 开销 |
100-1000 |
慢(线性) |
O(1) |
1000-10000 |
不可用(实际也接近 limit) |
高效 |
10000+ |
不可用 |
高效 |
When:(1) fd 上千、活跃 fd 比例小 → epoll;(2) 仅有几十 fd → select/poll 足够;(3) 监控数千 inactive fd 上偶发事件 → epoll。
Example:QT/GTK GUI 程序需要监控 stdio + X server socket + filesystem fd —— 数量小,select 完全够;Nginx 处理 10k 长连接 → epoll 必备。
63.4.6 Edge-triggered 模式的注意事项
What:ET 要求 (1) O_NONBLOCK 必须开启;(2) 接事件后循环 read/write 到 EAGAIN;(3) 防止一个 fd 饿死其他 fd。
Why:(1) ET 的语义就是「fd 就绪时只通知一次」,漏读则数据可能驻留内核直到下次 close;(2) 长 read 阻塞会让其他 fd 等不到机会。
How(epoll ET 服务器关键写法):
-
accept 后立即
accept4(SOCK_NONBLOCK); -
注册 EPOLLIN | EPOLLET;
-
事件到达后
while (read() > 0)直到 EAGAIN。
When:(1) 严格避免一 fd 饿死其他 fd 时 → ET;(2) 写 fd 时担心写阻塞 → 必须 O_NONBLOCK;(3) 单线程 ET server + 公平 schedule。
Example:写 epoll ET echo server:每 receive event 必须一次读到 EAGAIN,否则 echo 漏数据。
63.5 pselect 与 self-pipe trick
What:(1) pselect 在 select 之前原子地设置 sigmask;(2) self-pipe trick:signal handler 写 1 字节到 pipe,主循环用 select 监控 read 端——将「信号 + 多 fd」统一到 select。
Why:经典问题——「如何在主循环既能 select 又能处理信号」;原子地换 mask 可避免 EINTR 时 mask 没改导致的竞争。
How:
#include <signal.h>
#include <sys/select.h>
int pselect(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, const struct timespec *timeout,
const sigset_t *sigmask);
/* sigmask 临时代替本进程 mask;select 返回前恢复 */
self-pipe trick 5 步:
. 创建 pipe(fd[2]);
. 设 handler sigio_handler,handler 内 write(fd[1], "x", 1)(async-signal-safe);
. 把 fd[0] 加到 select 监控集合;
. 主循环 select 阻塞到 fd[0] ready → 读 1 字节 → 处理信号;
. 用单独的 sigprocmask 阻塞信号避免 race(通常在 select 前临时阻塞、handler 写完 pipe 后原子化)。
When:(1) 想阻塞等 fd ready + 同时响应信号 → pselect 或 self-pipe;(2) alarm/timeout 信号要唤醒 select → self-pipe。
Example:libev / libuv 内部实现即 self-pipe + epoll 或 kqueue。
三、关键图表
|
关键系统调用一览
|
|
poll / epoll 事件 bit 速查
|
四、思维导图
mindmap
root((第 63 章 备选 I/O))
三种模型
select poll 多路复用
signal driven SIGIO
epoll LT ET
阻塞模型局限
单 fd 阻塞
polling 费 CPU
多进程开销
select
fd_set 三类
FD_SETSIZE 1024
timeout NULL 无限
重初始化
poll
pollfd 数组
events revents 分开
POLLIN POLLOUT HUP
POLLRDHUP
fd 何时 ready
regular file
pipe socket
OOB 数据
peer close
signal driven
O_ASYNC F_SETOWN
SIGIO handler
F_SETSIG realtime
siginfo si_fd si_band
epoll
epoll_create
epoll_ctl add mod del
epoll_wait
EPOLLIN OUT HUP ET
EPOLLONESHOT EXCLUSIVE
LT vs ET
状态 vs 状态变化
O_NONBLOCK 必开
循环到 EAGAIN
防一个 fd 饿死
pselect self pipe
原子 sigmask
pipe trick 写 1 byte
libev libuv 实例
五、重点与易错点
-
三种模型的核心区别是「内核是否记住 fd 列表」——select/poll 每次重传;signal-driven 与 epoll 内核持久化,性能随 fd 数线性 vs 与事件数相关。
-
select 的 FD_SETSIZE=1024 是硬上限——不是由 nfds 控制,是 fd_set 类型大小限定;要突破必须改头文件 FD_SETSIZE 并重编译,几乎没人这么干。
-
select 三类 fd_set 都需在每次调用前重新初始化(value-result);可用
nfds = max_fd + 1减少内核工作量。 -
select 不应被信号自动重启——handler 内务必不让 SA_RESTART + select 组合;多数实现 errno = EINTR。
-
select 与 poll 在 2.6 性能差距已收敛——但大量 fd 仍 prefer poll(fd 集合可变、POLLNVAL 标识能力)。
-
O_ASYNC 必须先装 SIGIO handler 再启用——否则 default kill 进程;handler 内仅设置
sig_atomic_tflag,不要做复杂事。 -
signal-driven I/O 默认 SIGIO 是普通信号——多事件会丢失;用 F_SETSIG 改 realtime signal + SA_SIGINFO + siginfo.si_fd 才有完整事件信息。
-
epoll 是 Linux 2.6 起几乎替代 select/poll——C10K / C10M 推荐;学习曲线稍陡(LT/ET 语义、event.data 选取)。
-
epoll ET 模式必须 O_NONBLOCK——否则 read/write 阻塞会等到的下一次 ready 永远不会到,被该 fd 阻塞。
-
epoll ET + accept 必须 accept 到 EAGAIN——不要 accept 一次就 break。
-
epoll ET 容易饿死其他 fd——accept 后立即处理所有 ready fd,处理其他 fd 时不要在同一轮花太久。
-
EPOLLONESHOT 用于多线程共享同一 fd——一个 worker 处理完后用 EPOLL_CTL_MOD 重新 arm。
-
EPOLLEXCLUSIVE 防多 worker thundering herd(listen fd);多个 worker 同时 accept 时只唤醒一个。
-
pselect 是 select 的原子版本——临时换 sigmask;常配合 self-pipe trick;解决「select + 信号」race。
-
self-pipe trick handler 中必须 async-signal-safe——只能
write()/_exit()等;不要 printf,不要 malloc。 -
select timeout 在 Linux 上是 value-result(被减到剩余);portable code 每次调用前重新初始化,勿依赖。
-
跨章衔接:第 56-62 章铺垫 socket/term;本章 epoll 是高并发 server 的核心;第 64 章 pty 数据交换常用 epoll 监控;第 44 章管道+ poll demo(poll_pipes);第 33 章线程 + epollone-shot 是高性能网络编程模板。