Rust in Everything
基于 embassy-executor 0.10.0

Busy - Too many instances of this task are already running

主线位置:定时器主线开头,任务在被 poll 之前先要在静态池里占一格。池怎么分配、格子怎么释放,见任务存储;令牌和 Spawner 见 Spawner。

flowchart TB
  call["handle(id)"] --> find["find_map claim"]
  find --> free{"有空格"}
  free -->|有| tok["Ok SpawnToken"]
  free -->|没有| busy["Err Busy"]
  tok --> sp["spawner.spawn"]
  sp --> run["任务运行"]
  run --> done["Ready 后释放"]
  done --> find

实验 crate exp-embassy/pita-std,bin pool_busy、pool_fixed、spawn_old(platform-std,release);M2 Max、macOS 27、rustc 1.99.0,本机负载约 4。

现象

每来一个事件就 spawn 一个处理任务,这是从 tokio 带来的习惯。事件每 20 ms 一个,每个处理 50 ms:

#[embassy_executor::task]
async fn handle(id: u32) {
  Timer::after_millis(50).await;
  println!("handled {id} at {} ms", Instant::now().as_millis());
}

#[embassy_executor::main]
async fn main(spawner: Spawner) {
  for id in 0..3 {
    Timer::after_millis(20).await;
    println!("event {id} at {} ms", Instant::now().as_millis());
    spawner.spawn(handle(id).unwrap());
  }
  // ...
}
event 0 at 24 ms
event 1 at 46 ms

thread 'main' (5031856) panicked at src/bin/pool_busy.rs:17:30:
called `Result::unwrap()` on an `Err` value: Busy - Too many instances of this task are already running. Check the `pool_size` attribute of the task.
  • 第二个事件到来时第一个任务还在等它的 50 ms,handle 只有一格,handle(1) 返回 Err。
  • panic 的位置是调用任务函数的那一行(handle(id).unwrap(),第 30 列是 unwrap),不是 spawn。0.10.0 起错误在创建令牌时就返回,Spawner 根本没被碰到。
  • 芯片上 unwrap 失败就是整个程序停下;在 std 上测试时,事件间隔稍大一点就测不出来。

从 0.9 搬过来的代码通常写成 spawner.spawn(handle(0)).unwrap(),在 0.10.0 上编译(bin spawn_old,debug):

error[E0308]: mismatched types
   --> src/bin/spawn_old.rs:11:17
    |
  4 | #[embassy_executor::task]
    | ------------------------- the found opaque type
...
 11 |   spawner.spawn(handle(0)).unwrap();
    |           ----- ^^^^^^^^^ expected `SpawnToken<_>`, found `Result<SpawnToken<impl Sized>, _>`
...
error[E0599]: no method named `unwrap` found for unit type `()` in the current scope
  --> src/bin/spawn_old.rs:11:28

两个错误一起读就清楚了:unwrap 要从 spawn 的外面挪到任务函数调用的后面。

出处

宏为每个任务函数生成一个 static POOL: TaskPool<Fut, N>,N 就是 pool_size,默认 1(展开见宏)。调用任务函数走到 TaskPool::spawn_impl:

    fn spawn_impl<T>(&'static self, future: impl FnOnce() -> F) -> Result<SpawnToken<T>, SpawnError> {
        match self.pool.iter().find_map(AvailableTask::claim) {
            Some(task) => Ok(task.initialize_impl::<T>(future)),
            None => Err(SpawnError::Busy),
        }
    }

claim 给空着的一格置上 SPAWNED 状态位;只有任务的 future 返回 Ready 后才清掉(任务存储的「释放一格」)。SpawnError 只有 Busy 一种,它的 Debug 转发到 Display,所以 unwrap 打出来是一整句话。池是按任务函数分的:两个不同的任务函数各有各的池,互不影响;同一个函数的所有调用共用一个池。

📝NOTE
冷知识:0.10.0 之前,池满的令牌是「有毒」的

0.9.1 的 SpawnToken 里是 raw_task: Option<TaskRef>。池满时任务函数照样返回一个令牌,只是 raw_task 是 None(SpawnToken::new_failed()),错误要等到 Spawner::spawn(token) -> Result<(), SpawnError> 里才变成 Err(SpawnError::Busy);另有一个 must_spawn 在失败时 panic。#4606 里的提交「executor: return error when creating the spawntoken, not when spawning.」把 raw_task 改成非 Option,任务函数改为返回 Result<SpawnToken<_>, SpawnError>,spawn 不再返回值,new_failed 和 must_spawn 一起删掉。首次进入 embassy-executor 0.10.0,CHANGELOG 一句「Changed: return error when creating the SpawnToken, not when spawning」。上面 spawn_old 的两个错误就是这次改动的直接结果;raw 层 TaskPool::spawn 的注释还停在旧说法上:「a "poisoned" SpawnToken is returned」。

修法

pool_size 按最大并发数定,再把 Err 当成一个正常的分支处理(bin pool_fixed:三格,前三个事件间隔 20 ms,后两个间隔 5 ms,制造一次真正的超额):

#[embassy_executor::task(pool_size = 3)]
async fn handle(id: u32) {
  Timer::after_millis(50).await;
  println!("handled {id} at {} ms", Instant::now().as_millis());
}

// in main, for each event:
match handle(id) {
  Ok(token) => {
    println!("event {id} at {t} ms");
    spawner.spawn(token);
  }
  Err(e) => println!("event {id} at {t} ms dropped: {e}"),
}
event 0 at 24 ms
event 1 at 50 ms
handled 0 at 75 ms
event 2 at 75 ms
event 3 at 81 ms
event 4 at 87 ms dropped: Busy - Too many instances of this task are already running. Check the `pool_size` attribute of the task.
handled 1 at 103 ms
handled 2 at 130 ms
handled 3 at 131 ms
  • event 3 拿到的是 handle 0 刚在 75 ms 释放的那一格;event 4 到来时 1、2、3 三格都在用,被丢掉并记一行,程序继续。
  • 时间戳比 20 / 50 ms 的网格晚几毫秒,这是 std 驱动在本机负载下的抖动,和池无关。
  • 每多一格就多一份 future 的静态内存,pool_size = N 的池大小严格是 N 倍(任务存储的实测)。

事件可以排队的场合,另一种做法是固定几个常驻的工作任务,从一个 Channel 里取事件,池永远不会满;但多个工作任务等同一个 Channel 有自己的代价,见几个任务等同一个 Channel / Signal 时 CPU 不睡。

怎么确认没中招

  • 搜任务函数调用后面的 .unwrap() / .expect(:grep -rn 'spawn(.*\.unwrap())' src/(在 pool_busy.rs 上命中第 17 行,pool_fixed.rs 上不命中)。每一处问一句:这个任务会不会在上一个实例结束前被再调一次?会的话要么加 pool_size,要么处理 Err。
  • 测试时把事件间隔压到比处理时间短,让池真的满一次。
  • 用 llvm-nm -S --size-sort -C <elf> | grep POOL 看每个池占多少 .bss,pool_size 是用 RAM 换并发。

小结

  • 每个任务函数一个静态池,默认一格;没有空格时任务函数返回 Err(SpawnError::Busy),unwrap 的 panic 指向调用任务函数的那一行。
  • 0.10.0 起错误在创建令牌时返回(#4606);0.9 的 spawner.spawn(task()).unwrap() 在 0.10.0 上是 E0308 加 E0599。
  • 按最大并发设 pool_size,并把 Err 当正常分支处理。

💬 关联讨论

发起讨论
加载中...