Skip to content

事件驱动并发 ​

  • 写作时间:2026-03-04 首次提交,2026-07-13 最近修改
  • 当前字符:11058

线程让多条执行流可以分别等待 I/O,同步原语和死锁则说明,只要这些执行流共享状态,就必须同时处理互斥、顺序与活性问题。线程仍然是必要工具,但“每项等待都配一个线程”并不是组织大量 I/O 的唯一方式。

来看一个服务大量连接的程序。Linux 用文件描述符(fd)表示每条连接,程序需要在连接可读时接收数据,在连接可写时发送积压的数据。若为每个连接创建一个线程,多数线程会长期睡在各自的等待队列上。睡眠线程不占用 CPU,却仍需要线程栈的虚拟地址范围、内核任务结构和调度记录;连接频繁活跃时,还会增加唤醒与调度压力。这里不能把线程栈的最大范围直接等同于已经占用的物理内存,但线程数量本身仍是需要管理的资源。

另一种组织方式是让少量线程同时照看大量 fd。要建立这条路线,先用 I/O 语义区分“调用是否等待”与“通知表示什么”;多个 fd 可以交给 select 与 poll统一等待,但它们每次都要检查整组输入。Linux 的 epoll把关注集合保存在内核中,只把当前就绪的事件交回应用。应用再围绕这些事件构造 事件循环,用状态而不是阻塞的调用记录每项工作的进度。就绪通知仍要求应用另行调用 read() 或 write(),而 io_uring进一步提供提交与完成队列,让应用按“提交操作,稍后取得结果”的方式组织 I/O。

I/O 语义 ​

I/O 语义(I/O semantics)描述 I/O 调用何时返回,以及应用收到的通知与实际数据传输处于什么关系。

理解 Linux I/O 时,需要把两个容易混在一起的问题分开:

  1. 调用能否立即推进:不能推进时,调用者是睡眠等待,还是立即得到“稍后重试”的结果?
  2. 通知代表哪个阶段:它只表示某项操作现在可能不会阻塞,还是表示已经提交的操作完成了?

第一个问题区分阻塞与非阻塞。以管道或连接上的 read() 为例,阻塞 fd 暂时没有数据时,内核会把调用线程移入等待队列;数据到达、文件结束、信号中断或错误发生后,线程才有机会继续。这里阻塞的是调用线程,不是整个进程,其他可运行线程仍可使用 CPU。

把 fd 设置为 O_NONBLOCK 后,同一次 read() 在暂时无法取得数据时不再睡眠,而是返回 -1,并由 C 库包装层把 errno 设为 EAGAIN 或 EWOULDBLOCK。非阻塞只改变一次 I/O 调用的等待行为,并不会自动通知程序何时应该重试。如果应用不断循环调用 read(),仍会形成忙轮询并消耗 CPU。

c
ssize_t n = read(fd, buf, sizeof(buf));

if (n > 0) {
    consume(buf, (size_t)n);
} else if (n == 0) {
    /* EOF; for a connection, the peer closed its sending side. */
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
    /* No progress now; retry after another readiness notification. */
}

第二个问题区分就绪(readiness)与完成(completion)。就绪通知表示:内核观察 fd 时,所关心的操作具备立即推进的条件。例如“可读”可能表示缓冲区有数据,也可能表示已经到达文件结束或错误状态;“可写”通常表示至少有一定发送空间,不表示任意长度的数据都能一次写完。

就绪不是数据已经复制到应用缓冲区的完成通知。应用收到可读事件后仍要调用 read(),收到可写事件后仍要调用 write()。事件从内核产生到处理函数真正运行之间,状态还可能被其他线程改变;Linux 也可能产生偶发的就绪误报。因此,事件驱动程序通常把被监视的 fd 设置为非阻塞:即使通知已经过时,实际 I/O 也只会返回 EAGAIN,不会卡住负责处理所有 fd 的线程。

