Rust in Everything
基于 mio 1.2.4

Poll 与 Registry

主线位置:两条主线的入口。注册主线从 Registry::register 出发,事件主线从 Poll::poll 出发,两者在本页都只走一两步就到了平台 Selector。register 之后经过的 event::Source / IoSource 在 Source 与 IoSource,poll 取回的 Events 怎么解读在 Token、Interest 与 Event,Selector 内部在 epoll 和 kqueue。

flowchart TB
  user[用户代码] -->|"poll 独占借用"| pollnode[Poll]
  user -->|"register 共享借用"| reg[Registry]
  pollnode --> reg
  clone["try_clone 出的 Registry"] --> sel2["Selector(dup 的 fd)"]
  reg --> sel[Selector]
  sel --> kern["内核 epoll / kqueue 实例"]
  sel2 --> kern
  kern -->|"epoll_wait / kevent"| pollnode

这一页的代码几乎都在 src/poll.rs(814 行,其中大半是文档),加上两个 selector 的 new、select、try_clone。

结构:三层,一个 fd

Poll 和 Registry 的全部字段:

pub struct Poll {
    registry: Registry,
}

/// Registers I/O resources.
pub struct Registry {
    selector: sys::Selector,
    /// Whether this selector currently has an associated waker.
    #[cfg(all(debug_assertions, not(any(target_os = "wasi", target_os = "horizon"))))]
    has_waker: Arc<AtomicBool>,
}

sys::Selector 是按平台选出来的实现(选择规则见源码地图)。Linux 上的 epoll Selector 和 macOS 上的 kqueue Selector 都只有一个 fd,外加一个 debug 下才有的 id:

#[derive(Debug)]
pub struct Selector {
    #[cfg(debug_assertions)]
    id: usize,
    ep: OwnedFd,
}

所以一个 Poll 在 release 下就是一个 OwnedFd,三层包装都没有额外状态。id 来自全局计数器 NEXT_ID,给 IoSource 在 debug 下认「这个 source 注册在哪个 selector 上」用(见 Source 与 IoSource);has_waker 给 Waker 检查「一个 Poll 只有一个 Waker」用。两个都只在 debug_assertions 下存在。

Poll::new 只是 sys::Selector::new() 再包两层。epoll 版是一次 epoll_create1(libc::EPOLL_CLOEXEC);kqueue 版是 kqueue() 之后再 fcntl(F_SETFD, FD_CLOEXEC),因为 kqueue() 本身不接受 flag 参数。

两个方法签名决定了怎么用:

方法接收者含义
Poll::registry&self借出 &Registry
Poll::poll&mut self等事件,同一时刻只能有一个调用者
Registry::register / reregister / deregister&self改关注集合
Registry::try_clone&self得到一个独立拥有的 Registry

Poll、Registry 都没有手写 Send / Sync,全靠字段自动推出来。实验 crate 里一个只做类型检查的程序(fn check<T: Send + Sync>() 依次套在 Poll、Registry、Events、Event、Token、Interest 上)在两个平台上都能编译运行:

macos: Poll, Registry, Events, Event, Token, Interest are Send + Sync
linux: Poll, Registry, Events, Event, Token, Interest are Send + Sync

在 Linux、macOS 这类有 epoll / kqueue 的 Unix 上(cfg 排除了 aix、haiku、hurd 等十来个平台),Poll 实现 AsRawFd,Registry 实现 AsFd 和 AsRawFd,拿到的就是这个 epoll / kqueue fd,可以把它再注册到另一个事件循环里。

poll:一次 select

Poll::poll 只有一行:

    pub fn poll(&mut self, events: &mut Events, timeout: Option<Duration>) -> io::Result<()> {
        self.registry.selector.select(events.sys(), timeout)
    }

没有循环、没有重试、没有统计,返回值也只是 (),事件数看 events 本身。平台无关层到这里就结束了。

epoll:Duration 换成毫秒的 c_int

epoll 的 select:

    pub fn select(&self, events: &mut Events, timeout: Option<Duration>) -> io::Result<()> {
        let timeout = timeout
            .map(|to| {
                // `Duration::as_millis` truncates, so round up. This avoids
                // turning sub-millisecond timeouts into a zero timeout, unless
                // the caller explicitly requests that by specifying a zero
                // timeout.
                libc::c_int::try_from(
                    to.checked_add(Duration::from_nanos(999_999))
                        .unwrap_or(to)
                        .as_millis(),
                )
                .unwrap_or(libc::c_int::MAX)
            })
            .unwrap_or(-1);

        events.clear();
        syscall!(epoll_wait(
            self.ep.as_raw_fd(),
            events.as_mut_ptr(),
            events.capacity() as i32,
            timeout,
        ))
        .map(|n_events| {
            // This is safe because `epoll_wait` ensures that `n_events` are
            // assigned.
            unsafe { events.set_len(n_events as usize) };
        })
    }

epoll_wait 的超时参数是毫秒数的 int,-1 表示一直等。换算分三步:

  • None 变成 -1。
  • Some(to) 先加 999 999 纳秒再 as_millis(),也就是向上取整到毫秒。as_millis 本身是截断的,不加这一下,Duration::from_micros(500) 会变成 0,poll 就从「最多等半毫秒」变成了「立刻返回」,调用方会忙转。Duration::ZERO 加上 999 999 纳秒仍不到 1 毫秒,还是 0,所以显式的零超时不受影响,这正是注释里说的。checked_add 只在 to 接近 Duration::MAX 时失败,此时退回不加。
  • as_millis() 返回 u128,用 c_int::try_from 收窄,放不下就取 c_int::MAX,即 2 147 483 647 毫秒,约 24.86 天。

