Rust in Everything
基于 mio 1.2.4

源码地图

主线位置:不在主线上。本页说明两条主线经过的代码分在哪几层、哪些文件,哪个 feature 和 cfg 决定编译进来的是哪一份实现,以及仓库用哪些测试和 CI 任务守住十几个平台。

mio 的代码分两层:上面是平台无关的公开类型,下面是 sys 里每个平台各一份的实现。两层之间没有 trait,靠的是「每个平台模块都导出同名的类型和函数」这个约定,由 cfg 在编译期选一份:

flowchart TB
  lib["lib.rs:入口与 pub use"] --> pub["poll.rs、event/、token.rs、interest.rs、waker.rs"]
  lib --> ios["io_source.rs:IoSource T"]
  lib -.-> netm["net/:net feature"]
  pub --> sysm["sys/mod.rs:按平台选"]
  ios --> sysm
  netm --> sysm
  sysm -->|os-poll 且 Unix| unix["sys/unix"]
  sysm -->|os-poll 且 Windows| win["sys/windows"]
  sysm -->|"os-poll 且 WASI p1"| wasi["sys/wasip1"]
  sysm -->|"没开 os-poll"| shell["sys/shell"]
  unix --> selm["selector:epoll、kqueue、event ports、poll"]

src/ 的平台分层

v1.2.4 的 src/ 一共 61 个 .rs 文件、13 451 行(含文档注释和测试)。按目录分:

部分行数内容本卷
平台无关层2 555poll.rs、event/、token.rs、interest.rs、waker.rs、io_source.rs、lib.rs、macros.rs02 核心类型
net/2 311TcpListener、TcpStream、UdpSocket、UnixListener、UnixStream、UnixDatagram 的公开 API04 网络类型
sys/mod.rs86选平台模块;debug_detail! 宏本页
sys/unix/4 755下面细分03 平台实现
sys/windows/3 039IOCP、AFD、SockState、named pipe(named_pipe.rs 一个文件就 1 094 行)Windows:IOCP 与 AFD
sys/wasip1/369WASI preview 1,用 poll_oneoff只提一句
sys/shell/336没开 os-poll 时的空壳本页

平台无关层每个文件的分量:

文件行数管什么
poll.rs814Poll 和 Registry;一大半是文档,Poll 的文档里有一张「每个系统用哪个 selector」的表
io_source.rs311IoSource<T>:包住一个 std 类型,按平台实现 Source;debug 下的 SelectorId
lib.rs285crate 文档、pub use、features 和 guide 两个纯文档模块
event/events.rs230Events 和它的迭代器
event/event.rs230Event,每个 is_* 转给 sys::event
interest.rs202Interest:一个 NonZeroU8
token.rs140Token(pub usize)
event/source.rs139event::Source trait,以及 Box<T> 的转发实现
macros.rs98cfg_* 宏和日志宏,见下一节
waker.rs96Waker,转给 sys::Waker
event/mod.rs10声明子模块

sys/unix/ 的 4 755 行:

文件行数管什么
selector/kqueue.rs919kqueue:register 拼 changelist,EVFILT_USER 的 wake,Event 的 Debug 要列出各平台的一长串 NOTE_* 常量
selector/event_ports.rs740Solaris 的 event ports(1.2.2 新增)
selector/poll.rs676poll(2) 后备实现:用户态维护 fd 列表,模拟边沿触发
selector/epoll.rs246epoll,四个 selector 里最短的一个
selector/registered_io_source.rs151poll(2) 和 event ports 共用的 IoSourceState:do_io 遇到 WouldBlock 时重新武装
selector/stateless_io_source.rs50epoll 和 kqueue 共用的 IoSourceState:什么状态也不存,直接转发
pipe.rs622os-ext 的 pipe::new,以及 pipe 版 Waker 用的 new_raw
net.rs221new_socket:各平台怎么建出一个非阻塞、close-on-exec 的 socket
tcp.rs139bind、listen、accept(Linux 等用 accept4)
udp.rs36UDP 的 bind
uds/381Unix 域套接字
waker/265eventfd.rs 99、pipe.rs 93、single_threaded.rs 44、selector.rs 29
sourcefd.rs127SourceFd:让任意 fd 参与注册
mod.rs182syscall! 宏;按 target_os 选 selector 和 waker 文件,见后面的表

