一句话 + 关键问题

一句话:不创建线程,只用一个"事件循环"反复问操作系统"哪些 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);       // 一次处理一个事件(事件处理器)
}
事件来源 异步 I/O 完成 网络 socket:新请求 管道 / 终端输入 定时器、GUI 点击… getEvents() select()/poll() 本轮就绪的事件 e1 e2 e3 挑下一个 = 调度 processEvent(e) 一次只运行一个处理器 while (1):处理完这一批,回到 getEvents() 处理器可能发起新的异步 I/O,完成时又变成新事件
图 33-A 事件循环:从各种来源收集"就绪"事件,每次挑一个交给处理器。挑选顺序由程序决定,这就是显式的调度。

这里有两点非常关键:

剩下的大问题是: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() 把传入的集合替换成"已就绪的子集",返回值是所有集合中就绪描述符的总数。

传入 readFDs "我关心这些 fd" 0 0 0 1 1 1 1 0 fd0fd1fd2fd3fd4fd5fd6fd7 nfds = 7(最大 fd + 1) 内核逐个检查(没有就绪的就让进程睡眠,直到超时) fd3 监听 socket:无新连接 fd4 客户端 A:有数据 fd5 客户端 B:无数据   fd6 管道:有数据 返回的 readFDs "其中这些就绪了" 0 0 0 0 1 0 1 0 rc = 2
图 33-B select() 的输入是"关心哪些 fd"的位图,输出被原地改写成"已就绪的子集",返回值是就绪个数。所以每轮循环都要重新 FD_ZERO/FD_SET。

关于 select() 有两点值得注意:

  1. 既能查可读,也能查可写。可读让服务器知道"新包到了、需要处理";可写让服务器知道"现在可以回复了"(发送队列没满)。
  2. 超时参数 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);
    }
}

细节:书中的两个 for 循环写的是 fd < maxFD,而 select() 的第一个参数是 maxFD+1。如果 maxFD 本身也是要监听的描述符,循环应写成 fd <= maxFD;下面的模拟器和实验代码都采用 <=。真实服务器还要处理发送消息、发起磁盘 I/O 等更多细节,可参考 Stevens & Rago 的 APUE,以及 Pai 等人、Welsh 等人对事件服务器整体流程的论述。

模拟器

select() 一轮循环逐步执行

先设置哪些 fd"被关心"(FD_SET)、哪些 fd"有数据到达",再执行一轮 Figure 33.1 的循环。观察:传入位图 → 返回位图 → rc → FD_ISSET 逐个处理。试试"有数据但没 FD_SET"的 fd 和"超时=0 且无数据"的情况。

        

为什么更简单:无需加锁

阻塞(blocking,同步 synchronous)接口在返回调用者之前把所有工作做完;非阻塞(non-blocking,异步 asynchronous)接口开始一些工作后立即返回,让剩下的工作在后台完成。

阻塞调用的罪魁祸首通常是某种 I/O:比如一次调用需要从磁盘读数据才能完成,它就可能阻塞,等待发给磁盘的请求返回。非阻塞接口可以用在任何编程风格里(包括多线程),但对事件方式来说是必需的:一个阻塞的调用会让所有进展停下来。

在单 CPU 上运行事件驱动程序时,并发程序里那些问题都不存在了。原因很直接:同一时刻只处理一个事件,事件服务器不会被另一个线程打断——它毫无疑问是单线程的。因此不需要获取或释放锁,多线程程序里常见的并发 bug(原子性违反、顺序违反、死锁……)在基本的事件方式里不会出现。

TIP:在基于事件的服务器里不要阻塞。事件服务器让你能细粒度地控制任务调度;但为了保持这种控制,任何会阻塞调用者的调用都不能出现。违反这条的后果是:服务器被卡住、客户端很沮丧,而且别人会严重怀疑你是否读过这一部分。

问题:阻塞的系统调用

听起来很美:写个简单循环,事件来了就处理,连锁都不用想。但问题来了:如果某个事件需要发起一个可能阻塞的系统调用呢?

