2. 事件驱动编程模型
事件驱动编程模型是 Reactor 模式的运行基础。它把“等待事件”和“处理事件”从业务代码中抽离出来,交给一个统一的事件循环来调度。理解这一模型,关键要抓住五个概念:事件循环、回调注册、事件分发、非阻塞 IO 语义,以及就绪通知中的水平触发与边缘触发差异。它们不是并列的五个名词,而是一条从“注册兴趣”到“收到通知”再到“执行回调”的完整链路。
事件循环:单线程内的调度中枢
事件循环本质上是一个循环体,每一轮做三件事:通过多路复用器(如 epoll、kqueue、select)等待就绪事件;把就绪事件翻译成对应的回调;执行回调。伪代码可以简化为:
while (running) {
events = epoll_wait(epfd, evbuf, MAX_EVENTS, timeout);
for (e : events) {
dispatch(e.fd, e.mask); // 分发到注册的回调
}
run_pending_tasks(); // 处理定时器、跨线程投递任务
}
这里有一个容易被忽略的语义:事件循环本身是单线程的,所有回调都在同一个线程里串行执行。这意味着回调里不能做阻塞操作,否则整个循环会被卡住,其他连接的就绪事件无法被及时处理。这正是“非阻塞 IO 语义”必须成立的原因。
回调注册:把兴趣和处理器绑定
回调注册解决的是“当某个 fd 上发生某类事件时,应该调用谁”。注册通常包含三要素:文件描述符、关注的事件类型(可读、可写、错误、挂断)、以及对应的回调函数或处理器对象。以 epoll 为例,注册过程大致是:
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 关注可读,采用边缘触发
ev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
// 同时在应用层维护 fd -> handler 的映射
handlers[conn_fd] = on_readable;
注意,内核只负责告诉你“哪个 fd 就绪了”,它并不知道你的回调是什么。回调映射是应用层自己维护的,通常用数组、哈希表或对象字段来保存。注册的粒度决定了分发的精度:如果只注册了可读事件,那么可写事件永远不会触发对应回调。
事件分发:从就绪列表到回调执行
事件分发是事件循环的核心动作。epoll_wait 返回的是一个就绪事件数组,分发器遍历这个数组,对每个事件取出 fd 和事件掩码,再查表找到注册的回调并调用。分发过程需要处理几类情况:
- 事件掩码解析:EPOLLIN 表示可读,EPOLLOUT 表示可写,EPOLLERR 和 EPOLLHUP 需要优先处理,通常直接关闭连接。
- 回调不存在:如果 fd 已被关闭但事件仍被返回,应忽略或清理,避免访问已释放资源。
- 回调内修改注册:回调中可能关闭 fd 或修改关注事件,分发器需要保证遍历安全,常见做法是先收集再处理,或使用引用计数。
分发器的效率直接影响吞吐量。在高并发场景下,就绪事件数组的遍历和哈希查找是主要开销,因此很多框架会把 fd 直接映射到处理器指针,减少一次查表。
非阻塞 IO 语义:为什么必须非阻塞
非阻塞 IO 的语义是:当数据尚未就绪时,read/write 立即返回 EAGAIN 或 EWOULDBLOCK,而不是让线程睡眠等待。这与事件循环配合的方式是:事件循环只在“就绪通知”到达后才调用 read/write,因此正常情况下不会遇到 EAGAIN。但边缘触发模式下,必须循环读取直到 EAGAIN,否则会遗漏数据。
非阻塞还意味着 write 可能只写入部分数据。例如发送 1MB 响应,内核发送缓冲区只剩 64KB,write 返回 65536,剩余数据需要注册可写事件,等缓冲区可写时继续发送。这就是“写缓冲 + 可写回调”的典型处理方式。
就绪通知:水平触发与边缘触发的差异
水平触发(Level Triggered,LT)和边缘触发(Edge Triggered,ET)是两种就绪通知语义,差异体现在“什么时候重复通知”。
| 对比项 | 水平触发 LT | 边缘触发 ET |
|---|---|---|
| 通知条件 | 只要缓冲区有数据可读,每次 epoll_wait 都通知 | 仅在状态发生变化时通知一次 |
| 读取要求 | 可以只读一部分,下次还会通知 | 必须循环读到 EAGAIN,否则数据可能丢失 |
| 编程复杂度 | 较低,容错性好 | 较高,容易漏读 |
| 系统调用次数 | 较多,可能重复通知 | 较少,效率更高 |
| 典型使用 | 默认模式,适合大多数场景 | 高并发场景,如 Nginx、Redis |
用一个具体例子说明差异。假设客户端发送了 200 字节,内核缓冲区收到 200 字节。LT 模式下,第一次 epoll_wait 返回可读,你读了 100 字节,缓冲区还剩 100 字节;下一次 epoll_wait 仍会返回可读,你继续读剩余 100 字节。ET 模式下,第一次 epoll_wait 返回可读,如果你只读了 100 字节,那么下一次 epoll_wait 不会再通知你,剩余 100 字节会一直留在缓冲区,直到有新数据到达才可能再次触发。因此 ET 模式下必须这样写:
while (1) {
n = read(fd, buf, sizeof(buf));
if (n > 0) { /* 处理数据 */ }
else if (n == 0) { /* 对端关闭 */ break; }
else if (errno == EAGAIN || errno == EWOULDBLOCK) { break; }
else { /* 错误 */ break; }
}
这段循环是 ET 模式的标准写法:一直读到 EAGAIN 为止,确保缓冲区被清空。LT 模式则没有这个强制要求,读一次也可以,下次还会被通知。
把五个概念串起来
下面这张图展示了从注册到回调执行的完整链路,并对比了 LT 与 ET 在“是否重复通知”上的差异。
图中上半部分是应用层,回调注册表保存 fd 到处理器的映射;中间是内核层,epoll 实例维护关注集合,socket 缓冲区状态变化触发就绪;下半部分是事件循环,epoll_wait 返回后由 dispatch 分发到回调,回调执行完再回到下一轮。整个链路是单向的:注册 → 等待 → 分发 → 执行 → 回到等待。
实践中的选择与注意事项
对于大多数业务服务,水平触发是更稳妥的选择,因为它容错性好,即使一次没读完,下次还会通知。边缘触发适合追求极致性能的场景,但必须严格遵守“读到 EAGAIN”的规则,并且要处理好写事件:ET 模式下可写事件只在缓冲区从满变空时触发一次,如果一次没写完,需要自己维护写缓冲并主动注册可写事件,否则会漏掉后续发送机会。
另一个常见问题是回调中执行阻塞操作。例如在 on_readable 里做磁盘 IO 或调用同步数据库,会阻塞整个事件循环。正确做法是把阻塞操作交给线程池,完成后通过事件循环的跨线程投递机制(如 eventfd 或管道)唤醒循环,再执行后续回调。这样既保持了事件循环的非阻塞语义,又能利用多核处理耗时任务。
最后,回调注册和分发之间要保持一致:注册了什么事件,分发时就处理什么事件;关闭 fd 时要从注册表中移除,避免分发器访问已释放的处理器。这些细节决定了事件驱动模型能否稳定运行。