两条主线在 Linux 上经过的代码,几乎全在 poll.rs、io_source.rs、event/、selector/epoll.rs、waker/eventfd.rs 这几个文件里,加起来不到 2 000 行;src/ 的大部分是别的平台和文档。

Cargo.toml:三个 feature

Cargo.toml 第 31 行起:

[features]
# By default Mio only provides a shell implementation.
default = ["log"]

# Enables the `Poll` and `Registry` types.
os-poll = []
# Enables additional OS specific extensions, e.g. Unix `pipe(2)`.
os-ext = [
  "os-poll",
  "windows-sys/Win32_System_Pipes",
  "windows-sys/Win32_Security",
]
# Enables `mio::net` module containing networking primitives.
net = []

[dependencies]
log = { version = "0.4.8", optional = true }
feature默认打开了什么
log是可选依赖 log 的同名 feature。mio 内部的 trace!、warn!、error! 转给 log 宏;关掉时这些宏展开成 if false { let _ = (...); },只为了不报「变量未使用」
os-poll否Poll::new、真正的 sys::Selector 和 sys::Waker;不开时用 sys/shell
net否mio::net 模块和 io_source.rs
os-ext否Unix 上的 mio::unix::pipe 和 SourceFd,Windows 上的 NamedPipe;它依赖 os-poll

默认只开 log,所以光写 mio = "1.2.4" 得到的是一个什么都做不了的 crate。tokio 依赖 mio 时关掉默认 feature(default-features = false),由自己的 net feature 打开 mio/os-poll、mio/os-ext、mio/net,所以 tokio 下面的 mio 不带 log。

macros.rs:用宏包住 cfg

源码里很少直接写 #[cfg(feature = "...")],而是用 macros.rs 里的一组宏把一串条目包起来:

/// The `os-poll` feature is enabled.
macro_rules! cfg_os_poll {
    ($($item:item)*) => {
        $(
            #[cfg(feature = "os-poll")]
            #[cfg_attr(docsrs, doc(cfg(feature = "os-poll")))]
            $item
        )*
    }
}

每个宏给包住的每一项都加上同一个 #[cfg],以及给 docs.rs 用的 doc(cfg(...))(文档里那个「Available on crate feature os-poll only」的标签)。一共六个:

宏条件
cfg_os_poll!feature = "os-poll"
cfg_not_os_poll!not(feature = "os-poll"),不加 doc(cfg)
cfg_os_ext!feature = "os-ext"
cfg_net!feature = "net"
cfg_io_source!any(feature = "net", all(unix, feature = "os-ext")):需要 IoSource 的场合
cfg_any_os_ext!any(feature = "os-ext", feature = "net")

后面是 log! 和包着它的 trace!、warn!、error!。平台的选择不走这些宏,而是直接写 target_os,见下一节。

不开 os-poll:Poll 还在,Poll::new 没了

sys/mod.rs 的最后一段是:

cfg_not_os_poll! {
    mod shell;
    pub(crate) use self::shell::*;

    #[cfg(unix)]
    cfg_any_os_ext! {
        mod unix;
        #[cfg(feature = "os-ext")]
        pub use self::unix::SourceFd;
    }
}

sys/shell 导出和真实平台模块同名的 Selector、Event、Events、event 模块、Waker、tcp、udp、uds,每个函数体都是 os_required!():

macro_rules! os_required {
    () => {
        panic!("mio must be compiled with `os-poll` to run.")
    };
}

于是平台无关层照常编译:lib.rs 无条件地 pub use poll::{Poll, Registry},Poll 这个类型一直存在。被 cfg_os_poll! 包住的只有 Poll::new 一个函数:没有它就造不出 Poll,也就拿不到 Registry,register 和 Waker::new 都无从调用。

只开 net、不开 os-poll 时试两件事。依赖:

mio = { version = "=1.2.4", features = ["net"] }

第一个程序调 Poll::new:

use mio::Poll;

fn main() {
  let poll = Poll::new();
  println!("{poll:?}");
}