Events 内部就是 Vec<libc::epoll_event>:先 clear(),把容量当作 maxevents 交给内核,内核写了几项就 set_len 几项。Events 的容量为什么决定一次能拿多少,见 Token、Interest 与 Event。

kqueue:timespec,不会溢出

kqueue 的 select 把超时转成 timespec:

        let timeout = timeout.map(|to| libc::timespec {
            tv_sec: cmp::min(to.as_secs(), libc::time_t::MAX as u64) as libc::time_t,
            // `Duration::subsec_nanos` is guaranteed to be less than one
            // billion (the number of nanoseconds in a second), making the
            // cast to i32 safe. The cast itself is needed for platforms
            // where C's long is only 32 bits.
            tv_nsec: libc::c_long::from(to.subsec_nanos() as i32),
        });
        let timeout = timeout
            .as_ref()
            .map(|s| s as *const _)
            .unwrap_or(ptr::null_mut());

None 是空指针(一直等),秒数用 cmp::min 夹在 time_t::MAX 以内,纳秒原样传。这里没有取整问题,也没有 24.8 天的问题。之后同样是 events.clear()、一次 kevent(kq, NULL, 0, events, capacity, timeout)、set_len。changelist 为空,select 只取事件不改注册。

实验:几种 timeout 到了内核是多少

src/bin/timeout.rs 先把 1.2.0 及以前的写法(as libc::c_int,见下面的冷知识)和现在的写法对着算一遍,再在 Linux 上真的 poll 几次,用 strace 看 epoll_wait 收到的超时。超时长的两次靠另一个线程 100 ms 后 Waker::wake 叫醒:

use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use mio::{Events, Poll, Token, Waker};

// mio 1.0.0 ..= 1.2.0: `as` cast, keeps the low 32 bits
fn old_ms(to: Duration) -> i32 {
  to.checked_add(Duration::from_nanos(999_999)).unwrap_or(to).as_millis() as i32
}

// mio 1.2.1+: try_from, clamp to c_int::MAX
fn new_ms(to: Duration) -> i32 {
  i32::try_from(to.checked_add(Duration::from_nanos(999_999)).unwrap_or(to).as_millis()).unwrap_or(i32::MAX)
}

fn main() -> std::io::Result<()> {
  let day = 86_400;
  let cases = [
    ("ZERO", Duration::ZERO),
    ("1ns", Duration::from_nanos(1)),
    ("1.5ms", Duration::from_micros(1500)),
    ("2^31-1 ms", Duration::from_millis(i32::MAX as u64)),
    ("2^31 ms", Duration::from_millis(1 << 31)),
    ("30 days", Duration::from_secs(30 * day)),
    ("2^32+5 ms", Duration::from_millis((1 << 32) + 5)),
    ("Duration::MAX", Duration::MAX),
  ];
  println!("{:<14} {:>12} {:>12}", "timeout", "old(<=1.2.0)", "new(1.2.1+)");
  for (name, d) in cases {
    println!("{:<14} {:>12} {:>12}", name, old_ms(d), new_ms(d));
  }

  let mut poll = Poll::new()?;
  let waker = Arc::new(Waker::new(poll.registry(), Token(0))?);
  let mut events = Events::with_capacity(8);
  for (name, to) in [
    ("Some(ZERO)", Some(Duration::ZERO)),
    ("Some(1ns)", Some(Duration::from_nanos(1))),
    ("Some(1.5ms)", Some(Duration::from_micros(1500))),
    ("Some(30 days)", Some(Duration::from_secs(30 * day))),
    ("None", None),
  ] {
    let long = to.map_or(true, |t| t > Duration::from_secs(1));
    if long {
      let w = waker.clone();
      thread::spawn(move || {
        thread::sleep(Duration::from_millis(100));
        w.wake().unwrap();
      });
    }
    let start = Instant::now();
    poll.poll(&mut events, to)?;
    println!("poll({name}) -> {} events after {:?}", events.iter().count(), start.elapsed().as_millis());
  }
  Ok(())
}

Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0,strace -f -e trace=epoll_pwait:

timeout        old(<=1.2.0)  new(1.2.1+)
ZERO                      0            0
1ns                       1            1
1.5ms                     2            2
2^31-1 ms        2147483647   2147483647
2^31 ms         -2147483648   2147483647
30 days         -1702967296   2147483647
2^32+5 ms                 5   2147483647
Duration::MAX            -1   2147483647
epoll_pwait(3, [], 8, 0, NULL, 8)       = 0
poll(Some(ZERO)) -> 0 events after 0
epoll_pwait(3, [], 8, 1, NULL, 8)       = 0
poll(Some(1ns)) -> 0 events after 1
epoll_pwait(3, [], 8, 2, NULL, 8)       = 0
poll(Some(1.5ms)) -> 0 events after 2
strace: Process 4 attached
[pid     3] epoll_pwait(3, [{events=EPOLLIN, data=0}], 8, 2147483647, NULL, 8) = 1
poll(Some(30 days)) -> 1 events after 110
[pid     4] +++ exited with 0 +++
strace: Process 5 attached
[pid     3] epoll_pwait(3, [{events=EPOLLIN, data=0}], 8, -1, NULL, 8) = 1
poll(None) -> 1 events after 110
[pid     5] +++ exited with 0 +++
+++ exited with 0 +++
  • 第四个参数就是换算后的超时:ZERO → 0,1 纳秒 → 1,1.5 毫秒 → 2,30 天 → 2147483647,None → -1。
  • 代码里调的是 libc::epoll_wait,strace 看到的是 epoll_pwait,信号掩码参数为 NULL,效果相同:aarch64 Linux 没有 epoll_wait 这个系统调用,musl 用 epoll_pwait 实现它,见 epoll。
  • 表格的 old 一列:超过 i32::MAX 毫秒以后,as i32 只留低 32 位。2^31 毫秒和 30 天变成负数,epoll_wait 把负数当成「一直等」;2^32+5 毫秒变成 5,本来想等 49 天,5 毫秒就返回了。Duration::MAX 的低 32 位恰好是 -1,碰巧是对的。
