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
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
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,碰巧是对的。
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照样返回。
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。
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 aPollinstance was previously registered with thatPollinstance, then a call toderegisterhas already occurred. Consecutive calls toregisteris unspecified behavior.」以及「once an event source is registered with aPollinstance, it is bound to thatPollinstance 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 ofPoll, 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 的做法:
| 操作 | epoll | kqueue |
|---|---|---|
register | epoll_ctl(EPOLL_CTL_ADD) | 一次 kevent,每个关注的方向一项 EV_ADD | EV_CLEAR | EV_RECEIPT,忽略 EPIPE |
reregister | epoll_ctl(EPOLL_CTL_MOD) | 读、写两项都提交:关注的 EV_ADD,不关注的 EV_DELETE,忽略 ENOENT、EPIPE |
deregister | epoll_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 + IoSource | Linux(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 就 reregister | NotFound「I/O source not registered with `Registry`」 | ENOENT(2),没有注册上 | Ok,等于一次注册,之后有事件 |
deregister 两次 | NotFound(同上) | ENOENT | Ok,ENOENT 被忽略 |
没 register 就 deregister | NotFound(同上) | ENOENT | Ok |
| 先注册到 Poll A 再注册到 Poll B | AlreadyExists(同上) | Ok | Ok |
几点:
- 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 thatPollinstance for the lifetime of the event source」。
问: 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 从集合里摘掉,上面实验的最后一段就是在看这个:
- 单纯 drop:两个平台都收不到事件(
tokens [])。关闭 fd 之后它就不在集合里了。 - drop 时还有一个 dup 出来的 fd 活着:Linux 上对端写入后仍然收到
Token(1),macOS 上收不到。epoll 的集合项挂在「打开的文件描述」上,只有指向它的所有 fd 都关了才自动摘掉;kqueue 的过滤器跟着 fd 号走,close这个 fd 就删掉了。 - 想用 dup 的 fd 去补一次 deregister:Linux 上
ENOENT。集合项是按注册时的 fd 号(8)记的,换一个 fd 号(9)找不到它,而 8 已经关掉了,这一项在 dup 关闭之前删不掉。macOS 上是Ok,但那只是ENOENT被忽略了。 - fd 号被新 socket 复用:新 socket 恰好拿到 fd 8,用
Token(2)注册,两个平台都成功。之后两个对端都写,Linux 返回[2, 1]:一个 fd 号底下同时挂着新旧两项,旧 socket 的事件还带着旧 token 冒出来。macOS 只有[2]。 - 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 下多一个 selectorid和一个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 即删除。