Rust in Everything
基于 mio 1.2.4

取舍汇总

主线位置:两条主线的总账。第一条决定事件主线的形状(报一次、读到 WouldBlock 才算完),第二条是注册主线和事件主线交接的方式(token 进内核、原样出来),第三条划出 mio 的边界,第四条管注册主线上的误用,后两条是事件主线上的两个定长约束:一次取多少事件、几个 Waker。

flowchart TB
  d1[就绪加边沿触发] --> c1["必须读到 WouldBlock"]
  c1 --> f1["tokio 的 tick"]
  d2[Token 进内核] --> c2[旧 token 认不出]
  c2 --> f2["tokio 推迟释放"]
  d3[不缓冲不定时] --> c3[每个上层重写]
  c3 --> f3["tokio 时间轮"]
  d4["&mut 与 debug 检查"] --> c4[release 不查]
  c4 --> f4[debug 下跑测试]
  d5[Events 定长] --> c5[一次只取这么多]
  c5 --> f5["tokio 默认 1024"]
  d6[一个 Waker] --> c6[多个会丢 token]
  c6 --> f6["共享 Arc Waker"]

每一节的格式和 bytes 卷、tracing 卷的同名页相同:做了什么决定,换来了什么,代价是什么,代价由谁来补。机制都在别的页讲过,这里只给结论和链接;引到的数字都来自那些页的实验,mio = "=1.2.4"。Linux 是 Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接),macOS 是 M2 Max,都是 rustc 1.99.0。

就绪加边沿触发:只有一种语义

决定:mio 的 API 是就绪模型,只有边沿触发。epoll 上 interests_to_epoll 从 EPOLLET 起步,kqueue 上 register 给每个过滤器带 EV_CLEAR。0.7.0 起没有选择:#975 删掉了 PollOpt(mio 解决什么问题)。没有这种原生能力的平台由 mio 自己模拟:poll(2) 后备「报出即摘、WouldBlock 即放回」,Windows 用 IOCTL_AFD_POLL 把完成变成就绪(poll(2) 后备、Windows:IOCP 与 AFD)。

换来的:

  • 在 epoll 和 kqueue 上几乎直通。 一次 register 是一次 epoll_ctl(EPOLL_CTL_ADD),一次 poll 是一次 epoll_pwait;在 strace 跟踪的系统调用里,没有一次是 mio 为了维护自己的状态而发的(一个事件循环)。这两个平台上 do_io 就是一行 f(io),IoSourceState 是零大小类型(Source 与 IoSource)。
  • 每个连接只付 I/O 本身的系统调用。 一个 echo 服务器在 epoll 上每个连接约 11.9 个系统调用:accept4 2 个、recvfrom 3 个、sendto 1 个、close 1 个、epoll_ctl 2 个(ADD、DEL),其余是等待(poll(2) 后备 的「每个连接多少个系统调用」)。
  • 空闲的 fd 不花钱。 epoll 上注册 1 个到 1 万个从不收数据的 UDP socket,一次 poll(Some(0)) 都是 0.2 µs(poll(2) 后备 的「闲置的 fd 也要付钱」)。
  • 只需要模拟一种语义。 0.7.0 的 CHANGELOG 说这批改动是为了「reduce the complexity of the implementation」;只留边沿触发,Windows 和 poll(2) 后备都只模拟这一种(mio 解决什么问题)。