完成通知表示应用先描述一项具体操作,包括 fd、缓冲区和长度,系统稍后返回这项操作的结果。结果可能是传输字节数、文件结束或错误。由于内核可能在提交调用返回后继续访问缓冲区,应用必须保证缓冲区及其关联状态一直有效,直到收到完成通知。

阻塞、非阻塞与就绪、完成不是四个平级选项,而是两条维度。一个就绪等待接口本身可以阻塞,应用却仍把被监视的 fd 设成非阻塞;这并不矛盾,因为前者同时等待多个 fd,后者保证处理某个 fd 时不会意外睡眠。

机制应用提交的内容返回或通知的含义后续动作
阻塞 read()一次读取操作读取有结果后返回,期间线程可能睡眠直接处理返回值
非阻塞 read()一次读取操作不能立即推进时返回 EAGAIN稍后重试
就绪通知关心的 fd 与事件类型某类 I/O 当前可能不会阻塞应用再调用 read() 或 write()
完成通知一项带缓冲区的具体 I/O已提交操作已经得到结果读取完成结果并管理下一项操作

select 与 poll ​

select 与 poll 是让一个线程同时等待一组文件描述符就绪的同步 I/O 多路复用(I/O multiplexing)接口。

“同步”在这里表示接口返回的是就绪状态,实际 I/O 仍由应用随后执行。调用线程可以在 select() 或 poll() 中睡眠;只要任意被关注的 fd 就绪、超时到期或信号中断等待,调用就会返回。

select() 的接口把可读、可写和异常条件分成三组 fd_set:

c
int select(int nfds,
           fd_set *readfds,
           fd_set *writefds,
           fd_set *exceptfds,
           struct timeval *timeout);

fd_set 是一组 fd 的位集合。调用前,应用用 FD_SET() 指明关心哪些 fd;调用后,集合被原地改写,只保留当前就绪的 fd。nfds 不是集合大小,也不是 fd 数量,而是三组集合中最大 fd 加一。因为输入集合被覆盖,循环中的每次调用都必须从保存的关注集合重新复制一份。

c
fd_set ready = interest;
int count = select(max_fd + 1, &ready, NULL, NULL, NULL);

if (count > 0) {
    for (int fd = 0; fd <= max_fd; fd++) {
        if (FD_ISSET(fd, &ready)) {
            handle_readiness(fd);
        }
    }
}

在 glibc 中,fd_set 是固定大小的类型,FD_SETSIZE 为 1024。把负数或不小于 FD_SETSIZE 的 fd 交给 FD_SET() 等宏属于未定义行为。这个限制来自用户态接口的数据结构,不是“重新编译内核头文件”就能可靠消除的;现代程序需要监视更大 fd 时应改用不依赖固定 fd 位图的接口。

poll() 把每个 fd、关注事件和返回事件放在同一个数组元素中:

c
struct pollfd {
    int   fd;
    short events;
    short revents;
};

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

events 是应用写入的关注条件,revents 是内核写回的实际条件,因此返回后不必重建整个关注集合。数组长度在运行时决定,也没有 FD_SETSIZE 这一固定上限;程序仍受进程可打开 fd 数量、可用内存和其他系统资源限制。POLLERR、POLLHUP 与 POLLNVAL 等结果即使没有写进 events,也可能出现在 revents 中,所以处理循环不能只检查 POLLIN。

两者共同的扩展性限制来自“每次调用都重新提交整个关注集合”。Linux 内核需要逐项查询这些 fd 的当前状态,用户态返回后也需要扫描位集合或 pollfd 数组,找出具体哪些项就绪。若监视 10,000 个 fd 而只有 5 个活跃,检查成本仍主要由 10,000 个关注项决定。

特性selectpoll
关注集合三个固定大小的 fd_set运行时长度的 pollfd 数组
输入与输出同一集合被改写events 与 revents 分离
fd 范围glibc 中小于 FD_SETSIZE没有 FD_SETSIZE 限制
返回后定位事件扫描到 nfds - 1扫描全部数组元素
跨调用保存关注集合否否

