一句话 + 关键问题
一句话:不创建线程,只用一个"事件循环"反复问操作系统"哪些 I/O 已经就绪",然后一次处理一个事件——这样就不需要锁,调度权也回到了程序手里;代价是任何处理器都绝不能阻塞,于是需要异步 I/O 和手动管理状态。
如何不用线程构建并发服务器?既要保留对并发(调度)的控制,又要避开多线程程序中那些困扰人的问题。
How can we build a concurrent server without using threads, and thus retain control over concurrency as well as avoid some of the problems that seem to plague multi-threaded applications?
前面几章我们一直用线程 + 锁 + 条件变量/信号量写并发程序,也在第 32 章见识了它的两大麻烦:一是正确性难(忘记加锁、死锁、原子性/顺序违反);二是程序员几乎无法控制调度——创建线程后只能"祈祷"操作系统把它们安排得合理。
本章介绍另一种并发风格:基于事件的并发(event-based concurrency)。它在 GUI 程序和一些互联网服务器里非常常见(今天的 node.js 就是代表),根源则是 C/UNIX 里的 select()/poll()。本章是并发部分的最后一章(原书标注为 Advanced);其中"轮询还是中断"的取舍,会在下一部分第 36 章 I/O 设备里再次出现。
核心概念速览
事件循环(event loop)
while(1){ 取事件; 逐个处理 }。整个服务器只有这一个循环。
事件处理器(event handler)
处理某个事件的一小段代码;它运行时是系统里唯一在做事的活动。
显式调度
"下一个处理哪个事件"由程序自己决定——这本身就是调度,而且完全可控。
select() / poll()
一次询问一组文件描述符里哪些已就绪(可读/可写/异常),把网络 I/O 变成"事件"。
阻塞 vs 非阻塞
阻塞接口做完才返回;非阻塞(异步)接口发起后立刻返回。事件服务器里禁止阻塞调用。
异步 I/O(AIO)
struct aiocb + aio_read() 发起磁盘读并立即返回,再用 aio_error() 轮询或用信号得知完成。
续延(continuation)
发起异步 I/O 前,把"完成后还要做什么"(如回复哪个 socket)存进表里——即手动栈管理。
无需加锁(单 CPU)
一次只运行一个处理器,不会被别的线程打断,所以不存在临界区竞争。
基本思想:事件循环
事件驱动的做法简单到有点"可疑":等某件事(事件)发生;发生后看它是什么类型,做它需要的那一点点工作(可能包括发起 I/O 请求、为以后安排别的事件等)。就这样。规范的事件服务器围绕下面这个结构构建:
while (1) {
events = getEvents(); // 等待:哪些事情发生了?
for (e in events)
processEvent(e); // 一次处理一个事件(事件处理器)
}
这里有两点非常关键:
- 处理器运行时,它是系统里唯一的活动。没有别的线程会在它执行到一半时插进来修改共享数据。
- 决定下一个处理哪个事件,就等于调度。线程程序把调度交给操作系统;事件程序自己掌握调度——这种显式控制是事件方式的根本优势之一。
剩下的大问题是:getEvents() 到底怎么知道"网络包到了""磁盘读完了"?这就轮到 select() 了。
重要的 API:select()(或 poll())
大多数系统都提供 select() 或 poll() 系统调用。它们做的事情很简单:检查是否有需要处理的 I/O 到达。例如一个 Web 服务器想知道有没有新的网络包到了,就可以用它们。
int select(int nfds,
fd_set *restrict readfds, // 关心"可读"的 fd
fd_set *restrict writefds, // 关心"可写"的 fd
fd_set *restrict errorfds, // 关心"异常"的 fd
struct timeval *restrict timeout);
手册的描述(意译):select() 检查 readfds、writefds、errorfds 这三个描述符集合,看其中哪些已可读、可写或有异常待处理。只检查前 nfds 个描述符(0 到 nfds-1)。返回时,select() 把传入的集合替换成"已就绪的子集",返回值是所有集合中就绪描述符的总数。
关于 select() 有两点值得注意:
- 既能查可读,也能查可写。可读让服务器知道"新包到了、需要处理";可写让服务器知道"现在可以回复了"(发送队列没满)。
- 超时参数
timeout。常见用法是设为NULL:无限期阻塞,直到某个描述符就绪。更健壮的服务器通常会指定超时;一种常见技巧是把超时设为 0,让select()立即返回(纯轮询,不睡眠)。
poll() 与之非常相似(传入 struct pollfd 数组而不是位图)。无论哪个,它们都给了我们构建非阻塞事件循环的基础:检查哪些包到了,从有消息的 socket 读出来,需要时回复。
使用 select():逐行读原书 Figure 33.1
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int main(void) {
// open and set up a bunch of sockets (not shown)
// main loop
while (1) {
// initialize the fd_set to all zero
fd_set readFDs;
FD_ZERO(&readFDs);
// now set the bits for the descriptors
// this server is interested in
// (for simplicity, all of them from min to max)
int fd;
for (fd = minFD; fd < maxFD; fd++)
FD_SET(fd, &readFDs);
// do the select
int rc = select(maxFD+1, &readFDs, NULL, NULL, NULL);
// check which actually have data using FD_ISSET()
int fd;
for (fd = minFD; fd < maxFD; fd++)
if (FD_ISSET(fd, &readFDs))
processFD(fd);
}
}
FD_ZERO(&readFDs):清空集合。必须每轮都做,因为上一轮的select()已经把集合改写成了"就绪子集"。FD_SET(fd, &readFDs):把服务器关心的描述符(比如它正在服务的所有网络 socket)加进集合。select(maxFD+1, &readFDs, NULL, NULL, NULL):只关心可读;最后一个NULL是超时,表示"一直等到有 fd 就绪"。FD_ISSET(fd, &readFDs):逐个检查哪些 fd 真的有数据,对它们调用processFD()——这就是事件处理器。
细节:书中的两个 for 循环写的是 fd < maxFD,而 select() 的第一个参数是 maxFD+1。如果 maxFD 本身也是要监听的描述符,循环应写成 fd <= maxFD;下面的模拟器和实验代码都采用 <=。真实服务器还要处理发送消息、发起磁盘 I/O 等更多细节,可参考 Stevens & Rago 的 APUE,以及 Pai 等人、Welsh 等人对事件服务器整体流程的论述。
select() 一轮循环逐步执行
为什么更简单:无需加锁
阻塞(blocking,同步 synchronous)接口在返回调用者之前把所有工作做完;非阻塞(non-blocking,异步 asynchronous)接口开始一些工作后立即返回,让剩下的工作在后台完成。
阻塞调用的罪魁祸首通常是某种 I/O:比如一次调用需要从磁盘读数据才能完成,它就可能阻塞,等待发给磁盘的请求返回。非阻塞接口可以用在任何编程风格里(包括多线程),但对事件方式来说是必需的:一个阻塞的调用会让所有进展停下来。
在单 CPU 上运行事件驱动程序时,并发程序里那些问题都不存在了。原因很直接:同一时刻只处理一个事件,事件服务器不会被另一个线程打断——它毫无疑问是单线程的。因此不需要获取或释放锁,多线程程序里常见的并发 bug(原子性违反、顺序违反、死锁……)在基本的事件方式里不会出现。
TIP:在基于事件的服务器里不要阻塞。事件服务器让你能细粒度地控制任务调度;但为了保持这种控制,任何会阻塞调用者的调用都不能出现。违反这条的后果是:服务器被卡住、客户端很沮丧,而且别人会严重怀疑你是否读过这一部分。
问题:阻塞的系统调用
听起来很美:写个简单循环,事件来了就处理,连锁都不用想。但问题来了:如果某个事件需要发起一个可能阻塞的系统调用呢?
设想客户端请求服务器读一个磁盘文件并返回内容(很像一个简单的 HTTP 请求)。处理器最终要调用 open() 打开文件,再调用一串 read() 读它。只要所需的元数据或数据不在内存里,open() 和 read() 都可能向存储系统发 I/O 请求,从而花很长时间。
- 线程服务器:没问题。发起 I/O 的线程挂起等待时,其他线程照常运行,服务器继续推进。I/O 与计算的自然重叠,正是线程编程显得自然直接的原因。
- 事件服务器:没有别的线程可运行,只有主事件循环。处理器一旦阻塞,整个服务器就跟着阻塞,直到调用完成;此时系统闲着——巨大的资源浪费。所以事件系统必须遵守一条规则:不允许阻塞调用。
解决方案:异步 I/O
为克服这个限制,许多现代操作系统引入了向磁盘发起 I/O 的新方式,统称异步 I/O(asynchronous I/O):应用发起 I/O 请求后,在 I/O 完成之前就把控制权还给调用者;另有接口让应用判断各个 I/O 是否已完成。
以 Mac 上的接口为例(其他系统类似),核心是 struct aiocb,即 AIO 控制块(AIO control block)。简化版如下:
struct aiocb {
int aio_fildes; // 文件描述符 File descriptor
off_t aio_offset; // 文件内偏移 File offset
volatile void *aio_buf; // 结果放到哪块内存 Location of buffer
size_t aio_nbytes; // 读多少字节 Length of transfer
};
int aio_read(struct aiocb *aiocbp); // 发起异步读;成功则立即返回
int aio_error(const struct aiocb *aiocbp); // 完成返回 0;否则返回 EINPROGRESS
接口名 aio_error() 有点让人困惑,但它确实就是"查询这个请求完成没有"。于是,对每个未完成的异步 I/O,应用都可以周期性地轮询(poll)系统。
不过,逐个检查很痛苦:如果某一时刻有几十上百个 I/O 在飞,是一直反复检查、还是先等一会儿?为此一些系统提供了基于中断(interrupt)的办法:用 UNIX 信号(signal)在异步 I/O 完成时通知应用,从而无需反复询问。这种"轮询 vs 中断"的取舍在设备层面同样存在(第 36 章)。
所有现代 UNIX 变体里都有一套庞大而有趣的基础设施:信号。最简单地说,信号是与进程通信的一种方式:信号被递送给应用时,应用停下正在做的事,去运行一段信号处理程序(signal handler);结束后进程恢复原来的行为。
每个信号都有名字,如 HUP(挂断)、INT(中断)、SEGV(段错误)。有时信号由内核发出:程序段错误时,操作系统给它发 SIGSEGV;若程序配置了捕获该信号,就可以运行一段代码来应对(便于调试)。未配置处理程序时执行默认行为——对 SEGV 来说就是杀死进程。
void handle(int arg) {
printf("stop wakin' me up...\n");
}
int main(int argc, char *argv[]) {
signal(SIGHUP, handle);
while (1)
; // doin' nothin' except catchin' some sigs
return 0;
}
prompt> ./main &
[3] 36705
prompt> kill -HUP 36705
stop wakin' me up...
prompt> kill -HUP 36705
stop wakin' me up...
用 kill 命令行工具(名字确实有点凶)发信号,会打断主循环、运行 handle()。信号远不止这些,经典参考仍是 Stevens & Rago。
没有异步 I/O 的系统上,纯粹的事件方式无法实现。但研究者想出了效果不错的替代方案:Pai 等人(Flash Web 服务器)描述了一种混合方案——用事件处理网络包,用线程池管理未完成的磁盘 I/O。
另一个问题:状态管理
事件方式的另一个问题是:代码通常比线程版更难写。原因如下:事件处理器发起异步 I/O 后,必须把一些程序状态打包起来,留给 I/O 完成时的下一个处理器使用。线程程序不需要这一步,因为所需状态就在线程的栈上。Adya 等人把这项工作称为手动栈管理(manual stack management),它是事件编程的根本特征。
看一个具体例子:服务器要从文件描述符 fd 读数据,读完后把数据写到网络 socket sd。线程版(忽略错误检查)简单到不值一提:
int rc = read(fd, buffer, size); // 阻塞:线程睡眠,别的线程照跑
rc = write(sd, buffer, size); // 醒来后 sd 就在栈上,直接用
事件版呢?先用 AIO 异步发起读,再周期性地用 aio_error() 检查完成情况。等它告诉我们"读完了",事件服务器怎么知道接下来该干什么——写到哪个 socket?发起读的那个处理器早就返回了,它的局部变量 sd 随栈帧一起没了。
解决办法(Adya 等人)是用一个古老的程序设计语言构造:续延(continuation)。听起来复杂,其实很简单:把完成这个事件所需的信息记在某个数据结构里;事件发生(磁盘 I/O 完成)时,查出这些信息并继续处理。本例中,就是把 socket 描述符 sd 记在一张以 fd 为键的哈希表里;磁盘 I/O 完成时,事件处理器用 fd 查表得到 sd,终于可以把数据写到 socket。
// 事件版的伪代码(示意)
void on_request(int sd, const char *path) { // 处理器①
int fd = open(path, O_RDONLY); // (open 本身也可能阻塞!)
struct aiocb *cb = make_aiocb(fd, buffer, size);
aio_read(cb); // 立即返回
table_put(fd, (cont_t){ .sd = sd, .cb = cb }); // ← 续延:记住"之后要写给谁"
}
void on_io_done(int fd) { // 处理器②:aio_error()==0 或收到信号
cont_t c = table_get(fd); // ← 查回状态
write(c.sd, (void *)c.cb->aio_buf, size);
table_remove(fd);
}
continuation 表:多个异步读乱序完成
模拟器:三种服务器正面对比
把上面的讨论放进同一个场景:一批请求按时间到达,有的只需一点 CPU(数据在内存里),有的还要读磁盘。三种服务器在单 CPU上处理同一批请求:
- 每请求一线程:线程调用阻塞
read()时睡眠,调度器运行别的就绪线程(这里用非抢占的 FIFO 调度)。 - 事件循环 + 阻塞 I/O:处理器直接调用阻塞
read(),整个循环卡住直到磁盘返回。 - 事件循环 + 异步 I/O:处理器发起
aio_read()、登记续延后立即返回;I/O 完成后作为新事件排队,由另一个处理器查表并回复。
模型假设:请求 = CPU 计算 c₁ →(若需要)磁盘读 L → 回复 CPU 1 个单位;磁盘可以同时处理多个请求;事件按"就绪时间,再按请求编号"排队。"线程切换开销"只加在线程版上,用来体会线程的额外成本。
线程 vs 阻塞事件循环 vs 异步事件循环
事件方式仍然困难的地方
- 多 CPU。从单 CPU 走向多 CPU 时,事件方式的一部分简单性消失了:要用上多个 CPU,事件服务器就得并行运行多个事件处理器,于是临界区等常见同步问题又出现了,也就又需要锁。在现代多核系统上,"无锁的简单事件处理"已经不再可能。
- 与系统活动(如分页)集成不好。如果事件处理器发生缺页(page fault),它就会阻塞,服务器在缺页处理完之前毫无进展。尽管服务器刻意避免了显式阻塞,这种由缺页造成的隐式阻塞很难避免,频繁时会造成严重的性能问题。
- 代码随时间难以维护。各种例程的确切语义会变化。例如某个例程从非阻塞变成阻塞,调用它的事件处理器也必须跟着改——把自己拆成两半。由于阻塞对事件服务器是灾难性的,程序员必须时刻留意所用 API 的语义变化。
- 异步磁盘 I/O 与网络 I/O 难以统一。虽然如今大多数平台都有异步磁盘 I/O,但花了很长时间才走到这一步,而且它与异步网络 I/O 的集成并不像想象中那样简单统一:我们希望只用
select()管理所有未完成 I/O,实际上通常要把select()(网络)和 AIO 调用(磁盘)组合起来用。
线程 vs 事件:对比表
| 方面 | 多线程(阻塞 I/O) | 基于事件(非阻塞/异步 I/O) |
|---|---|---|
| 调度由谁决定 | 操作系统;程序员难以控制 | 程序自己:挑下一个事件 = 调度 |
| 锁(单 CPU) | 需要:线程随时可能被抢占 | 不需要:一次只运行一个处理器 |
| 锁(多 CPU) | 需要 | 并行跑多个处理器时同样需要 |
| 遇到磁盘 I/O | 该线程睡眠,其他线程继续,I/O 与计算自然重叠 | 必须用异步 I/O;一次阻塞调用卡住整个服务器 |
| 跨 I/O 的状态 | 在线程栈上,自动保存 | 手动栈管理:续延(continuation)表 |
| 代码形态 | 顺序、直观 | 被 I/O 切成多个处理器(回调) |
| 缺页等隐式阻塞 | 只阻塞出事的那个线程 | 阻塞整个事件循环 |
| API 语义变化(非阻塞→阻塞) | 影响不大 | 调用方处理器必须拆成两半 |
| 典型代表 | 每连接一线程的服务器、线程池 | GUI 事件循环、node.js、Flash、SEDA(混合) |
原书的结论:事件服务器把调度控制权交给应用本身,但代价是复杂性,以及与现代系统其他部分(如分页)集成的困难。没有哪种方法胜出,线程和事件很可能会作为同一个并发问题的两种解法长期共存。
先预测再验证
看答案
看答案
看答案
看答案
看答案
current_sd 记住"下一次要回复的 socket",会出什么问题?换几个种子想一想。看答案
动手实验:用 select() 写事件循环
实验目录 labs/33-events/:
echo_server.c:单线程、基于select()的多客户端 TCP echo 服务器(只监听 127.0.0.1)。-b 毫秒让每个处理器调用一次阻塞的usleep(),模拟"处理器里做了阻塞 I/O"。client.c:连接服务器、发 N 条消息、测每条的往返时间(RTT)。run_demo.sh同时启动 3 个客户端,分别以-b 0和-b 100各跑一轮,跑完自动关闭服务器。pipes.c:fork 3 个子进程,分别每 150/250/400 ms 往自己的管道写一条消息;父进程用select()同时监听三个管道,按就绪顺序读取。
cd labs/33-events && make
./pipes
./run_demo.sh 9090
示例输出(你的机器上数字会不同)——./pipes:
[parent] child0 间隔 150ms 写 4 条,读端 fd=3
[parent] child1 间隔 250ms 写 3 条,读端 fd=4
[parent] child2 间隔 400ms 写 2 条,读端 fd=5
[ 150.8ms] select rc=1 → fd3 可读: "child0 msg0"
[ 250.9ms] select rc=1 → fd4 可读: "child1 msg0"
[ 300.9ms] select rc=1 → fd3 可读: "child0 msg1"
[ 401.1ms] select rc=1 → fd5 可读: "child2 msg0"
[ 451.0ms] select rc=1 → fd3 可读: "child0 msg2"
[ 501.0ms] select rc=1 → fd4 可读: "child1 msg1"
[ 601.2ms] select rc=1 → fd3 可读: "child0 msg3"
[ 601.2ms] fd3 EOF(child0 结束)
[ 751.2ms] select rc=1 → fd4 可读: "child1 msg2"
[ 751.2ms] fd4 EOF(child1 结束)
[ 801.3ms] select rc=1 → fd5 可读: "child2 msg1"
[ 801.3ms] fd5 EOF(child2 结束)
[parent] 全部管道关闭,事件循环结束
示例输出(你的机器上数字会不同)——./run_demo.sh 9191(节选):
===== 服务器阻塞模拟 -b 0 ms =====
[client A] 5 条消息,平均 RTT = 0.24 ms,最大 RTT = 0.41 ms
[client B] 5 条消息,平均 RTT = 0.15 ms,最大 RTT = 0.19 ms
[client C] 5 条消息,平均 RTT = 0.18 ms,最大 RTT = 0.33 ms
--- 服务器日志前 12 行 ---
[server] 监听 127.0.0.1:9191,阻塞模拟 = 0 ms,listen fd = 3
[server 302.2ms] select 第 1 轮返回 rc=1,就绪: fd3
[server 302.3ms] accept → 新连接 fd4
[server 302.3ms] select 第 2 轮返回 rc=1,就绪: fd4
[server 302.4ms] fd4 收到 "A#0" 并回显
[server 304.9ms] select 第 3 轮返回 rc=1,就绪: fd3
[server 304.9ms] accept → 新连接 fd5
...
===== 服务器阻塞模拟 -b 100 ms =====
[client A] 5 条消息,平均 RTT = 140.36 ms,最大 RTT = 150.58 ms
[client B] 5 条消息,平均 RTT = 240.89 ms,最大 RTT = 350.90 ms
[client C] 5 条消息,平均 RTT = 260.88 ms,最大 RTT = 400.19 ms
--- 服务器日志前 12 行 ---
[server] 监听 127.0.0.1:9191,阻塞模拟 = 100 ms,listen fd = 3
[server 303.3ms] select 第 1 轮返回 rc=1,就绪: fd3
[server 303.4ms] accept → 新连接 fd4
[server 303.4ms] select 第 2 轮返回 rc=2,就绪: fd3 fd4
[server 303.4ms] accept → 新连接 fd5
[server 403.6ms] fd4 收到 "A#0" 并回显
[server 403.7ms] select 第 3 轮返回 rc=2,就绪: fd3 fd5
[server 403.7ms] accept → 新连接 fd6
[server 504.0ms] fd5 收到 "B#0" 并回显
[server 504.1ms] select 第 4 轮返回 rc=2,就绪: fd4 fd6
[server 604.3ms] fd4 收到 "A#1" 并回显
[server 704.6ms] fd6 收到 "C#0" 并回显
该观察什么:
pipes只有一个线程,却按 150、250、300、400… ms 的"归并顺序"处理三个管道:谁先就绪先处理谁。子进程退出后读端也会"就绪",只是read()返回 0(EOF)。-b 0:三个客户端的 RTT 都不到 1 ms。日志里select每轮报告就绪的 fd:监听 socket(fd3)就绪就accept(),客户端 socket 就绪就read()并回显,全程没有一把锁。-b 100:每个处理器只"阻塞" 100 ms,但 B、C 的平均 RTT 被拖到 240~260 ms,最大 350~400 ms。看日志:t=303.4 时 fd5 的连接已就绪,服务器却在 fd4 的处理器里睡到 403.6 才去accept它。这正是"事件处理器里不要阻塞"的真实代价。
也可以用 bash 手动当客户端:exec 3<>/dev/tcp/127.0.0.1/9090; echo hi >&3; head -c 3 <&3。用完记得 kill 掉后台服务器。
自测题
常见误区与小结
- "事件驱动 = 不会阻塞。"错。事件方式的前提是你保证不阻塞;一次阻塞 read()、一次同步 open()、甚至一次缺页,都会让整个服务器停下。
- "select() 能告诉我磁盘文件的数据是否已在内存。"不能。普通磁盘文件总是被报告为"可读",读的时候照样可能阻塞,所以磁盘 I/O 需要 AIO 或线程池这类方案;这也是网络 I/O 与磁盘 I/O 难以统一的原因。
- "select() 调用一次后,集合可以直接复用。"不行。返回时集合已被改写成就绪子集,每轮都要重新设置(或者从一份备份复制)。
- "事件程序永远不需要锁。"只在单 CPU、单个事件循环时成立。多个处理器并行运行时,锁又回来了。
- "异步 I/O 发起后,数据马上就在 aio_buf 里。"aio_read() 返回只表示"已提交"。必须等 aio_error() 返回 0(或收到完成信号)后,缓冲区里的数据才有效。
本章小结
- 事件并发 = 事件循环(
getEvents+ 逐个processEvent);选择下一个事件就是调度,程序自己掌握。 select()/poll()检查一组描述符中哪些就绪:传入"关心的集合",返回"就绪的子集"和就绪个数;timeout 为 NULL 时一直等,为 0 时立即返回。- 单 CPU 上一次只运行一个处理器,不需要锁;但任何阻塞调用都会卡住整个服务器。
- 磁盘 I/O 用异步 I/O(
aiocb、aio_read、aio_error,或用信号通知);没有 AIO 时,可以用"事件 + 线程池"的混合方案。 - 异步化的代价是手动栈管理:用续延(continuation)表保存跨 I/O 的状态。
- 仍然困难的地方:多核又需要锁;缺页造成隐式阻塞;API 语义变化会迫使处理器拆分;网络与磁盘异步接口不统一。线程与事件会长期共存。
- 承上启下:并发部分到此结束。下一部分"持久化"从第 36 章 I/O 设备开始,你会再次看到轮询 vs 中断的权衡(对应本章的 aio_error 轮询与信号通知),以及磁盘 I/O 为什么慢,也就是为什么阻塞在它上面代价这么高。