网络触发逻辑笔记

macOS也是使用FD,用法几乎和linux相同。

windows在系统层面的是疾病,没有原生的fd。如果你在windows上用open,read,write这类posix风格的fd,那其实是C运行库额外的一层兼容包装。

ET和LT

LT(Level-Triggered,水平触发)
只要条件持续满足(如电平保持高、缓冲区有数据),就会持续触发通知。优点是不容易丢失事件,编程简单;缺点是可能造成冗余触发,在事件未及时处理时会反复打断 CPU。

ET(Edge-Triggered,边缘触发)
只在条件发生变化的瞬间(如电平从低变高、缓冲区从空变不空)触发一次通知。优点是效率高,系统调用少;缺点是需要应用层确保一次处理完所有数据,否则后续事件可能丢失。

  • select:只有 LT(水平触发) 语义,没有 ET 的概念。
  • poll:同样只有 LT,没有 ET。
  • epoll:同时支持 LT 和 ET,通过 epoll_ctl 的 EPOLLET 标志切换。

reactor模型同时支持LT和ET。

使用了ET的库

  • Netty(Java 异步网络框架)
  • Tokio(Rust 异步运行时)
  • libevent(C 事件通知库)
  • libuv(Node.js 底层异步 I/O 库)
  • dragonflydb(epoll情况下)
  • ASIO(epoll情况下)

linux epoll默认使用的是LT,使用了LT的组件:

  • Redis
  • HAProxy(可选ET)
  • envoy
  • memcached
  • pgsql

不同的FD可以分别使用LT或者ET,模式是epoll_ctl()注册时的event决定。上面说的主要是网络中的fd的模式。

网络库喜欢采用ET。因为网络库可以统一承担ET的复杂度,让应用享受它可能带来的性能收益。封装让采用ET的成本更容易承担。网络库可以替应用维护这些状态。比如tokio会保留就绪状态,直到读到WouldBlock等情况才消费该状态。应用层可以分多次读取,中间处理业务,不需要每次把socket一口气读空。

模型差异

windows上没有epoll

LinuxmacOS 对应Windows 对应
epollkqueueIOCP(I/O Completion Port)
select / pollselect / poll(但 poll 不可靠)select / WSAPoll

epoll本质上是一个就绪事件通知器。你告诉它,帮我盯着这些fd,等它们可读可写时叫我。然后你调用epoll_wait等待。当fd就绪时,epoll翻译,你自己再去执行read/write。ET和LT的区别在于通知的频率:LT是只要缓冲区里还有数据就一直通知你,ET是从无状态数据变成有状态数据时通知一次。

io_uring是一个完成事件队列。你想提交队列(SQ)里放入一个具体的操作,比如从socket A读取4096字节到buffer B。然后你等待完成队列(CQ)。当这个操作真正执行完毕时,内核会往CQ里放一个完成事件,告诉你结果(堵了多少字节,或者什么错误)。你不需要自己再去调用read,操作已经完成了。

ET/LT是就绪通知其的内部行为:它描述的是我们什么时候叫你。在IO_uring里,你提交的不是帮我登fd就绪,就是帮我做这个I/O。这个IO做完了就是做完了。没有还没做完但你可以再做一次的中间状态。

LT/ET:fd 现在是否就绪?要不要持续通知?

io_uring:我提交的这次读/写操作完成了吗?结果是什么?

在超高压不提供不epoll路径里,数据包到达网卡后,流程是:网卡DMA到内核内存,内核协议栈处理,你调用read时,再从内核内存拷贝到用户内存。这中间必然有一次内存拷贝。

io_uring的零拷贝接收改变了这个路径:网卡直接DMA到用户态注册的内存。内核只是通知用户态:“数据在你指定的内存地址里了,去那里读取”。消除了一次内核到用户的内存拷贝。


ET和LT的语义不仅限于网络socket,它本质上是对文件描述符就绪状态的通用通知策略。只要一个fd被注册到epoll中,无论背后是网络、管道、还是设备,都遵循同样的LT,ET规则。

fd 类型典型场景ET/LT 是否适用
网络 socketTCP/UDP 连接✅ 最常用
管道(pipe/fifo)进程间通信✅ 适用
eventfd线程/进程间事件通知✅ 适用
signalfd将信号转为 fd 读取✅ 适用
timerfd定时器到期通知✅ 适用
字符设备/终端tty、串口等✅ 适用
普通文件磁盘文件⚠️ epoll 对普通文件通常总是就绪,ET/LT 意义不大