select 与 poll 并非在所有场景都慢。关注项较少、集合经常变化或程序需要可移植接口时,线性扫描可能足够简单有效。问题只在大量 fd 长期注册、每次仅少数就绪时变得突出,下一种 Linux 接口正是通过改变数据组织方式处理这个场景。

epoll ​

epoll 是 Linux 提供的有状态就绪通知接口,它在内核中保存关注集合,并通过就绪列表向应用返回发生事件的项目。

epoll 实例包含两个逻辑集合:关注列表(interest list)记录应用希望监视的 fd 与事件类型,就绪列表(ready list)记录当前有事件可交付的关注项。三个主要接口分别管理这两个集合:

c
int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

epoll_create1() 创建实例并返回 epoll fd。应用用 epoll_ctl() 的 EPOLL_CTL_ADD、EPOLL_CTL_MOD 和 EPOLL_CTL_DEL 操作关注列表,再用 epoll_wait() 取得不超过 maxevents 个就绪事件。没有事件时,调用线程可以睡在 epoll 实例的等待队列上。

普通磁盘文件通常会被 select() 和 poll() 视为立即可读写,因为这两个接口无法为普通文件建立有意义的就绪等待;epoll_ctl() 注册普通文件时通常会返回 EPERM,不能把它也归入“始终就绪”。就绪多路复用主要用于管道、终端、套接字等具有等待语义的 fd。后文使用网络连接作为主要场景,但同样的机制也适用于这些其他 fd 类型。

注册 fd 时,epoll 会接入该文件对象的等待机制。底层状态变化触发回调后,相应关注项会进入就绪列表,并唤醒等待者。当前 Linux 实现使用树结构查找关注项,并用链表组织可交付事件;这些是实现手段,不是 epoll 的接口语义。应用真正依赖的是“关注集合跨调用保留”和“等待时不必重新扫描所有未活跃 fd”。

因此,把 epoll 简写为“所有操作都是 O(1)”并不准确。epoll_ctl() 要查找或修改关注项,事件产生时要执行回调和入队,epoll_wait() 还要把最多 maxevents 个结果复制到用户空间。epoll 的关键收益是避免每次等待都重新提交并检查全部不活跃 fd,实际成本仍取决于注册变化、就绪事件数、唤醒和并发访问。

epoll 有两种主要触发方式。水平触发(level-triggered, LT)是默认模式:只要所关心的条件仍成立,后续 epoll_wait() 仍可再次报告该 fd。边缘触发(edge-triggered, ET)关注状态变化;一次事件到达后,如果应用没有把当前可处理的数据消耗完,不能依赖下一次等待再次报告同一批数据。

ET 模式通常与非阻塞 fd 配合,并持续处理到操作返回 EAGAIN:

c
for (;;) {
    ssize_t n = read(fd, buf, sizeof(buf));

    if (n > 0) {
        consume(buf, (size_t)n);
        continue;
    }
    if (n == 0) {
        close_connection(fd);
        break;
    }
    if (errno == EINTR) {
        continue;
    }
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
        break;
    }
    handle_read_error(fd, errno);
    break;
}

这里的 EAGAIN 不是失败,而是“这一轮已经处理到当前边界”。如果 fd 仍为阻塞模式,循环在读完现有数据后可能睡在最后一次 read() 上,其他所有 fd 都无法被分发。监听 fd 上的 accept() 也要循环到 EAGAIN,写操作则必须处理短写(short write),也就是 write() 成功但只接收部分数据的情况,并保存尚未发送的部分。

LT 模式更容易写对,但也不能把一次通知等同于一次完整消息。流式连接只提供字节序列,一次 read() 可能取得半条、恰好一条或多条应用消息;消息边界必须由上层协议自行恢复。ET 与 LT 改变的是事件何时再次交付,不改变字节流语义。

无论哪种模式,就绪事件都只是重新尝试 I/O 的依据。fd 可能在事件入队后被其他执行流处理,错误或挂断也可能与可读、可写标志同时出现。程序还要规定 fd 所有权,也就是哪条执行流负责读写、修改关注项和关闭该 fd。非阻塞 I/O、完整的返回值检查和明确的所有权规则,才能保证负责处理整组 fd 的线程不被单个连接卡住。

