Rust in Everything
基于 embassy-executor 0.10.0

任务存储

主线位置:定时器主线的第 2 步。#[embassy_executor::main] 展开后,main 和每个 #[task] 都对应一个 static 的任务池;Timer::after(100ms).await 所在的那个 future 就存在池里的一格中,任务头里还有一块留给定时器队列的内存。本页讲这一格长什么样、多大、什么时候被占用和释放;宏怎么生成这个 static,见宏。

flowchart TB
  pool["TaskPool F N"] --> s0["TaskStorage 0"]
  pool --> s1["TaskStorage 1"]
  s0 --> hdr["TaskHeader"]
  s0 --> fut["future 未初始化"]
  hdr --> st["state"]
  hdr --> rqi["run_queue_item"]
  hdr --> ex["executor"]
  hdr --> pf["poll_fn"]
  hdr --> tqi["timer_queue_item"]
  hdr --> md["metadata"]

代码在 embassy-executor/src/raw/mod.rs(623 行)和 raw/util.rs。本页的交叉编译实验在 exp-embassy/exec-qemu 和 exec-bare(thumbv7em-none-eabihf,release,opt-level = "s"),std 实验在 exp-embassy/exec-std(release);M2 Max、macOS 27、rustc 1.99.0。

任务头

一个任务在内存里是一个 TaskStorage:

// repr(C) is needed to guarantee that the Task is located at offset 0
// This makes it safe to cast between TaskHeader and TaskStorage pointers.
#[repr(C)]
pub struct TaskStorage<F: Future + 'static> {
    raw: TaskHeader,
    future: UninitCell<F>, // Valid if STATE_SPAWNED
}

repr(C) 保证任务头在偏移 0,于是指向 TaskStorage<F> 的指针可以直接当成指向 TaskHeader 的指针用。执行器的其余部分只认识任务头,不认识 F:

pub(crate) struct TaskHeader {
    pub(crate) state: State,
    pub(crate) run_queue_item: RunQueueItem,

    pub(crate) executor: AtomicPtr<SyncExecutor>,
    poll_fn: SyncUnsafeCell<Option<unsafe fn(TaskRef)>>,

    /// Integrated timer queue storage. This field should not be accessed outside of the timer queue.
    pub(crate) timer_queue_item: TimerQueueItem,

    pub(crate) metadata: Metadata,

    #[cfg(feature = "rtos-trace")]
    all_tasks_next: AtomicPtr<TaskHeader>,
}
字段作用讲它的页
stateSPAWNED、RUN_QUEUED 两个标志状态与运行队列
run_queue_item运行队列的链表指针,任务自己就是链表节点同上
executor任务被 spawn 到哪个执行器,唤醒时按它找队列Spawner
poll_fn类型擦除后的 poll:TaskStorage::<F>::poll 的函数指针本页
timer_queue_item一块不透明的内存,归定时器队列用定时器队列
metadata名字、优先级、截止时间,都由 feature 决定有没有调度策略

TaskRef 就是一个 NonNull<TaskHeader>,注释说它「essentially a &'static TaskStorage<F> where the type of the future has been erased」。运行队列、waker、定时器队列传来传去的都是它,一个字长。

timer_queue_item 的类型来自单独的小 crate embassy-executor-timer-queue:一个 #[repr(align(8))] 的 [usize; ITEM_WORDS],字数由 timer-item-size-{4,6,8}-words 三个 feature 决定,一个都不开就是 0。embassy-time-queue-utils 0.3.2 依赖它时打开了 timer-item-size-6-words,所以只要项目里有这个 crate(embassy-time 的 std 驱动、自己写的驱动、HAL 的时间驱动都会带进来),每个任务头就多出 6 个字。执行器这边只负责两件事:留出这块内存,再导出一个从 waker 找到它的函数:

#[unsafe(no_mangle)]
extern "Rust" fn __embassy_time_queue_item_from_waker(waker: &Waker) -> &'static mut TimerQueueItem {
    unsafe { task_from_waker(waker).timer_queue_item() }
}

__embassy_time_queue_item_from_waker 靠链接期的符号名接到定时器队列那边,执行器不依赖 embassy-time。队列怎么把这块内存解释成链表节点,见定时器队列。

为什么放得进 .bss

TaskStorage::new 是 const fn,每个字段的初值都是全零:状态 0、链表指针空、executor 空指针、poll_fn 是 None、定时器项是 [0; N]、future 是 MaybeUninit::uninit()。源码里专门写了一句:

                // Note: this is lazily initialized so that a static `TaskStorage` will go in `.bss`
                poll_fn: SyncUnsafeCell::new(None),