📝NOTE
冷知识:24.8 天的截断,是删一段「过时的兼容代码」时带进来的

1.2.1 的 CHANGELOG 有一条「Fixed a casting issue when polling with a timeout larger than ~24.8 days on Linux」,对应 PR #1948(「Clamp epoll timeout to c_int::MAX to prevent truncation」)。提交说明:「Duration::as_millis() returns u128, which was cast directly to libc::c_int (i32). Timeouts exceeding ~24.8 days would silently truncate to a negative value, causing epoll_wait to block indefinitely. The kqueue, poll(2), and IOCP backends already clamp correctly; this brings epoll in line.」PR 说明补了一句来历:「This issue has been introduced by #1741, which missed the fact that c_int is smaller than u128.」

PR #1741(「Remove outdated work arounds」,首次发布于 1.0.0)里的提交「Remove timeout work around」删掉了一个 MAX_SAFE_TIMEOUT 常量,理由是「Only affected Linux kernels < 2.6.37, which Rust doesn't support any more.」那个常量在 32 位上是 libuv 用的 1789569,64 位上是 c_int::max_value(),cmp::min(MAX_SAFE_TIMEOUT, to_ms) as libc::c_int 顺带保证了不溢出。删掉之后只剩 as libc::c_int,于是 1.0.0 到 1.2.0 都有这个问题。现在的写法是 c_int::try_from(...).unwrap_or(libc::c_int::MAX)。poll(2) 后备实现(sys/unix/selector/poll.rs 的 poll 函数)里至今还留着同样的 MAX_SAFE_TIMEOUT 和那段 2.6.37 的注释,所以它从来没有这个问题。

poll 的文档对超时有两句保留:超时会「rounded up to the system clock granularity (usually 1ms)」,而超时到了、没有事件时返回 Ok(()),「However we're not guaranteeing this behaviour as this depends on the OS.」调用方应该看 events.is_empty(),不要假设返回就说明有事件。

EINTR:原样交给调用方

两个 select 都用同一个 syscall! 宏:

macro_rules! syscall {
    ($fn: ident ( $($arg: expr),* $(,)* ) ) => {{
        #[allow(unused_unsafe)]
        let res = unsafe { libc::$fn($($arg, )*) };
        if res < 0 {
            Err(std::io::Error::last_os_error())
        } else {
            Ok(res)
        }
    }};
}

返回值小于 0 就把 errno 包成 io::Error,没有对 EINTR 的特殊处理;select 和 Poll::poll 也没有。所以等待中的线程收到一个信号,poll 就返回 Err,kind() 是 ErrorKind::Interrupted。poll 文档的 Notes 里写明了:「This returns any errors without attempting to retry, previous versions of Mio would automatically retry the poll call if it was interrupted (if EINTR was returned).」

整个 Unix 部分只有三处提到中断,都不在 select 上:

  • kqueue 的 kevent_register 把 register / reregister / deregister 时的 EINTR 当成功,注释引 FreeBSD 手册:「When kevent() call fails with EINTR error, all changes in the changelist have been applied」。这是改注册,不是等事件。
  • pipe 版 Waker 的 wake 遇到 Interrupted 就再写一次(见 Waker)。
  • poll(2) 后备实现的 poll 函数遇到 EAGAIN 重试,EINTR 照样返回。
📝NOTE
冷知识:poll_interruptible 曾经是另一个方法

0.6 时代的 Poll::poll 遇到 EINTR 会自己重试:记下开始时间,被打断就用剩余时间再调一次,直到超时;不想重试的人用另一个方法 poll_interruptible。PR #1056(「Remove poll interruptible」,首次发布于 0.7.0)的提交说明是「Poll::poll is changed to not retry the system call if is its interrupted. Removes Poll::poll_interruptible as Poll::poll now does this.」PR 说明只有一句「And return errors directly in Poll::poll.」删掉的是一个 poll2(events, timeout, interruptible) 循环,同一个 PR 也把返回值从 io::Result<usize>(事件数)改成了今天的 io::Result<()>,并在文档里加上了上面引的那段 Notes。

