Rust in Everything
基于 mio 1.2.4

怎么读这一卷

主线位置:两条主线的总览。本页把注册主线和事件主线各画一遍,标出每一步落在哪个函数、哪个系统调用上,再用一个最小程序在 Linux 容器里跑 strace 对照;下一部分的「一个事件循环」用一个完整的 echo 服务器把两条线反复走。

flowchart TB
  start[从这里开始] --> q{读这一卷为了什么}
  q -->|会用| use1[01 全景]
  use1 --> use2[02 核心类型]
  use2 --> use3[04 网络类型]
  use3 --> use4[06 常见坑]
  q -->|读懂实现| all[00 到 08 按顺序]

锁定版本

项值
cratemio 1.2.4
tagv1.2.4
commit79d1417(2026-10-03,「Release v1.2.4 (#2017)」)
MSRV1.71(Cargo.toml 的 rust-version,README 写明 1.1 和 1.2 都是 1.71)
依赖log(可选,默认开);Unix 上 libc,Windows 上 windows-sys 0.61,WASI 上 wasi。见源码地图
对照的 tokio1.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改了什么生效的平台LinuxmacOS
1.2.2#1966poll.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 portsSolaris否否
1.2.3#1976两处注释的拼写—只有注释只有注释
1.2.3#1977sys/unix/tcp.rs 的 accept 在 WASI 上改用 fcntl 设非阻塞;删掉 udp.rs 文档测试里跳过 WASI 的一行WASI否否
1.2.3#1978UDP、UDS 的文档测试在 Miri 下标成 ignore只影响 cargo test --doc否否
1.2.3#1975macros.rs 的 log! 宏里 log::$level!(...) 后面加一个分号,消掉 semicolon_in_expressions_from_macros 警告所有平台编译产物不变编译产物不变
1.2.3#1985删掉 Poll::new 文档里关于「老 Linux 没有 epoll_create1」的说明—只有文档只有文档
1.2.3#1987、#1988kqueue 的 Waker 不再在创建时做 setup,wake 改用 EV_ADD | EV_ONESHOT;waker/kqueue.rs 并入 waker/selector.rsFreeBSD、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#1998UnixListener::accept 改成经过 IoSource::do_io所有 Unix;只在 poll(2) 和 event ports 上有实际效果代码路径变了,行为不变同左
1.2.4#2014Windows named pipe:提交 I/O 后立刻失败时的 use-after-freeWindows否否
1.2.4#2016kqueue Event 的 Debug 输出里删掉 NOTE_PCTRLMASK 一项所有 kqueue 平台否只有 Debug 输出
1.2.4#2015Haiku 创建 socket 时没有设非阻塞,补上 fcntlHaiku否否

结论: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 提交过滤器"]
  1. TcpListener::bind 建 socket、设 SO_REUSEADDR、bind、listen(128)。Linux 上 new_socket 在 socket(2) 的类型里直接带上 SOCK_NONBLOCK | SOCK_CLOEXEC;macOS 没有这两个标志,事后用 fcntl 设,另外像标准库一样设 SO_NOSIGPIPE。得到的 std TcpListener 放在 IoSource<T> 里(TcpListener 只有一个 inner: IoSource<net::TcpListener> 字段)。见 TCP。
  2. Registry::register 只打一行 trace 日志,然后调 source.register(self, token, interests)。source 是任何实现了 event::Source 的类型,register、reregister、deregister 三个方法;参数要 &mut S。见 Poll 与 Registry。
  3. TcpListener 的 Source 实现转给 IoSource。Unix 上 IoSource 的 Source 实现在 debug 构建下先调 SelectorId::associate:同一个 source 已经注册过就返回 AlreadyExists 错误「I/O source already registered with a Registry」,release 构建下这段检查不存在。然后取出 fd,交给 IoSourceState::register;epoll 和 kqueue 上它不保存任何状态,直接转给 Selector::register。见 Source 与 IoSource。
  4. Linux:Selector::register 用 interests_to_epoll 算出标志:总是带 EPOLLET,关心读加 EPOLLIN | EPOLLRDHUP,关心写加 EPOLLOUT;Token 的 usize 转成 u64 放进 epoll_event 的 data;一次 epoll_ctl(EPOLL_CTL_ADD)。见 epoll。
  5. 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
  1. Poll::poll 只有一行:self.registry.selector.select(events.sys(), timeout)。timeout 为 None 时一直等,Some(0) 立即返回。见 Poll 与 Registry。
  2. 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。
  3. Events 只包着一个 sys::Events,Linux 上就是 Vec<libc::epoll_event>。容量在 Events::with_capacity 时定下,一次 poll 最多拿这么多项,剩下的留给下一次。
  4. Event 是 #[repr(transparent)] 包着平台的事件结构。token()、is_readable() 这些方法转给平台的 sys::event 模块,比如 epoll 上的 token 就是把 data 转回 Token,is_readable 看 EPOLLIN 等位。见 Token、Interest 与 Event。
  5. 用户对 source 做非阻塞 I/O:accept、read、write 直到返回 WouldBlock。注册时带了 EPOLLET / EV_CLEAR,是边沿触发:没读干净就回去 poll,不会再收到这个 fd 的通知。见 一个事件循环。
  6. 旁路:Waker::new 在 debug 构建下检查这个 Poll 还没有别的 Waker(register_waker 里的 assert!,消息是「Only a single Waker can be active per Poll instance」)。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
注册 socketRegistration 调 Registry::register,token 是 ScheduledIo 的地址注册主线 2 到 5
一轮 IOturn 里的 self.poll.poll(events, max_wait)事件主线 1 到 4
读到 WouldBlockPollEvented::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/mainlines
    

    Linux 上的系统调用说法一律以 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 之后马上 drop Waker,macOS 上事件还在,Linux 上没了。

💬 关联讨论

发起讨论
加载中...