事件循环 ​

事件循环(event loop)是反复等待一批事件、把事件分派给处理逻辑并记录下一步状态的控制流程。

最小结构可以写成下面的 C 控制流骨架:

c
for (;;) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);

    for (int i = 0; i < n; i++) {
        dispatch(events[i]);
    }
}

真正的事件循环并不只是这个双层循环。每条连接都需要一份状态,记录当前处于读取头部、读取正文、处理请求还是发送响应,以及缓冲区中已有多少字节。状态机(state machine)就是有限状态及其转换规则的集合;一次事件只推进当前能够完成的步骤,遇到 EAGAIN 后保存状态并返回,下一次事件从保存位置继续。

这种组织方式提供并发,但单个事件循环线程并不自动提供并行。多个连接的工作在时间上交错推进,同一时刻仍只有一个处理函数占用该线程。若某个处理函数执行阻塞 I/O、长时间计算或无界循环,其他已经就绪的连接也只能等待。因此,处理函数应当完成有限工作后尽快归还控制权。

CPU 密集工作可以交给工作线程池,也可以让多个事件循环分别拥有一组连接,从而利用多个 CPU。此时又会重新出现跨线程通信:任务队列、完成通知和共享缓存都可能需要原子操作或锁。事件循环并非“天然无锁”;只有明确归某一个循环线程独占的状态,才可以在该所有权范围内避免锁。

事件循环还必须处理背压(backpressure)。背压是下游处理速度低于上游产生速度时,系统主动限制继续接收工作的机制。假设应用读取请求很快,但对端接收响应很慢,未发送数据就会不断堆积。正确做法是把待发送量限制在明确范围内,只在确实有积压时关注可写事件;达到高水位后暂停读取或拒绝新工作,降到低水位后再恢复。高水位和低水位分别是触发暂停与恢复的队列阈值。

可写事件尤其容易造成忙循环。连接的发送缓冲区通常大部分时间都有空间,如果始终关注 EPOLLOUT,epoll_wait() 可能持续返回同一个 fd。应用应在待发送队列从空变为非空时启用可写关注,数据全部发送后关闭该关注。

公平性也需要显式设计。某个持续可读的 fd 若每次都处理到无限量数据,可能让其他连接饥饿;可以为一次分派设置字节数、消息数或时间预算,到达预算后把控制权交回循环。事件驱动减少的是等待所需的线程数量,不会替应用自动解决状态管理、背压与公平。

io_uring ​

io_uring 是 Linux 的完成式异步 I/O 接口,它通过用户空间与内核共享的提交队列(Submission Queue, SQ)和完成队列(Completion Queue, CQ)传递请求与结果。

epoll 的流程是“等待 fd 就绪,再调用 I/O 接口”;io_uring 的流程是“先提交一项具体操作,再取得该操作的完成结果”。两者都可以驱动事件循环,但事件对象不同:epoll 事件关联某个 fd 的当前状态,io_uring 完成项关联此前提交的某个请求。

创建 io_uring 实例后,应用把共享队列映射到自己的地址空间。SQ 和 CQ 都使用环形缓冲区(ring buffer):头、尾索引到达固定容量存储区的末端后会回绕到开头,因此也常称环形队列。每个提交队列项(Submission Queue Entry, SQE)描述一项操作,例如从某个 fd 读取到指定缓冲区;每个完成队列项(Completion Queue Entry, CQE)返回对应操作的结果。简化后的数据流如下:

text
应用填写 SQE
      |
      v
发布到 SQ -> 内核取得请求 -> 执行或安排 I/O
                                  |
                                  v
应用读取 CQE <- 发布到 CQ <- 生成完成结果