poll_fn 本来可以在 new 里就填成 Some(Self::poll),但那样初值就不全是零,整个任务池会进 .data:既占 RAM,又要在 flash 里存一份初值、启动时拷过去。推迟到 spawn 时再写,池子就只占 .bss,启动代码清零即可。这个约束有多容易被打破,见调度策略里 scheduler-deadline 的实测。

future 用 UninitCell(MaybeUninit<UnsafeCell<T>>)包着:任务没 spawn 时那块内存是垃圾,只有 SPAWNED 置位后才有效。

实测:一格多大

exec-qemu/src/bin/size.rs 定义了五个任务:一个空的 async fn empty() {},和四个函数体相同、pool_size 分别是 1、2、4、8 的任务,函数体在 .await 前后持有一个 64 字节数组。llvm-nm -S --size-sort -C 的输出(第二列是字节数,十六进制;b 表示在 .bss):

20000850 00000030 b size::empty::POOL
20000010 00000048 b size::__embassy_main::POOL
20000058 00000088 b size::p1::POOL
200000e0 00000110 b size::p2::POOL
200001f0 00000220 b size::p4::POOL
20000410 00000440 b size::p8::POOL
任务pool_size池大小每格
empty14848
p11136136
p22272136
p44544136
p881088136

池大小严格是 pool_size 乘以一格,没有额外的簿记。空任务的 48 字节拆开:state、run_queue_item、executor、poll_fn 各 4 字节,共 16;timer_queue_item 6 个字 24 字节,要求 8 字节对齐,正好接在 16 之后;metadata 在没开任何 feature 时是零大小。任务头 40 字节,再加 1 字节的 future(空 async fn 只剩一个状态字节),按 8 对齐到 48。p1 那一格是 40 字节的头加 96 字节的 future。

整个程序的 .bss 是 2188 字节(llvm-size -A),其中六个任务池占 2160。任务太多、RAM 不够时,是链接器报 .bss 溢出,而不是运行时分配失败,这正是 README 里「If tasks don't fit in RAM, this is detected at compile time by the linker」的意思。

❗IMPORTANT
动脑筋:项目里没有 embassy-time-queue-utils 时,空任务那一格多大?

exp-embassy/exec-bare 和上面的程序用同一个芯片、同一个执行器配置,只是不依赖 embassy-time 和 embassy-time-queue-utils,同样定义一个 pool_size = 1 的 async fn empty() {}。先算一下再看答案。

占用一格:claim 和 initialize

TaskPool 就是 [TaskStorage<F>; N]。spawn 时从头到尾找第一个能占的格子:

    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),
        }
    }

AvailableTask::claim 调 state.spawn():把状态从 0 原子地改成 SPAWNED | RUN_QUEUED,失败说明这格有人用。一步同时置两位,是因为刚占下的任务马上要进运行队列,不能让别的地方在这之间把它入队。initialize_impl 再做三件事:清空元数据、把 poll_fn 写成 TaskStorage::<F>::poll、在格子里原地构造 future。

    fn initialize_impl<S>(self, future: impl FnOnce() -> F) -> SpawnToken<S> {
        unsafe {
            self.task.raw.metadata.reset();
            self.task.raw.poll_fn.set(Some(TaskStorage::<F>::poll));
            self.task.future.write_in_place(future);

            let task = TaskRef::new(self.task);

            SpawnToken::new(task)
        }
    }

future 以闭包传入、在 write_in_place 里才调用,TaskStorage::spawn 的注释说这是为了「the future is constructed in-place, avoiding a temporary copy in the stack thanks to NRVO optimizations」。在栈只有几 KB 的芯片上,一个几百字节的 future 先在栈上建好再拷进 static,可能直接把栈撑爆。write_in_place 标了 #[inline(never)],让闭包的返回值直接写到目标地址。

到这里任务还没进任何执行器:返回的是一个 SpawnToken,交给 Spawner::spawn 才会写 executor 字段并入队,见 Spawner。

池满:0.10.0 起在创建令牌时报错

0.10.0 起任务函数的返回类型是 Result<SpawnToken<impl Sized>, SpawnError>。exec-std/src/bin/pool.rs 用 pool_size = 2 的任务连调三次,然后等前两个结束、再调一次(std,release):

job(0) -> Ok, task id 0x2354e98
job(1) -> Ok, task id 0x2354f08
job(2) -> Err(Busy - Too many instances of this task are already running. Check the `pool_size` attribute of the task.)
job 1 done
job 0 done
job(3) after the others ended -> Ok, task id 0x2354e98
  • 第三次调用 job(2) 时就拿到了 Err,这时还没碰到任何 Spawner。错误只有一种,SpawnError::Busy,Debug 直接转发到 Display,所以 {:?} 打出来是一整句话。
  • 任务 id 就是任务头的地址(TaskRef::id 把指针截成 u32)。两格相差 0x70 = 112 字节,这是 64 位 std 上这个任务的一格。
  • 前两个任务结束后,job(3) 又拿到了第一格(地址和 job(0) 相同):find_map 总是从第 0 格开始找。
  • job 1 done 先于 job 0 done,这是运行队列批内后进先出的结果,见状态与运行队列。