编译不过(M2 Max、rustc 1.99.0):

error[E0599]: no associated function or constant named `new` found for struct `mio::Poll` in the current scope
 --> src/bin/poll_new.rs:4:20
  |
4 |   let poll = Poll::new();
  |                    ^^^ associated function or constant not found in `mio::Poll`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `exp-mio-intro-nopoll` (bin "poll_new") due to 1 previous error

报错说的是「Poll 上没有 new」,不是「找不到 Poll」。第二个程序不碰 Poll,只建一个 TcpListener:

use mio::net::TcpListener;

fn main() {
  println!("mio::Poll exists: {}", std::any::type_name::<mio::Poll>());
  let listener = TcpListener::bind("127.0.0.1:0".parse().unwrap());
  println!("bind returned: {:?}", listener.is_ok());
}

能编译,运行时 panic。跑 3 次,除了线程号都一样(cargo 注册表路径缩写成 .../):

mio::Poll exists: mio::poll::Poll

thread 'main' (2019875) panicked at .../mio-1.2.4/src/sys/shell/tcp.rs:6:5:
mio must be compiled with `os-poll` to run.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

shell/tcp.rs 第 6 行是 new_for_addr 里的 os_required!():bind 的第一步建 socket 就停了。所以 net 不开 os-poll 的组合能编译、类型都在,却一调用就 panic。这样设计的用意见下面的框。

📝NOTE
冷知识:空壳是故意的,最初有五个 feature

sys/shell 和这套 cfg_* 宏来自「Introduce default API shim and feature flags for net/OS resources」(#1192),首次随 0.7.0 发布。之前依赖 mio 就会拿到全部平台实现和所有网络类型。PR 的动机是:

If a library is interested only in networking primitives but aims to provide an
other strategy for I/O source readiness polling, there is no way to disable the
provided implementation.

改法是默认只给「API shim」:类型都在,用了就 panic;Poll::new 这样的方法直接藏起来。PR 说这些宏「conditionally provide shell types that panic upon use, or hide/expose methods on types an API shim should not provide (e.g. Poll::new)」,正是上面两个程序看到的两种表现。当时的 feature 是 os-poll、os-util、tcp、udp、uds 五个。「Reduce the number of features」(#1380,0.7.6)把 tcp、udp、uds 合成 net,os-util 和 pipe 合成 os-ext,旧名字保留为过渡;0.8.0 的 #1429 删掉了旧名字,成了现在的样子。

每个平台用哪个 selector

sys/mod.rs 先按大类分:unix、target_os = "hermit" 和 WASI preview 2 及以后走 sys/unix,Windows 走 sys/windows,WASI preview 1 走 sys/wasip1。lib.rs 还拒绝其他 wasm 目标:compile_error!("This wasm target is unsupported by mio. If using Tokio, disable the net feature.")。

sys/unix 里没有 selector/mod.rs。sys/unix/mod.rs 只声明一个 mod selector;,前面叠四个 #[cfg_attr(..., path = "...")],条件成立的那个决定这个模块实际是哪个文件:

cfg_os_poll! {
    #[cfg_attr(all(
        not(mio_unsupported_force_poll_poll),
        any(
            target_os = "android",
            target_os = "illumos",
            target_os = "linux",
            target_os = "redox",
        )
    ), path = "selector/epoll.rs")]
    // ... kqueue.rs、event_ports.rs、poll.rs 三个同样的 cfg_attr
    mod selector;
    pub(crate) use self::selector::*;

紧接着的下一组 cfg_attr 用同样的办法给 mod waker; 选 Waker 的实现。把两组 cfg_attr 读出来,再加上 Windows 和 WASI p1,合成一张表(不加任何 --cfg 时):

