任务的内存布局
主线位置:任务主线的
spawn之后、进队列之前。OwnedTasks::bind调new_task,这一步把 future 搬到堆上。
flowchart TB
raw["RawTask / Task / Notified / JoinHandle / Waker<br/>都只存一个指向 Header 的指针"] --> H
subgraph cell["Cell:一次 Box 分配,按缓存行对齐"]
direction TB
H["Header(热):state · queue_next · vtable · owner_id"]
C["Core:scheduler · task_id · stage(future 或输出)"]
T["Trailer(冷):owned 链表指针 · join waker · hooks"]
H --- C --- T
end
把 Cell 想成一个快递箱。Header 是贴在箱子正面的面单:分拣员(调度器)只看面单就能干活,上面有状态、传送带上的下一个箱子(queue_next)、处理说明(vtable)、归哪个仓库管(owner_id)。Core 是箱子里的货,路上装的是 future,送到之后换成输出。Trailer 是箱底的回执联,只在发货和签收时翻出来。
一个任务就是一个 Cell
#[repr(C)]
pub(super) struct Cell<T: Future, S> {
/// Hot task state data
pub(super) header: Header,
/// Either the future or output, depending on the execution stage.
pub(super) core: Core<T, S>,
/// Cold data
pub(super) trailer: Trailer,
}
T 是 future 的类型,S 是调度器句柄的类型(多线程运行时是 Arc<multi_thread::Handle>)。三段放在同一次 Box::new 里,spawn 一个任务只做这一次堆分配。
两个属性决定了布局:
#[repr(C)]:字段按声明顺序排列,header一定在偏移 0。于是指向Cell的指针和指向Header的指针是同一个地址,可以互相 cast。后面所有的类型擦除都建立在这一点上。- 按缓存行对齐:源码在
repr(C)上面按架构加了repr(align(N)),x86_64、aarch64、powerpc64 是 128 字节,其余架构按各自的缓存行大小取 32、64 或 256。注释解释了 128 的来历:从 Sandy Bridge 开始,Intel 的空间预取器一次拉相邻的两条 64 字节缓存行,按 64 对齐仍然会和相邻的任务伪共享。
tokio 0.1 的执行器只接受 Box<Future<Item = (), Error = ()>>:任务没有返回值,spawn 也不给句柄。想拿结果,得在任务里自己塞一个 oneshot,每个任务多一次分配。2019 年 2 月的 issue #887 提出让 spawn 总是返回句柄,同时不付这次分配。同年 10 月重写线程池的 #1657 把它做了出来:future、输出和 join 句柄要用的状态放进同一次分配,随 0.2.0 发布。Stage 里的 Finished 就是那时候来的。
按缓存行对齐要到 2023 年 6 月的 #5809 才加上(1.29.0),理由是相邻两个任务可能落在同一条缓存行上。各架构取多大,源码注释写明是从 crossbeam-utils 的 cache_padded.rs 抄来的。
Header:调度器天天碰的部分
#[repr(C)]
pub(crate) struct Header {
/// Task state.
pub(super) state: State,
/// Pointer to next task, used with the injection queue.
pub(super) queue_next: UnsafeCell<Option<NonNull<Header>>>,
/// Table of function pointers for executing actions on the task.
pub(super) vtable: &'static Vtable,
/// This integer contains the id of the `OwnedTasks` or `LocalOwnedTasks`
/// that this task is stored in. ...
pub(super) owner_id: UnsafeCell<Option<NonZeroU64>>,
// ...(tracing_id,仅 tokio_unstable + tracing)
/// The last time this task was scheduled. Used to measure schedule latency.
pub(super) scheduled_at: UnsafeCell<ScheduleLatencyInstant>,
}
| 字段 | 用途 | 在哪一章展开 |
|---|---|---|
state | 一个 AtomicUsize:状态位加引用计数 | 一个 usize 的状态机 |
queue_next | 全局注入队列是侵入式链表,「下一个」指针就存在任务自己身上,入队不用再分配节点 | 全局注入队列 |
vtable | 类型擦除后找回具体类型的入口,见下文 | 本章 |
owner_id | 任务属于哪个 OwnedTasks,用来防止把 A 运行时的任务交给 B 运行时去 poll | OwnedTasks 与关闭 |
scheduled_at | 入队时刻,算调度延迟用;不开相关 feature 时不占空间 | — |
文件末尾有一个测试把 Header 的大小卡在 8 个指针以内,也就是 64 位平台上一条 64 字节的缓存行(第 591 行):
#[test]
#[cfg(not(loom))]
fn header_lte_cache_line() {
assert!(std::mem::size_of::<Header>() <= 8 * std::mem::size_of::<*const ()>());
}
问: Header 被测试卡在 64 字节以内,整个 Cell 却按 128 字节对齐。这两个数字是不是矛盾?
答: 不矛盾,它们防的是两件不同的事。64 管的是「一个任务自己」:调度器读 state、queue_next、vtable 只需要加载一条缓存行。128 管的是「两个任务之间」:预取器一次拉相邻的两条 64 字节缓存行,如果任务 A 和任务 B 的 Header 落在同一对里,一个核改 A 的 state,另一个核上 B 的缓存也跟着失效。按 128 对齐,每个任务独占一对缓存行。
Core:future 和输出共用一块内存
#[repr(C)]
pub(super) struct Core<T: Future, S> {
/// Scheduler used to drive this future.
pub(super) scheduler: S,
/// The task's ID, used for populating `JoinError`s.
pub(super) task_id: Id,
// ...(spawned_at,仅 tokio_unstable)
/// Either the future or the output.
pub(super) stage: CoreStage<T>,
}
pub(super) enum Stage<T: Future> {
Running(T),
Finished(super::Result<T::Output>),
Consumed,
}
Stage 是一个枚举,future 和它的输出不会同时存在:
Running(T):还在跑,里面是 future 本身。Finished(Result<T::Output>):跑完了,future 已经 drop,位置让给输出。Result的错误类型是JoinError,取消和 panic 都存成Err。Consumed:输出被JoinHandle取走了,或者没人要、已经丢掉了。
所以一个任务占多少内存,主要取决于 future 和输出里较大的那个。一个在 .await 之间持有 64KB 缓冲区的 async 块,每 spawn 一次就是 64KB 多一点的分配。
平台是 aarch64,Cell 按 128 字节对齐。
tokio::spawn(async {});
tokio::spawn(async {
let buf = [1u8; 1024];
tokio::task::yield_now().await;
std::hint::black_box(&buf);
});
tokio::spawn(async { [0u8; 4096] });
tokio::spawn(async { vec![0u8; 4096] });
提示:Stage 是枚举;Rust 里一个类型的大小总是它对齐的整数倍。答案在本页末尾。
stage 外面包的是 UnsafeCell,没有锁。谁有权改它,由状态里的 RUNNING 和 COMPLETE 两个位决定:没完成时,把 RUNNING 置 1 的线程独占它;完成后,JoinHandle 独占它。这是下一章的内容。
Trailer:只在创建和结束时用到的部分
pub(super) struct Trailer {
/// Pointers for the linked list in the `OwnedTasks` that owns this task.
pub(super) owned: linked_list::Pointers<Header>,
/// Consumer task waiting on completion of this task.
pub(super) waker: UnsafeCell<Option<Waker>>,
/// Optional hooks needed in the harness.
pub(super) hooks: TaskHarnessScheduleHooks,
}
owned 是存活任务链表的前后指针,waker 是正在 .await 这个 JoinHandle 的那个任务的 waker。它们只在 spawn、完成、关闭时用到,放在 future 后面,不和 Header 抢缓存行。
只拿着 NonNull<Header>,怎么找到 Trailer
Core<T, S> 的大小随 T 变,所以 Trailer 的偏移量每种 future 都不一样。而运行时里流转的指针只有 NonNull<Header>,没有类型参数。
tokio 的做法是手写 vtable,把偏移量和函数指针一起放进去(task/raw.rs 第 24 行):
pub(super) struct Vtable {
/// Polls the future.
pub(super) poll: unsafe fn(NonNull<Header>),
/// Schedules the task for execution on the runtime.
pub(super) schedule: unsafe fn(NonNull<Header>),
/// Deallocates the memory.
pub(super) dealloc: unsafe fn(NonNull<Header>),
/// Reads the task output, if complete.
pub(super) try_read_output: unsafe fn(NonNull<Header>, *mut (), &Waker),
// ...(drop_join_handle_slow、drop_abort_handle、shutdown)
/// The number of bytes that the `trailer` field is offset from the header.
pub(super) trailer_offset: usize,
/// The number of bytes that the `scheduler` field is offset from the header.
pub(super) scheduler_offset: usize,
/// The number of bytes that the `id` field is offset from the header.
pub(super) id_offset: usize,
// ...
}
每个 (T, S) 组合有一份 vtable,由 vtable::<T, S>() 返回:
pub(super) fn vtable<T: Future, S: Schedule>() -> &'static Vtable {
&Vtable {
poll: poll::<T, S>,
schedule: schedule::<S>,
dealloc: dealloc::<T, S>,
// ...
trailer_offset: OffsetHelper::<T, S>::TRAILER_OFFSET,
scheduler_offset: OffsetHelper::<T, S>::SCHEDULER_OFFSET,
id_offset: OffsetHelper::<T, S>::ID_OFFSET,
// ...
}
}
这里返回的是对临时值的引用,能编译过是因为整个结构体是常量表达式,编译器把它提升(promote)成了 'static。偏移量要先放进 OffsetHelper 的关联常量里再取,源码注释说直接调 const fn 会让提升失败。
偏移量本身由 get_trailer_offset 这类 const fn 按 repr(C) 的规则手算:从 Header 的大小开始,按 Core 的对齐补齐,加上 Core 的大小,再按 Trailer 的对齐补齐。debug 构建下 Cell::new 会拿真实字段地址和算出来的地址比一遍。
有了偏移量,找 Trailer 就是一次指针加法(Header::get_trailer):
pub(super) unsafe fn get_trailer(me: NonNull<Header>) -> NonNull<Trailer> {
let offset = me.as_ref().vtable.trailer_offset;
let trailer = me.as_ptr().cast::<u8>().add(offset).cast::<Trailer>();
NonNull::new_unchecked(trailer)
}
为什么不用 Box<dyn Future>?trait object 是胖指针,每次 wake 都要经过它;而且 tokio 要的不只是 poll,还有调度、读输出、释放等七个操作,偏移量也得跟着。手写 vtable 让所有引用都是一个瘦指针,Waker 的 data 字段也正好放得下。
RawTask 和 Harness:无类型的指针,有类型的视图
RawTask 只有一个字段:
#[derive(Clone)]
pub(crate) struct RawTask {
ptr: NonNull<Header>,
}
它是 Copy 的,本身不管引用计数。所有操作都是「读 vtable,调函数指针」:
pub(crate) fn poll(self) {
let vtable = self.header().vtable;
unsafe { (vtable.poll)(self.ptr) }
}
vtable 里的函数在 T、S 上是泛型的,进去第一件事就是把指针转回有类型的 Harness<T, S>(raw.rs 第 335 行):
unsafe fn poll<T: Future, S: Schedule>(ptr: NonNull<Header>) {
let harness = Harness::<T, S>::from_raw(ptr);
harness.poll();
}
Harness 里装的是 NonNull<Cell<T, S>>,可以直接访问 core 和 trailer,不用再查偏移量。
还有一处细节:harness.rs 把不依赖 T、S 的操作(wake_by_val、wake_by_ref、remote_abort、drop_reference)写在 impl RawTask 上,注释写明「最终二进制里只该有一份」。泛型函数每种 future 都会单态化一次,一个程序里有几百种 async 块,能不泛型的就不泛型,可以省下不少代码体积。
2022 年 11 月的 #5213(1.23.0)之前,wake_by_val、wake_by_ref、remote_abort、clone_waker、drop_waker、try_set_join_waker 都在 T、S 上泛型,程序里每 spawn 一种 future,它们就多生成一份。这个 PR 把它们改成非泛型,同时往 vtable 里加了 schedule。唤醒时如果需要重新调度,就走这个函数指针。schedule::<S> 只在调度器类型上泛型,不在 future 类型上,一个程序只会生成一两份。改完以后 JoinHandle 和 AbortHandle 也不再需要自己存任务 ID 了。
同一个任务的几种引用
RawTask 不管引用计数,管引用计数的是包在它外面的几个类型。new_task 一次造出三个:
fn new_task<T, S>(
task: T,
scheduler: S,
id: Id,
spawned_at: SpawnLocation,
) -> (Task<S>, Notified<S>, JoinHandle<T::Output>)
where
S: Schedule,
T: Future + 'static,
T::Output: 'static,
{
let raw = RawTask::new::<T, S>(
task,
scheduler,
id,
spawned_at,
);
let task = Task {
raw,
_p: PhantomData,
};
let notified = Notified(Task {
raw,
_p: PhantomData,
});
let join = JoinHandle::new(raw);
(task, notified, join)
}
三个值指向同一块内存,各占一个引用计数,所以任务的初始引用计数是 3。task/mod.rs 开头的模块文档列出了全部引用类型:
| 引用 | 谁持有 | 个数 | 独占的字段 |
|---|---|---|---|
Task<S> | OwnedTasks 链表 | 0 或 1 | Trailer 的 owned 指针 |
Notified<S> | 某个运行队列 | 0 或 1 | Header 的 queue_next |
JoinHandle<T> | 用户 | 0 或 1 | 完成后的 stage(读输出) |
AbortHandle | 用户 | 任意 | — |
Waker | IO 驱动、定时器、channel 等 | 任意 | — |
UnownedTask<S> | 阻塞线程池(spawn_blocking) | 0 或 1,占 2 个计数 | — |
「Notified 至多一个」是关键约束:一个任务最多在一个队列里出现一次,queue_next 只有一个,也不会被同时 poll 两次。这条约束由状态里的 NOTIFIED 位保证。
Task 实现了 Drop:引用计数减到 0 的那个人负责调 dealloc 释放整个 Cell(mod.rs 第 570 行):
impl<S: 'static> Drop for Task<S> {
fn drop(&mut self) {
if self.header().state.ref_dec() {
self.raw.dealloc();
}
}
}
动脑筋答案
用一个全局分配器包住 tokio::spawn,只记录对齐不小于 32 的分配。这个程序里按 128 对齐的分配只有 Cell:
unsafe impl GlobalAlloc for Spy {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
if ON.load(Ordering::Relaxed) && l.align() >= 32 {
SIZE.store(l.size(), Ordering::Relaxed);
ALIGN.store(l.align(), Ordering::Relaxed);
}
unsafe { System.alloc(l) }
}
// dealloc 原样转给 System
}
M2 Max、rustc 1.99.0、--release 下的输出(future= 是 size_of_val 量的 async 块本身):
async {} future= 1B cell= 128B align=128
hold [u8;1024] over await future= 1048B cell= 1152B align=128
return [u8;4096] future= 1B cell= 4224B align=128
return Vec (24B header) future= 1B cell= 256B align=128
async {}:128 字节,正好一个对齐单位。Header、调度器句柄、任务 ID、Trailer 和一个空的Stage全都塞得下。- 跨
.await持有 1KB 数组:数组被存进了 future 的状态机,future 本身变成 1048 字节,Cell是 1152 = 9 × 128。 - 返回
[u8; 4096]:future 只有 1 字节,但Stage必须放得下Finished(Result<[u8; 4096], JoinError>),所以从 spawn 那一刻起就按输出的大小分配,4224 = 33 × 128。大输出和大 future 一样占内存,只是更不容易被注意到。 - 返回
Vec:堆上那 4096 字节不算在Cell里,Stage只多了Vec本身的 24 字节,但这就超过了 128,被补齐到 256。
小结
- 一个任务是一个
Cell<T, S>,一次Box分配,repr(C)保证 Header 在偏移 0,并按缓存行对齐(x86_64 上 128 字节)防止伪共享。 - Header 放调度器常用的字段,测试把它卡在一条缓存行内;Core 里 future 和输出共用
Stage枚举;Trailer 放只在创建、结束时用的字段。 - 运行时里只流转
NonNull<Header>,靠每个(T, S)一份的手写 vtable 找回函数和字段偏移量;不依赖类型的操作写在RawTask上,减少单态化。 Task、Notified、JoinHandle等是同一块内存的不同引用,各占一个引用计数,初始为 3;Notified至多一个,保证任务不会同时在两个队列里。