Rust in Everything
基于 mio 1.2.4

Only a single Waker can be active per Poll instance

主线位置:事件主线的旁路。Waker::new 在 debug 下先查 Registry 里的 has_waker 标志,再到平台 selector 里建唤醒通道;第二个 Waker 在第一步就 panic。release 下跳过检查,两个 Waker 在内核里是一个还是两个,由平台决定。

flowchart TB
  second["第二次 Waker::new"] --> dbg{debug_assertions}
  dbg -->|是| boom["panic"]
  dbg -->|否| plat{平台}
  plat -->|epoll| two["两个 token 都到"]
  plat -->|kqueue| one["只剩后一个 token"]
  plat -->|"poll(2) 后备"| slot["后一个覆盖前一个"]
  fix["一个 Arc Waker"] --> ok["原因记在位图里"]

检查怎么实现、各平台的 Waker 是什么、两个 Waker 同时 wake 的结果,在 Waker 里都讲过并跑过。这一页看程序里通常是怎么走到第二个 Waker 的,以及正确的写法。

现象

debug 构建里 Waker::new panic:

thread 'main' (2294090) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/mio-1.2.4/src/poll.rs:727:9:
Only a single `Waker` can be active per `Poll` instance

release 构建不报任何错,但在 macOS 上有的唤醒会带错 token,在 Linux 上一切正常。只在 Linux 上跑 release 测试的话,这个误用看不出来。

常见的两种写法会走到这里:

  1. 每个组件各建一个。 事件循环里有一个跨线程的消息通道,还有一个「停止」句柄,两者各自 Waker::new(registry, 自己的 token),想靠 token 区分是谁叫醒的。
  2. drop 了再建。 某个组件重启时 drop 掉旧的 Waker,再用同一个 Poll 建一个新的。文档说的是「Only a single Waker can be active per Poll」,旧的已经 drop 了,看起来是合法的。

复现

src/bin/second_waker.rs 依次做这两种写法,最后是修法:

use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use std::time::Duration;

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

// Two parts of a program, each wants to wake the same event loop.
struct Channel {
  waker: Waker,
}
struct Shutdown {
  waker: Waker,
}

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 try_new(registry: &Registry, token: Token) -> Option<Waker> {
  match catch_unwind(AssertUnwindSafe(|| Waker::new(registry, token))) {
    Ok(w) => Some(w.unwrap()),
    Err(_) => None,
  }
}

const CHANNEL: u8 = 1;
const SHUTDOWN: u8 = 2;

fn main() {
  println!("os={} debug_assertions={}", std::env::consts::OS, cfg!(debug_assertions));

  // 1. each component creates its own Waker
  let mut poll = Poll::new().unwrap();
  let chan = Channel { waker: Waker::new(poll.registry(), Token(10)).unwrap() };
  match try_new(poll.registry(), Token(11)) {
    None => println!("1. second Waker::new: panicked"),
    Some(w) => {
      let stop = Shutdown { waker: w };
      chan.waker.wake().unwrap();
      stop.waker.wake().unwrap();
      println!("1. channel wakes, shutdown wakes -> tokens {:?}", tokens(&mut poll));
    }
  }

  // 2. drop the first Waker, then create a new one
  let mut poll = Poll::new().unwrap();
  let first = Waker::new(poll.registry(), Token(10)).unwrap();
  drop(first);
  match try_new(poll.registry(), Token(11)) {
    None => println!("2. Waker::new after dropping the first: panicked"),
    Some(w) => {
      w.wake().unwrap();
      println!("2. Waker::new after dropping the first: ok, wake -> tokens {:?}", tokens(&mut poll));
    }
  }

  // 3. the fix: one shared Waker, a bit per reason
  let mut poll = Poll::new().unwrap();
  let waker = Arc::new(Waker::new(poll.registry(), Token(10)).unwrap());
  let reasons = Arc::new(AtomicU8::new(0));
  let wake_for = |bit: u8| {
    reasons.fetch_or(bit, Ordering::Release);
    waker.wake().unwrap();
  };
  wake_for(CHANNEL);
  wake_for(SHUTDOWN);
  let got = tokens(&mut poll);
  let bits = reasons.swap(0, Ordering::Acquire);
  println!("3. shared Waker, two reasons -> tokens {got:?}, channel={} shutdown={}", bits & CHANNEL != 0, bits & SHUTDOWN != 0);
}

M2 Max、macOS、rustc 1.99.0,debug 构建,标准输出和标准错误合在一起:

os=macos debug_assertions=true

thread 'main' (2294090) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/mio-1.2.4/src/poll.rs:727:9:
Only a single `Waker` can be active per `Poll` instance
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
1. second Waker::new: panicked

thread 'main' (2294090) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/mio-1.2.4/src/poll.rs:727:9:
Only a single `Waker` can be active per `Poll` instance
2. Waker::new after dropping the first: panicked
3. shared Waker, two reasons -> tokens [10], channel=true shutdown=true

Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0 的 debug 构建:标准输出除了第一行是 os=linux 以外相同,标准错误里是同样两条 panic(线程号是 1,路径是交叉编译时的 macOS 路径)。release 构建,三种配置各跑 3 次,输出不变。macOS:

os=macos debug_assertions=false
1. channel wakes, shutdown wakes -> tokens [11]
2. Waker::new after dropping the first: ok, wake -> tokens [11]
3. shared Waker, two reasons -> tokens [10], channel=true shutdown=true

