wake() 了,poll 却等到超时
主线位置:事件主线的旁路。
wake()让内核在Poll的就绪列表里放一个带 waker token 的事件,poll把它取走才算送达。这一页的坑出在中间:事件已经放进去,还没被取走,放它的那个 fd 就关了。
flowchart TB
wake["wake()"] --> queued["事件进就绪列表"]
queued --> dropw["drop Waker"]
dropw --> plat{平台}
plat -->|Linux| closeefd["eventfd 关闭"]
closeefd --> lost["事件被删,等到超时"]
plat -->|macOS| knote["knote 还在"]
plat -->|"poll(2) 后备"| pend["token 还在"]
knote --> got["poll 拿到事件"]
pend --> got
怎么读这一卷 的「用 strace 看两条主线」一节最早碰到这个坑:示例程序的第一版在线程里 wake 之后直接 drop 了 Waker,在 strace 下跑得好好的,去掉 strace 就卡住了。各平台的 Waker 是什么,在 Waker。
现象
一个线程调了 wake(),返回 Ok(()),事件循环的 poll 却没有返回,直到超时(timeout 是 None 时就是永远)。只在 Linux 上出现,macOS 上同一个程序正常。
最容易写出这个坑的是「drop 时通知事件循环」的句柄:
struct StopHandle {
waker: Waker,
}
impl Drop for StopHandle {
fn drop(&mut self) {
self.waker.wake().unwrap();
// ...and right after this, the field `waker` is dropped too
}
}
drop 函数体执行完,字段 waker 紧接着被 drop。另一种是把 Waker 本身 move 进工作线程:线程在结束前 wake() 一下,线程一结束,Waker 也没了。
复现
src/bin/waker_drop.rs:1 是事件循环线程在忙(睡 50 毫秒),StopHandle 在另一个线程里被 drop,之后才 poll;2 是事件循环已经阻塞在 poll 里,另一个线程 100 毫秒后 drop StopHandle;3 是修法:事件循环自己留一个 Arc<Waker> 的克隆。poll 的超时都是 500 毫秒。
use std::io;
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use mio::{Events, Poll, Token, Waker};
const WAKE: Token = Token(9);
// A handle that tells the event loop to stop when it goes away.
struct StopHandle {
waker: Waker,
}
impl Drop for StopHandle {
fn drop(&mut self) {
self.waker.wake().unwrap();
// ...and right after this, the field `waker` is dropped too
}
}
struct StopHandleArc {
waker: Arc<Waker>,
}
impl Drop for StopHandleArc {
fn drop(&mut self) {
self.waker.wake().unwrap();
}
}
fn wait(poll: &mut Poll, label: &str) -> io::Result<()> {
let mut events = Events::with_capacity(8);
let start = Instant::now();
poll.poll(&mut events, Some(Duration::from_millis(500)))?;
let got: Vec<usize> = events.iter().map(|e| e.token().0).collect();
println!("{label:<44} -> tokens {got:?} after {}ms", start.elapsed().as_millis());
Ok(())
}
fn main() -> io::Result<()> {
println!("os={}", std::env::consts::OS);
// 1. loop thread is busy: the handle is dropped before it gets to poll
let mut poll = Poll::new()?;
let handle = StopHandle { waker: Waker::new(poll.registry(), WAKE)? };
let t = thread::spawn(move || drop(handle));
thread::sleep(Duration::from_millis(50)); // busy with something else
t.join().unwrap();
wait(&mut poll, "1. handle dropped before poll")?;
// 2. loop thread is already blocked in poll when the handle is dropped
let mut poll = Poll::new()?;
let handle = StopHandle { waker: Waker::new(poll.registry(), WAKE)? };
let t = thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
drop(handle);
});
wait(&mut poll, "2. handle dropped while poll is blocked")?;
t.join().unwrap();
// 3. the fix: the loop keeps its own clone of the Waker
let mut poll = Poll::new()?;
let waker = Arc::new(Waker::new(poll.registry(), WAKE)?);
let handle = StopHandleArc { waker: Arc::clone(&waker) };
let t = thread::spawn(move || drop(handle));
thread::sleep(Duration::from_millis(50));
t.join().unwrap();
wait(&mut poll, "3. Arc clone kept by the loop, dropped before poll")?;
Ok(())
}
Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0,--release,跑了 5 次,只有毫秒数不同,其中一次:
os=linux
1. handle dropped before poll -> tokens [] after 500ms
2. handle dropped while poll is blocked -> tokens [] after 505ms
3. Arc clone kept by the loop, dropped before poll -> tokens [9] after 0ms
又单独数了 10 次第 2 行,10 次都是 tokens []。M2 Max、macOS、rustc 1.99.0,--release,3 次,只有毫秒数不同:
os=macos
1. handle dropped before poll -> tokens [9] after 0ms
2. handle dropped while poll is blocked -> tokens [9] after 105ms
3. Arc clone kept by the loop, dropped before poll -> tokens [9] after 0ms
用 RUSTFLAGS="--cfg mio_unsupported_force_poll_poll" 重新编译,Linux 和 macOS 各 3 次,三行都是 tokens [9],和上面 macOS 的结果一样。
第 2 行最出乎意料:poll 线程已经阻塞在 epoll_wait 里,wake() 也确实叫醒了它,它却又睡回去,睡满 500 毫秒。加上 strace 再看,第 2 种反而成功了(3 次都一样,strace -f -o 写进文件,下面去掉了程序自己的 write(1, ...),其中一次的第 1、2 种):
4 epoll_create1(EPOLL_CLOEXEC) = 3
4 eventfd2(0, EFD_CLOEXEC|EFD_NONBLOCK) = 4
4 epoll_ctl(3, EPOLL_CTL_ADD, 4, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x9}) = 0
5 write(4, "\1\0\0\0\0\0\0\0", 8) = 8
5 close(4) = 0
5 +++ exited with 0 +++
4 epoll_pwait(3, [], 8, 500, NULL, 8) = 0
4 epoll_create1(EPOLL_CLOEXEC) = 4
4 eventfd2(0, EFD_CLOEXEC|EFD_NONBLOCK) = 5
4 epoll_ctl(4, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x9}) = 0
4 epoll_pwait(4 <unfinished ...>
6 write(5, "\1\0\0\0\0\0\0\0", 8 <unfinished ...>
4 <... epoll_pwait resumed>, [{events=EPOLLIN, data=0x9}], 8, 500, NULL, 8) = 1
6 <... write resumed>) = 8
6 close(5 <unfinished ...>
6 <... close resumed>) = 0
- 第 1 种:线程 5
write之后立刻close(4),主线程之后才进epoll_pwait,等满 500 毫秒,= 0。 - 第 2 种:线程 6 的
write一返回,主线程的epoll_pwait就带着data=0x9返回了,close(5)排在后面。strace 拦截每个系统调用都要花时间,close被推迟,poll线程抢先取走了事件。不带 strace 时close紧跟着write,赶在被叫醒的线程去读就绪列表之前,就绪列表已经空了,epoll_wait没有可返回的东西,继续等到超时。
所以「poll 线程已经在等,就不会丢」不成立。是否丢只取决于 close 和 poll 线程谁先碰到就绪列表,这是一个竞争,在这台机器上不带 strace 时 15 次全输。
为什么
Waker 的文档第一条就是这个:
/// `Waker` events are only guaranteed to be delivered while the `Waker` value
/// is alive.
这句话从 0.7.0 加入 Waker(当时叫 Awakener,PR #969)起就在。文档的例子里还专门写了一句注释:「We need to keep the Waker alive, so we'll create a clone for the thread we create below.」
三种实现里,事件挂在什么上不一样:
| 实现 | Waker 持有的 | wake() 的事件挂在 | drop Waker 之后 |
|---|---|---|---|
Linux eventfd(new) | 一个 eventfd,File | eventfd 在 epoll 里的那一项 | eventfd 关闭,它是指向这个文件的唯一 fd,epoll 删掉这一项,就绪列表里的事件一起删掉 |
macOS kqueue(new) | kqueue fd 的一个副本(try_clone) | kqueue 上的 (0, EVFILT_USER) knote | 关掉的是副本,kqueue 本身还开着,knote 和待送达的事件都还在 |
poll(2) 后备(new) | selector 的一个克隆 | selector 里的 pending_wake_token 和内部通道 | 内部通道属于 selector,不随 Waker 关闭 |
Linux 这一行和 drop 之后还收到旧 token 的事件 是同一条内核规则的两面:fd 关闭时,如果它是最后一个指向这个文件的 fd,epoll 把这一项连同还没取走的事件一起删掉。那一页的坑是「还有别的 fd,所以删不掉」,这一页是「没有别的 fd,所以删得太早」。
问: 第 2 种里 poll 线程已经被 write 叫醒了,为什么不返回 0 个事件,而是继续睡到超时?
答: 被叫醒不等于 epoll_wait 返回。被叫醒的线程要先去读就绪列表,列表空了(close 已经把那一项删掉),这次唤醒就不算数,它在剩下的超时里接着等。实验里第 2 行的耗时是 505 毫秒而不是 100 毫秒,说明它确实接着等了;mio 这一层也看不到这次唤醒,因为 epoll_wait 根本没有返回到用户态。
poll(2) 后备的另一条路
poll(2) 后备上 drop Waker 不会丢唤醒,但有另一种丢法:wake() 之后、poll 之前,同一个 Poll 上发生了一次 register(或 reregister、deregister,或别的线程上的读写碰到 WouldBlock)。这些操作都会读空 selector 的内部通道,把 wake() 写进去的那一下也读掉,pending_wake_token 里的 token 要等到下一次有东西写通道时才会出来。机制和 strace 在 poll(2) 后备实现 的「和用户 Waker 共用一个通道」。那一节的 lost_wake.rs(先 wake、再 register、再 poll 500 毫秒)这次在 Linux 和 macOS 上都用 --cfg mio_unsupported_force_poll_poll 重跑了,Linux 3 次、macOS 2 次,结果相同,只有毫秒数不同,其中一次:
wake, poll -> [10] after 0ms
wake, register, poll -> [] after 505ms
poll again -> [] after 505ms
macOS 的 poll(2) 后备内部通道是 pipe 而不是 eventfd(Waker 的平台表),结果一样。这一条不是用户的误用,只能靠「poll 线程要能容忍一次迟到的唤醒」来防:给 poll 一个有限的超时,醒来后总是检查一遍自己的消息队列。
怎么修
- 让
Waker和Poll活得一样长。 在创建Poll的地方建Arc<Waker>,事件循环自己留一份,给别人的是克隆(上面第 3 种)。克隆被 drop 不影响事件循环手里那一份。 - 不要把唯一的
Wakermove 进会结束的线程或会被 drop 的句柄。StopHandle这类句柄里放Arc<Waker>。 - 重建
Waker也是 drop。 「drop 旧的、建新的」中间的那次唤醒在 Linux 上会丢,而且 debug 下第二次Waker::new会 panic(Only a singleWakercan be active perPollinstance)。
tokio
tokio 的 I/O 驱动在 Driver::new 里建唯一的 Waker,放进驱动的 Handle,和 Registry 并列;Handle 跟运行时一起活着,unpark 调的始终是这一个。用 tokio 的代码碰不到这个坑。
怎么确认自己没踩到
- 找到每个
Waker的主人。 它应该是和Poll一起创建、一起销毁的那个结构体的字段,或者被那个结构体持有一份Arc。如果一个Waker(而不是Arc<Waker>)出现在线程闭包、句柄、消息里,就是这个坑。 - 在 Linux 上、不带 strace 跑关闭路径的测试。 macOS 上测不出来,strace 下也测不出来(上面第 2 种在 strace 下 3 次都成功)。
- 给
poll设超时并记录超时次数。 关闭时poll走了超时分支,而不是被 waker 事件叫醒,说明唤醒丢了。
小结
Waker的事件只在Waker活着时保证送达。Linux 上 dropWaker就是关 eventfd,epoll 把还没取走的唤醒一起删掉。- 实测 Linux:
wake()后立刻 drop,poll线程无论是之后才进epoll_wait,还是已经在里面等,都等到超时(15 次全丢);strace 会掩盖它。macOS 和poll(2)后备上不丢。 poll(2)后备另有一条:wake()之后的register会读空内部通道,那次唤醒推迟到下一次通道被写,Linux 和 macOS 上都是。- 修法:
Arc<Waker>由事件循环自己持有一份;tokio 的驱动把唯一的Waker放在驱动Handle里。