设想客户端请求服务器读一个磁盘文件并返回内容(很像一个简单的 HTTP 请求)。处理器最终要调用 open() 打开文件,再调用一串 read() 读它。只要所需的元数据或数据不在内存里,open() 和 read() 都可能向存储系统发 I/O 请求,从而花很长时间。

0510152025 ① 每请求一线程 CPU T1 T2 T3 空闲 磁盘 T1 的 read():T1 睡眠 T2、T3 在 T1 等磁盘时就完成了(R2 响应 = 5 − 2 = 3) ② 事件循环 + 阻塞 read() CPU R1 整个事件循环卡在 read() 里 R2 R3 磁盘 R1 的 read() R2、R3 在 t=2、3 就到了,却只能等 R2 响应 = 16 − 2 = 14
图 33-C 同样三个请求:只有 R1 需要读磁盘(10 个时间单位)。线程版里 T2、T3 在 T1 睡眠时照常运行;阻塞事件版里,一个处理器的阻塞调用让所有请求一起等。

解决方案:异步 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
};
struct aiocb cb aio_fildes = 5 aio_offset = 4096 aio_buf = buf aio_nbytes = 8192 磁盘上的文件(fd 5) [4096, 12288) 内存缓冲区 buf 完成后数据被拷到这里 应用(事件循环)视角 aio_read(&cb) → 立即返回 …去处理别的事件… aio_error(&cb) → EINPROGRESS aio_error(&cb) → EINPROGRESS aio_error(&cb) → 0 完成! 轮询:一遍遍问"好了没?" ① 填好控制块:读哪个文件、从哪、读多少、放哪。 ② aio_read() 只"发起"请求,成功就马上返回。 ③ 之后用 aio_error() 检查:未完成返回 EINPROGRESS,   完成返回 0,此时 aio_buf 里已有数据。
图 33-D 异步读的三步:填 aiocb → aio_read() 发起并立即返回 → aio_error() 轮询是否完成。
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。

事件循环 select() 监听网络 socket + 完成通知管道 磁盘请求 请求队列 (读 file.html…) 辅助线程池 helper 1:阻塞 read() 没关系 helper 2 helper 3 完成后写一个字节到管道 → 该 fd 可读 → 变成事件
图 33-E 混合方案(Pai 等人):阻塞只发生在辅助线程里,事件循环本身永不阻塞。

另一个问题:状态管理

事件方式的另一个问题是:代码通常比线程版更难写。原因如下:事件处理器发起异步 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。

线程版:状态天然在栈上 线程 T7 的栈帧 fd = 7 sd = 12 buffer[...] read(fd, buffer, size); // 睡眠 write(sd, buffer, size); // sd 还在 事件版:手动把状态存进表 处理器①:请求到达 aio_read(fd 7); 返回 continuation 表 fd 3 → sd 9 fd 7 → sd 12 fd 11 → sd 4 记下 处理器②:fd 7 读完 sd = 查表(7); write(sd) 查出 sd 12
图 33-F 同一件事"读 fd 7 再写 sd 12":线程版靠栈保存 sd;事件版必须在发起 aio_read 前把 fd→sd 存进 continuation 表,完成时再查回来。
// 事件版的伪代码(示意)
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 表:多个异步读乱序完成

4 个客户端(sd 10–13)各请求一个文件(fd 20–23)。先逐个"发起 aio_read 并登记续延",然后 I/O 按随机顺序完成。观察:完成顺序和发起顺序无关,但每次都能靠 fd 查表找回正确的 sd。换个种子看看不同的完成顺序。

模拟器:三种服务器正面对比

把上面的讨论放进同一个场景:一批请求按时间到达,有的只需一点 CPU(数据在内存里),有的还要读磁盘。三种服务器在单 CPU上处理同一批请求:

  1. 每请求一线程:线程调用阻塞 read() 时睡眠,调度器运行别的就绪线程(这里用非抢占的 FIFO 调度)。
  2. 事件循环 + 阻塞 I/O:处理器直接调用阻塞 read(),整个循环卡住直到磁盘返回。
  3. 事件循环 + 异步 I/O:处理器发起 aio_read()、登记续延后立即返回;I/O 完成后作为新事件排队,由另一个处理器查表并回复。

