Rust in Everything
基于 embassy-executor 0.10.0

Spawner

主线位置:定时器主线的「Executor::run → 任务第一次被 poll」之前那一步。#[embassy_executor::main] 展开出的 main 在 executor.run(|spawner| ...) 里拿到一个 Spawner,把 main 任务的令牌交给它;main 里再 spawner.spawn(ticker(..).unwrap()),任务这才进入运行队列。本页讲令牌、两种 spawner,以及任务怎么知道自己属于哪个执行器。

sequenceDiagram
  participant U as 调用方
  participant P as TaskPool
  participant T as SpawnToken
  participant S as Spawner
  participant E as SyncExecutor
  U->>P: ticker(1, 100)
  P->>P: claim 并原地构造 future
  P-->>U: Ok(SpawnToken)
  U->>S: spawn(token)
  S->>E: spawn(TaskRef)
  E->>E: 写 header.executor
  E->>E: enqueue 并 pend

代码在 embassy-executor/src/spawner.rs(228 行)和 metadata.rs(148 行)。实验在 exp-embassy/exec-std(std,release,M2 Max、macOS 27、rustc 1.99.0)。

SpawnToken:占好了格子、还没进执行器

调用一个任务函数,得到的是 SpawnToken<S>:

#[must_use = "Calling a task function does nothing on its own. You must spawn the returned SpawnToken, typically with Spawner::spawn()"]
pub struct SpawnToken<S> {
    pub(crate) raw_task: raw::TaskRef,
    phantom: PhantomData<*mut S>,
}

此时格子已经被 claim、future 已经构造好(见任务存储),只差告诉它属于哪个执行器、把它入队。令牌上能做的事只有两件:id()(任务头地址截成 u32)和 metadata(),后者用来在 spawn 之前设好名字、优先级或截止时间(见调度策略)。

令牌不能丢掉。Drop 直接 panic,源码里还留着一句 // TODO deallocate the task instead.。exec-std/src/bin/pool.rs 用 DROP=1 跑时拿到令牌后 drop 掉(std,release):

job(3) after the others ended -> Ok, task id 0x27c8e98

thread 'main' (4725832) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-executor-0.10.0/src/spawner.rs:54:9:
SpawnToken instances may not be dropped. You must pass them to Spawner::spawn()

panic 的原因在状态位上:claim 已经把 SPAWNED | RUN_QUEUED 置上了,令牌一丢,这一格既不会被 poll(没进任何队列),也不会被释放(只有 future 返回 Ready 才清 SPAWNED),等于永久泄漏一格。与其悄悄漏掉,不如当场报错。

类型参数 S 只是一个标记,决定令牌是不是 Send(PhantomData<*mut S> 让令牌默认 !Send,S: Send 时 SendSpawner 才收)。它是什么类型,见下文「参数是 Send 就够了」。

📝NOTE
没有蠢问题

问: 我想把几个任务的令牌放进一个数组循环 spawn,为什么编不过?

答: 每个任务函数返回的都是 SpawnToken<impl Sized>,每个 impl Sized 是不同的不透明类型。exec-std/src/bin/trace.rs 最初这样写,rustc 报:

error[E0308]: mismatched types
  --> src/bin/trace.rs:89:71
...
   = note: expected struct `SpawnToken<impl Sized>`
              found struct `SpawnToken<impl Sized>`
   = note: distinct uses of `impl Trait` result in different opaque types

两行 SpawnToken<impl Sized> 看起来一样,其实是两个类型。逐个 spawn 就行;同一个任务函数(pool_size > 1)调用多次得到的令牌类型相同,可以放进一个数组。

Spawner:只在执行器线程用

Spawner 是一个 &'static raw::Executor 加一个 PhantomData<*mut ()>,后者让它 !Send;它是 Copy 的,可以随便复制、作为参数传给任务。spawn 收任何令牌:

    pub fn spawn<S>(&self, token: SpawnToken<S>) {
        let task = token.raw_task;
        mem::forget(token);
        unsafe { self.executor.spawn(task) }
    }