重试交给了上层。tokio 的 I/O 驱动在 turn 里把 Interrupted 当成「这一轮没有事件」,其他错误直接 panic(见 tokio 卷的反应器):

        match self.poll.poll(events, max_wait) {
            Ok(()) => {}
            Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {}

它也不补剩余的超时:被打断的这一轮直接结束,外层的调度循环下一次再算 max_wait。

❗IMPORTANT
动脑筋:装了 SA_RESTART 的信号处理函数,能让 poll 自动重来吗?

SA_RESTART 让被信号打断的 read、write 这类系统调用在处理函数返回后自动重启。下面的程序先不带、再带 SA_RESTART 给 SIGUSR1 装一个空的处理函数,让一个线程 poll 一个空的 Poll(超时 2 秒),200 ms 后用 pthread_kill 给它发 SIGUSR1,打印 poll 的结果和耗时。在 M2 Max、macOS、rustc 1.99.0 和 Linux 6.12.28(容器内 Alpine,aarch64 musl)上各跑一次,--release。

use std::os::unix::thread::JoinHandleExt;
use std::thread;
use std::time::{Duration, Instant};

use mio::{Events, Poll};

extern "C" fn on_signal(_: libc::c_int) {}

fn install(flags: libc::c_int) {
  unsafe {
    let mut sa: libc::sigaction = std::mem::zeroed();
    sa.sa_sigaction = on_signal as *const () as usize;
    sa.sa_flags = flags;
    libc::sigemptyset(&mut sa.sa_mask);
    assert_eq!(libc::sigaction(libc::SIGUSR1, &sa, std::ptr::null_mut()), 0);
  }
}

fn main() -> std::io::Result<()> {
  for (name, flags) in [("no SA_RESTART", 0), ("SA_RESTART", libc::SA_RESTART)] {
    install(flags);
    let poller = thread::spawn(|| {
      let mut poll = Poll::new().unwrap();
      let mut events = Events::with_capacity(8);
      let start = Instant::now();
      let res = poll.poll(&mut events, Some(Duration::from_secs(2)));
      (res, start.elapsed())
    });
    thread::sleep(Duration::from_millis(200));
    unsafe { libc::pthread_kill(poller.as_pthread_t() as _, libc::SIGUSR1) };
    let (res, elapsed) = poller.join().unwrap();
    match res {
      Ok(()) => println!("{} {name:<14}: Ok(()) after {}ms", std::env::consts::OS, elapsed.as_millis()),
      Err(e) => println!("{} {name:<14}: Err kind={:?} raw={:?} \"{e}\" after {}ms", std::env::consts::OS, e.kind(), e.raw_os_error(), elapsed.as_millis()),
    }
  }
  Ok(())
}

四行输出里,带 SA_RESTART 的两行,耗时是 200 ms 左右还是 2000 ms 左右?

try_clone:在别的线程注册

poll 要 &mut self,registry() 借出的是 &self 的一部分,两者不能同时活着。想让一个线程阻塞在 poll 里、另一个线程注册,直接借用编译不过(src/bin/borrow.rs,thread::scope 里一个线程拿 poll.registry() 注册,外面调 poll.poll):

error[E0502]: cannot borrow `poll` as mutable because it is also borrowed as immutable
  --> src/bin/borrow.rs:10:17
   |
 9 |   let registry = poll.registry(); // &Registry borrowed from poll
   |                  ---- immutable borrow occurs here
10 |   thread::scope(|s| {
   |                 ^^^ mutable borrow occurs here
...
13 |       registry.register(&mut l, Token(1), Interest::READABLE).unwrap();
   |       -------- immutable borrow later captured here by closure
14 |     });
15 |     poll.poll(&mut events, None).unwrap();
   |     ---- second borrow occurs due to use of `poll` in closure

For more information about this error, try `rustc --explain E0502`.
error: could not compile `exp-mio-poll` (bin "borrow") due to 1 previous error

Registry 本身是 Sync 的,问题只在借用关系。解法是 Registry::try_clone,得到一个不借 Poll 的、独立拥有的 Registry:

    pub fn try_clone(&self) -> io::Result<Registry> {
        self.selector.try_clone().map(|selector| Registry {
            selector,
            #[cfg(all(debug_assertions, not(any(target_os = "wasi", target_os = "horizon"))))]
            has_waker: Arc::clone(&self.has_waker),
        })
    }

epoll 的 Selector::try_clone(kqueue 版逐字相同,只是字段叫 kq):

    pub fn try_clone(&self) -> io::Result<Selector> {
        self.ep.try_clone().map(|ep| Selector {
            // It's the same selector, so we use the same id.
            #[cfg(debug_assertions)]
            id: self.id,
            ep,
        })
    }

OwnedFd::try_clone 复制的是 fd,不是内核对象:新 fd 和原来的 fd 指向同一个 epoll / kqueue 实例,在哪个上面注册,等在另一个上面的 poll 都能看到。id 原样复制,因为「It's the same selector」,IoSource 的检查会把两者当成同一个 Registry;has_waker 是 Arc 共享的,从克隆出来的 Registry 建 Waker 也算在同一个 Poll 头上。

实验:一个线程在 poll(None),另一个线程注册

src/bin/cross_thread.rs:主线程 poll(None),不设超时;辅助线程 200 ms 后用克隆的 Registry 注册一个新的 TcpListener,再过 200 ms 去连它。

use std::os::fd::AsRawFd;
use std::thread;
use std::time::{Duration, Instant};

use mio::net::TcpListener;
use mio::{Events, Interest, Poll, Token};

fn main() -> std::io::Result<()> {
  let mut poll = Poll::new()?;
  let registry = poll.registry().try_clone()?;
  println!("poll fd = {}, cloned registry fd = {}", poll.as_raw_fd(), registry.as_raw_fd());

  let start = Instant::now();
  let helper = thread::spawn(move || -> std::io::Result<()> {
    thread::sleep(Duration::from_millis(200));
    let mut listener = TcpListener::bind("127.0.0.1:0".parse().unwrap())?;
    // register while the main thread is blocked in poll(None)
    registry.register(&mut listener, Token(7), Interest::READABLE)?;
    println!("[{:>3}ms] helper: registered listener fd {} as Token(7)", start.elapsed().as_millis(), listener.as_raw_fd());
    thread::sleep(Duration::from_millis(200));
    let _client = std::net::TcpStream::connect(listener.local_addr()?)?;
    println!("[{:>3}ms] helper: connected", start.elapsed().as_millis());
    thread::sleep(Duration::from_millis(200));
    Ok(())
  });

  let mut events = Events::with_capacity(8);
  poll.poll(&mut events, None)?;
  for e in &events {
    println!("[{:>3}ms] main: {:?} readable={}", start.elapsed().as_millis(), e.token(), e.is_readable());
  }
  helper.join().unwrap()
}

M2 Max、macOS、rustc 1.99.0,--release,跑了 3 次,毫秒数在 418–421 之间,其余相同,这是第一次:

poll fd = 3, cloned registry fd = 4
[210ms] helper: registered listener fd 5 as Token(7)
[418ms] helper: connected
[418ms] main: Token(7) readable=true

Linux 上同样跑了 3 次,结果形状相同。加上 strace(-e trace=epoll_create1,epoll_ctl,epoll_pwait,fcntl,connect,accept4)的一次:

epoll_create1(EPOLL_CLOEXEC)            = 3
fcntl(3, F_DUPFD_CLOEXEC, 3)            = 4
fcntl(4, F_SETFD, FD_CLOEXEC)           = 0
poll fd = 3, cloned registry fd = 4
strace: Process 4 attached
[pid     3] epoll_pwait(3 <unfinished ...>
[pid     4] epoll_ctl(4, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=[209ms] helper: registered listener fd 5 as Token(7)
0x7}) = 0
[pid     4] connect(6, {sa_family=AF_INET, sin_port=htons(43701), sin_addr=inet_addr("127.0.0.1")}, 16 <unfinished ...>
[pid     3] <... epoll_pwait resumed>, [{events=EPOLLIN, data=0x7}], 8, -1, NULL, 8) = 1
[pid     4] <... connect resumed>)      = 0
[414ms] main: Token(7) readable=true
[414ms] helper: connected
[pid     4] +++ exited with 0 +++
+++ exited with 0 +++
  • try_clone 就是 fcntl(3, F_DUPFD_CLOEXEC, 3):std 的 try_clone_to_owned 只发这一次(最小 fd 号 3)。紧跟着的 F_SETFD 不是 std 或 mio 发的,是 musl 的 fcntl 包装在 F_DUPFD_CLOEXEC 之后补的一步。
  • 线程 4 对 fd 4 做 EPOLL_CTL_ADD 的时候,线程 3 正阻塞在 fd 3 的 epoll_pwait(…, -1) 里;连接到达后线程 3 带着 data=0x7 返回。两个 fd,一个 epoll 实例。
  • epoll_ctl 那一行被程序自己的输出切成了两半,strace 和程序共用 stderr / stdout 的终端,原样保留。

