网络触发逻辑笔记
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
Linux macOS 对应 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 是否适用 |
|---|---|---|
| 网络 socket | TCP/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 更有优势。
| 库 | 默认模式 | 能否切换 | 原因 |
|---|---|---|---|
| Netty | LT | 不能 | 官方已移除 ET 支持,认为其弊大于利。 |
| Tokio | ET (依赖) | 不能 | 运行时逻辑深度依赖 ET 的唤醒语义,无切换选项。 |
| ASIO | LT | 不能 | 为了跨平台统一抽象(对齐 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的就绪模型对它来说没有意义。
| 对比 | Reactor | Proactor |
|---|---|---|
| 事先提交什么 | 关注某个 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是表达异步控制流,并不决定使用哪种模型。