怎么读这一卷
主线位置:两条主线的总览。本页把注册主线和事件主线各画一遍,标出每一步落在哪个函数、哪个系统调用上,再用一个最小程序在 Linux 容器里跑 strace 对照;下一部分的「一个事件循环」用一个完整的 echo 服务器把两条线反复走。
flowchart TB
start[从这里开始] --> q{读这一卷为了什么}
q -->|会用| use1[01 全景]
use1 --> use2[02 核心类型]
use2 --> use3[04 网络类型]
use3 --> use4[06 常见坑]
q -->|读懂实现| all[00 到 08 按顺序]
锁定版本
| 项 | 值 |
|---|---|
| crate | mio 1.2.4 |
| tag | v1.2.4 |
| commit | 79d1417(2026-10-03,「Release v1.2.4 (#2017)」) |
| MSRV | 1.71(Cargo.toml 的 rust-version,README 写明 1.1 和 1.2 都是 1.71) |
| 依赖 | log(可选,默认开);Unix 上 libc,Windows 上 windows-sys 0.61,WASI 上 wasi。见源码地图 |
| 对照的 tokio | 1.54.1,和 tokio 卷相同 |
源码链接都用 tag,形如 https://github.com/tokio-rs/mio/blob/v1.2.4/src/poll.rs#L271。想在本地对照着读:
git clone https://github.com/tokio-rs/mio
git -C mio checkout v1.2.4
写作时本地仓库的 master 比 v1.2.4 多 14 个提交,都还没有发版。其中一个和本卷有关:「Mark EVFILT_USER Waker events readable on NetBSD, OpenBSD and DragonFly」(#2027),它把 kqueue 的 is_readable 里认 EVFILT_USER 的平台列表补上了这三个 BSD。也就是说,1.2.3 把这三个平台切到 EVFILT_USER 版 Waker(见下一节)之后,到 1.2.4 为止,它们上面 waker 事件的 is_readable() 是 false。macOS 和 FreeBSD 一直在列表里,不受影响。
本站自己的 Cargo.lock 锁的是 mio 1.2.1(经由 tokio 1.52.3),比本卷早三个补丁版本。
1.2.1 到 1.2.4:哪些改动碰到 Linux 和 macOS
git log v1.2.1..v1.2.4 有 34 个提交,其中 20 个改了 src/,其余是 CI、README、CHANGELOG 和测试。CHANGELOG 里三个版本的条目是:
# 1.2.4
* Fix FreeBSD build with libc v0.2.190.
(https://github.com/tokio-rs/mio/pull/2016).
* Fix named pipe use-after-free when I/O fails right after submit
(https://github.com/tokio-rs/mio/pull/2014).
* Fix non-blocking socket creation on Haiku
(https://github.com/tokio-rs/mio/pull/2015).
# 1.2.3
* Running under Wine should work again
(https://github.com/tokio-rs/mio/pull/1974).
* Fixed an issue with the UDS listener not reregistering readiness correctly
when using the `poll(2)` implementation
(https://github.com/tokio-rs/mio/pull/1998).
* NetBSD, OpenBSD and DragonflyBSD are switched to using `EVFILT_USER` based
`Waker`, one that was already used on FreeBSD and macOS
(https://github.com/tokio-rs/mio/pull/1993).
# 1.2.2
* Add support for Solaris using event ports
(https://github.com/tokio-rs/mio/pull/1962).
* Add experiment support for NuttX using `poll(2)`
(https://github.com/tokio-rs/mio/pull/1966).
CHANGELOG 只列了用户可见的条目。逐个读这 20 个提交的 diff,看它改的文件和外面套的 cfg,结果如下(「Linux」「macOS」两列指这个提交有没有改变在该平台上编译进来的代码):
| 版本 | PR | 改了什么 | 生效的平台 | Linux | macOS |
|---|---|---|---|---|---|
| 1.2.2 | #1966 | poll.rs、sys/unix/mod.rs、pipe.rs、tcp.rs 的 cfg 列表里加 target_os = "nuttx" | NuttX | 否 | 否 |
| 1.2.2 | #1962(3 个提交) | 新增 selector/event_ports.rs;poll.rs 和 sys/unix/mod.rs 的列表里把 Solaris 从 poll(2) 挪到 event ports | Solaris | 否 | 否 |
| 1.2.3 | #1976 | 两处注释的拼写 | — | 只有注释 | 只有注释 |
| 1.2.3 | #1977 | sys/unix/tcp.rs 的 accept 在 WASI 上改用 fcntl 设非阻塞;删掉 udp.rs 文档测试里跳过 WASI 的一行 | WASI | 否 | 否 |
| 1.2.3 | #1978 | UDP、UDS 的文档测试在 Miri 下标成 ignore | 只影响 cargo test --doc | 否 | 否 |
| 1.2.3 | #1975 | macros.rs 的 log! 宏里 log::$level!(...) 后面加一个分号,消掉 semicolon_in_expressions_from_macros 警告 | 所有平台 | 编译产物不变 | 编译产物不变 |
| 1.2.3 | #1985 | 删掉 Poll::new 文档里关于「老 Linux 没有 epoll_create1」的说明 | — | 只有文档 | 只有文档 |
| 1.2.3 | #1987、#1988 | kqueue 的 Waker 不再在创建时做 setup,wake 改用 EV_ADD | EV_ONESHOT;waker/kqueue.rs 并入 waker/selector.rs | FreeBSD、macOS 和其他 Apple 平台;Solaris | 否 | 是 |
| 1.2.3 | #1991(2 个提交) | poll(2) 实现里的 IoSourceState 挪到新文件 registered_io_source.rs,event ports 复用它 | 用 poll(2) 或 event ports 的平台 | 否 | 否 |
| 1.2.3 | #1993(3 个提交) | FreeBSD 把 setup 加回来;DragonFly、NetBSD、OpenBSD 从 pipe 版 Waker 换成 EVFILT_USER 版 | 四个 BSD | 否 | 否 |
| 1.2.3 | #1998 | UnixListener::accept 改成经过 IoSource::do_io | 所有 Unix;只在 poll(2) 和 event ports 上有实际效果 | 代码路径变了,行为不变 | 同左 |
| 1.2.4 | #2014 | Windows named pipe:提交 I/O 后立刻失败时的 use-after-free | Windows | 否 | 否 |
| 1.2.4 | #2016 | kqueue Event 的 Debug 输出里删掉 NOTE_PCTRLMASK 一项 | 所有 kqueue 平台 | 否 | 只有 Debug 输出 |
| 1.2.4 | #2015 | Haiku 创建 socket 时没有设非阻塞,补上 fcntl | Haiku | 否 | 否 |
结论:Linux 上没有一个提交改变行为;macOS 上真正换了实现的只有 Waker,另有一处 Debug 输出。下面逐条说明标了「是」或「行为不变」的几行。
macOS 的 Waker(#1987、#1988)。 1.2.1 里 Waker::new 先让 kqueue 接收 EVFILT_USER 事件(setup_waker),wake 每次再提交一个 NOTE_TRIGGER,并带 EV_RECEIPT 逐项检查错误。1.2.1 的 waker/kqueue.rs:
pub(crate) fn new(selector: &Selector, token: Token) -> io::Result<Waker> {
let selector = selector.try_clone()?;
selector.setup_waker(token)?;
Ok(Waker { selector, token })
}
以及 1.2.1 的 Selector::wake:
let mut kevent = kevent!(
0,
libc::EVFILT_USER,
libc::EV_ADD | libc::EV_RECEIPT,
token.0
);
kevent.fflags = libc::NOTE_TRIGGER;
let kq = self.kq.as_raw_fd();
syscall!(kevent(kq, &kevent, 1, &mut kevent, 1, ptr::null())).and_then(|_| {
if (kevent.flags & libc::EV_ERROR) != 0 && kevent.data != 0 {
Err(io::Error::from_raw_os_error(kevent.data as i32))
} else {
Ok(())
}
})
#1987 的说明是:
For the Waker implementation based on the kqueue selector we used to setup a
listener for EVFILT_USER events before send the events on each wake-up.
However, this is not actually needed if we use EV_ONESHOT.
1.2.4 的 Selector::wake 在一次 kevent 里同时完成「加上这个事件」和「触发它」,EV_ONESHOT 让它被取走一次之后就从 kqueue 里删掉,下次 wake 再加;不再要回执,kevent 的输出数组传空:
pub fn wake(&self, token: Token) -> io::Result<()> {
let mut kevent = kevent!(
0,
libc::EVFILT_USER,
libc::EV_ADD | libc::EV_ONESHOT,
token.0
);
kevent.fflags = libc::NOTE_TRIGGER;
let kq = self.kq.as_raw_fd();
syscall!(kevent(kq, &kevent, 1, ptr::null_mut(), 0, ptr::null()))?;
Ok(())
}
Waker::new 里的 setup 没有了,kqueue 版和 Solaris 版的 Waker 变得一模一样,#1988 把它们合成一个 waker/selector.rs:只持有一个 try_clone 出来的 Selector 和 token,wake 转给 Selector::wake。
这个改法在 FreeBSD 上不成立。#1987 在 7 月 31 日合并,两天后合并的 #1993,第一个提交「Add back Waker setup for FreeBSD」写道:
On Darwin, macOS, iOS, etc., the Waker setup is not needed and an event
with EVFILT_USER and NOTE_TRIGGER is all we need.
However, on FreeBSD this doesn't work. On FreeBSD you first have to
set up the mechanism before events are delivered. When this is not the
submitting of the events is silently ignored.
所以 1.2.4 的 setup_waker 只在 DragonFly、FreeBSD、NetBSD、OpenBSD 上编译,上面的注释是「For Darwin (macOS, iOS, etc.) we don't need any setup.」macOS 上走的是「不 setup、每次 EV_ADD | EV_ONESHOT」这条路。
这个改动在 macOS 上用户看得到的效果是:创建 Waker 少一次 kevent,wake 的 kevent 不再要回执。事件数有没有变,用同一个程序分别依赖两个版本跑一下。依赖分别是 mio = { version = "=1.2.4", features = ["os-poll", "net", "os-ext"] } 和把版本换成 =1.2.1:
use std::io;
use std::time::Duration;
use mio::{Events, Poll, Token, Waker};
fn main() -> io::Result<()> {
let mut poll = Poll::new()?;
let mut events = Events::with_capacity(8);
let waker = Waker::new(poll.registry(), Token(9))?;
let zero = Some(Duration::ZERO);
poll.poll(&mut events, zero)?;
println!("before wake: {} event(s)", events.iter().count());
waker.wake()?;
waker.wake()?;
poll.poll(&mut events, zero)?;
println!("after 2 wakes: {} event(s)", events.iter().count());
poll.poll(&mut events, zero)?;
println!("poll again: {} event(s)", events.iter().count());
waker.wake()?;
poll.poll(&mut events, zero)?;
println!("after 1 more wake: {} event(s)", events.iter().count());
Ok(())
}
M2 Max、macOS、rustc 1.99.0,cargo run --release,两个版本各跑 3 次,6 次输出完全相同:
before wake: 0 event(s)
after 2 wakes: 1 event(s)
poll again: 0 event(s)
after 1 more wake: 1 event(s)
连续两次 wake 只得到一个事件,取走之后不会再来,再 wake 又有一个。两个版本在这几步上看不出区别。Waker 在各平台上的实现和「多次 wake 合并成一个事件」的原因,在 Waker 一章讲。
NOTE_PCTRLMASK(#2016)。 kqueue 的 Event 实现 Debug 时,会把 fflags 按一张常量表逐个列出名字(debug_details)。libc 0.2.190 里这个常量出了问题,PR 的说明是「The mio couldn't be compiled with libc 0.2.190」,并说「Since libc::NOTE_PCTRLMASK is only for debug output」。删掉的那一行没有平台 cfg,所以 macOS 上 {:?} 打印一个 kqueue 事件时少了这一项,别的不变。
UnixListener::accept(#1998)。 1.2.1 里 UnixListener::accept 直接调 sys::uds::listener::accept(&self.inner),1.2.3 改成 self.inner.do_io(sys::uds::listener::accept)。do_io 在 epoll 和 kqueue 上用的是 stateless_io_source.rs,原样调用传进来的函数:
pub(crate) fn do_io<T, F, R>(&self, f: F, io: &T) -> io::Result<R>
where
F: FnOnce(&T) -> io::Result<R>,
{
// We don't hold state, so we can just call the function and
// return.
f(io)
}
所以 Linux 和 macOS 上多了一层调用,行为不变。在 poll(2) 后备实现里,do_io 遇到 WouldBlock 要把这个 fd 重新放回关注集合,漏了这一步,第二个连接就收不到通知,这就是修的问题。机制见 poll(2) 后备。
本站在 Linux 生产、macOS 开发,这一节的结论对本站意味着什么,放在「在本站中」一部分讲。
两条主线
mio 的公开 API 很小:Poll、Registry、Token、Interest、Events / Event、event::Source、Waker,加上 net 里的套接字类型。全卷沿两条线展开,每页开头都会标出本页在哪条主线上。
注册主线(一次 register 落到 epoll_ctl / kevent):
flowchart TB
bind["TcpListener::bind"] --> sock["非阻塞 socket"]
sock --> wrap["包进 IoSource T"]
wrap --> reg["Registry::register"]
reg --> src["Source::register"]
src --> sid["debug:SelectorId"]
sid --> sel{"平台 Selector"}
sel -->|Linux| ep["epoll_ctl ADD"]
sel -->|macOS| kq["kevent 提交过滤器"]
TcpListener::bind建 socket、设SO_REUSEADDR、bind、listen(128)。Linux 上new_socket在socket(2)的类型里直接带上SOCK_NONBLOCK | SOCK_CLOEXEC;macOS 没有这两个标志,事后用fcntl设,另外像标准库一样设SO_NOSIGPIPE。得到的 stdTcpListener放在IoSource<T>里(TcpListener只有一个inner: IoSource<net::TcpListener>字段)。见 TCP。Registry::register只打一行 trace 日志,然后调source.register(self, token, interests)。source是任何实现了event::Source的类型,register、reregister、deregister三个方法;参数要&mut S。见 Poll 与 Registry。TcpListener的Source实现转给IoSource。Unix 上IoSource的Source实现在 debug 构建下先调SelectorId::associate:同一个 source 已经注册过就返回AlreadyExists错误「I/O source already registered with aRegistry」,release 构建下这段检查不存在。然后取出 fd,交给IoSourceState::register;epoll 和 kqueue 上它不保存任何状态,直接转给Selector::register。见 Source 与 IoSource。- Linux:
Selector::register用interests_to_epoll算出标志:总是带EPOLLET,关心读加EPOLLIN | EPOLLRDHUP,关心写加EPOLLOUT;Token的usize转成u64放进epoll_event的data;一次epoll_ctl(EPOLL_CTL_ADD)。见 epoll。 - macOS:
Selector::register按Interest准备至多两项 changelist(先EVFILT_WRITE、再EVFILT_READ),标志EV_CLEAR | EV_RECEIPT | EV_ADD,token 放进udata,一次kevent提交;EV_RECEIPT让内核逐项回报错误,EPIPE被忽略。见 kqueue。
两个平台都把 token 交给内核保存,事件回来时原样取出。mio 没有「fd → token」的表,也就不需要锁和分配;token 的含义完全由使用者决定。tokio 把 ScheduledIo 的地址当 token(见反应器)。见 Token、Interest 与 Event。
事件主线(一次 poll 落到 epoll_wait / kevent,用户读到 WouldBlock):
flowchart TB pollnode["Poll::poll"] --> select["Selector::select"] select -->|Linux| wait["epoll_wait"] select -->|macOS| kev["kevent 取事件"] wait --> evs["写进 Events 的 Vec"] kev --> evs evs --> each["遍历 Event"] each --> io["非阻塞 I/O"] io -->|还有数据| io io -->|WouldBlock| pollnode wk["另一线程 Waker::wake"] -.-> select
Poll::poll只有一行:self.registry.selector.select(events.sys(), timeout)。timeout为None时一直等,Some(0)立即返回。见 Poll 与 Registry。- Linux:
Selector::select把 timeout 向上取整到毫秒(避免把 1 微秒变成 0),超出c_int的截成c_int::MAX,None是-1;先events.clear(),再epoll_wait(ep, events.as_mut_ptr(), events.capacity(), timeout),内核写了几项就set_len几项。macOS:Selector::select换成timespec,调kevent(kq, 空 changelist, events, capacity, timeout)。见 epoll、kqueue。 Events只包着一个sys::Events,Linux 上就是Vec<libc::epoll_event>。容量在Events::with_capacity时定下,一次poll最多拿这么多项,剩下的留给下一次。Event是#[repr(transparent)]包着平台的事件结构。token()、is_readable()这些方法转给平台的sys::event模块,比如 epoll 上的token就是把data转回Token,is_readable看EPOLLIN等位。见 Token、Interest 与 Event。- 用户对 source 做非阻塞 I/O:
accept、read、write直到返回WouldBlock。注册时带了EPOLLET/EV_CLEAR,是边沿触发:没读干净就回去poll,不会再收到这个 fd 的通知。见 一个事件循环。 - 旁路:
Waker::new在 debug 构建下检查这个Poll还没有别的Waker(register_waker里的assert!,消息是「Only a singleWakercan be active perPollinstance」)。Linux 上的实现是一个 eventfd:new用eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK)建好、按READABLE注册进 epoll;wake往里写一个 8 字节的 1。macOS 上是上一节的EVFILT_USER。见 Waker。
两条线在 tokio 里是这样接起来的(tokio 1.54.1,见 反应器、从 TcpStream::read 到挂起):
| 步骤 | tokio 的代码 | mio 的主线 |
|---|---|---|
| 建驱动 | mio::Waker::new(poll.registry(), TOKEN_WAKEUP),TOKEN_WAKEUP 是 Token(0) | 事件主线 6 |
| 注册 socket | Registration 调 Registry::register,token 是 ScheduledIo 的地址 | 注册主线 2 到 5 |
| 一轮 IO | turn 里的 self.poll.poll(events, max_wait) | 事件主线 1 到 4 |
读到 WouldBlock | PollEvented::poll_read 清掉就绪位,任务挂起 | 事件主线 5 |
用 strace 看两条主线
把两条线压进一个最小程序:注册一个 listener 和一个 Waker,用 std 的 TcpStream 连上来,poll 一次、accept 到 WouldBlock,再从另一个线程 wake 一次。依赖:
mio = { version = "=1.2.4", features = ["os-poll", "net", "os-ext"] }
use std::io::{self, ErrorKind};
use std::net::TcpStream as StdTcpStream;
use std::thread;
use mio::net::TcpListener;
use mio::{Events, Interest, Poll, Token, Waker};
const LISTENER: Token = Token(7);
const WAKER: Token = Token(9);
fn print_events(round: &str, events: &Events) {
for event in events.iter() {
println!(
"{round}: token={:?} readable={} writable={}",
event.token(),
event.is_readable(),
event.is_writable()
);
}
}
fn main() -> io::Result<()> {
let mut poll = Poll::new()?;
let mut events = Events::with_capacity(8);
// 注册主线
let mut listener = TcpListener::bind("127.0.0.1:0".parse().unwrap())?;
poll.registry().register(&mut listener, LISTENER, Interest::READABLE)?;
let waker = Waker::new(poll.registry(), WAKER)?;
// 事件主线:一个连接到达
let _client = StdTcpStream::connect(listener.local_addr()?)?;
poll.poll(&mut events, None)?;
print_events("poll 1", &events);
loop {
match listener.accept() {
Ok(_) => println!("accept: ok"),
Err(e) if e.kind() == ErrorKind::WouldBlock => {
println!("accept: WouldBlock");
break;
}
Err(e) => return Err(e),
}
}
// 旁路:另一个线程 wake
// 线程把 waker 交回来:drop 了 Waker 就关掉了 eventfd,事件可能随之丢失
let handle = thread::spawn(move || waker.wake().map(|()| waker));
poll.poll(&mut events, None)?;
print_events("poll 2", &events);
let _waker = handle.join().unwrap()?;
Ok(())
}
token 故意用 7 和 9,好在 strace 里一眼认出来。程序在 macOS(M2 Max、rustc 1.99.0)和 Linux 容器里各跑 3 次,标准输出都一样:
poll 1: token=Token(7) readable=true writable=false
accept: ok
accept: WouldBlock
poll 2: token=Token(9) readable=true writable=false
Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0,strace -f -e trace=epoll_create1,epoll_ctl,epoll_pwait,epoll_wait,eventfd2,accept4,socket,bind,listen,connect,setsockopt,recvfrom,sendto,shutdown,read,write,close,程序的标准输出重定向到 /dev/null,只留 strace 的记录。3 次运行除了端口号和栈地址,其余逐行相同;其中一次:
epoll_create1(EPOLL_CLOEXEC) = 3
socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 4
setsockopt(4, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(4, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
listen(4, 128) = 0
epoll_ctl(3, EPOLL_CTL_ADD, 4, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x7}) = 0
eventfd2(0, EFD_CLOEXEC|EFD_NONBLOCK) = 5
epoll_ctl(3, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x9}) = 0
socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 6
connect(6, {sa_family=AF_INET, sin_port=htons(39453), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
epoll_pwait(3, [{events=EPOLLIN, data=0x7}], 8, -1, NULL, 8) = 1
write(1, "poll 1: token=Token(7) readable="..., 52) = 52
accept4(4, {sa_family=AF_INET, sin_port=htons(42012), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 7
write(1, "accept: ok\n", 11) = 11
close(7) = 0
accept4(4, 0xffffca8837d8, [128], SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)
write(1, "accept: WouldBlock\n", 19) = 19
strace: Process 4 attached
[pid 3] epoll_pwait(3 <unfinished ...>
[pid 4] write(5, "\1\0\0\0\0\0\0\0", 8 <unfinished ...>
[pid 3] <... epoll_pwait resumed>, [{events=EPOLLIN, data=0x9}], 8, -1, NULL, 8) = 1
[pid 4] <... write resumed>) = 8
[pid 3] write(1, "poll 2: token=Token(9) readable="..., 52) = 52
[pid 4] +++ exited with 0 +++
close(5) = 0
close(6) = 0
close(4) = 0
close(3) = 0
+++ exited with 0 +++
逐行对照两条主线:
Poll::new是第一行epoll_create1(EPOLL_CLOEXEC),得到 fd 3。TcpListener::bind是socket(类型里已经带着SOCK_CLOEXEC|SOCK_NONBLOCK,不需要事后fcntl)、setsockopt(SO_REUSEADDR)、bind、listen(4, 128)。register是一次epoll_ctl(3, EPOLL_CTL_ADD, 4, ...):标志EPOLLIN|EPOLLRDHUP|EPOLLET,data=0x7就是Token(7)。注册主线到这里就结束了,内核之外 mio 没有记任何东西。Waker::new是eventfd2(0, EFD_CLOEXEC|EFD_NONBLOCK)加一次epoll_ctl,data=0x9。waker 和普通 socket 走的是同一个Selector::register,所以标志也一样。poll在源码里调的是epoll_wait,strace 里看到的却是epoll_pwait(..., NULL, 8):aarch64 Linux 没有单独的epoll_wait系统调用(libc crate 在 aarch64 上只定义了SYS_epoll_pwait,x86_64 上两个都有),musl 的epoll_wait用信号掩码为NULL的epoll_pwait实现。8是Events::with_capacity(8)的容量,-1是timeout: None。返回的一项里data=0x7,Event::token()把它转回Token(7)。accept是accept4(..., SOCK_CLOEXEC|SOCK_NONBLOCK):接受下来的连接直接就是非阻塞的。第二次accept4返回EAGAIN,Rust 里就是ErrorKind::WouldBlock,循环结束。close(7)是Ok(_)分支里丢掉的那条连接。wake是另一个线程(pid 4)往 fd 5 写 8 个字节的 1;主线程阻塞中的epoll_pwait随之返回一项data=0x9。
整个程序一次「注册」对应一次 epoll_ctl,一次 poll 对应一次 epoll_pwait,中间没有别的系统调用。这就是 mio 在 Linux 上的全部开销。
程序里有一行注释值得解释:Waker 由线程交回主线程,等 poll 返回后才 drop。如果线程在 wake 之后直接 drop 掉 Waker,eventfd 被关闭,Linux 上关闭 fd 会把它从 epoll 里摘掉,还没被取走的事件也一起没了;主线程不管是之后才进 epoll_wait,还是已经阻塞在里面,都可能一直等下去(wake() 了,poll 却等到超时 里不加 strace 跑了 15 次,15 次都丢了)。第一版程序就是这么写的,在 strace 下(主线程总是先进 epoll_pwait)跑得好好的,去掉 strace 直接跑就卡住了。同一件事在两个平台上的结果不同,用一个更直接的程序可以看到:wake 一次、立刻 drop、再 poll(超时 200 毫秒):
use std::io;
use std::time::Duration;
use mio::{Events, Poll, Token, Waker};
fn main() -> io::Result<()> {
let mut poll = Poll::new()?;
let mut events = Events::with_capacity(8);
let waker = Waker::new(poll.registry(), Token(9))?;
waker.wake()?;
drop(waker);
poll.poll(&mut events, Some(Duration::from_millis(200)))?;
println!("events after wake + drop: {}", events.iter().count());
Ok(())
}
macOS 上 3 次都是:
events after wake + drop: 1
Linux 容器里 3 次都是:
events after wake + drop: 0
macOS 的 Waker 持有的是 kqueue fd 的一个副本,EVFILT_USER 事件挂在 kqueue 上,关掉副本不影响它;Linux 的事件挂在 eventfd 上,eventfd 一关就没了。原因在 Waker 一章展开。这也是本卷坚持两个平台都跑的原因。
阅读路线
- 只想会用:读 01 全景、02 核心类型、04 网络类型,以及常见坑。读完能写一个正确的 mio 事件循环:知道
Token由自己分配、Events的容量是上限、边沿触发下必须读到WouldBlock、一个Poll只能有一个Waker。 - 想读懂实现:按顺序读。02 是重点,讲平台无关的那一层怎么把调用转给
sys::Selector;03 分平台往下走,epoll 最薄,kqueue 要处理一个 fd 两个过滤器,poll(2)后备和 Windows 要在用户态模拟边沿触发;05 把各章的取舍汇总。
前置知识
这一卷要求会写 Rust,知道文件描述符和 read / write 系统调用。下面几样东西在正文里反复出现:
| 概念 | 讲它的页 | 本卷用到它的地方 |
|---|---|---|
非阻塞 I/O 与 WouldBlock | 从 TcpStream::read 到挂起 | mio 的所有 socket 都是非阻塞的;事件主线的最后一步是读到 WouldBlock |
| 就绪事件怎么变成任务唤醒 | 反应器 | tokio 每轮调一次 Poll::poll,按 token 分派;本卷讲这一调用的下面 |
| 就绪位与边沿触发下的清位 | ScheduledIo 与就绪状态 | tokio 自己记就绪位,因为 mio 的边沿触发事件只来一次 |
| 边沿触发与水平触发 | mio 解决什么问题 | epoll 的 EPOLLET、kqueue 的 EV_CLEAR;poll(2) 是水平触发,要模拟 |
cfg 与 Cargo feature | — | 平台选择全靠 #[cfg_attr(..., path = "...")],见源码地图 |
实验环境
关于行为的说法(用了哪个标志、调了几次 epoll_ctl、返回几个事件、报什么错)都配一个小程序和它的真实输出。每个示例上方写出依赖,版本用 = 锁死,只开需要的 feature。
macOS(kqueue):M2 Max、macOS、rustc 1.99.0,直接
cargo run --release。看 kqueue 的系统调用要用dtruss,它需要 root,本卷不用;macOS 上的系统调用细节只引源码,行为用程序输出证明。Linux(epoll):在 macOS 上交叉编译成 aarch64 musl 静态二进制,用 rust-lld 链接,不需要装 Linux 工具链:
RUSTFLAGS="-C linker=rust-lld -C linker-flavor=ld.lld" \ cargo build --release --target aarch64-unknown-linux-musl --bin mainlines再把二进制挂进 Apple
container里一个装了 strace 的 Alpine 镜像运行,内核 6.12.28:container run --rm -v ./bin:/w mio-strace:latest \ strace -f -e trace=epoll_create1,epoll_ctl,epoll_pwait,eventfd2,accept4,recvfrom,sendto,setsockopt,read,write /w/mainlinesLinux 上的系统调用说法一律以 strace 的真实输出为准。aarch64 Linux 没有
epoll_wait、recv、send这几个系统调用:epoll_wait在 strace 里显示为epoll_pwait(..., NULL, 8),std 的 socket 读写显示为recvfrom/sendto,跟踪列表要按这个写。页面上标作「Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0」。网络只用
127.0.0.1和 0 号端口,不连外网。和时序有关的实验至少跑 3 次。
本卷的约定
- 每页开头的「主线位置」说明本页在注册主线、事件主线上的哪一步,还是两条都不在(比如源码地图)。
- 摘自 mio 的代码片段一律保留原来的格式(4 空格缩进),删掉的行用
// ...标出;本站自己写的示例用 2 空格缩进。 - 「平台」默认指
target_os。说「kqueue 平台」时指 DragonFly、FreeBSD、NetBSD、OpenBSD 和 Apple 各系统;哪个平台用哪个实现,以源码地图里那张从sys/unix/mod.rs读出来的表为准。 - 报错和 panic 信息照源码和实际输出原样引用,不翻译。
目录
| 部分 | 内容 |
|---|---|
| 00 导读 | 本页;源码地图 |
| 01 全景 | mio 解决什么问题;一个事件循环 |
| 02 核心类型 | 本卷重点:Poll 与 Registry、Token / Interest / Event、Source 与 IoSource、Waker |
| 03 平台实现 | epoll、kqueue、poll(2) 后备、Windows 的 IOCP 与 AFD |
| 04 网络类型 | TCP;UDP 与 Unix 域套接字 |
| 05 设计取舍 | 把各章的取舍汇总到一起 |
| 06 常见坑 | 用报错原文或现象做标题,每个都有最小复现 |
| 07 在本站中 | 本站经由 tokio 用 mio;Linux 与 macOS 的系统调用对照;1.2.1 与 1.2.4 的差异对本站的影响 |
| 08 动手 | 不用 mio,从零写一个只支持 epoll 的迷你 mio |
小结
- 全卷对着
v1.2.4(79d1417,2026-10-03)读;本地 master 上还有 14 个没发版的提交,其中 #2027 补上了三个 BSD 上 waker 事件的is_readable。本站锁的是 1.2.1。 - 1.2.1 到 1.2.4 改
src/的 20 个提交里,没有一个改变 Linux 上的行为;macOS 上Waker去掉了创建时的 setup,wake改成一次EV_ADD | EV_ONESHOT的kevent(#1987),实测事件数和 1.2.1 一样;另外 kqueue 事件的Debug输出少了NOTE_PCTRLMASK(#2016)。 - 注册主线:
bind得到非阻塞 socket →Registry::register→Source::register→ debug 下SelectorId检查 → Linux 一次epoll_ctl(ADD, EPOLLIN|EPOLLRDHUP|EPOLLET, data=token),macOS 一次kevent提交读、写过滤器(EV_CLEAR)。 - 事件主线:
Poll::poll→Selector::select→epoll_wait(aarch64 上落成epoll_pwait)或kevent直接写进Events的Vec→Event解释标志位 → 非阻塞 I/O 到WouldBlock。Waker在 Linux 上是往 eventfd 写一个 1。 - 同一个程序在 macOS 和 Linux 上可能不同:
wake之后马上 dropWaker,macOS 上事件还在,Linux 上没了。