epoll 和 kqueue 本来就允许别的线程在等待期间改关注集合,所以这两个平台上 mio 什么都不用做。poll(2) 没有这种能力,后备实现为此要在 Selector 里放一把锁和一个唤醒用的 fd,见 poll(2) 后备。

tokio 的 I/O 驱动正是这么用的:Driver::new 里用 poll.registry().try_clone() 克隆出一个 Registry 放进所有线程共享的 Handle,Poll 自己留在 Driver 里给抢到驱动的那个 worker 用(反应器)。

register / reregister / deregister 的约定

三个方法都只是转给 source。Registry::register:

    pub fn register<S>(&self, source: &mut S, token: Token, interests: Interest) -> io::Result<()>
    where
        S: event::Source + ?Sized,
    {
        trace!(
            "registering event source with poller: token={:?}, interests={:?}",
            token,
            interests
        );
        source.register(self, token, interests)
    }

reregister 和 deregister 形状一样。反方向的调用(source 回头调 registry.selector().register(fd, …))和 IoSource 在中间做的 debug 检查,在 Source 与 IoSource。

文档写的约定

文档里和误用有关的话一共这几句(原文):

  • register:「Callers must ensure that if a source being registered with a Poll instance was previously registered with that Poll instance, then a call to deregister has already occurred. Consecutive calls to register is unspecified behavior.」以及「once an event source is registered with a Poll instance, it is bound to that Poll instance for the lifetime of the event source. This remains true even if the event source is deregistered」。
  • reregister:参数「fully override the previous values」;「The event source must have previously been registered with this instance of Poll, otherwise the behavior is unspecified.」
  • deregister:之后「it is guaranteed that the token previously registered to this handle will not be returned by a future poll, so long as a happens-before relationship is established between this call and the poll.」同样要求之前注册过,否则 unspecified;重新注册必须回到同一个 Poll。

「unspecified」意味着结果取决于平台。两个 selector 的做法:

操作epollkqueue
registerepoll_ctl(EPOLL_CTL_ADD)一次 kevent,每个关注的方向一项 EV_ADD | EV_CLEAR | EV_RECEIPT,忽略 EPIPE
reregisterepoll_ctl(EPOLL_CTL_MOD)读、写两项都提交:关注的 EV_ADD,不关注的 EV_DELETE,忽略 ENOENT、EPIPE
deregisterepoll_ctl(EPOLL_CTL_DEL)读、写两项都 EV_DELETE,忽略 ENOENT

kqueue 的 reregister 和 deregister 之所以忽略 ENOENT,注释说得很直白:mio 不记得一个 fd 当初注册了哪几个方向(「there is no way to check with which interests the fd was registered」),干脆两个都改,删不存在的那个报的 ENOENT 不算错。副作用是:对 kqueue 来说,删一个根本没注册过的 fd、删两次、对没注册过的 fd reregister(等于 EV_ADD),全都成功。EV_RECEIPT 怎么把每一项的错误单独带回来,见 kqueue。

在这之上,debug 构建里的 IoSource(mio::net 的所有类型都是)还有一层自己的检查,在系统调用之前就把一部分误用拦下来;SourceFd 不经过 IoSource,没有这层检查。

实验:每种误用在三种情况下的结果

src/bin/contracts.rs 对同一组误用跑两遍:一遍用 mio::net::TcpListener(经过 IoSource),一遍用 SourceFd 包一个 std TcpListener 的 fd(直接到 selector)。再用 SourceFd 看两种情况下内核里实际留下的是哪个 token,最后看「drop 之前没有 deregister」:

use std::io::{self, Write};
use std::os::fd::AsRawFd;
use std::time::Duration;

use mio::net::{TcpListener, TcpStream};
use mio::unix::SourceFd;
use mio::{Events, Interest, Poll, Token};

const R: Interest = Interest::READABLE;

fn show(name: &str, r: io::Result<()>) {
  match r {
    Ok(()) => println!("  {name:<32} Ok(())"),
    Err(e) => println!("  {name:<32} Err({e:?})"),
  }
}