target_osselector文件Waker
linux、androidepollselector/epoll.rseventfd
illumosepollselector/epoll.rseventfd
redoxepollselector/epoll.rspipe
macos、ios、tvos、visionos、watchoskqueueselector/kqueue.rsEVFILT_USER(waker/selector.rs)
freebsd、dragonfly、netbsd、openbsdkqueueselector/kqueue.rsEVFILT_USER,创建时先 setup_waker
solarisevent portsselector/event_ports.rsevent ports 的用户事件(waker/selector.rs)
fuchsia、hermit、espidf、nuttxpoll(2)selector/poll.rseventfd
aix、haiku、hurd、nto、vita、cygwinpoll(2)selector/poll.rspipe
horizon、wasi(p2 及以后)poll(2)selector/poll.rssingle_threaded.rs,但不导出公开的 Waker
wasi(p1)poll_oneoffsys/wasip1/mod.rs无
windowsIOCP + AFDsys/windows/selector.rssys/windows/waker.rs

几处值得留意:

  • illumos 用的是 epoll,不是 Solaris 的 event ports;Poll 文档里的表也这么写。Redox 用 epoll 但 Waker 走 pipe。
  • 公开的 Waker 在 lib.rs 里是 #[cfg(not(any(target_os = "horizon", target_os = "wasi")))]:这两个平台上 mio::Waker 根本不存在。
  • Poll 和 Registry 只在有单个 selector fd 的平台上实现 AsRawFd / AsFd(impl AsRawFd for Poll 前面的 cfg 排除了全部 poll(2) 平台):poll(2) 实现没有一个能交给别人去等的 fd。
  • 本站的两个平台,Linux 走第一行,macOS 走第四行。

mio_unsupported_force_poll_poll 与 mio_unsupported_force_waker_pipe

上表里每个 cfg_attr 都带着 not(mio_unsupported_force_poll_poll)。这是两个不属于任何 feature 的 --cfg 标志,要通过 RUSTFLAGS 传。Cargo.toml 最后两行把它们登记进 check-cfg,免得编译器报「unexpected cfg」:

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(mio_unsupported_force_poll_poll)', 'cfg(mio_unsupported_force_waker_pipe)'] }

README 的「Unsupported flags」一节说明了用途和态度:

Current flags:
 * `mio_unsupported_force_poll_poll`, uses an implementation based on `poll(2)`
   for `mio::Poll`.
 * `mio_unsupported_force_waker_pipe`, uses an implementation based on `pipe(2)`
   for `mio::Waker`.

**Again, Mio does not officially supports this**. Furthermore these flags may
disappear in the future.