mem::forget 绕开那个会 panic 的 Drop。SyncExecutor::spawn 只做两件事:把执行器地址写进任务头的 executor 字段,然后入队:

    pub(super) unsafe fn spawn(&'static self, task: TaskRef) {
        task.header()
            .executor
            .store((self as *const Self).cast_mut(), Ordering::Relaxed);

        #[cfg(feature = "_any_trace")]
        trace::task_new(self, &task);

        state::locked(|l| {
            self.enqueue(task, l);
        })
    }

入队前不用再改状态位:claim 时已经置好了 RUN_QUEUED,任何唤醒在这之前都不会重复入队。从此以后,这个任务的每次唤醒都按 executor 字段找队列(状态与运行队列里的 wake_task)。任务结束后格子可以被 spawn 到另一个执行器,TaskStorage::spawn 的注释写着「It is allowed to spawn it on a different executor」,executor 字段会被重写。

Spawner::spawn 自 0.10.0 起不返回任何东西,因为能失败的那一步已经挪到调用任务函数时了,见下面的冷知识。

📝NOTE
冷知识:0.10.0 把「池满」从 spawn 挪到了令牌创建

0.10.0 之前,池满时任务函数照样返回一个令牌,只是里面的 raw_task 是 None(SpawnToken::new_failed()),等交给 Spawner::spawn 时才返回 Err(SpawnError::Busy);为了少写 unwrap,还有一个 must_spawn。提交 8aec341f2(「executor: return error when creating the spawntoken, not when spawning.」,经 PR #4606 合并,首次进入 embassy-executor 0.10.0)把 raw_task 改成不带 Option 的 TaskRef,删掉 new_failed 和两个 must_spawn,spawn 的返回值从 Result<(), SpawnError> 改成 ();这一个提交改了 164 个文件,大部分是仓库里的示例和测试:185 处 unwrap!(spawner.spawn(task())) 改成 spawner.spawn(unwrap!(task())),另有 33 处 must_spawn 和 56 处 .spawn(..).unwrap()。好处在 metadata() 上看得出来:旧版是 self.raw_task.unwrap().metadata(),对一个失败的令牌设优先级会 panic;现在拿到令牌就说明格子是真的。CHANGELOG 0.10.0 记为「Changed: return error when creating the SpawnToken, not when spawning」。

SendSpawner:跨线程,但只收 Send 的令牌

SendSpawner 存的是 &'static raw::SyncExecutor,没有那个 PhantomData<*mut ()>,所以它本身是 Send 的。代价在 spawn 的约束上:

    pub fn spawn<S: Send>(&self, token: SpawnToken<S>) {
        let header = token.raw_task;
        mem::forget(token);
        unsafe { self.executor.spawn(header) }
    }

从别的线程(或者中断)spawn,等于把任务送到执行器所在的线程去跑,所以要求 Send。拿到它有两种办法:Spawner::make_send(),或者 InterruptExecutor::start() 的返回值,后者见各平台的执行器。

参数是 Send 就够了

按理说跨线程送过去的是 future,应该要求 future 是 Send。但 future 只在 spawn 那一刻「被送」,之后一直在执行器线程上 poll。raw/mod.rs 的 __initialize_async_fn 有一大段注释讲这件事,要点是:

        // Luckily, an `async fn` future contains just the args when freshly constructed. So, if the
        // args are Send, it's OK to send a !Send future, as long as we do it before first polling it.
        // ...
        // The `FutFn` captures all the args, so if it's Send, the task can be send-spawned.
        // This is why we return `SpawnToken<FutFn>` below.

所以 #[task] 生成的代码调用 TaskPool::_spawn_async_fn,令牌的 S 是「捕获了全部参数的那个闭包」的类型,而不是 future 的类型。注释也说明了这只对 async fn 成立:手写的 impl Future 在刚构造时可能已经持有 !Send 的东西,所以那条路(宏遇到 -> impl Future 时生成 spawn)令牌的 S 就是 future 本身,CHANGELOG 0.8.0 的「Fixed Send unsoundness with -> impl Future tasks」指的就是这个区分。

exec-std/src/bin/send.rs 验证两边:hold(n: u32) 在 .await 前后持有一个 Rc(future 是 !Send),参数 u32 是 Send;report 打印自己在哪个线程上运行。一个名叫 helper 的 OS 线程通过 make_send() 得到的 SendSpawner spawn 它们(std,release):

helper thread calls SendSpawner::spawn
task spawned from main task runs on Some("main")
task spawned from helper runs on Some("main")
hold(7) finished: !Send future, spawned through SendSpawner
for_current_executor id == spawner id: true

从 helper 线程 spawn 的任务仍然在 main 线程上运行(std 执行器跑在 main 线程),!Send 的 hold 也被接受了。前两行的先后取决于线程调度,多跑几次会变。参数本身不是 Send 时就编不过(exec-std/src/bin/err_send.rs,show(value: Rc<u32>)):

error[E0277]: `Rc<u32>` cannot be sent between threads safely
   --> src/bin/err_send.rs:12:29
    |
  5 | #[embassy_executor::task]
    | ------------------------- within this `impl Sized`
...
 12 |   spawner.make_send().spawn(show(Rc::new(1)).unwrap());
    |                       ----- ^^^^^^^^^^^^^^^^^^^^^^^^^ `Rc<u32>` cannot be sent between threads safely
    |                       |
    |                       required by a bound introduced by this call
    |
    = help: within `impl Sized`, the trait `Send` is not implemented for `Rc<u32>`
note: required because it's used within this closure
...
note: required by a bound in `SendSpawner::spawn`

报错里的「required because it's used within this closure」正是那个 FutFn。

for_current_executor:从 waker 反查执行器

任务里拿不到 Spawner 时,可以从当前的 Context 反查。Spawner::for_current_executor 是一个立即完成的 future:task_from_waker(cx.waker()) 拿到任务头,读 executor 字段,包成 Spawner。上面 send.rs 的最后一行验证它和传进来的那个 Spawner 指向同一个执行器。

它在 0.8.0 被标成了 unsafe fn(提交 0c136c7b0,PR #4382,提交说明「It's unsound with manually-created Contexts」)。# Safety 一节要求只在 embassy 执行器创建的 Context 里调用。原因可以从两种 spawner 的差别看出来:Spawner 能 spawn !Send 的任务,它的安全性建立在「我就在这个执行器的线程上」;而 waker 是可以 clone 后送到别的线程的,别人用这个 waker 手工构造一个 Context 去 poll,就能在错误的线程上得到一个 Spawner。SendSpawner::for_current_executor 没有这个问题,所以仍然是安全的。文档也说,非用 Spawner::for_current_executor 不可的情况只有一种:在 InterruptExecutor 里的任务想要一个能 spawn !Send 任务的 Spawner。

任务元数据

Metadata 是 0.10.0 新增的公开类型,放在任务头里,三个字段全由 feature 控制:metadata-name 开 name(critical_section::Mutex<Cell<Option<&'static str>>>),scheduler-priority 开 priority(AtomicU8),scheduler-deadline 开 deadline。都不开时它是零大小类型,任务存储里实测的 40 字节任务头里没有它的份。

两个入口:spawn 之前用 SpawnToken::metadata(),任务运行中用 Metadata::for_current_task().await(同样是从 waker 反查任务头)。每次 initialize_impl 都会先调 reset 把三项清回默认值,所以名字、优先级不会从上一次运行的任务继承下来。开了 metadata-name 时,#[main] 生成的代码会给主任务设名字,写的是 "main\0"(带结尾的 NUL,给 rtos-trace 这类 C 风格的消费者用),name() 读出来也带着这个 \0。

小结

  • 调用任务函数就占了格子、构造了 future,得到 SpawnToken;令牌被 drop 会 panic,因为那一格会永久泄漏。
  • Spawner 是 !Send 的执行器引用,什么令牌都收;SyncExecutor::spawn 写 executor 字段、入队,不再改状态位。
  • SendSpawner 可以跨线程,只收 S: Send 的令牌;async fn 任务的 S 是捕获参数的闭包,所以参数 Send 就够了,future 可以不是。
  • 0.10.0 起 spawn 不返回 Result,失败在调用任务函数时就已经发生。
  • Spawner::for_current_executor 从 waker 反查执行器,0.8.0 起是 unsafe;SendSpawner 的同名函数仍然安全。
  • Metadata 每次 spawn 都被重置;不开 feature 时不占空间。

💬 关联讨论

发起讨论
加载中...