fn listener() -> TcpListener {
  TcpListener::bind("127.0.0.1:0".parse().unwrap()).unwrap()
}

fn tokens(poll: &mut Poll) -> Vec<usize> {
  let mut events = Events::with_capacity(8);
  poll.poll(&mut events, Some(Duration::from_millis(100))).unwrap();
  events.iter().map(|e| e.token().0).collect()
}

fn contract_cases(label: &str, mk: &dyn Fn() -> Box<dyn FnMut(&mio::Registry, u8) -> io::Result<()>>) {
  // op: 0 = register(Token(1)), 1 = reregister(Token(1)), 2 = deregister
  println!("{label}:");
  let run = |ops: &[u8]| -> io::Result<()> {
    let poll = Poll::new()?;
    let mut src = mk();
    let n = ops.len();
    for &op in &ops[..n - 1] {
      src(poll.registry(), op)?;
    }
    src(poll.registry(), ops[n - 1])
  };
  show("register, register", run(&[0, 0]));
  show("reregister (never registered)", run(&[1]));
  show("register, deregister, deregister", run(&[0, 2, 2]));
  show("deregister (never registered)", run(&[2]));
  let two = || -> io::Result<()> {
    let (a, b) = (Poll::new()?, Poll::new()?);
    let mut src = mk();
    src(a.registry(), 0)?;
    src(b.registry(), 0)
  };
  show("register on Poll A, then Poll B", two());
}

fn op<S: mio::event::Source + ?Sized>(src: &mut S, reg: &mio::Registry, op: u8) -> io::Result<()> {
  match op {
    0 => reg.register(src, Token(1), R),
    1 => reg.reregister(src, Token(1), R),
    _ => reg.deregister(src),
  }
}

fn main() -> io::Result<()> {
  println!("os={} debug_assertions={}", std::env::consts::OS, cfg!(debug_assertions));
  contract_cases("IoSource (mio::net::TcpListener)", &|| {
    let mut l = listener();
    Box::new(move |reg, o| op(&mut l, reg, o))
  });
  contract_cases("SourceFd (raw fd of a std TcpListener)", &|| {
    let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
    Box::new(move |reg, o| op(&mut SourceFd(&l.as_raw_fd()), reg, o))
  });

  println!("kernel view, via SourceFd (no debug check):");
  for (name, second) in [("register T1, register T2", 0u8), ("reregister T1 only", 1)] {
    let poll_l = std::net::TcpListener::bind("127.0.0.1:0")?;
    let mut poll = Poll::new()?;
    let fd = poll_l.as_raw_fd();
    let r = if second == 0 {
      poll.registry().register(&mut SourceFd(&fd), Token(1), R)?;
      poll.registry().register(&mut SourceFd(&fd), Token(2), R)
    } else {
      poll.registry().reregister(&mut SourceFd(&fd), Token(1), R)
    };
    let _c = std::net::TcpStream::connect(poll_l.local_addr()?)?;
    println!("  {name:<32} {:?}, then connect -> tokens {:?}", r.map_err(|e| e.raw_os_error()), tokens(&mut poll));
  }

  println!("drop without deregister:");
  let l = std::net::TcpListener::bind("127.0.0.1:0")?;
  let accept = || -> io::Result<TcpStream> {
    let (s, _) = l.accept()?;
    s.set_nonblocking(true)?;
    Ok(TcpStream::from_std(s))
  };
  // 1. plain drop: the fd is closed
  let mut poll = Poll::new()?;
  let mut c = std::net::TcpStream::connect(l.local_addr()?)?;
  let mut s = accept()?;
  poll.registry().register(&mut s, Token(1), R)?;
  drop(s);
  let _ = c.write_all(b"x");
  println!("  1. dropped, peer writes            -> tokens {:?}", tokens(&mut poll));

  // 2. drop while a dup of the fd is still open
  let mut poll = Poll::new()?;
  let mut c = std::net::TcpStream::connect(l.local_addr()?)?;
  let std_s = std::net::TcpStream::from(accept()?);
  let dup = std_s.try_clone()?; // same open file description, another fd
  let mut s = TcpStream::from_std(std_s);
  let old_fd = s.as_raw_fd();
  poll.registry().register(&mut s, Token(1), R)?;
  let mut c2 = std::net::TcpStream::connect(l.local_addr()?)?; // take its fd now
  drop(s);
  c.write_all(b"x")?;
  println!("  2. dropped fd {old_fd}, dup fd {} alive, peer writes -> tokens {:?}", dup.as_raw_fd(), tokens(&mut poll));
  show("   deregister via the dup fd", poll.registry().deregister(&mut SourceFd(&dup.as_raw_fd())));

  // 3. a new socket reuses the old fd number
  let mut s2 = accept()?;
  println!("  3. new socket got fd {} (old fd was {old_fd})", s2.as_raw_fd());
  show("   register it as Token(2)", poll.registry().register(&mut s2, Token(2), R));
  c2.write_all(b"y")?;
  c.write_all(b"x")?;
  println!("     both peers write              -> tokens {:?}", tokens(&mut poll));

  // 4. close the dup as well (drain it first so close sends FIN, not RST)
  let mut buf = [0u8; 16];
  let n = io::Read::read(&mut &dup, &mut buf)?;
  drop(dup);
  c.write_all(b"x")?;
  println!("  4. read {n} bytes, closed the dup, peer writes -> tokens {:?}", tokens(&mut poll));
  Ok(())
}

两个平台各跑 debug 和 --release,每种组合跑 3 次,输出逐字节相同。Linux(容器内 Alpine,aarch64 musl),debug:

