任务池比想的大:future 当参数传给 async fn 后翻倍
主线位置:定时器主线开头,
#[task]生成的静态池。一格 = 40 字节的任务头(thumbv7em)+ 任务的 future,池 = 格数 × 一格,全在.bss里,见任务存储的「实测:一格多大」。本页讲 future 本身为什么会意外地大。
flowchart TB w["work 88 B"] --> a["直接 await"] w --> b["传给 async fn"] w --> c["闭包里构造"] w --> d["手写 Future"] a --> pa["池 136 B"] b --> pb["池 248 B"] c --> pc["池 160 B"] d --> pd["池 152 B"]
实验 crate exp-embassy/pita-qemu,bin task_size(thumbv7em-none-eabihf,release,opt-level = "s",SysTick 时间驱动);llvm-nm 来自 rustc 1.99.0 的 sysroot;运行在 QEMU 11.1.2 b-l475e-iot01a 上,embassy-stm32 用 exp-embassy/qemu-patched(和 0.6.0 只差注释掉 rcc/bd.rs 里的 while !cr.read().dbp() {})。
现象
同一段工作(一个 64 字节缓冲区跨过一次 Timer 的 .await),用三种方式放进任务:
async fn work(tag: u8) {
let buf = [tag; 64];
Timer::after_millis(1).await;
core::hint::black_box(&buf);
}
// 1. await the work directly in the task
#[embassy_executor::task]
async fn direct() {
work(1).await;
}
// 2. pass the work future to a helper async fn
async fn logged<F: Future<Output = ()>>(name: &'static str, fut: F) {
hprintln!("{} start", name);
fut.await;
hprintln!("{} end", name);
}
#[embassy_executor::task]
async fn via_async_fn() {
logged("b", work(2)).await;
}
// 3. the same helper as a hand-written future: struct Logged<F> { name, started, fut }
#[embassy_executor::task]
async fn via_struct() {
Logged { name: "c", started: false, fut: work(3) }.await;
}
llvm-nm -S --size-sort -C 里的池(第二列是十六进制字节数):
20000240 00000048 b task_size::__embassy_main::POOL
20000288 00000088 b task_size::direct::POOL
20000010 00000098 b task_size::via_struct::POOL
...
20000148 000000f8 b task_size::via_async_fn::POOL
程序开头用 size_of_val 打印各个 future 的大小,然后 spawn 所有任务;QEMU 上的输出(省略的那一行和 logged_with、d 属于修法里的第四种写法,第二行在修法末尾解释,同一个程序):
work 88 logged 200 logged_with 112 Logged 104
async block awaiting select(work, Timer) 112
d start
c start
b start
d end
c end
b end
| 写法 | future | 池(一格) |
|---|---|---|
work(1).await 直接写在任务里 | 96 | 136 |
Logged { .. } 手写结构体 | 112 | 152 |
logged("b", work(2)) 包装 async fn | 208 | 248 |
work 自己 88 字节;包装成 async fn 后是 200 字节,比两份 work 还多。表里的 future 一列是池减去 40 字节任务头,比 size_of_val 多出的 8 字节是任务外层那个 async fn 自己的状态(再按 8 字节对齐)。pool_size = 4 时这一差别乘以 4。
出处
任务池的格子是 TaskStorage<F>,F 就是任务 async fn 的 future 类型,原样内嵌:
pub struct TaskStorage<F: Future + 'static> {
raw: TaskHeader,
future: UninitCell<F>, // Valid if STATE_SPAWNED
}
所以池多大完全由 rustc 给这个 future 排的布局决定,embassy 不做任何压缩。翻倍出在 rustc 一侧:logged 的状态机里,参数 fut 占一个槽;fut.await 先把它交给 IntoFuture::into_future,结果是另一个局部变量,要跨过 await 点保存,又占一个槽。两个槽的生存期在源码层面重叠(参数槽从函数开始就在),rustc 不把它们放进同一块内存,于是同一个 88 字节的 future 在状态机里有两个位置。数字对得上:200 = 2 × 88 + 24,多出的 24 字节里有 name 这个 8 字节的胖指针和状态字节,其余是对齐填充,具体布局没有逐字节核对。手写的 Logged 只是把 fut 当一个字段,poll 里原地 pin 住,没有第二份。
写一个迷你执行器在自己的执行器上量到同样的现象(56 字节的 future 包进 async fn plain(future: F) { future.await } 后 120 字节),和 embassy 无关,是 async fn 收 future 参数的通病。
修法
不手写 Future 也能避开:让包装函数自己构造 future,参数改成一个返回 future 的闭包(task_size 里的 via_closure):
async fn logged_with<F: Future<Output = ()>>(name: &'static str, make: impl FnOnce() -> F) {
hprintln!("{} start", name);
make().await;
hprintln!("{} end", name);
}
#[embassy_executor::task]
async fn via_closure() {
logged_with("d", || work(4)).await;
}
200000a8 000000a0 b task_size::via_closure::POOL
闭包 || work(4) 不捕获任何东西,是零大小的;future 只在 make() 之后出现一次,logged_with 的 future 是 112 字节。池从 248 字节降到 160 字节,和手写结构体(152)只差 8 字节。四种写法的行为一样,QEMU 上 start / end 都成对打印。
还有两条和体积有关、但不必改代码结构的办法:
- 大缓冲区不要跨
.await放在任务的局部变量里,放进StaticCell,任务只拿&'static mut,future 里只剩一个指针。 embassy-futures的select/join返回结构体,不是async fn,把 future 交给它们不会复制第二份(embassy-futures)。上面输出的第二行:一个async块里select(work(0), Timer::after_millis(1)).await是 112 字节,正好是 88 字节的work加 16 字节的Timer再加 8。
怎么确认没中招
- 每次加任务后看一眼池:
llvm-nm -S --size-sort -C <elf> | grep POOL。一格明显大于「任务头 + 你估计的局部变量」时,找函数体里有没有把 future 当参数交给另一个async fn。 - 拿不准时在程序里临时打印
core::mem::size_of_val(&some_async_fn(args)),它不 poll future,只看类型大小。 - 只看
.bss总量会漏掉这个问题:芯片 RAM 还够时一切正常,直到某天加了一个pool_size链接器报.bss溢出。
小结
- 一格池 = 任务头 + 整个 future,大小由 rustc 的状态机布局决定。
- 把 future 当参数交给
async fn再 await,状态机里存两份:88 字节的工作变成 200 字节,池从 136 变成 248 字节。 - 包装函数改收闭包、在里面构造 future(160 字节),或手写
Future结构体(152 字节)。