epoll本身只是一个通知机制,不搬运数据。ET和LT里说的数据,是指被监控的fd对应的内核缓冲区里的数据,而不是epoll自己产生的。

每个被监控的fd,在内核里都关联着一个缓冲区(或队列):

fd 类型内核中的"数据"位置
管道 / FIFO内核管道缓冲区(pipe buffer)
终端tty 的输入/输出队列
套接字socket 的接收队列(sk_receive_queue)
POSIX 消息队列mqueue 的消息链表

当外部事件发生时,比如网卡收到包、对端write()、键盘被按下。内核会把这个数据放进这个缓冲区,并唤醒正在等待的fd进程。epoll做的就是:监视这可缓冲区从空编程非空(或从满编程不满)的状态变化,然后通知你。

关键区别在通知的判断依据是缓冲区当前状态(LT),还是事件变化状态(ET)。

LT

epoll_wait的返回条件是,该fd当前处于就绪状态。也就是说只要socket缓冲区里还有数据没有被读走,每次epoll_wait都会返回这个fd。

linux的默认模式,非常简单。

// LT:只读了一部分,缓冲区还有数据
recv(fd, buf, 10, 0);   // 假设缓冲区有 100 字节
// 下次 epoll_wait 依然会返回这个 fd,因为还有 90 字节没读

ET

epoll_wait返回的条件是:该fd的状态发生了从就非就绪到就绪的变化。只有缓冲区从空变为非空的时候才通知一次。

// ET:只读了一部分,缓冲区还有数据,但不会再次通知
recv(fd, buf, 10, 0);   // 缓冲区从 100 变 90
// 下次 epoll_wait 不会返回这个 fd,除非有【新数据】到达

当库已经知道这个连接还有数据,并准备自行继续处理的时候,内核反复报告同一就绪状态的价值就比较低。ET可以减少这列重复事件的返回和处理。

性能

ET 在门铃抑制(Doorbell Suppression)和合并(Coalescing)场景下通常比 LT 更有优势。

库默认模式能否切换原因
NettyLT不能官方已移除 ET 支持,认为其弊大于利。
TokioET (依赖)不能运行时逻辑深度依赖 ET 的唤醒语义,无切换选项。
ASIOLT不能为了跨平台统一抽象(对齐 poll/select),牺牲了 ET 的潜在性能。

Dragonflydb在不支持io_uring的情况下会回退到epoll。epoll使用的是ET模式。

proactor和actor

epoll更适合reactor模型,io_uring更适合proactor模型。

epoll提供的是就绪通知:告诉你这个fd现在可读可写了,但实际读取和写入的syscall还得自己调用。这就是典型的reactor模式——事件循环等待就绪,然后分发给 handler 去执行真正的 I/O。

io_uring提供的是完成通知:提交一个读取这个fd的操作,内核完成之后把结果(读到了多少字节数据在哪)放到完成队列里。这就是proactor模式。发起的操作,内核替你做完,你只收结果。对于文件来说,proactor几乎是唯一合理的选择,因为普通文件永远处于可读可写的状态,reactor的就绪模型对它来说没有意义。

对比ReactorProactor
事先提交什么关注某个 fd 的可读、可写事件提交具体操作,例如读取到指定 buffer
通知含义fd 已就绪,可以尝试读写提交的操作已完成,或失败
谁执行实际读写应用或框架收到就绪通知后执行异步操作执行器,原生异步 I/O 下通常由内核执行
通知后做什么调用 read/recv/write/send,再处理结果直接处理字节数、buffer 内容或错误

reactor和proactor是应用层实现的,属于编程模型,epoll这类属于内核机制,理论上底层的实现可以相互更换。

比如

const std::size_t n = co_await socket.async_read_some(buffer, use_awaitable);

提交的是吧数据读到这个buffer,恢复协程后拿到的是这次读了多少字节。业务代码处理的是操作完成结果,没有自己等待socket可读、再调用recv().

Boost.Asio官方说明,其异步接口基于Proactor,并且可以通过epoll等Reactor机制实现。co_await是表达异步控制流,并不决定使用哪种模型。

Last modification:October 4, 2026
如果觉得我的文章对你有用,请随意赞赏