任务存储
主线位置:定时器主线的第 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>,
}
| 字段 | 作用 | 讲它的页 |
|---|---|---|
state | SPAWNED、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 | 池大小 | 每格 |
|---|---|---|---|
empty | 1 | 48 | 48 |
p1 | 1 | 136 | 136 |
p2 | 2 | 272 | 136 |
p4 | 4 | 544 | 136 |
p8 | 8 | 1088 | 136 |
池大小严格是 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」的意思。
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 也不再返回任何东西。
稳定版 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;内存不归还,只被复用。