mio_unsupported_force_poll_poll 打开时:所有平台的 selector 都换成 selector/poll.rs;kqueue 平台的 Waker 换成 pipe(sys/unix/mod.rs 的注释:「kqueue(2) based waker doesn't work with poll(2)」);Linux 上 Waker 仍是 eventfd。上面的最小程序用 RUSTFLAGS="... --cfg mio_unsupported_force_poll_poll" 重新编译,在 Linux 容器里跑(Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0,strace -f -e trace=epoll_create1,epoll_ctl,epoll_pwait,ppoll,poll,eventfd2,pipe2,accept4,read,write,标准输出重定向掉)。3 次除端口和栈地址外逐行相同,其中一次:

ppoll([{fd=0, events=0}, {fd=1, events=0}, {fd=2, events=0}], 3, {tv_sec=0, tv_nsec=0}, NULL, 8) = 0 (Timeout)
eventfd2(0, EFD_CLOEXEC|EFD_NONBLOCK)   = 3
write(3, "\1\0\0\0\0\0\0\0", 8)         = 8
read(3, "\1\0\0\0\0\0\0\0", 8)          = 8
ppoll([{fd=3, events=POLLIN}, {fd=4, events=POLLIN|POLLRDHUP}], 2, NULL, NULL, 8) = 1 ([{fd=4, revents=POLLIN}])
write(1, "poll 1: token=Token(7) readable="..., 52) = 52
accept4(4, {sa_family=AF_INET, sin_port=htons(46236), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 6
write(1, "accept: ok\n", 11)            = 11
accept4(4, 0xffffd21b7538, [128], SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)
write(3, "\1\0\0\0\0\0\0\0", 8)         = 8
read(3, "\1\0\0\0\0\0\0\0", 8)          = 8
write(1, "accept: WouldBlock\n", 19)    = 19
strace: Process 4 attached
[pid     3] ppoll([{fd=3, events=POLLIN}, {fd=4, events=POLLIN|POLLRDHUP}], 2, NULL, NULL, 8 <unfinished ...>
[pid     4] write(3, "\1\0\0\0\0\0\0\0", 8 <unfinished ...>
[pid     3] <... ppoll resumed>)        = 1 ([{fd=3, revents=POLLIN}])
[pid     4] <... write resumed>)        = 8
[pid     3] read(3, "\1\0\0\0\0\0\0\0", 8) = 8
[pid     3] write(1, "poll 2: token=Token(9) readable="..., 52) = 52
[pid     4] +++ exited with 0 +++
write(3, "\1\0\0\0\0\0\0\0", 8)         = 8
read(3, "\1\0\0\0\0\0\0\0", 8)          = 8
+++ exited with 0 +++

程序的标准输出和 epoll 版一样,但系统调用完全换了一套:没有一个 epoll_*;register 没有对应的内核调用,fd 列表由 mio 在用户态维护,每次 poll 都把整个列表交给 ppoll;整个进程只有一个 eventfd(fd 3),既用来叫醒正在 ppoll 的线程,也是用户 Waker 的事件来源;accept4 返回 EAGAIN 之后紧跟着一对 write(3) / read(3),那是 do_io 在把 listener 重新放回关注集合。第一行 ppoll 查的是 fd 0、1、2,那是 Rust 标准库启动时检查标准输入输出是否打开,和 mio 无关。这些机制在 poll(2) 后备一章逐项讲。

这个标志对上层也有影响。tokio 的 PollEvented::poll_read 有一个「读不满就说明已经读空」的优化,只在 epoll 和 kqueue 上成立,它的 cfg 里就写着 not(mio_unsupported_force_poll_poll);来历见从 TcpStream::read 到挂起。tokio 的 Cargo.toml 也把这个 cfg 登记进了自己的 check-cfg。

mio_unsupported_force_waker_pipe 只换 Waker:eventfd 平台和 kqueue 平台都改用 pipe 版。

tests/:17 个测试文件

tests/ 下每个文件编译成一个独立的测试程序。几乎每个文件开头都有 #![cfg(all(feature = "os-poll", feature = "net"))],所以要 cargo test --all-features 才跑得到:

文件行数#[test] 数测什么
udp_socket.rs1 20333UdpSocket;Miri 下整个文件不编译(「Miri doesn't support UDP sockets」)
tcp_stream.rs1 09925TcpStream:IPv4 / IPv6、TTL 与 nodelay、shutdown、peek、注销后不再有事件、断开时的 hup 事件
tcp.rs98319listener 和 stream 配合:accept、connect、读写、关闭、各种错误、注销后不再有事件
poll.rs72917Poll 和 Registry:零超时、WouldBlock 之后重新就绪、poll 期间从别的线程注册、错误的注册
unix_stream.rs68014UnixStream
unix_datagram.rs42911UnixDatagram
tcp_listener.rs30811TcpListener
unix_listener.rs2839UnixListener;1.2.3 的 #1998 在这里加了「排空之后第二个连接还能收到通知」的回归测试
registering.rs2465register / reregister / deregister 的约定:不 poll 就改 interest、注销后再注册
aio.rs2282Interest::AIO,只在 FreeBSD 和 DragonFly 上编译
unix_pipe.rs2275os-ext 的 pipe
waker.rs1627Waker:同线程、跨线程的单次和多次唤醒
close_on_drop.rs1311服务端 drop 掉连接之后,客户端收到可读事件并读到 0 字节
regressions.rs1303具体 issue 的回归测试(issue_776、issue_1205 等)
events.rs533Events
interest.rs524Interest 的位运算;文件开头没有 cfg,默认 feature 下也会跑
win_named_pipe.rs78716Windows named pipe;1.2.4 的 #2014 加了 340 多行
util/mod.rs397—公用工具:init_with_poll、expect_events、expect_no_events、assert_would_block、assert_socket_non_blocking 等

发布的包里没有 tests/:Cargo.toml 的 include 只列了 Cargo.toml、LICENSE、README.md、CHANGELOG.md、src/**/*.rs、examples/**/*.rs。想对照本页读测试,要看 git 仓库。

CI:哪些平台真跑测试,哪些只编译

.github/workflows/ci.yml 在 v1.2.4 有 18 个任务。按「在哪个平台上跑了什么」整理:

任务平台做什么
Testubuntu、macos、windowscargo hack check --feature-powerset;debug 和 release 各 cargo test --all-features
TestPollubuntuRUSTFLAGS="--cfg mio_unsupported_force_poll_poll" 下跑全部测试
TestWakerPipeubuntu--cfg mio_unsupported_force_waker_pipe 下跑全部测试
MinimalVersionsubuntu、macos、windowscargo update -Zminimal-versions 之后跑测试
MSRVubuntu、macos、windows1.71 上只 cargo check --all-features(注释:好让测试能用新特性)
TestWASIubuntu + wasmtimewasm32-wasip2 上跑测试
Nightly、Clippy、Docs、Rustfmtubuntunightly 测试、clippy、cargo doc 不许警告、rustfmt --check
Miriubuntu集成测试和文档测试在 Miri 下跑,再用 poll(2) selector 跑一遍集成测试
CheckTier116 个目标cargo hack check -Z build-std --feature-powerset:Apple 五个系统、Android、FreeBSD、Linux gnu / musl、NetBSD、OpenBSD、Windows msvc
CheckTier224 个目标同上:Fuchsia、Hermit、QNX、Redox、Hurd、ESP-IDF、Solaris、illumos、Haiku、DragonFly、3DS、PS Vita、wasm32-wasip1 等
Sanitizerubuntuaddress、leak、memory、thread 四种 sanitizer 跑 make test_sanitizer
TestFreeBSDFreeBSD 虚拟机14.4 和 15.0 的 x86_64,14.4 的 i686
TestSolarisSolaris 11.4 虚拟机跑全部测试
Wineubuntu 22.04 + Winex86_64-pc-windows-gnu 在 Wine 里跑测试

合并 PR 要求通过的 Passed 任务只依赖其中 11 个:Test、TestPoll、MinimalVersions、MSRV、Nightly、Clippy、Docs、Rustfmt、Miri、CheckTier1、TestFreeBSD。TestWakerPipe、TestWASI、Sanitizer、TestSolaris、Wine 和 CheckTier2 失败不挡合并;Solaris 是 1.2.3 发版前的提交「Remove testing Solaris from CI pass requirement」从这个列表里拿掉的。

也就是说,真正跑测试的平台是 Linux、macOS、Windows、FreeBSD、Solaris、Wine 和 WASI p2;其余平台只保证编译通过。本站的两个平台都在第一行里,每个 PR 都跑。

小结

  • src/ 13 451 行:平台无关层 2.6k、net/ 2.3k、sys/unix 4.8k(四个 selector 占 2.6k)、sys/windows 3.0k。Linux 上两条主线经过的代码不到 2 000 行。
  • 两层之间没有 trait:每个平台模块导出同名的 Selector、Event、Events、Waker、IoSourceState,sys/unix/mod.rs 用 #[cfg_attr(..., path = "...")] 在编译期挑一个文件当 mod selector。
  • feature 默认只有 log;os-poll 决定 Poll::new 在不在,net 给网络类型,os-ext 给 pipe、SourceFd、NamedPipe。不开 os-poll 时类型都在,Poll::new 编译报 E0599,网络类型落到 sys/shell,一调用就 panic「mio must be compiled with os-poll to run.」(#1192,0.7.0 起)。tokio 用 default-features = false 加 os-poll、os-ext、net。
  • Linux、Android、illumos 用 epoll,Apple 系统和四个 BSD 用 kqueue,Solaris 用 event ports,其余 Unix 用 poll(2),Windows 用 IOCP + AFD。--cfg mio_unsupported_force_poll_poll 把所有平台换成 poll(2),strace 里 epoll_* 全部变成 ppoll。
  • CI 在 Linux、macOS、Windows、FreeBSD 上跑测试并挡合并,poll(2) 后备和 Miri 也挡合并;Solaris、Wine、WASI 跑但不挡;CheckTier1 / CheckTier2 的 40 个目标只做编译检查。

💬 关联讨论

发起讨论
加载中...