模型假设:请求 = CPU 计算 c₁ →(若需要)磁盘读 L → 回复 CPU 1 个单位;磁盘可以同时处理多个请求;事件按"就绪时间,再按请求编号"排队。"线程切换开销"只加在线程版上,用来体会线程的额外成本。

模拟器

线程 vs 阻塞事件循环 vs 异步事件循环

单步推进时间,三条时间线上的竖线同步移动。红色块 = 事件循环卡在阻塞调用里;淡色块 = 尚未发生。对比平均响应时间和 CPU 利用率。

事件方式仍然困难的地方

处理器访问 cache[k](没有任何系统调用) 页不在内存缺页异常 → 内核 从交换区读页毫秒级磁盘 I/O 整个循环停住隐式阻塞 代码里一个阻塞调用都没有,事件循环照样会被一次缺页卡住(参见第 21 章交换机制)
图 33-G 隐式阻塞:缺页不经过任何你能替换成"异步版"的 API。

线程 vs 事件:对比表

方面多线程(阻塞 I/O)基于事件(非阻塞/异步 I/O)
调度由谁决定操作系统;程序员难以控制程序自己:挑下一个事件 = 调度
锁(单 CPU)需要:线程随时可能被抢占不需要:一次只运行一个处理器
锁(多 CPU)需要并行跑多个处理器时同样需要
遇到磁盘 I/O该线程睡眠,其他线程继续,I/O 与计算自然重叠必须用异步 I/O;一次阻塞调用卡住整个服务器
跨 I/O 的状态在线程栈上,自动保存手动栈管理:续延(continuation)表
代码形态顺序、直观被 I/O 切成多个处理器(回调)
缺页等隐式阻塞只阻塞出事的那个线程阻塞整个事件循环
API 语义变化(非阻塞→阻塞)影响不大调用方处理器必须拆成两半
典型代表每连接一线程的服务器、线程池GUI 事件循环、node.js、Flash、SEDA(混合)

原书的结论:事件服务器把调度控制权交给应用本身,但代价是复杂性,以及与现代系统其他部分(如分页)集成的困难。没有哪种方法胜出,线程和事件很可能会作为同一个并发问题的两种解法长期共存。

先预测再验证

在"三种服务器正面对比"里点预设:全部命中内存(无 I/O)。先预测:三条时间线会有什么不同?平均响应时间谁最小?
看答案
三条时间线完全相同,统计也一样。没有 I/O 时,三种服务器做的都是"按到达顺序依次计算",阻塞与否根本不会发生。事件方式的麻烦(以及线程的优势)只在出现阻塞 I/O 时才会显现。(线程切换开销设为非 0 时,线程版会略慢。)
点预设:一个磁盘读卡住所有人。R1 在 t=2 到达,只需 3 个单位 CPU。预测它在"事件循环 + 阻塞 I/O"里的响应时间,再单步到 t=2 附近验证。
看答案
R0 在 t=0 到达,算 2 个单位后在 t=2 调用阻塞 read(),循环卡到 t=32,回复到 t=33。R1 虽然 t=2 就到了,直到 t=33 才被分派,t=36 完成:响应时间 34。线程版和异步版里 R1 在 t=2~5 就跑完了,响应时间只有 3。平均响应时间:阻塞版约 50.3,另外两种约 14.2。
仍用这个预设,把 I/O 延迟 L 从 30 改成 60。预测:阻塞版和异步版的平均响应时间各自大约变化多少?
看答案
异步版只有两个需要 I/O 的请求(R0、R3)的响应时间各增加 30,平均约 +10。阻塞版里每次阻塞都让之后到达的所有请求多等 30,两次阻塞叠加:平均响应从约 50.3 升到约 95.3(+45),而异步版从约 14.2 升到约 24.2。阻塞调用的代价会被所有排队者分摊并放大。
把"线程切换开销"设为 1,重新生成。线程版和异步版还相等吗?这能说明"事件方式一定更快"吗?
看答案
线程版变慢了(预设场景平均响应从约 14.2 升到 17.0),因为每次换线程都要付出开销,而事件循环里分派处理器只是一次函数调用。但这不能说明事件方式一定更快:原书强调的优势是对调度的控制和单 CPU 下无需加锁,代价是复杂的状态管理,以及阻塞(包括缺页)带来的风险。本模型也忽略了 select() 系统调用本身的成本。
在 select() 模拟器里:让 fd7(客户端 C)有数据但不勾选 FD_SET,其他 fd 都没数据,timeout 分别选 NULL 和 {0,0}。预测 select() 的行为和 rc。
看答案
fd7 不在传入集合里,内核根本不看它。timeout=NULL 时,select() 在所关心的 fd 上睡眠,一直等下去(fd7 的数据被晾着);timeout={0,0} 时立即返回 rc=0,FD_ISSET 循环什么也找不到。这就是"忘记 FD_SET 某个连接"的 bug:那个客户端永远得不到服务。
continuation 模拟器里,如果不用表,而是用一个全局变量 current_sd 记住"下一次要回复的 socket",会出什么问题?换几个种子想一想。
看答案
多个异步读同时在飞,完成顺序与发起顺序无关。全局变量只能记住最后一次发起时的 sd,先完成的 I/O 会把数据写给错误的客户端。续延必须以 I/O 本身(fd 或 aiocb)为键来保存,才能在任意完成顺序下找回正确的上下文。