代价:

  • 用户必须读写到 WouldBlock。 文档的「Draining readiness」:不读到 WouldBlock,不保证再有事件。两个连接在服务器醒来前都到了,监听 socket 只报 1 个事件,只 accept 一次第二个连接就留在队列里(一个事件循环)。
  • 模拟的平台付得多。 poll(2) 后备每个连接约 22 个系统调用,多出的 10 个全是 modify_fds 的 write / read / futex,register、放回、注销一视同仁,哪怕只有一个线程;1 万个闲置 fd 时一次空 poll 要 615–656 µs;别的线程每改一次关注集合,正在 poll 的线程就被叫醒,超时从头算(300ms 的 poll 实测 412–420ms 才返回)(poll(2) 后备)。
  • 模拟的「边」更窄。 poll(2) 后备的「边」是 do_io 看到 WouldBlock:读一半就回去 poll,对端再写 8 字节,epoll 和 kqueue 都报 1 个事件,poll(2) 报 0 个;同时关注读写时,每次读到 WouldBlock 都会因为整体放回而多一个可写事件(poll(2) 后备)。
  • 所有 I/O 都必须经过 mio。 放回靠 do_io,绕过它在 poll(2) 和 Windows 上就收不到下一次事件:UnixListener::accept 绕过 do_io 到 1.2.2(#1998,poll(2) 后备),peek 到 1.1.0(#1895,TCP);SourceFd 永远不经过 do_io,poll(2) 后备上只收到第一个事件(Source 与 IoSource)。
  • 只统一到这一层。 同一次就绪拆成几个事件不统一:已就绪的 socket 以 READABLE | WRITABLE 注册,Linux 1 个事件、macOS 2 个(mio 解决什么问题)。

谁来补:读到 WouldBlock 这件事,tokio 替用户做:poll_read 遇到 WouldBlock 才带着 tick 清掉就绪位(从 TcpStream::read 到挂起);它「读不满就当作已读空」的优化在 cfg 里排除了 mio_unsupported_force_poll_poll,因为那里少读一次 WouldBlock 关注位就回不来(poll(2) 后备)。自己写事件循环的,见常见坑:没读到 WouldBlock。模拟平台的系统调用开销没有人补,它换来的是 AIX、Haiku、ESP-IDF 这类只有 poll(2) 的系统也能跑 mio 和 tokio。

Token 原样进内核:mio 不记任何表

决定:Token 是 pub usize,没有保留值。epoll 存进 epoll_event.u64,kqueue 存进 kevent.udata,事件回来时原样转回。mio 里没有 fd 到 token、也没有 token 到 source 的表(Token、Interest 与 Event)。poll(2) 是例外:pollfd 里没有放用户数据的地方,token 只能存在用户态的 fd_data 里(poll(2) 后备)。

换来的:

  • Poll 在 release 下就是一个 OwnedFd。 Poll、Registry、epoll Selector 三层都没有额外状态,debug 下只多一个 selector id 和一个 has_waker(Poll 与 Registry)。
  • 事件路径上没有查表、没有锁、没有分配。 取回 token 是一行 Token(event.u64 as usize);Events 是复用的缓冲区,poll 的路上不分配(Token、Interest 与 Event)。
  • 别的线程可以和 poll 同时注册。 没有用户态的表要保护,try_clone 出的 Registry 在另一个线程 epoll_ctl,阻塞在 poll(None) 里的线程照样收到新 token,Linux 和 macOS 都实测有效(Poll 与 Registry)。对照组是要在用户态记表的 poll(2) 后备:select 持锁等待,跨线程注册得先把它叫醒(poll(2) 后备)。
  • token 的含义由上层定。 tokio 直接把 ScheduledIo 的地址当 token,事件回来一次指针转换就找到状态(反应器)。

代价:

  • mio 认不出迟到的旧 token。 内核里的那一项活多久,mio 管不了。Linux 上 drop 一个还有 dup 副本的 socket,对端再写,Token(1) 照样出现,用 dup 的 fd 去 deregister 得到 ENOENT;fd 号被新 socket 复用后,一次 poll 返回 [2, 1],新旧两个 token 都在。macOS 上关 fd 就删掉过滤器,只有 [2](Poll 与 Registry)。
  • 保证只到「以后」。 deregister 的文档只保证和之后的 poll 有 happens-before 关系时不再出现这个 token;已经拷进 Events 的那一个管不了(反应器 的注销一节)。
  • 不查重复。 两个 source 用同一个 token,mio 照收;macOS 上同一个 fd register 两次,EV_ADD 覆盖 udata,之后的事件带第二次的 token(Poll 与 Registry 的契约表)。

谁来补:上层。tokio 1.54.1 的做法不是查表:1.30 之前 token 是 slab 下标加 7 位代数,#5833 之后就是 ScheduledIo 的地址。它用两件事保证地址不悬空:驱动的 RegistrationSet 一直攥着一份 Arc,注销时先放进 pending_release,等下一次 turn() 开头、上一轮事件分发完了才释放;PollEvented 的 Drop 在 fd 还开着时先 deregister。dup 过的 fd 仍然能让旧地址回来,#8540 撤回了「注销失败也释放」,#8587 把 AsyncFd::new 换成 unsafe 的 AsyncFd::register(反应器)。自己写事件循环的,见常见坑:没 deregister 就 drop。

不做缓冲、定时器、调度

决定:TcpStream 的 Read 经 do_io 直接转给 std;Poll::poll 只接受一个超时;Waker 只让 poll 返回,不知道该叫醒谁。Timer 和 channel 0.6.5 标成 deprecated、0.7.0 删除(#929),用户态就绪队列 Registration / SetReadiness 也在 0.7.0 删除(mio 解决什么问题)。1.2.4 里还做内部缓冲的只有 Windows 的 NamedPipe(Windows:IOCP 与 AFD)。

换来的:

  • 很薄。 src/ 1.35 万行里,Linux 上两条主线经过的代码不到 2000 行(源码地图)。Poll::poll 本身只有一行,没有循环、没有重试、没有统计(Poll 与 Registry)。
  • 缓冲区一直在用户手里。 读到哪块内存、读多少由调用方决定,没有数据的连接不占缓冲区(mio 解决什么问题)。tokio、hyper 各自有自己的缓冲策略,mio 不和它们重复。

代价:

  • 每个事件循环都要自己写一遍。 最小的 echo 服务器就要自己维护 token 到连接的表、写不完的 pending: Vec<u8>、写不完时 reregister 加 WRITABLE、写完再改回去(一个事件循环)。
  • 超时是唯一的时间工具,而且粗。 epoll 把超时向上取整到毫秒;1.0.0 到 1.2.0 超过约 24.8 天的超时会被截成负数或很小的数(#1948 在 1.2.1 修,起因是 #1741 删掉的 MAX_SAFE_TIMEOUT)(Poll 与 Registry)。
  • 不重试。 EINTR 原样返回成 Interrupted,0.7.0 起不再重试(#1056);SA_RESTART 也救不了,实测四种组合都是 200ms 左右返回 Err(Poll 与 Registry)。

谁来补:tokio。时间轮算出最近的到期时间,作为 poll 的超时传下去(驱动栈与 park);turn 把 Interrupted 当成没有事件的一轮(反应器);就绪状态记在 ScheduledIo 里(ScheduledIo 与就绪状态);缓冲交给 BufReader 或 hyper 这样的上层协议。

register 要 &mut,误用只在 debug 下拦

决定:event::Source 的三个方法都要 &mut self(#1180,0.7.0)。debug 构建里,IoSource 用 SelectorId 记住自己注册在哪个 selector 上,在系统调用之前拒绝重复注册和未注册的 reregister / deregister;release 下这个类型不存在(Source 与 IoSource)。

换来的:

  • 编译器保证同一个 source 不会被两个线程同时注册。 #1180 的提交说明是:并发注册「在需要用户态记账的平台上(比如 Windows)会让事情更复杂」。poll(2) 后备的 registered_io_source.rs 在 register 时往 self 里写一个 Box<InternalState>,因为有 &mut self,这里不用加锁(Source 与 IoSource)。
  • 测试里误用在系统调用之前就暴露,两个平台报同一个错。 debug 下重复 register、注册到两个 Poll 都是 AlreadyExists「I/O source already registered with a `Registry`」,未注册就 reregister / deregister 是 NotFound(Poll 与 Registry 的契约表)。
  • release 下零开销。 Poll 就是一个 fd,IoSource 在 epoll 上没有任何附加字段(Source 与 IoSource)。

代价:

  • release 下结果取决于平台。 同一组误用,Linux 报 EEXIST(17)/ ENOENT(2),macOS 全部 Ok;注册到两个 Poll 两个平台都 Ok,两边都收到事件。在 macOS 上开发、在 Linux 上部署,误用只在 Linux 上暴露(Poll 与 Registry)。
  • SourceFd 不经过这层检查。 它直接调 selector,debug 下也是内核的错误码(Source 与 IoSource)。
  • 检查本身有边角。 associate 失败时也改了编号,之后对原 Poll 的 reregister / deregister 在 debug 下都被拒(Source 与 IoSource)。
  • 失败不回滚。 kqueue 上 register(READABLE | WRITABLE) 一项失败返回 Err 时,另一项可能已经装上,事件照来(kqueue)。

谁来补:在 debug 下跑测试(mio 自己的 tcp_register_multiple_event_loops 就标着只在 debug 下有效);tokio 每个 source 只在 PollEvented::new 里注册一次 READABLE | WRITABLE,之后不改兴趣集(反应器)。对应的坑:注册两次、kqueue 注册失败一半。

Events 定长:容量就是一次 poll 的上限

决定:Events::with_capacity 就是 Vec::with_capacity。epoll 的 select 先 clear(),把 capacity() 当 maxevents 交给内核,内核写了几项就 set_len 几项;kqueue 同样(Token、Interest 与 Event)。

换来的:

  • 内核直接写进用户的缓冲区。 Event 是 #[repr(transparent)] 包着 epoll_event / kevent,不拷贝、不转换(Token、Interest 与 Event)。
  • poll 的路上不分配,一次处理多少事件由调用方定。 没取完的留在内核里,下一次 poll 再拿:容量 2、5 个就绪 socket,Linux 分 3 次取完(Token、Interest 与 Event)。

代价:

  • 容量 0 在两个平台上都不对。 Linux 是 EINVAL,macOS 立刻返回、变成忙等(Token、Interest 与 Event)。
  • kqueue 的一个 socket 占两格。 同时关注读写时同一个 token 在一批里出现两次,上面那个实验 macOS 要 5 次才取完(Token、Interest 与 Event)。
  • poll(2) 后备不守这条约定。 Events 是 Vec<Event>,select 先 reserve 再 push,一次拿全 5 个、容量自己长到 5,顺序每次不同;文档「At most events.capacity() events will be returned」在那里不成立(poll(2) 后备)。

谁来补:调用方选一个合适的容量。tokio 默认 1024(max_io_events_per_tick 可改),忙时用 0 超时查一次时还可以用 max_io_events_per_busy_tick 换一个小一点的缓冲区,免得一次唤醒上千个任务、溢出 worker 只有 256 个槽的本地队列(反应器)。对应的坑:一次 poll 的事件数。

一个 Poll 一个 Waker

决定:文档说一个 Poll 只能有一个活跃的 Waker,多个时「会发生什么是未指定的」。debug 下 Waker::new 先调 register_waker,第二次就 panic;标志从不复位,drop 第一个再建也 panic(Waker)。

这条限制不是 epoll 实现的需要。Linux 上每个 Waker 是自己的一个 eventfd,各自注册进 epoll,release 下建两个,两个 token 都能收到。限制来自另外两种实现:kqueue 的 wake 总是提交 (ident 0, EVFILT_USER),一个 kqueue 里只有这一个 knote;poll(2) 后备把 token 记进 pending_wake_token,它是一个 Option<Token>(Waker)。

换来的:

  • kqueue 上不需要 eventfd 或 pipe。 macOS 上 Waker::new 只复制一份 kqueue 的 fd,wake 是一次 kevent;EV_ONESHOT 让 knote 送达后自己消失,不用读回、不用清零(#1987,1.2.3)。NetBSD、OpenBSD、DragonFly 在 1.2.3 从 pipe 换成这种实现,PR 给的理由是「它比基于 pipe 的版本快」(#1993)(Waker)。
  • poll(2) 后备不用为用户的 Waker 再建通道。 它借用 selector 内部本来就有的 notify_waker,只多一个 token 槽(poll(2) 后备)。
  • 几次 wake 合成一个事件。 三种实现都是:连续 wake 三次再 poll,只有 1 个事件(Waker)。

代价:

  • release 下多个 Waker 会丢 token,而且只在部分平台丢。 先 w1.wake() 再 w2.wake():epoll 给 [10, 11],kqueue 和 poll(2) 只给后一个 [11](Waker)。
  • 限制比需要的更严。 标志不复位,「活跃的」实际上是「曾经建过的」(Waker)。
  • 同一个 Waker 的语义也随平台变。 wake() 之后马上 drop Waker:Linux 上关闭 eventfd 带走了这次唤醒,macOS 上事件还在(怎么读这一卷);poll(2) 后备上,没有线程在 poll 时先 wake 再 register,modify_fds 的 ack_and_reset 会把这次唤醒读掉(poll(2) 后备)。
📝NOTE
没有蠢问题

问: 既然 epoll 上两个 Waker 都能用,debug 检查为什么在 Linux 上也 panic?

答: 检查不看平台。has_waker 字段和 register_waker 的 cfg 是 all(debug_assertions, not(any(target_os = "wasi", target_os = "horizon"))),Linux、macOS、poll(2) 后备一视同仁(Poll 与 Registry)。它守的是文档的跨平台约定,不是 epoll 的实现限制:只在 Linux 上开发的程序建了两个 Waker,测试全过,换到 macOS 上就会丢唤醒;debug 下的 panic 让这个差别在 Linux 的测试里就暴露出来。

谁来补:调用方只建一个 Waker,放进 Arc 共享,用自己的队列或标志区分是谁叫醒的。tokio 的 I/O 驱动就只有一个 TOKEN_WAKEUP(Token(0)),Handle::unpark 触发它,turn() 看到它什么都不做(反应器)。对应的坑:第二个 Waker、poll 之前 drop 了 Waker。

小结

  • 只有就绪加边沿触发:epoll、kqueue 上一次 register 一次 epoll_ctl、一次 poll 一次 epoll_pwait,每个连接约 11.9 个系统调用,空闲 fd 不花钱;代价是用户必须读到 WouldBlock,poll(2) 后备要约 22 个、1 万个闲置 fd 时一次空 poll 0.6ms 以上,Windows 和 poll(2) 上所有 I/O 都必须经过 do_io。tokio 的 tick 替用户守住 WouldBlock。
  • Token 原样进内核:Poll 就是一个 fd,事件路径不查表、可以跨线程并发注册;代价是迟到的旧 token mio 认不出(Linux 上 dup 过的 fd 实测 [2, 1])。tokio 用 ScheduledIo 地址做 token,推迟到下一轮再释放,drop 前先 deregister。
  • 不做缓冲、定时器、调度:Linux 主线不到 2000 行;代价是每个事件循环都要自己写缓冲和 reregister,超时只有毫秒粒度、EINTR 不重试。tokio 的时间轮和 turn 补上。
  • register 要 &mut、误用只在 debug 下拦:免锁、测试里报同一个错;release 下 Linux 报 EEXIST / ENOENT、macOS 全部 Ok,kqueue 失败不回滚。
  • Events 定长:内核直接写进复用的缓冲区;容量 0 是 EINVAL 或忙等,kqueue 一个 socket 占两格,poll(2) 后备不守容量。
  • 一个 Poll 一个 Waker:这条限制来自 kqueue 的固定 (0, EVFILT_USER) 和 poll(2) 的单个 token 槽,换来不用 eventfd / pipe、一次 kevent 就能唤醒;epoll 上两个都能用,kqueue 和 poll(2) 只剩后一个。共享一个 Arc<Waker>,tokio 只有 TOKEN_WAKEUP。

💬 关联讨论

发起讨论
加载中...