os=linux debug_assertions=true
IoSource (mio::net::TcpListener):
  register, register               Err(Custom { kind: AlreadyExists, error: "I/O source already registered with a `Registry`" })
  reregister (never registered)    Err(Custom { kind: NotFound, error: "I/O source not registered with `Registry`" })
  register, deregister, deregister Err(Custom { kind: NotFound, error: "I/O source not registered with `Registry`" })
  deregister (never registered)    Err(Custom { kind: NotFound, error: "I/O source not registered with `Registry`" })
  register on Poll A, then Poll B  Err(Custom { kind: AlreadyExists, error: "I/O source already registered with a `Registry`" })
SourceFd (raw fd of a std TcpListener):
  register, register               Err(Os { code: 17, kind: AlreadyExists, message: "File exists" })
  reregister (never registered)    Err(Os { code: 2, kind: NotFound, message: "No such file or directory" })
  register, deregister, deregister Err(Os { code: 2, kind: NotFound, message: "No such file or directory" })
  deregister (never registered)    Err(Os { code: 2, kind: NotFound, message: "No such file or directory" })
  register on Poll A, then Poll B  Ok(())
kernel view, via SourceFd (no debug check):
  register T1, register T2         Err(Some(17)), then connect -> tokens [1]
  reregister T1 only               Err(Some(2)), then connect -> tokens []
drop without deregister:
  1. dropped, peer writes            -> tokens []
  2. dropped fd 8, dup fd 9 alive, peer writes -> tokens [1]
     deregister via the dup fd     Err(Os { code: 2, kind: NotFound, message: "No such file or directory" })
  3. new socket got fd 8 (old fd was 8)
     register it as Token(2)       Ok(())
     both peers write              -> tokens [2, 1]
  4. read 2 bytes, closed the dup, peer writes -> tokens []

Linux --release 只有 IoSource 一段不同,变得和 SourceFd 一段一模一样:

os=linux debug_assertions=false
IoSource (mio::net::TcpListener):
  register, register               Err(Os { code: 17, kind: AlreadyExists, message: "File exists" })
  reregister (never registered)    Err(Os { code: 2, kind: NotFound, message: "No such file or directory" })
  register, deregister, deregister Err(Os { code: 2, kind: NotFound, message: "No such file or directory" })
  deregister (never registered)    Err(Os { code: 2, kind: NotFound, message: "No such file or directory" })
  register on Poll A, then Poll B  Ok(())

(后面三段和 debug 相同,不重复贴。)M2 Max、macOS、rustc 1.99.0,--release:

os=macos debug_assertions=false
IoSource (mio::net::TcpListener):
  register, register               Ok(())
  reregister (never registered)    Ok(())
  register, deregister, deregister Ok(())
  deregister (never registered)    Ok(())
  register on Poll A, then Poll B  Ok(())
SourceFd (raw fd of a std TcpListener):
  register, register               Ok(())
  reregister (never registered)    Ok(())
  register, deregister, deregister Ok(())
  deregister (never registered)    Ok(())
  register on Poll A, then Poll B  Ok(())
kernel view, via SourceFd (no debug check):
  register T1, register T2         Ok(()), then connect -> tokens [2]
  reregister T1 only               Ok(()), then connect -> tokens [1]
drop without deregister:
  1. dropped, peer writes            -> tokens []
  2. dropped fd 8, dup fd 9 alive, peer writes -> tokens []
     deregister via the dup fd     Ok(())
  3. new socket got fd 8 (old fd was 8)
     register it as Token(2)       Ok(())
     both peers write              -> tokens [2]
  4. read 2 bytes, closed the dup, peer writes -> tokens []

macOS debug 的 IoSource 一段和 Linux debug 的那段逐字相同,其余和 macOS release 相同。

汇总成一张表(「debug IoSource」一列两个平台相同):

误用debug + IoSourceLinux(release,或 SourceFd)macOS(release,或 SourceFd)
同一个 Poll 上 register 两次AlreadyExists「I/O source already registered with a `Registry`」EEXIST(17,File exists),第一次的 token 留着Ok,EV_ADD 覆盖了 udata,之后事件带第二次的 token
没 register 就 reregisterNotFound「I/O source not registered with `Registry`」ENOENT(2),没有注册上Ok,等于一次注册,之后有事件
deregister 两次NotFound(同上)ENOENTOk,ENOENT 被忽略
没 register 就 deregisterNotFound(同上)ENOENTOk
先注册到 Poll A 再注册到 Poll BAlreadyExists(同上)OkOk

几点:

  • debug 下的报错不是内核给的。 IoSource 的检查在 epoll_ctl / kevent 之前,错误类型是 Custom,消息是 mio 自己写的。到了 release,同一段代码在 Linux 上得到 Os { code: 17 },在 macOS 上什么错都没有。
  • Linux 对误用比 macOS 严格。 epoll 的 ADD / MOD / DEL 是三种不同操作,内核会检查 fd 在不在集合里;kqueue 的 EV_ADD 本身就是「有则改、无则加」,删除时的 ENOENT 又被 mio 吞掉了。在 macOS 上开发、在 Linux 上部署,release 下的误用只会在 Linux 上暴露。
  • 注册到两个 Poll 在内核看来是合法的:一个 fd 可以同时在两个 epoll 实例(或两个 kqueue)里。拦它的只有 debug 下的 IoSource,而文档说的是「bound to that Poll instance for the lifetime of the event source」。
📝NOTE
没有蠢问题

问: debug 下 register 两次报 AlreadyExists,release 下在 macOS 上却成功了。哪一个才是 mio 的「真实行为」?

答: 都不是承诺。文档把连续两次 register 定为 unspecified behavior,mio 不保证报错,也不保证成功。debug 检查是一张安全网,目的是让误用在测试里尽早暴露;release 下 mio 不做任何检查,结果就是平台 selector 的结果(上表后两列)。代码不能依赖其中任何一个:要换 token 或关注集合,用 reregister;要换 Poll,没有正确的做法。