动手实验:用 select() 写事件循环

实验目录 labs/33-events/:

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" 并回显

该观察什么:

也可以用 bash 手动当客户端:exec 3<>/dev/tcp/127.0.0.1/9090; echo hi >&3; head -c 3 <&3。用完记得 kill 掉后台服务器。

自测题

1. select() 返回后,传入的 readFDs 集合里是什么?
select() 原地把集合替换成"已就绪的子集",返回值是就绪总数。所以每轮循环都要重新 FD_ZERO + FD_SET,再用 FD_ISSET 逐个检查。
2. 把 select() 的 timeout 参数设为 NULL 意味着什么?
NULL 表示一直等;若想立即返回,要传一个值为 0 的 timeval。更健壮的服务器通常会指定某种超时。
3. 为什么在单 CPU 上,基本的事件驱动服务器不需要锁?
事件服务器是单线程的,处理器一个接一个地运行,因此不存在多个执行流交错访问共享数据的问题。注意:硬件中断照样会发生,只是不会让另一个处理器插进来运行。
4. 事件处理器里调用了阻塞的 read() 读磁盘文件,而数据不在内存中。会发生什么?
事件服务器只有一个执行流。它阻塞,整个服务器就停下,CPU 空闲。这正是"不要在事件服务器中阻塞"这条 TIP 的由来;在模拟器的阻塞版时间线上表现为红色块。
5. 对一个已提交的异步读调用 aio_error(),返回 EINPROGRESS,说明什么?
aio_error() 完成时返回 0,未完成时返回 EINPROGRESS。应用可以周期性地轮询它,或改用信号在完成时得到通知。
6. 事件编程中的"续延(continuation)"主要解决什么问题?
线程版的状态在栈上;事件处理器发起异步 I/O 后就返回了,局部变量随之消失。所以要把所需信息(如 fd→sd)存进数据结构,完成时再查回来,这就是手动栈管理。
7. 下面哪一种阻塞,即使把所有 I/O 调用都换成异步版本,事件服务器也很难避免?
缺页是隐式阻塞,不经过任何可替换的 API;处理器一缺页,整个循环就停住。C、D 都有办法用非阻塞/异步方式或辅助线程处理;B 本来就是循环"无事可做时"的等待点。
8. 在多核机器上想让事件服务器利用所有 CPU,会怎样?
原书指出:多 CPU 让事件方式失去一部分简单性,并行处理器之间照样要同步。所以在现代多核系统上,"不用锁的简单事件处理"已不再可能。
9. 原书认为,与线程相比,事件方式的一个根本优势是什么?
原书在介绍事件循环时说,决定处理哪个事件就等于调度,这种显式控制是根本优势之一。B、D 恰恰是事件方式的弱点;C 不对,它依赖 select()/poll()/AIO 等系统调用。

常见误区与小结

本章小结