SQE 的 user_data 字段由应用自行填写,内核会把它原样带回 CQE。不同操作的完成顺序可能与提交顺序不同,这就是乱序完成;应用通常在 user_data 中保存请求编号或指向请求状态的标识,以便每个 CQE 都能找到对应上下文。CQE 的 res 保存操作结果:读写成功时通常是传输字节数,失败时是负的错误号,例如 -EIO,而不是返回 -1 后再通过 errno 取错。

c
struct io_uring_cqe {
    __u64 user_data;
    __s32 res;
    __u32 flags;
};

共享队列不等于没有同步。应用必须先完整写好 SQE,再以规定的内存顺序发布队列尾位置;内核也必须先完整写好 CQE,再发布完成队列尾位置。同步原语讲过的 release/acquire 顺序在这里承担同样职责:消费者看到新的尾位置时,也必须能看到该位置之前写入的条目内容。实际程序通常使用 io_uring 用户态封装库 liburing 处理这些队列操作,避免直接管理映射布局和内存屏障。

普通模式下,应用填入一个或多个 SQE 后调用 io_uring_enter(),一次系统调用可以批量提交多项 I/O,也可以同时等待一定数量的完成项。CQ 中已经有结果时,应用可以直接读取共享内存,不必为每个完成项单独进入内核;若需要睡眠等待新的完成,仍可能调用 io_uring_enter()。所以 io_uring 的默认优势是批处理和共享队列,不是无条件“零系统调用”。

提交队列轮询(SQ polling, SQPOLL)模式会创建一个内核线程轮询 SQ。只要该线程保持活跃,应用发布 SQE 后可以由它直接取得请求,省去提交阶段的系统调用;线程空闲并睡眠后,内核会设置 IORING_SQ_NEED_WAKEUP,应用仍需调用 io_uring_enter() 将其唤醒。持续轮询也占用 CPU,因此 SQPOLL 是否有收益取决于负载,不能作为所有程序的默认选择。

完成式接口也不保证每项操作都由设备“原生异步”完成。内核可能在提交路径立即完成某些操作,也可能等待设备,或者把可能阻塞的工作转交异步工作线程。应用依赖的保证是稍后用 CQE 取得统一结果,而不是某一种固定的内核执行路径。

缓冲区生命周期是完成式 I/O 最重要的正确性约束之一。提交读取后,在 CQE 到达前复用或释放目标缓冲区,会让内核写入已经改变用途的内存;提交写入后过早修改源缓冲区,也可能改变实际发送内容。关闭 fd、取消请求和处理部分完成同样需要与在途请求协调。io_uring 减少了系统调用和重复通知,但把更多生命周期管理责任交给了应用。

路线等待对象应用收到什么主要适用方式
select / poll每次提交的一组 fd当前就绪的 fd 状态关注集合较小或需要可移植性
epoll内核保存的关注集合就绪列表中的事件大量长期注册、少量活跃 fd
io_uring已提交的具体操作每项操作的完成结果批量、完成式 I/O 与统一事件循环

小结 ​

概念说明
I/O 语义分别描述调用是否等待,以及通知代表就绪还是完成
阻塞与非阻塞不能立即推进时,线程睡眠等待或返回 EAGAIN
就绪与完成就绪要求应用再次执行 I/O,完成直接给出已提交操作的结果
select用固定大小集合同时等待多类 fd,返回时改写输入集合
poll用动态数组分离关注事件与返回事件,但每次仍扫描整个数组
epoll在内核保存关注列表,并从就绪列表交付事件
LT 与 ETLT 在条件持续成立时可重复报告,ET 要求处理到 EAGAIN
事件循环等待事件、有限分派并保存每项工作的状态
背压下游变慢时限制继续接收工作,防止队列无界增长
io_uring通过共享 SQ/CQ 批量提交操作并取得 CQE 完成结果
SQPOLL由内核线程轮询提交队列,可减少提交系统调用但消耗 CPU

事件驱动并发并不是把线程替换成一个更快的接口,而是把“等待哪个条件、谁保存进度、何时限制生产速度”变成显式协议:就绪接口复用等待,事件循环管理状态,完成接口再把具体 I/O 纳入同一套生命周期。


Linux 接口与源码入口: