Rust in Everything
基于 mio 1.2.4

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,Fileeventfd 在 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,所以删得太早」。

📝NOTE
没有蠢问题

问: 第 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 不影响事件循环手里那一份。
  • 不要把唯一的 Waker move 进会结束的线程或会被 drop 的句柄。 StopHandle 这类句柄里放 Arc<Waker>。
  • 重建 Waker 也是 drop。 「drop 旧的、建新的」中间的那次唤醒在 Linux 上会丢,而且 debug 下第二次 Waker::new 会 panic(Only a single Waker can be active per Poll instance)。

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 上 drop Waker 就是关 eventfd,epoll 把还没取走的唤醒一起删掉。
  • 实测 Linux:wake() 后立刻 drop,poll 线程无论是之后才进 epoll_wait,还是已经在里面等,都等到超时(15 次全丢);strace 会掩盖它。macOS 和 poll(2) 后备上不丢。
  • poll(2) 后备另有一条:wake() 之后的 register 会读空内部通道,那次唤醒推迟到下一次通道被写,Linux 和 macOS 上都是。
  • 修法:Arc<Waker> 由事件循环自己持有一份;tokio 的驱动把唯一的 Waker 放在驱动 Handle 里。

💬 关联讨论

发起讨论
加载中...