如果直接 .unwrap()(同一个程序,UNWRAP=1),panic 的位置指向调用任务函数的那一行:

thread 'main' (4725863) panicked at src/bin/pool.rs:35:26:
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.

raw 层的文档没有跟上这个改动:TaskStorage::spawn 和 TaskPool::spawn 的注释还写着失败时返回「a "poisoned" SpawnToken is returned, which will cause [Spawner::spawn()] to return the error」,而函数签名已经是 Result,Spawner::spawn 也不再返回任何东西。

📝NOTE
冷知识:0.8.0 之前,稳定版上的任务池是从一块共享的 arena 里切出来的

稳定版 Rust 不能写 static POOL: TaskPool<impl Future, N>(要 type_alias_impl_trait),所以 0.4.0 起稳定版走另一条路:_export 里有一个全局的 static ARENA: Arena<{ crate::config::TASK_ARENA_SIZE }>,每个任务第一次 spawn 时在临界区里从 arena 上按 Layout 切一块当自己的池;arena 的大小靠 task-arena-size-* 这组 feature 选(0.7.0 的 Cargo.toml 里有 40 个),切不下时运行期 panic「embassy-executor: task arena is full. You must increase the arena size」。PR #4020(提交「Statically allocate task pools on stable Rust.」,首次进入 embassy-executor 0.8.0)换成现在的做法:宏用 const fn 从任务函数的函数项类型算出 size_of::<TaskPool<Fut, N>>() 和对齐,生成一个同样大小的字节数组 TaskPoolHolder<SIZE, ALIGN> 当 static。CHANGELOG 0.8.0 写的是「Task pools are now statically allocated on stable rust. All task-arena-size-* features have been removed and are no longer necessary.」本页 llvm-nm 里每个任务一个 POOL 符号,就是这个改动的结果;展开后的代码见宏。

释放一格:任务结束

任务的 future 返回 Ready 时,TaskStorage::poll 按固定顺序收尾:

            Poll::Ready(_) => {
                // ...
                // As the future has finished and this function will not be called
                // again, we can safely drop the future here.
                this.future.drop_in_place();

                // We replace the poll_fn with a despawn function, so that the task is cleaned up
                // when the executor polls it next.
                this.raw.poll_fn.set(Some(poll_exited));

                // Make sure we despawn last, so that other threads can only spawn the task
                // after we're done with it.
                this.raw.state.despawn();
                // ...
            }

先析构 future,再把 poll_fn 换成什么也不做的 poll_exited,最后清 SPAWNED。顺序不能反:清了 SPAWNED 之后别的线程(或中断)就能 claim 这一格、往里写新的 future。换 poll_fn 是因为任务结束后,别处留着的旧 waker 还可能把这一格塞进运行队列,那时执行器 poll 到的是 poll_exited,不会去碰已经析构的 future,细节见状态与运行队列。

内存本身从不归还:TaskStorage 要求 &'static self,注释写明「A TaskStorage must live forever, it may not be deallocated even after the task has finished running」,只能被同一个任务函数的下一次调用复用。

动脑筋答案

exec-bare 的 llvm-nm -S -C 输出:

20000000 00000028 b exec_bare::__embassy_main::POOL
20000028 00000018 b exec_bare::empty::POOL

空任务一格 0x18 = 24 字节,比有 embassy-time-queue-utils 时少 24 字节,正好是 6 个字的 timer_queue_item。剩下的是 16 字节的任务头,加 1 字节 future,按 8 对齐(TimerQueueItem 即使是零个字也带着 align(8))到 24。也就是说,在 Cortex-M 上每个任务为集成定时器队列付 24 字节 RAM,不管它用不用定时器。

小结

  • 一个任务是 TaskStorage:40 字节(Cortex-M4,带定时器项)的任务头加一块未初始化的 future;TaskPool 是它的数组,大小严格等于 pool_size 乘以一格。
  • 所有初值都是零,poll_fn 推迟到 spawn 时才写,所以任务池落在 .bss。
  • 任务头里的 timer_queue_item 是执行器替定时器队列预留的 6 个字,经 __embassy_time_queue_item_from_waker 交出去。
  • 占格是一次把状态从 0 改成 SPAWNED | RUN_QUEUED 的原子操作;future 原地构造,不经过栈。
  • 0.10.0 起池满在调用任务函数时就返回 SpawnError::Busy;raw 层的注释还停在旧行为。
  • 任务结束时先析构 future、换 poll_fn、最后清 SPAWNED;内存不归还,只被复用。

💬 关联讨论

发起讨论
加载中...