Linux:

os=linux debug_assertions=false
1. channel wakes, shutdown wakes -> tokens [10, 11]
2. Waker::new after dropping the first: ok, wake -> tokens [11]
3. shared Waker, two reasons -> tokens [10], channel=true shutdown=true

Linux 加 RUSTFLAGS="--cfg mio_unsupported_force_poll_poll"(poll(2) 后备):和 macOS 的三行逐字相同,只有第一行是 os=linux。

  • 第 1 种:debug 下第二个 Waker::new 直接 panic。release 下 Linux 两个 token 都到;macOS 和 poll(2) 后备只有 [11],通道发来的那次唤醒带的是停止句柄的 token。按 token 分派的代码会把「有新消息」当成「该停了」。
  • 第 2 种:drop 掉第一个再建,debug 下照样 panic;release 下三种配置都能用。这一条是 debug 检查比文档严格的地方。
  • 第 3 种:一个 Waker,三种配置的结果相同:一个事件,两个原因都在位图里。

为什么

Waker::new 在 debug 下先调 Registry::register_waker:

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

Waker 没有 Drop 实现,整个 crate 里没有把 has_waker 改回 false 的地方,所以第 2 种也 panic。has_waker 是 Arc<AtomicBool>,try_clone 出来的 Registry 共用它,从克隆的注册器建也拦得住(Waker 的冷知识讲了它怎么从 Selector 挪到 Registry 上)。

release 下各平台的结果来自各自的实现,Waker 的动脑筋答案逐条解释过:epoll 上每个 Waker 是一个独立的 eventfd;kqueue 上每次 wake 都提交 (ident 0, EVFILT_USER) 这同一个 knote,后一次 EV_ADD 把 udata 换成自己的 token;poll(2) 后备的 pending_wake_token 是一个 Option<Token>,后写的覆盖先写的。第 2 种在 release 下能用也是这个原因:Linux 上旧的 eventfd 关了,新的另起一个;macOS 上 knote 本来就只有一个。

文档对这种情况的说法是「What happens if multiple Wakers are registered with the same Poll is unspecified.」第 1 种的三种结果都在这句话的范围内。

📝NOTE
没有蠢问题

问: 第 2 种在 release 下三个平台都能用,文档也只说「active」的 Waker 只能有一个。那 debug 下的 panic 是不是可以无视,比如关掉 debug_assertions 跑测试?

答: 关掉它会同时关掉 IoSource 的重复注册检查(I/O source already registered with a Registry),代价比这一条大。而且「drop 了再建」只在 Poll 线程不在乎中间丢掉的那次唤醒时才安全:旧 Waker 在 Linux 上 drop 的那一刻,还没被取走的唤醒也一起没了(wake() 了,poll 却等到超时)。和 Poll 同生共死的一个 Waker,两个问题都不会有。

怎么修

  • 一个 Poll 只在创建时建一个 Waker,包进 Arc,谁要叫醒就给谁一个克隆。 wake(&self) 只要共享引用,Waker 是 Send + Sync。文档的例子就是这么写的。
  • 唤醒原因自己记。 token 只能告诉你「是 Waker 叫醒的」。用一个原子位图(上面的第 3 种)、一个 channel 或一个带锁的队列记下原因,poll 返回后读取并清零。先写原因、再 wake:几次 wake 合成一个事件(Waker 的「wake 三次,poll 几次」),原因不能靠事件个数数。
  • 组件不要自己 Waker::new。 需要叫醒事件循环的组件,在构造时接收一个 Arc<Waker>(或者一个封装好的「发消息并唤醒」句柄),而不是接收 &Registry 自己建。

tokio

tokio 的 I/O 驱动在 Driver::new 里只建一个 Waker(token 是 TOKEN_WAKEUP),放在驱动的 Handle 里,所有需要叫醒驱动的地方都调 Handle::unpark,也就是这一个 Waker 的 wake。tokio 不暴露它的 Poll,用 tokio 的程序碰不到这个坑;直接用 mio 写事件循环才会。驱动醒来之后不看「是谁叫醒的」,TOKEN_WAKEUP 的事件什么都不做(tokio 卷反应器的「两个保留的 token」)。

怎么确认自己没踩到

  • 跑 debug 测试。 这是唯一会报错的地方,而且是 panic,不会被 ? 吞掉。release 测试抓不到。
  • 在 macOS 上测「两个来源同时唤醒」。 Linux 上两个 Waker 的程序表现正常;macOS(或 Linux 的 poll(2) 后备)上会丢 token。
  • 搜代码。 全项目里 Waker::new 应该只出现在创建 Poll 的那个函数里,紧挨着 Poll::new。

小结

  • debug 下第二个 Waker::new panic,消息是 Only a single \Waker` can be active per `Poll` instance,出自 Registry::register_waker(poll.rs:727`)。
  • has_waker 从不复位:drop 掉第一个再建也 panic,尽管 release 下这样做在三种配置上都能用。
  • release 下两个 Waker 同时 wake:Linux epoll 给两个 token,macOS kqueue 和 poll(2) 后备只给后一个。
  • 修法:一个 Poll 一个 Arc<Waker>,唤醒原因记在自己的位图或队列里。tokio 的驱动就只有一个 Waker。

💬 关联讨论

发起讨论
加载中...