drop 之前没有 deregister

文档其实要求 drop 前先 deregister:event::Source 的文档写着「All event::Sources, unless otherwise specified, need to be [deregistered] before being dropped for them to not leak resources.」(src/event/source.rs)。不照做时,真正决定后果的是内核什么时候把一个 fd 从集合里摘掉,上面实验的最后一段就是在看这个:

  1. 单纯 drop:两个平台都收不到事件(tokens [])。关闭 fd 之后它就不在集合里了。
  2. drop 时还有一个 dup 出来的 fd 活着:Linux 上对端写入后仍然收到 Token(1),macOS 上收不到。epoll 的集合项挂在「打开的文件描述」上,只有指向它的所有 fd 都关了才自动摘掉;kqueue 的过滤器跟着 fd 号走,close 这个 fd 就删掉了。
  3. 想用 dup 的 fd 去补一次 deregister:Linux 上 ENOENT。集合项是按注册时的 fd 号(8)记的,换一个 fd 号(9)找不到它,而 8 已经关掉了,这一项在 dup 关闭之前删不掉。macOS 上是 Ok,但那只是 ENOENT 被忽略了。
  4. fd 号被新 socket 复用:新 socket 恰好拿到 fd 8,用 Token(2) 注册,两个平台都成功。之后两个对端都写,Linux 返回 [2, 1]:一个 fd 号底下同时挂着新旧两项,旧 socket 的事件还带着旧 token 冒出来。macOS 只有 [2]。
  5. dup 也关掉之后:Linux 的旧项终于消失,tokens []。

所以在 Linux 上,「drop 了就等于 deregister」只在没有别的 fd 指向同一个 socket 时成立:try_clone、dup、fork 后子进程继承的 fd,都会让旧 token 的事件继续出现。tokio 不依赖这种自动摘除:PollEvented 的 Drop 先 deregister,fd 还开着,EPOLL_CTL_DEL 一定找得到那一项,然后才让 fd 随值一起关掉。这很要紧,因为 tokio 的 token 就是 ScheduledIo 的地址(反应器),迟到的旧 token 会指向已经释放的内存。「常见坑」一部分会用这个实验的结果单独讲这个坑。

has_waker

Registry 的最后一个字段只服务于一个断言。register_waker 在 Waker::new 里被调用,第二次调用时 panic:

    pub(crate) fn register_waker(&self) {
        assert!(
            !self.has_waker.swap(true, Ordering::AcqRel),
            "Only a single `Waker` can be active per `Poll` instance"
        );
    }

它和 IoSource 的检查一样只在 debug 下存在。为什么一个 Poll 只能有一个 Waker、release 下建第二个会怎样,见 Waker。

动脑筋答案

macOS 和 Linux 交替各跑 3 次,第一轮:

macos no SA_RESTART : Err kind=Interrupted raw=Some(4) "Interrupted system call (os error 4)" after 210ms
macos SA_RESTART    : Err kind=Interrupted raw=Some(4) "Interrupted system call (os error 4)" after 203ms
linux no SA_RESTART : Err kind=Interrupted raw=Some(4) "Interrupted system call (os error 4)" after 210ms
linux SA_RESTART    : Err kind=Interrupted raw=Some(4) "Interrupted system call (os error 4)" after 202ms

答案是 200 ms 左右:四种组合都返回 Err(Interrupted),errno 4(EINTR)。3 轮的耗时都在 201–210 ms 之间,没有一次等满 2 秒。

  • mio 这边没有重试,这是本页前面读过的代码。
  • SA_RESTART 也救不了:Linux 的 epoll_wait 和 macOS 的 kevent 被信号处理函数打断后都不会自动重启,SA_RESTART 只对一部分系统调用有效。Linux 上 strace 看到的就是这一行:
[pid     4] epoll_pwait(3, 0xffff98bbc630, 8, 2000, NULL, 8) = -1 EINTR (Interrupted system call)
[pid     4] --- SIGUSR1 {si_signo=SIGUSR1, si_code=SI_TKILL, si_pid=3, si_uid=0} ---

所以只要进程里装了信号处理函数(不管带不带 SA_RESTART),poll 的调用方就必须处理 ErrorKind::Interrupted,像 tokio 那样当成一次空的 poll。

小结

  • Poll → Registry → sys::Selector → 一个 epoll / kqueue fd;debug 下多一个 selector id 和一个 has_waker。poll 要 &mut self,注册只要 &self。
  • poll 一行转给 select。epoll 把超时向上取整到毫秒,None 是 -1,超过 c_int::MAX 毫秒(约 24.86 天)截到 c_int::MAX;1.0.0–1.2.0 用 as 直接截断,会变成负数(永远等)或很小的数(提前返回),1.2.1 修复(#1948)。kqueue 用 timespec,没有这个问题。
  • EINTR 原样返回成 ErrorKind::Interrupted,0.7.0 起不再重试(#1056);SA_RESTART 也不会让 epoll_wait / kevent 重启。tokio 把它当成空的一轮。
  • try_clone 是一次 fcntl(F_DUPFD_CLOEXEC),两个 fd 指向同一个内核实例;别的线程可以在 poll(None) 阻塞期间注册,Linux 和 macOS 都实测有效。
  • 重复 register、未注册就 reregister / deregister、注册到两个 Poll:debug 下 IoSource 先报 Custom 错误;release 下 Linux 报 EEXIST / ENOENT(两个 Poll 除外),macOS 全部 Ok。
  • drop 不 deregister:只要还有 dup 的 fd 活着,Linux 会继续用旧 token 报事件,而且无法用 dup 的 fd 删掉;macOS 关闭 fd 即删除。

💬 关联讨论

发起讨论
加载中...