Tokio 源码深度解析
第6章 Task:轻量级任务的生命周期
本章要点
- Tokio Task 是一块连续的
#[repr(C)]堆内存,由Cell<T, S>定义:Header(hot,64 位平台上 4 个字长 = 32 字节)+ Core(scheduler + Future 本体)+ Trailer(cold 数据,JoinHandle 通信槽);整个Cell按 cache line 对齐(x86_64/aarch64/powerpc64 上是repr(align(128)))来防 false sharing Header的 5 个字段是所有通用逻辑的入口——state 原子字段、queue_next 链表指针、vtable 函数表、owner_id 名册、可选 tracing_idState用单个AtomicUsize同时编码 6 个状态位 + 引用计数:lifecycle(2 位:RUNNING/COMPLETE)、NOTIFIED、JOIN_INTEREST、JOIN_WAKER、CANCELLED,剩余高位存 refcount- 初始 state =
(REF_ONE * 3) | JOIN_INTEREST | NOTIFIED——refcount 从 3 起步(JoinHandle + scheduler-notified + owned list 各 1) - 绝大多数状态转移通过
fetch_update_action一个 CAS 循环完成(少数位形态已知的转移直接用一条fetch_xor/fetch_sub)——状态位和 refcount 永远原子同步 JoinHandle::await的幕后是JOIN_WAKER位 + Trailer 里的 waker 槽——这个机制让 Task 完成时能幂等地唤醒 handle 持有者spawn的完整路径:spawn_inner→Handle::spawn→OwnedTasks::bind→new_task→RawTask::new→Cell::new一次堆分配 →schedule(Notified)→ 进入 worker 队列。整个过程一次堆分配,零后续分配
6.1 Task 在内存里长什么样:Cell 的三件套
Task 是 Tokio 里内存布局最精密的结构——因为它在 hot path 上被反复访问(每次 poll / wake / spawn 都要操作它),每一个字节的安排都经过深思熟虑。理解 Task 的内存布局相当于理解 Tokio 的"神经元"——后面所有调度、唤醒、JoinHandle 的行为都建立在这个布局之上。本节把 Header / Core / Trailer 三段式结构讲清——你会看到 Tokio 把"冷热数据分离"这个 CPU cache 优化原则贯彻到了极致。
打开 tokio/src/runtime/task/core.rs,Tokio 1.40 里一个 Task 的本体是 Cell<T, S> 结构体(tokio/src/runtime/task/core.rs:115)。原样贴出:
// 来源:tokio-rs/tokio · tokio/src/runtime/task/core.rs (tokio-1.40.0)
#[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,
}
三个字段,按访问频率排列:
header(hot):state、vtable、queue_next、owner_id —— runtime 在 wake / schedule / poll 每一步都要读写的核心字段core(middle):scheduler 和 Future 本体 —— poll 时访问trailer(cold):JoinHandle 的通信槽、链表指针 —— 偶尔访问
#[repr(C)] 是关键:它告诉编译器不要重排字段顺序。Tokio 依赖这个顺序——内部的 unsafe 代码通过精确的偏移量计算把一个 *const Header 指针变成 *const Cell 再找到 core 或 trailer。如果字段顺序被编译器自由重排(默认的 #[repr(Rust)] 允许这么干),这些偏移量就错了。
一次 tokio::spawn(future) 的本质,是分配一个 Cell<T, S> 到堆上,然后把它的 Header 指针作为 Task 的"身份标识"传来传去。
为什么三件套要分开
如果把 state + scheduler + Future 全塞一个 struct,会有一个硬问题:state 和 queue_next 需要随时被其他线程访问(进出注入队列时要读写 queue_next、wake 时要写 state),而 Future 在 poll 时只能被当前 worker 独占访问。混在一起会污染 cache line——两个 worker 对同一 cache line 的读写会触发 cache coherence 协议,性能骤降。
Tokio 的解法不是给 Header 补齐一条 cache line,而是给整个 Cell 加 cache 对齐——core.rs 里 Cell 上面挂着一长串 #[cfg_attr(..., repr(align(N)))](tokio/src/runtime/task/core.rs:52 起,注释出处在 tokio/src/runtime/task/core.rs:34),按目标架构选 N:
// 来源:tokio/src/runtime/task/core.rs:33-115 (tokio-1.40.0),注释摘录、属性合并简写
// 规则抄自 crossbeam-utils/src/cache_padded.rs:
// Sandy Bridge 起 Intel 的空间预取器一次拉两条 64 字节 cache line,
// 所以这里对齐到 128 而不是 64。
#[cfg_attr(any(target_arch = "x86_64", target_arch = "aarch64",
target_arch = "powerpc64"), repr(align(128)))]
#[cfg_attr(target_arch = "s390x", repr(align(256)))]
// arm / mips / mips64 / sparc / hexagon → 32;m68k → 16;其余 → 64
#[repr(C)]
pub(super) struct Cell<T: Future, S> { /* header / core / trailer */ }
也就是说:相邻两个 Task 之间不会共享 cache line,这才是 false sharing 的防御点。至于 Header 本身,64 位平台上只有 4 个字长 = 32 字节(state / queue_next / vtable / owner_id 各 8 字节),远没有填满一条 cache line。
6.2 Header:Task 的神经中枢
Header 是 Task 内存布局里被访问得最频繁的一段——每次 wake、每次 state 转移、每次 refcount 变化都在 Header 上操作。Tokio 把它放在整个 Task 的最前面是经过深思熟虑的——这样 Header 占据的几个字节总是在 CPU cache line 的开头、hot data 和 cold data 分离、cache 利用率最优。这种"把最常访问的字段放前面"的布局策略在系统编程里非常普遍(Linux 内核的 struct page、Java 对象的 mark word、V8 的 object header 全都遵循这个原则)。
// 来源:tokio/src/runtime/task/core.rs:150
#[repr(C)]
pub(crate) struct Header {
pub(super) state: State,
pub(super) queue_next: UnsafeCell<Option<NonNull<Header>>>,
pub(super) vtable: &'static Vtable,
// :160-166 的注释给了两条不变量:owner_id 记的是「它曾在哪个 OwnedTasks 里」,
// 从没进过任何链表才是 None;而且**一旦绑到某个链表就再也不能绑到别的链表**,
// 即使已经从第一个链表里移除
pub(super) owner_id: UnsafeCell<Option<NonZeroU64>>,
#[cfg(all(tokio_unstable, feature = "tracing"))]
pub(super) tracing_id: Option<tracing::Id>,
}
5 个字段,每个都有明确职责:
state: State —— 原子状态 + 引用计数
这是 Header 里唯一的原子字段,也是整个 Task 协调的"引力中心"。任何跨线程的状态变化都在这里:
- worker 要开始 poll Task →
transition_to_running - 其他 worker wake 这个 Task →
transition_to_notified_by_val - Task 完成 →
transition_to_complete(随后transition_to_terminal扣引用计数) - 引用计数加减 →
ref_inc/ref_dec
6.5 节会完整拆开这个字段。
queue_next: UnsafeCell<Option<NonNull<Header>>> —— 链表指针
当 Task 在全局注入队列(inject)里等待被 worker 取走时,它在一个由互斥锁保护的侵入式单链表里(tokio/src/runtime/scheduler/inject.rs:27 的 Mutex<Synced>)——这个字段就是那个链表的 next 指针。
注意包装层次:
UnsafeCell<...>:允许在有&Header的情况下修改字段内容(内部可变)Option<...>:null 表示"不在链表里"NonNull<Header>:非空指针(Rust 类型系统的 niche 优化让Option<NonNull>和*mut Header同大小)
用裸指针而不是 Box<Header> 或 Arc<Header>——因为 Task 的生命周期由 state 里的 refcount 管理,不能再套一层。每次"入队"逻辑上就是在某个 refcount 上的借用,不涉及额外分配。
vtable: &'static Vtable —— 行为表
这就是第 3 章讲 wake 路径时遇到过的第二层 vtable(不是标准库的 Waker vtable,是 Tokio 内部的 Task vtable)。
// 来源:tokio/src/runtime/task/raw.rs:14
pub(super) struct Vtable {
pub(super) poll: unsafe fn(NonNull<Header>),
pub(super) schedule: unsafe fn(NonNull<Header>),
pub(super) dealloc: unsafe fn(NonNull<Header>),
pub(super) try_read_output: unsafe fn(NonNull<Header>, *mut (), &Waker),
pub(super) drop_join_handle_slow: unsafe fn(NonNull<Header>),
pub(super) drop_abort_handle: unsafe fn(NonNull<Header>),
pub(super) shutdown: unsafe fn(NonNull<Header>),
pub(super) trailer_offset: usize,
pub(super) scheduler_offset: usize,
pub(super) id_offset: usize,
}
注意最后三个字段:trailer_offset(tokio/src/runtime/task/raw.rs:37)/ scheduler_offset(tokio/src/runtime/task/raw.rs:40)/ id_offset(tokio/src/runtime/task/raw.rs:43)。这些是从 Header 起始地址到各个字段的字节偏移。
因为 Cell<T, S> 是泛型,不同 T、S 组合的 Core 大小不同,因此不同类型的 Task,Trailer 的位置也不同。但从 Header 指针访问 Trailer 的代码拿不到泛型信息(RawTask 只存一个 Header 指针),没法靠单态化知道偏移。
解决办法:在编译期用 const fn 按 #[repr(C)] 的布局算法算出偏移量,存进 vtable 的这些字段。raw.rs 里的 OffsetHelper<T, S>(tokio/src/runtime/task/raw.rs:67)就干这个——三个关联常量 TRAILER_OFFSET(tokio/src/runtime/task/raw.rs:72)/ SCHEDULER_OFFSET / ID_OFFSET(tokio/src/runtime/task/raw.rs:86)分别由 get_trailer_offset(tokio/src/runtime/task/raw.rs:99)/ get_core_offset(tokio/src/runtime/task/raw.rs:126)/ get_id_offset(tokio/src/runtime/task/raw.rs:142)这三个 const fn 从 size_of / align_of 推出来(源码注释还解释了为什么要绕一层 OffsetHelper:直接在 vtable 里调 const fn 会导致 vtable 无法被提升为 &'static,见 tokio/src/runtime/task/raw.rs:62)。
取 Trailer 就是:
// 来源:tokio/src/runtime/task/core.rs:407-416 (tokio-1.40.0)
/// Gets a pointer to the `Trailer` of the task containing this `Header`.
///
/// # Safety
///
/// The provided raw pointer must point at the header of a task.
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)
}
顺带一提,Cell::new(tokio/src/runtime/task/core.rs:211)在 #[cfg(debug_assertions)] 下还会当场断言这三个偏移量算对了——把 &result.trailer 的真实地址和 Header::get_trailer 算出来的地址比一遍(tokio/src/runtime/task/core.rs:248-tokio/src/runtime/task/core.rs:271)。
Header 指针 + offset → 目标指针。这就是为什么 vtable 要"塞下偏移量"——让运行时代码在不知道 T、S 的情况下也能访问 Cell 的任何字段。
这种 "静态分派 + 运行时偏移"的组合是 Tokio 最巧妙的设计之一。静态分派保证泛型代码被编译器单态化出高效代码;运行时偏移保证外层逻辑只需要 *mut Header、不用到处传泛型 T、S。两者合一,既有泛型的灵活、又有非泛型代码的简洁。
这个模式在 Linux kernel 里叫 "container_of" 模式(用一个成员指针反推 struct 指针);在 C++ 里叫 offsetof trick;在 Rust 生态里 intrusive collections(如 intrusive-collections crate) 也是这种思路。Tokio 的 OwnedTasks 名册内部就是一个侵入式链表(tokio/src/runtime/task/mod.rs:530)——链表里存的是 Header 指针,要改前后指针时经 trailer_offset 找到 Trailer.owned。
vtable 本身怎么分配
有一个细节值得注意:每个 Cell<T, S> 的 vtable 都是 &'static Vtable。静态引用意味着 vtable 必须在整个程序生命周期都存在。
Tokio 靠 Rust 的常量提升(promotion)——raw::vtable::<T, S>() 直接返回 &Vtable { .. } 字面量,编译器把它提升成 &'static(tokio/src/runtime/task/raw.rs:47),每个具体的 (T, S) 类型组合在编译期得到一份对应的 Vtable 静态数据。Task 构造时把静态引用塞进 Header.vtable 字段。
这意味着:
- 不同
(T, S)组合 → 不同的 vtable 静态变量 - 同一
(T, S)组合的多个 Task → 共享同一个 vtable 静态变量 - spawn 一个 Task 不产生任何"创建 vtable" 的开销——vtable 早在二进制里装着了
这是零成本抽象的又一体现——运行时零代价,所有的"多态"在编译期就结束了。
owner_id —— 全局名册 ID
每个 Tokio scheduler 维护一个 OwnedTasks 全局名册——记录所有存活的 Task。每次 spawn 时给 Task 填上 owner_id,shutdown 时 scheduler 遍历这个名册强制 drop 所有 Task。
tracing_id(可选)—— tokio-console 的锚点
tokio_unstable + feature="tracing" 时启用。每个 Task 一个 tracing id——让 tokio-console 能把运行时事件关联到具体 Task。
6.3 Core:Future 和 Scheduler 的承载
Core 段在 Task 的中间位置——它装的是 Task 的"实质内容":用户 spawn 进来的 Future 本体、关联的 Scheduler 引用。Core 不像 Header 那么 hot(只在 poll 时访问)、但它占的空间最大(Future 本体可能有几百甚至上千字节)。把 Core 放中间就是因为它"占地大但访问次数少"——cache 利用率可以让位给 Header。这是"按访问频率分层布局"的典型应用。
// 来源:tokio/src/runtime/task/core.rs:137
#[repr(C)]
pub(super) struct Core<T: Future, S> {
pub(super) scheduler: S,
pub(super) task_id: Id,
pub(super) stage: CoreStage<T>,
}
三个字段:
scheduler: S—— Task 所属的 scheduler 引用(通常是Arc<multi_thread::Handle>或Arc<current_thread::Handle>)。schedule 时用它把自己放进队列task_id: Id—— 唯一标识,用于 JoinError 里报告 "task 42 panicked"(tokio/src/runtime/task/error.rs:152)stage: CoreStage<T>—— Future 本体或 Output(见下)
CoreStage —— Future 和 Output 共享一块内存
// 来源:tokio/src/runtime/task/core.rs:126(`Stage` 在 tokio/src/runtime/task/core.rs:202)
pub(super) struct CoreStage<T: Future> {
stage: UnsafeCell<Stage<T>>,
}
// Stage 定义在同模块
#[repr(C)] // 见 https://github.com/rust-lang/miri/issues/3780
pub(super) enum Stage<T: Future> {
Running(T),
Finished(super::Result<T::Output>), // = Result<T::Output, JoinError>
Consumed,
}
Task 生命周期中有三个阶段:
- Running:Future 还没跑完,
stage里装着 Future 本体 - Finished:Future 跑完了,
stage里装着Result<Output, JoinError>,等 JoinHandle 来收 - Consumed:输出已经被 JoinHandle 取走,
stage是空的,Task 等死
enum 的三个 variant 共享同一块内存,大小取 Running(T) 和 Finished(Result<T::Output, JoinError>) 两者的较大者(别忘了 Finished 装的是 Result 而不是裸 Output——JoinError 里还有 Repr 和 Id 两个字段,见 tokio/src/runtime/task/error.rs:9,所以哪怕 Output 是 (),这一支也不是零大小)——在阶段切换时原地替换,不用额外分配。
这是一个极其节约内存的设计:一个 async fn 返回 String,Future 状态机可能有几百字节,Output 只有 24 字节(String 是 ptr + len + cap),Tokio 会在 Future 跑完后"压缩"这块内存,把 Output 塞进原来 Future 所在的位置。
6.4 Trailer:JoinHandle 的通信槽
Trailer 在 Task 的最后——它是 Task 和 JoinHandle 之间的"通信接口"。按源码注释,这里的数据只在任务创建或结束时才用到(tokio/src/runtime/task/core.rs:181)。放在 Task 最后是因为它是"可选访问"的——不是每个 poll 周期都会碰到。这种"按使用频率从前向后排布"的内存布局让 hot path 的 cache miss 率降到最低。
// 来源:tokio/src/runtime/task/core.rs:183
pub(super) struct Trailer {
pub(super) owned: linked_list::Pointers<Header>,
pub(super) waker: UnsafeCell<Option<Waker>>,
pub(super) hooks: TaskHarnessScheduleHooks,
}
三个字段:
owned: linked_list::Pointers<Header>—— Task 被放进OwnedTasks名册(一个侵入式双向链表)时的 prev / next 指针。它是一个UnsafeCell包着内部的PointersInner<Header>,里面才是prev/next两个Option<NonNull<Header>>外加一个PhantomPinned(tokio/src/util/linked_list.rs:89,那个PhantomPinned是为了不让编译器给可变引用打noalias),一共 16 字节waker: UnsafeCell<Option<Waker>>—— JoinHandle 在.await时把自己的 Waker 存在这里;Task 完成时通过这个 Waker 唤醒 JoinHandle 的持有者hooks: TaskHarnessScheduleHooks(tokio/src/runtime/task/mod.rs:261)—— 只有一个字段task_terminate_callback: Option<TaskCallback>(TaskCallback = Arc<dyn Fn(&TaskMeta<'_>) + Send + Sync>,tokio/src/runtime/task_hooks.rs:36;消费点在tokio/src/runtime/task/harness.rs:337),也就是Builder::on_task_terminate注册的回调。注意before_spawn回调不在这里——它在 spawn 时由 scheduler handle 直接调用,不需要随 Task 一起存活
为什么叫 "Trailer"(尾部):因为它在 Cell 的最后,对应冷数据(访问频率低)。JoinHandle 完成时才用它,和 runtime 调度的 hot 路径无关。把它放在 Future 之后是冷热分离——Future 稍大时,它就落在离 Header 较远的 cache line 上(只有 128 字节的小 Task 里两者仍然挨着)。
6.5 State:一个 AtomicUsize 塞下的整个协议
把整个 Task 生命周期协议压缩到一个 AtomicUsize 里——这是 Tokio 设计里最硬核的工程决策之一。6 个状态位 + 引用计数全部挤在一个 64 位整数里、每次 CAS 操作原子更新整个协议、避免了"状态和 refcount 分裂"这类最难调试的并发 bug。这种"极致压缩 + 原子更新"的思路在系统编程里广泛用于高性能并发数据结构(Linux 内核的 struct page、Java 的 AbstractQueuedSynchronizer 都是同类设计)。
这是整个 Task 最精巧的字段。打开 state.rs,先看定义:
// 来源:tokio/src/runtime/task/state.rs:6
pub(super) struct State {
val: AtomicUsize,
}
就一个 AtomicUsize。但它的 bit layout 极其讲究——一个 usize(64 位平台上 64 位)装下了:
// 来源:tokio/src/runtime/task/state.rs:17-61(各常量原样,注释为本专栏所加)
const RUNNING: usize = 0b0001; // bit 0
const COMPLETE: usize = 0b0010; // bit 1,:21 注明一旦置位就再不会清
const LIFECYCLE_MASK: usize = 0b11; // bits 0-1
const NOTIFIED: usize = 0b100; // bit 2
const JOIN_INTEREST: usize = 0b1_000; // bit 3
const JOIN_WAKER: usize = 0b10_000; // bit 4
const CANCELLED: usize = 0b100_000; // bit 5
const STATE_MASK: usize = LIFECYCLE_MASK | NOTIFIED | JOIN_INTEREST | JOIN_WAKER | CANCELLED;
const REF_COUNT_MASK: usize = !STATE_MASK;
const REF_COUNT_SHIFT: usize = REF_COUNT_MASK.count_zeros() as usize;
const REF_ONE: usize = 1 << REF_COUNT_SHIFT;
const INITIAL_STATE: usize = (REF_ONE * 3) | JOIN_INTEREST | NOTIFIED;
这段代码把 usize 拆成了两部分:
低 6 位:状态位
| Bit | 常量 | 含义 |
|---|---|---|
| 0 | RUNNING | Task 正在被某个 worker poll |
| 1 | COMPLETE | Future 已经 Ready,output 已存储 |
| 2 | NOTIFIED | 有人 wake 了这个 Task(已经或即将入调度队列) |
| 3 | JOIN_INTEREST | JoinHandle 还活着(有人关心这个 Task 的输出) |
| 4 | JOIN_WAKER | Trailer.waker 字段有有效 Waker |
| 5 | CANCELLED | 被取消(abort 或 shutdown) |
RUNNING 和 COMPLETE 互斥——任一时刻至多一个为 1,代表 lifecycle(LIFECYCLE_MASK 在 tokio/src/runtime/task/state.rs:25)。
高位:引用计数
const REF_COUNT_MASK: usize = !STATE_MASK;
const REF_COUNT_SHIFT: usize = REF_COUNT_MASK.count_zeros() as usize;
const REF_ONE: usize = 1 << REF_COUNT_SHIFT;
REF_COUNT_MASK = !STATE_MASK(tokio/src/runtime/task/state.rs:43)—— 状态位的反面就是引用计数位REF_COUNT_SHIFT(tokio/src/runtime/task/state.rs:46)—— 引用计数的起始 bit。它写成REF_COUNT_MASK.count_zeros():STATE_MASK占低 6 位,取反后正好有 6 个 0,所以 shift = 6REF_ONE = 1 << REF_COUNT_SHIFT(tokio/src/runtime/task/state.rs:49)—— "一个引用"对应的数值(= 64)
加减引用计数就是 val.fetch_add(REF_ONE, ...)(tokio/src/runtime/task/state.rs:433)/ val.fetch_sub(REF_ONE, ...)——不会影响状态位(因为 REF_ONE 的低 6 位是 0)。
INITIAL_STATE = (REF_ONE * 3) | JOIN_INTEREST | NOTIFIED
新建 Task 时 state = 3 份引用 + JOIN_INTEREST + NOTIFIED(tokio/src/runtime/task/state.rs:61)。
为什么是 3 份引用?源码注释(tokio/src/runtime/task/state.rs:51-tokio/src/runtime/task/state.rs:60)自己列了这三份:
- JoinHandle 持有一份(调用
spawn返回的那个) OwnedTasks名册持有一份(作为"全局活动 Task 列表")- 即将被 schedule 的 Notified Task 持有一份(push 到 worker 队列的那个)
spawn 时三方同时需要 refcount,所以起始值就是 3。
把多个标志位和计数打包进同一个原子整数,是并发代码里常见的做法——JVM 对象头的 mark word 就把锁状态、GC 年龄等打包在一个字里。
State 可视化:一个 usize 的完整布局图
为了让你彻底记住 state 的 bit 分布,画一张图:
64 位平台上的 State (AtomicUsize) 布局:
bit 63..6 5 4 3 2 1 0
┌──────────────────────┬───────┬───────────┬──────────────┬──────────┬─────────┬─────────┐
│ Reference Count │CANCEL │JOIN_WAKER │ JOIN_INTEREST│ NOTIFIED │ COMPLETE│ RUNNING │
│ (58 bits, 64x each) │ (1) │ (1) │ (1) │ (1) │ (1) │ (1) │
└──────────────────────┴───────┴───────────┴──────────────┴──────────┴─────────┴─────────┘
INITIAL_STATE:
┌──────────────────────┬───────┬───────────┬──────────────┬──────────┬─────────┬─────────┐
│ 3 │ 0 │ 0 │ 1 │ 1 │ 0 │ 0 │
└──────────────────────┴───────┴───────────┴──────────────┴──────────┴─────────┴─────────┘
refcount=3 无cancel 无waker 有JOIN_INTEREST 已notified 未complete 未running
对应的十六进制值(假设 REF_ONE = 64 = 0x40):
INITIAL_STATE = (3 * 0x40) | 0x08 | 0x04 = 0xC0 | 0x08 | 0x04 = 0xCC
一个 usize 同时承载 6 个 1-bit 状态标志 + 1 个 58-bit 引用计数——这就是 Tokio 最标志性的一段设计。
为什么不用 enum
你可能会问:为什么不用 enum TaskState { Idle, Running, Complete, ... } 加一个独立的 AtomicUsize refcount?
四个原因:
- 原子性:enum + 单独 refcount 意味着两个 atomic 字段——"状态转移 + refcount 变化"要两次原子操作,不能同时完成,中间可能被观察到不一致状态
- 内存开销:两个 atomic 字段占 16 字节;合一个只占 8 字节,Header 也因此能压到 32 字节
- CAS 效率:两个 atomic 的协同更新需要 2 次 CAS + 可能的 retry,失败率和开销双倍
- 复合状态:NOTIFIED 可以和 RUNNING 并存(RUNNING 中又被 wake),JOIN_WAKER 可以和任意 lifecycle 并存——用 enum 要写大量
X_With_Y_And_Z组合 variant
原子性是决定性的。其他三点都是额外红利。
#[repr(C)] 的含义和代价
这里补一个关于 #[repr(C)] 的技术提示。你在本章看到 Cell / Header / Core 都标了这个。它告诉 Rust:
- 字段按声明顺序排列(不重排)
- 按 C ABI 规则对齐(每个字段按自身的对齐要求放置,整个 struct 对齐到最大字段对齐)
- ABI 稳定(和 C / 其他语言可以安全交互)
代价:可能比 #[repr(Rust)] 多 padding。例如:
// 默认 #[repr(Rust)] 下 Rust 可以重排字段减少 padding
struct A {
a: u8,
b: u64,
c: u8,
}
// Rust 可能重排成 (u64, u8, u8, padding: 6),总 16 字节
// #[repr(C)] 强制按声明顺序
#[repr(C)]
struct B {
a: u8, // offset 0
// padding 7 字节(b 的 u64 对齐要求)
b: u64, // offset 8
c: u8, // offset 16
// padding 7 字节(struct 整体对齐到 8)
}
// 总 24 字节
Tokio 接受这个代价是为了:offset 必须是编译期已知的、稳定的——vtable 里存的 offset 要和真实内存布局一致,不能让编译器任意重排。
6.6 状态转移:fetch_update_action 的 CAS 循环
"CAS 循环"是所有无锁数据结构的核心模式——读当前状态、计算新状态、尝试 CAS、如果冲突就重试。Tokio 的 fetch_update_action 把这个模式封装成了一个复用工具、让各种状态转移(schedule / wake / finish / drop)共享同一套 CAS 循环代码。本节把这个封装的精巧之处讲清——读完你会看到"如何用一个通用工具支撑十几种不同的状态转移"这种高级抽象能力。
绝大多数 state 变化都通过一个内部辅助函数 fetch_update_action(tokio/src/runtime/task/state.rs:470)实现——它本质上是一个 CAS 循环(例外见下文案例三):
// 简化示意:大多数 transition_to_* 方法的共同结构
fn fetch_update_action<F, T>(&self, mut f: F) -> T
where F: FnMut(Snapshot) -> (T, Option<Snapshot>)
{
let mut current = self.val.load(Acquire);
loop {
let snapshot = Snapshot(current);
let (result, new) = f(snapshot);
let new_val = match new { Some(n) => n.0, None => return result };
match self.val.compare_exchange(current, new_val, AcqRel, Acquire) {
Ok(_) => return result,
Err(actual) => current = actual, // race, retry
}
}
}
每次状态转移的步骤:
- 读当前值
- 用闭包决定"应该转成什么" + "返回给调用者什么 action"
- CAS 尝试更新——失败则循环重试
整个过程是原子的——状态位和 refcount 作为一个整体被更新。
案例一:transition_to_running
原样(删去了源码注释):
// 来源:tokio/src/runtime/task/state.rs:111
pub(super) fn transition_to_running(&self) -> TransitionToRunning {
self.fetch_update_action(|mut next| {
let action;
assert!(next.is_notified());
if !next.is_idle() {
next.ref_dec();
if next.ref_count() == 0 {
action = TransitionToRunning::Dealloc;
} else {
action = TransitionToRunning::Failed;
}
} else {
next.set_running();
next.unset_notified();
if next.is_cancelled() {
action = TransitionToRunning::Cancelled;
} else {
action = TransitionToRunning::Success;
}
}
(action, Some(next))
})
}
决策逻辑:
- 前提:
is_notified()—— 只有被 notify 过的 Task 才会进到这里 - 如果 Task 不在 idle(已经 RUNNING 或 COMPLETE)→ refcount -1 → 根据结果返回 Failed / Dealloc
- 如果在 idle → 设置 RUNNING、清 NOTIFIED → 检查是否被 cancel → Success 或 Cancelled
一次 CAS 完成":状态位改 2 bit + 可能的 refcount -1 + 给调用者明确 action。
案例二:transition_to_notified_by_val(回到第 3 章)
第 3 章讲 waker.wake_by_val 的内部实现时引用过这段,现在我们有了完整 bit layout 的上下文,可以看得更清楚:
// 来源:tokio/src/runtime/task/state.rs:209
pub(super) fn transition_to_notified_by_val(&self) -> TransitionToNotifiedByVal {
self.fetch_update_action(|mut snapshot| {
let action;
if snapshot.is_running() {
snapshot.set_notified();
snapshot.ref_dec();
assert!(snapshot.ref_count() > 0);
action = TransitionToNotifiedByVal::DoNothing;
} else if snapshot.is_complete() || snapshot.is_notified() {
snapshot.ref_dec();
if snapshot.ref_count() == 0 {
action = TransitionToNotifiedByVal::Dealloc;
} else {
action = TransitionToNotifiedByVal::DoNothing;
}
} else {
snapshot.set_notified();
snapshot.ref_inc();
action = TransitionToNotifiedByVal::Submit;
}
(action, Some(snapshot))
})
}
三个分支的语义:
- Task 正在 RUNNING:设置 NOTIFIED 位(worker 当前 poll 完会看到、把它重新调度)、refcount -1(调用者的 Waker 消费),action = DoNothing(不额外入队)
- Task 已 COMPLETE 或已 NOTIFIED:refcount -1;如果归零就 Dealloc
- Task 处于 idle 且未 notified:设置 NOTIFIED + refcount +1(为即将 schedule 的新 Task 预留),action = Submit(需要外层真的把 Task 入队)
一次 CAS 做了:状态位改、refcount 改、给调用者明确指令。这就是 Tokio 调度正确性的基石。
ref_inc 的简洁实现
// 来源:tokio/src/runtime/task/state.rs:433(省略了中间引用 Boost 文档的那段长注释)
pub(super) fn ref_inc(&self) {
use std::process;
use std::sync::atomic::Ordering::Relaxed;
let prev = self.val.fetch_add(REF_ONE, Relaxed);
if prev > isize::MAX as usize {
process::abort();
}
}
6 行代码,两个要点:
- Relaxed ordering:加 refcount 不需要同步别的内存(Arc 的 clone 也用 Relaxed)。需要更强顺序的是减计数——Tokio 的
ref_dec用AcqRel(tokio/src/runtime/task/state.rs:458),保证减到 0、释放内存之前能看到其他线程的写入 - 溢出检查 →
process::abort:如果 refcount 超过isize::MAX(理论上 64 位平台几乎不可能达到),整个进程 abort——这是防止 refcount wrap 到 0 导致双重释放的经典技巧,和 Rust 标准库 Arc 一致
案例三:transition_to_complete —— 一次 fetch_xor 关掉 RUNNING、打开 COMPLETE
Future 返回 Ready 时 Task 进终态。这一步根本没用 CAS 循环,因为 RUNNING 必然是 1、COMPLETE 必然是 0,一次 fetch_xor 就能把两位同时翻转:
// 来源:tokio/src/runtime/task/state.rs:178 (tokio-1.40.0)
pub(super) fn transition_to_complete(&self) -> Snapshot {
const DELTA: usize = RUNNING | COMPLETE;
let prev = Snapshot(self.val.fetch_xor(DELTA, AcqRel));
assert!(prev.is_running());
assert!(!prev.is_complete());
Snapshot(prev.0 ^ DELTA)
}
已知当前位形态时,fetch_xor 比 CAS 循环更划算——它是无条件成功的单条 RMW 指令,不会重试。
唤醒 JoinHandle 的动作在原子操作之外,由 Harness::complete(tokio/src/runtime/task/harness.rs:311)拿着返回的 snapshot 决定:
// 来源:tokio/src/runtime/task/harness.rs:311 (tokio-1.40.0,节选)
let snapshot = self.state().transition_to_complete();
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
if !snapshot.is_join_interested() {
// JoinHandle 已经没了,output 由我们负责 drop
self.core().drop_future_or_output();
} else if snapshot.is_join_waker_set() {
self.trailer().wake_join();
}
}));
// ... on_task_terminate 回调 ...
let num_release = self.release();
if self.state().transition_to_terminal(num_release) {
self.dealloc();
}
顺带澄清一个容易混淆的名字:transition_to_terminal(count)(tokio/src/runtime/task/state.rs:192)不管状态位,它只是 fetch_sub(count * REF_ONE),返回"减完之后 refcount 是不是 0"——也就是"要不要 dealloc"(release 在 tokio/src/runtime/task/harness.rs:357,dealloc 在 tokio/src/runtime/task/harness.rs:252)。
为什么不在原子操作里直接 wake?因为 waker.wake() 可能触发任意代码(被唤醒的 Task 又去 spawn),而 fetch_update_action 的闭包在 CAS 失败时会被重新调用——副作用会重复执行。
这是一个常见的无锁编程陷阱——Tokio 的处理很规范:状态转移在原子里做,副作用在原子外做(还统统包了 catch_unwind,因为 drop output 和 wake 都可能 panic)。
6.7 Task 的完整生命周期
本节把 Task 从"诞生"(spawn)到"死亡"(dealloc)的每一个关键节点串成一条时间轴。Task 的生命周期比普通对象复杂得多——它涉及多个所有者(JoinHandle、Scheduler、Notification queue)、多次状态转移、幂等 wake、和复杂的 refcount 协议。读这一节最好的方法是:把 Task 想象成一个有生命的"小人"——它被 spawn 函数"创造"出来、在调度器里"活动"、被 poll 时"工作"、被 wake 时"觉醒"、最终 refcount 归零时"逝去"。这种拟人化理解能让你对复杂的生命周期协议产生直觉。
把前 6 节拼起来,一个 Task 从生到死:
Step 1:spawn
let handle = tokio::spawn(async { compute() });
- 调
tokio::spawn→spawn_inner→ 当前 runtime 的Handle::spawn→OwnedTasks::bind→new_task(完整路径见 6.11 节) RawTask::new→Cell::new(future, scheduler, State::new(), id)—— 一次堆分配,大小 = sizeof(Header) + sizeof(Core) + sizeof(Trailer)(再按Cell的 cache line 对齐向上取整)- 初始化 Header.state =
INITIAL_STATE(refcount=3 + JOIN_INTEREST + NOTIFIED) - 把 Task 放进 scheduler 的
OwnedTasks名册(一个引用) - 把 Notified Task 放进 worker 的本地队列或 LIFO slot(一个引用;不在 worker 线程上 spawn 时进注入队列)
- 返回 JoinHandle(一个引用)——三引用合计
Step 2:首次 poll
- worker 从队列拿到 Notified Task → 调
Harness::poll(tokio/src/runtime/task/harness.rs:152,内层poll_inner在tokio/src/runtime/task/harness.rs:192) transition_to_running→ state 从 NOTIFIED 变 RUNNING- 调 vtable.poll → 内部最终调 Future::poll
Step 3:Pending 路径
- Future 返回 Pending → Future 已经通过
cx.waker()注册到某个 Driver - worker 的 Harness::poll 调
transition_to_idle(state 从 RUNNING 变 idle) - Task 在队列外"沉睡",等 Waker 被触发
Step 4:被 wake
- Driver 触发
waker.wake()→ 6 层调用路径(第 3 章讲过)最终调transition_to_notified_by_val - 状态变 NOTIFIED,Task 重新入队
- 某个 worker pop 到、重新 poll
Step 5:Ready
- Future 返回 Ready(output),
poll_future当场把 output 写进CoreStage::Finished——这一步发生在任何状态位改动之前 Harness::poll拿到PollFuture::Complete分派给Harness::completetransition_to_complete一次fetch_xor关 RUNNING、开 COMPLETE(注意transition_to_terminal不碰状态位,它只减 refcount,见 6.6 节)- 如果 JOIN_INTEREST 已经没了 → 直接 drop output;否则如果 JOIN_WAKER 置位(JoinHandle 之前 .await 过)→
trailer().wake_join()唤醒 JoinHandle 持有者 release()把自己从OwnedTasks摘掉,transition_to_terminal(num_release)一次性扣掉相应的 refcount
Step 6:JoinHandle.await 取走 output
- JoinHandle 的 Future 实现再次 poll → 看到 COMPLETE → 调 vtable.try_read_output
- CoreStage 从 Finished 变 Consumed
- JoinHandle 随后被 drop 时 refcount -1(它的那份)
Step 6½:COMPLETE 状态下的 CoreStage 替换
这一步最有意思——也展示了 Rust 零成本抽象的一个高光时刻。
Future 返回 Ready(output) 时,Core::poll 先把 Future 丢弃(stage 置为 Consumed),poll_future 再经 Core::store_output 写入输出:
// 简化示意(真实代码分散在 core.rs 的 Core::poll / store_output 与 harness.rs 的 poll_future)
let output = /* 从 Future::poll 返回值拿到 */;
let stage = unsafe { &mut *self.core().stage.stage.get() };
// 从 Running(Future) 原地替换成 Finished(Result<Output>)
*stage = Stage::Finished(Ok(output));
同一块内存从"装 Future 状态机"变成"装 Output Result"。enum 的 tag 切换,其余字节复用。
这个"阶段化的内存复用"把 spawn 的内存成本降到极致:
- 如果 Future 有 800 字节状态机、Output 只有 24 字节
String - 别的设计方案:Future 跑完 → 分配 24 字节堆空间装 Output → 释放 800 字节 Future
- Tokio 方案:原地在这块内存里装
Result<String, JoinError>(只用到其中一小部分,其余闲置)→ Task 整个释放时一次性回收
省掉了 Output 的单独堆分配——一个 Task 一辈子只在 spawn 那一次分配堆。
Step 7:释放
- 从 OwnedTasks 摘除早在 Step 5 的
release()里就做了(scheduler 交还那份引用,由transition_to_terminal一并扣掉),不需要 scheduler 事后遍历 - 最后一个持有者(常见的是 JoinHandle 被 drop)让 refcount 归零 → dealloc → 释放整块 Cell 内存
整个 Task 一次堆分配、零额外分配。refcount 从 3 到 2 到 1 到 0,每一步都是原子的 CAS 或 fetch_sub。
6.8 Harness:Task 的"操作手柄"
Harness 是 Tokio 对 Task 进行类型擦除后的操作接口——它隐藏了具体 Future 类型、暴露统一的 poll / wake / abort 接口。这是 Rust 里"类型擦除 + 类型恢复"模式的高级应用:Tokio 需要在一个统一的调度器里管理不同类型的 Future(spawn async fn A() 和 spawn async fn B() 的 Future 类型不同),必须有一层抽象把它们当成同一种东西。Harness 就是这层抽象。
Harness<T, S> 是 Tokio 源码里所有 Task 泛型逻辑的集中地。它的定义简洁:
// 来源:tokio/src/runtime/task/harness.rs:16
pub(super) struct Harness<T: Future, S: 'static> {
cell: NonNull<Cell<T, S>>,
}
只一个字段——指向 Cell 的裸指针,所以 Harness 和一个裸指针一样大(不是 ZST,是"零额外开销的指针包装")。它把 Cell 的所有需要泛型 T、S 的操作集中到一处:
// 来源:tokio/src/runtime/task/harness.rs:137 (tokio-1.40.0,方法签名节选,函数体略)
impl<T: Future, S: Schedule> Harness<T, S> {
pub(super) fn poll(self) { /* 完整 Future 驱动逻辑 */ }
pub(super) fn shutdown(self) { /* shutdown 强制结束 */ }
pub(super) fn dealloc(self) { /* 释放 Cell */ }
pub(super) fn try_read_output(self, dst: &mut Poll<..>, waker: &Waker) { /* JoinHandle 取 output */ }
pub(super) fn drop_join_handle_slow(self) { /* JoinHandle drop 的慢路径 */ }
}
和它对照的是同一个文件里非泛型的 impl RawTask——注释写着"Only one version of these methods should exist in the"「final binary」(这些方法在最终二进制里只该有一份,tokio/src/runtime/task/harness.rs:52-tokio/src/runtime/task/harness.rs:54):
impl RawTask {
pub(super) fn drop_reference(self) { /* refcount -1 + 可能 dealloc */ }
pub(super) fn wake_by_val(&self) { /* 第 3 章看过 */ }
pub(super) fn wake_by_ref(&self) { /* 第 3 章看过 */ }
pub(super) fn remote_abort(&self) { /* abort 入口 */ }
pub(super) fn try_set_join_waker(&self, waker: &Waker) -> bool { }
}
这条泛型 / 非泛型的分界线是有意画的:能不依赖 T、S 的逻辑就别单态化,否则 spawn 一百种 Future 就会在二进制里复制一百份同样的代码。Tokio 源码里到处能看到"Separated to reduce LLVM codegen"这样的注释,就是在守这条线。
Harness::poll 的完整路径
harness.rs 的 poll 函数(简化):
真实结构是两层:poll 只负责分派 poll_inner 返回的四种结果,状态转移全在 poll_inner 里。
// 来源:tokio/src/runtime/task/harness.rs:152(`poll_inner` 在 tokio/src/runtime/task/harness.rs:192)
pub(super) fn poll(self) {
// We pass our ref-count to `poll_inner`.
match self.poll_inner() {
PollFuture::Notified => {
// poll 期间又被 wake 了:把自己重新交回 scheduler(yield)
self.core().scheduler.yield_now(Notified(self.get_new_task()));
self.drop_reference();
}
PollFuture::Complete => self.complete(),
PollFuture::Dealloc => self.dealloc(),
PollFuture::Done => (),
}
}
fn poll_inner(&self) -> PollFuture {
match self.state().transition_to_running() {
TransitionToRunning::Success => {
let header_ptr = self.header_ptr();
let waker_ref = waker_ref::<S>(&header_ptr);
let cx = Context::from_waker(&waker_ref);
let res = poll_future(self.core(), cx);
if res == Poll::Ready(()) {
return PollFuture::Complete; // Future 完成
}
let transition_res = self.state().transition_to_idle();
if let TransitionToIdle::Cancelled = transition_res {
cancel_task(self.core()); // poll 期间被 abort 了
}
// Ok → Done;OkNotified → Notified;OkDealloc → Dealloc;Cancelled → Complete
transition_result_to_poll_future(transition_res)
}
TransitionToRunning::Cancelled => {
cancel_task(self.core());
PollFuture::Complete
}
TransitionToRunning::Failed => PollFuture::Done,
TransitionToRunning::Dealloc => PollFuture::Dealloc,
}
}
注意 PollFuture::Notified 这条分支:Future 返回 Pending,但在 poll 期间又被 wake 了(transition_to_idle 看到 NOTIFIED 位还在),于是走 yield_now 把自己重新排到队尾——而不是原地立刻重 poll,这样其他任务才有机会跑。
这就是 Task 的"心跳"——每次 worker 调用 vtable.poll,走的就是这段代码。一个 Task 在生命周期中可能被调几次到几千次(取决于它 pending 多少次)。
6.9 JoinHandle:如何"等 Task 完成"
JoinHandle<T> 是 Tokio 面向用户的"Task 句柄"——你 spawn 一个 task 得到一个 JoinHandle、await 它获得 task 的返回值。表面看它和线程的 JoinHandle 类似,但实现机制截然不同——它基于 Task Trailer 里的通信槽实现,整个机制是无锁的、支持多次 poll。这一节把 JoinHandle 的实现拆清——读完你对"如何在并发环境中安全地等待一个异步任务完成"会有完整理解。
// 来源:tokio/src/runtime/task/join.rs:156(`impl Future` 在 tokio/src/runtime/task/join.rs:324;注释为本专栏改写)
pub struct JoinHandle<T> {
raw: RawTask,
_p: PhantomData<T>,
}
impl<T> Future for JoinHandle<T> {
type Output = super::Result<T>; // = Result<T, JoinError>
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
ready!(crate::trace::trace_leaf(cx));
let mut ret = Poll::Pending;
// 协作式预算:JoinHandle 自己也算一次"进展"
let coop = ready!(crate::runtime::coop::poll_proceed(cx));
// 返回值的槽位先放在栈上,再以 *mut () 传进 vtable——这样调用点不需要泛型
unsafe {
self.raw
.try_read_output(&mut ret as *mut _ as *mut (), cx.waker());
}
if ret.is_ready() {
coop.made_progress();
}
ret
}
}
注意两个细节:Future for JoinHandle<T> 没有 T: 'static 约束;以及返回值槽位 ret 是先在栈上建好、再把 *mut () 传进 vtable 的——因为 vtable 函数不能带泛型。
try_read_output 是 vtable 的一员——它做三件事:
- 读当前 state
- 如果 COMPLETE:从 CoreStage 读取 output 写入调用方的槽位
- 如果不 COMPLETE:把 cx 的 Waker 存到 Trailer.waker,设置 JOIN_WAKER 位——等 Task 完成时 Task 侧会自动调这个 Waker
JOIN_WAKER 是"有效 Waker 存在"的标志——Task 完成时根据这个位决定要不要调 Trailer.waker 的 Waker。
这里有个微妙的 race:JoinHandle 存 Waker 的同时,Task 可能刚好完成并去读 Waker——两边必须约好谁在什么时候能碰 Trailer.waker 这个字段。Tokio 把这套约定写成了 6 条规则,就放在 tokio/src/runtime/task/mod.rs:82-tokio/src/runtime/task/mod.rs:99 的模块注释里(源码里到处能看到对这些规则的引用,例如 tokio/src/runtime/task/harness.rs:326 的注释末尾就写着「safe per rule 4」)。落到代码上是 State::set_join_waker(tokio/src/runtime/task/state.rs:397)/ State::unset_waker(tokio/src/runtime/task/state.rs:417)这一对 fetch_update:换 waker 必须先清 JOIN_WAKER 再写字段再置位,任一步撞上 COMPLETE 就放弃、改为直接读输出。这段协议由 Loom 穷举线程交错来验证。
JoinHandle 的一个反直觉行为:drop 不会取消 Task
std::mem::drop(handle) 并不会停止底层 Task。Task 继续在后台跑到完成。这个行为出乎很多人的意料——他们期待"JoinHandle drop = task 结束",像线程句柄那样。
Tokio 选择了"不取消"是因为:
- 异步代码经常 spawn "fire and forget" 的后台任务(日志刷盘、metrics 上报)——这些 task 的 JoinHandle 本来就不该阻止 drop
- 如果 drop = cancel,用户每次都得手动保留 handle、在合适的时机检查——代码复杂度爆炸
如果你真的想"drop 时取消",自己在 Drop 里调 abort(),或用 tokio-util 里的包装器 AbortOnDropHandle(注意 AbortHandle 被 drop 时同样不会取消任务)。
drop JoinHandle 会发生什么
drop(handle) 的副作用(tokio/src/runtime/task/join.rs:358):
- 走快路径
State::drop_join_handle_fast(tokio/src/runtime/task/state.rs:353):只有 state 还原封不动等于INITIAL_STATE(也就是这个 handle 从没被 poll 过、Task 也没动过)时那次compare_exchange_weak才成功,一步清掉 JOIN_INTEREST 并把 refcount 减 1 - 快路径失败就走
drop_join_handle_slow(tokio/src/runtime/task/harness.rs:286):unset_join_interested清 JOIN_INTEREST;如果这一步发现 Task 已经 COMPLETE,说明 output 归自己负责,当场drop_future_or_output;最后 refcount -1 - JOIN_WAKER 位不会在这里被清。
unset_join_interested(tokio/src/runtime/task/state.rs:378)只动 JOIN_INTEREST 一位;模块注释(tokio/src/runtime/task/mod.rs:98)里的规则 6 也写明 JoinHandle 只能在 COMPLETE 为 0 时改 JOIN_WAKER。Trailer 里那个Option<Waker>是在整块 Celldealloc时随 Trailer 一起析构的
如果此时 Task 还没完成,它继续跑。完成后因为 JOIN_INTEREST=0,output 会被直接 drop(不再有人要取)。这是合理的行为——没人等,就没必要保留输出。
6.10 Task abort 和 cancellation 的机制
JoinHandle::abort() 或 AbortHandle::abort() 被调用时,Tokio 做什么?
答案是——它几乎什么都不做,只做一件最轻的事:设置 state 里的 CANCELLED 位。具体代码(简化自 state.rs):
// 来源:tokio/src/runtime/task/state.rs:296(简化,非逐字)
// 返回值:是否需要外层把 Task 重新提交给 scheduler
pub(super) fn transition_to_notified_and_cancel(&self) -> bool {
self.fetch_update_action(|mut snapshot| {
if snapshot.is_cancelled() || snapshot.is_complete() {
// 对已取消 / 已完成的 Task,abort 是 no-op
(false, None)
} else if snapshot.is_running() {
// 正在跑:只打标记,跑它的那个线程停 poll 时会看到 CANCELLED
snapshot.set_notified();
snapshot.set_cancelled();
(false, Some(snapshot))
} else {
// 空闲:打标记,并且只有在 NOTIFIED 原本没置位时才需要真的入队
snapshot.set_cancelled();
if !snapshot.is_notified() {
snapshot.set_notified();
snapshot.ref_inc();
(true, Some(snapshot))
} else {
(false, Some(snapshot))
}
}
})
}
abort 不会"强制立刻停止"正在 poll 的 Future——它标记 CANCELLED,然后等 Task 下次被 poll 时 transition_to_running 会返回 Cancelled(正在 poll 的,则在这次 poll 结束、transition_to_idle 返回 Cancelled 时)、Harness 进入 cancel 流程、drop 整个 Future 状态机(也就是"取消"的本质)。
如果 Task 当前正在 poll 一段纯 CPU 循环(没有 .await),它不会响应 abort——直到它在某个 .await 上真正返回 Pending、这次 poll 结束时才被取消。这就是 JoinHandle::abort() 不保证立刻停止的根本原因。
这是协作式调度的又一体现:Task 必须自己让出执行权,Tokio 不会抢占。对比 Go 的 context.Context + 定期检查也是类似——只是 Go 代码习惯手动查 ctx.Err(),Rust async 因为 .await 频繁,检查发生得更自然。
abort 的幂等性
abort() 可以被调任意多次,都是幂等的——上面代码第一个分支就写死了:is_cancelled() || is_complete() 时返回 (false, None),None 意味着连 CAS 都不发,直接返回。已经 CANCELLED 的 Task 再被 abort 没有任何副作用。
这个幂等性让 abort 在并发环境下极其安全:两个线程同时 abort 同一个 Task,不会有 race 导致的双重 drop 或其他异常。
6.11 spawn 真实代码的完整路径
从用户视角的 tokio::spawn(fut) 到一个 Task 真的进入 scheduler 队列——这整条路径只有大约几百行代码,但它触达了本章前几节讲的所有机制。简化版:
// ① 顶层 API:tokio/src/task/spawn.rs:165
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where F: Future + Send + 'static, F::Output: Send + 'static {
// debug 模式下 Future 太大就先 Box::pin 一下,免得在栈上炸掉
// BOX_FUTURE_THRESHOLD = 2048,定义在 tokio/src/runtime/mod.rs:397
if cfg!(debug_assertions) && std::mem::size_of::<F>() > BOX_FUTURE_THRESHOLD {
spawn_inner(Box::pin(future), None)
} else {
spawn_inner(future, None)
}
}
// ② 同文件 tokio/src/task/spawn.rs:180:spawn_inner —— 分配 Id,然后找到当前 runtime 的 Handle
pub(super) fn spawn_inner<T>(future: T, name: Option<&str>) -> JoinHandle<T::Output> {
let id = task::Id::next();
let task = crate::util::trace::task(future, "task", name, id.as_u64());
match context::with_current(|handle| handle.spawn(task, id)) {
Ok(join_handle) => join_handle,
Err(e) => panic!("{}", e), // 不在 runtime 上下文里 → panic
}
}
// ③ tokio/src/runtime/scheduler/multi_thread/handle.rs:51
pub(super) fn bind_new_task<T>(me: &Arc<Self>, future: T, id: task::Id) -> JoinHandle<T::Output> {
let (handle, notified) = me.shared.owned.bind(future, me.clone(), id);
me.task_hooks.spawn(&TaskMeta { /* ... */ }); // on_task_spawn 回调
me.schedule_option_task_without_yield(notified); // 入队
handle
}
// ④ tokio/src/runtime/task/list.rs:89:OwnedTasks::bind —— 建 Task 并登记进名册
pub(crate) fn bind<T>(&self, task: T, scheduler: S, id: super::Id)
-> (JoinHandle<T::Output>, Option<Notified<S>>)
{
let (task, notified, join) = super::new_task(task, scheduler, id);
let notified = unsafe { self.bind_inner(task, notified) };
(join, notified)
}
// ⑤ tokio/src/runtime/task/mod.rs:295:一次分配,三份 refcount 对应三个返回值
fn new_task<T, S>(task: T, scheduler: S, id: Id)
-> (Task<S>, Notified<S>, JoinHandle<T::Output>)
{
let raw = RawTask::new::<T, S>(task, scheduler, id); // → Cell::new(.., State::new(), id)
let task = Task { raw, _p: PhantomData };
let notified = Notified(Task { raw, _p: PhantomData });
let join = JoinHandle::new(raw);
(task, notified, join)
}
5 层调用,一次 Cell::new 堆分配——整个 spawn 完成。返回的三个对象(Task(进 OwnedTasks)、Notified(进队列)、JoinHandle(给用户))共享同一块内存,正好对应 INITIAL_STATE 里的三份 refcount。这就是"spawn 轻量级"的机械本质。
注意 bind_inner(tokio/src/runtime/task/list.rs:106)返回的是 Option<Notified<S>>——如果此刻 OwnedTasks 已经被 shutdown 关闭,绑定会失败并返回 None,Task 当场被取消,不会有"runtime 关了还往队列里塞任务"的漏网之鱼。
6.12 Task 和调度器的双向绑定
Task 和调度器之间不是"单向关系"——不是调度器单方面拥有 Task、也不是 Task 单方面引用调度器。它们是双向绑定:Task 持有调度器的句柄(为了 wake 时能知道 schedule 到哪里)、调度器持有 Task 的 refcount(OwnedTasks)。这种双向持有需要精心设计生命周期协议——任何一方提前释放都会导致另一方的悬垂引用。本节讲 Tokio 怎么通过 Arc + refcount 优雅地解决这个双向绑定问题。
Core 里的 scheduler: S 字段是反向引用——Task 知道自己属于哪个 scheduler。这让 Waker::wake 触发时,可以从 Task Header 找到 scheduler、把 Task push 回队列:
// 第 3 章讲过的 raw.rs schedule 函数
unsafe fn schedule<S: Schedule>(ptr: NonNull<Header>) {
let scheduler = Header::get_scheduler::<S>(ptr); // 通过 scheduler_offset 取
scheduler.as_ref().schedule(Notified(Task::from_raw(ptr.cast())));
}
Task ←→ scheduler 是双向引用:
- Scheduler 通过 OwnedTasks 持有 Task
- Task 通过 Core.scheduler 反向持有 scheduler 的
Arc<Handle>
这个双向引用不会循环泄漏——因为 Scheduler 在 shutdown 时会主动 drop 所有 Task(遍历 OwnedTasks),断开所有 Task → scheduler 的反向引用。无 GC 的 Rust 能安全支撑这种循环引用,靠的是显式的生命周期管理。
6.13 内存布局实测(1.40.0):一个真实 Task 占多少字节
把 6.1-6.4 拼起来,计算一下 Tokio 1.40 一个真实 Task 的内存占用(64 位 Linux x86_64,无 tracing feature):
Header
state: State= 1 个 AtomicUsize = 8 字节queue_next: UnsafeCell<Option<NonNull<Header>>>= 8 字节(niche 优化 Option)vtable: &'static Vtable= 8 字节owner_id: UnsafeCell<Option<NonZeroU64>>= 8 字节(niche 优化) (tokio_unstable + tracing时还多一个Option<tracing::Id>)
小计:32 字节。注意 Header 自己没有任何对齐属性——repr(align(...)) 是加在外层 Cell 上的。
Core
scheduler: Arc<multi_thread::Handle>= 1 指针 = 8 字节task_id: Id= 1 个 u64 = 8 字节stage: CoreStage<T>=UnsafeCell<Stage<T>>里存 enum = max(sizeof(T), sizeof(Result<Output, JoinError>)) + tag——注意Finished那一支装的是Result<T::Output, JoinError>而不是裸 Output,所以即使 Output 是(),这一支也要为JoinError(Repr+Id两个字段,tokio/src/runtime/task/error.rs:9)留位置
小计:16 字节 + stage 大小。Future 大小取决于 async fn 复杂度:
- 一个
async fn noop() {}展开 = 1 字节(只有状态 tag;rustc 1.96.0 实测) - 一个调了几层
.await的 async fn = 几十到几百字节 - 一个含大 buffer 的复杂 async fn = 可能几 KB
Trailer
owned: linked_list::Pointers<Header>= 16 字节(UnsafeCell里两个Option<NonNull<Header>>,各 8 字节)waker: UnsafeCell<Option<Waker>>= 16 字节(Waker是 data 指针 + vtable 引用;Option借 vtable 引用的 niche,不额外占位)hooks: TaskHarnessScheduleHooks(tokio/src/runtime/task/mod.rs:261)= 一个Option<TaskCallback>,TaskCallback = Arc<dyn Fn(&TaskMeta<'_>) + Send + Sync>(tokio/src/runtime/task_hooks.rs:36)是胖指针 = 16 字节
小计:48 字节。
总计
固定部分 = 32(Header)+ 16(Core 里 scheduler + task_id)+ 48(Trailer)= 96 字节,再加上 stage 里 max(sizeof(Future), sizeof(Result<Output, JoinError>)) 和 enum tag。
但这不是最终占用:Cell<T, S> 带 repr(align(128))(x86_64/aarch64/powerpc64),所以实际分配的字节数会向上取整到 128 的倍数——一个"什么都不干"的 Task 也要吃满 128 字节。这是拿空间换 false sharing 的自觉取舍。
Cell 是私有类型,没法直接 size_of;用一个记录分配请求的 #[global_allocator] 在 tokio =1.40.0(x86_64 Linux,rustc 1.96.0,release)上实测 tokio::spawn 的那次堆分配:spawn(noop()) 请求 128 字节、对齐 128,恰好是上面算的 32 + 48(scheduler 8 + task_id 8 + stage 32)+ 48;Future 里带一个 [u8; 200] 时是 384 字节,带 [u8; 1000] 时是 1152 字节——都是 128 的倍数,且 spawn 期间只有这一次分配。
对比一下同类"并发单位"的起步内存:
- Go goroutine:初始栈 2 KB(Go 官方仓库 runtime 包栈实现里的最小栈常量
stackMin = 2048,旧版本叫_StackMin) - Linux pthread:默认栈 8 MB(
ulimit -s的典型值,虚拟内存,按页实缴) - Tokio Task:128 字节起 + Future 状态机大小
这是几个数量级的差距。这就是为什么你能在 Tokio 里 spawn 几十万个 Task 而内存不爆——它们每个只占一两百字节。
6.14 和这个系列的其他专栏的关联
本章讲的 "单 atomic 装多字段 + bit layout + CAS 循环" 是整个现代并发编程的基石设计模式。《Rust 编译器与运行时揭秘》专栏第 5 章(内存布局) 里讲的 Option<NonNull<T>> 打包、enum 的 niche 优化——和本章的 state bit layout 同一类思维:最大化利用类型系统和内存布局承载信息。Trailer.waker 那个 Option<Waker> 之所以还是 16 字节,靠的正是那一章讲的 niche。
Task 的 #[repr(C)] + const fn 算 offset 的设计,也和那一章讲的 #[repr(C)] 布局算法直接相关——raw.rs 里的 get_trailer_offset 就是把 Rust Reference 里"repr(C) 结构体布局"的伪代码原样翻译了一遍。
《Vue 3 设计与实现》专栏第 12 章(生命周期与调度) 里 Vue 的调度器也有一个"标脏 → 入队 → 批量 flush"的循环:ReactiveEffect 靠 flags 上的 Dirty / Pending 位决定要不要真的 run(),和本章 Task 的 NOTIFIED 位是同一类机制——用一个位记住"有人叫过我",从而让重复触发变成幂等。同一个工程问题(外部触发器 + 状态机),在前端响应式和后端运行时给出了同构的解答。
6.15 一个易被忽视的工程细节:Loom 覆盖的 Task 状态空间
Tokio 对 Task 状态机的验证放在 tokio/src/runtime/tests/ 下:task.rs、task_combinations.rs,以及一整排 loom_*.rs(loom_multi_thread.rs、loom_current_thread.rs、loom_join_set.rs、loom_local.rs、loom_blocking.rs …)。其中 task_combinations.rs 的做法很直白——把"是否 abort、什么时候 abort、JoinHandle 是 await 还是 drop、Future 是 panic 还是正常返回"这些维度做笛卡尔积跑一遍;loom_* 那批则交给 Loom 去穷举线程交错。
为什么必须这样测:state 的状态位组合是 2^6,再叠上任意 refcount 和"一次操作可以在任意指令处被别的线程打断",手写测试不可能覆盖所有组合。Loom 通过模型检查,在每个测试场景(有限的线程数和操作序列)内系统性地走过每一种可能的交错,这让 Tokio 对 Task state 协议不出现 UB、双重释放、死锁、wakeup loss 有了远强于压测的把握。
这是 Rust 生态里罕见的正确性保证水平。对比一般项目的"跑过 1000 次压测没崩就认为稳定",Loom 给的是穷举验证。Tokio 作为基础设施软件,必须达到这个水平——上层大量业务库依赖它不出 bug。
作为读者,这意味着什么?意味着你读 Tokio state.rs 的代码时,可以对它的正确性多一分信任(Loom 的穷举只覆盖测试写到的场景和有限的线程数,并不等于证明),把主要精力放在理解它为什么这么写上。
6.16 本章小结
带走三件事:
- Task 是一次堆分配、
#[repr(C)]三件套(Header + Core + Trailer)。Header 是 hot 数据(32 字节)、Core 装 scheduler 和 Future、Trailer 放 JoinHandle 通信槽;整个Cell按 cache line 对齐来防 false sharing - State 用一个 AtomicUsize 装下 6 个状态位 + 引用计数。状态转移都是对这一个字的原子操作,大多数经
fetch_update_action的 CAS 循环完成——状态位和 refcount 永不分裂 - 初始 refcount = 3(JoinHandle + OwnedTasks + Notified Task)、一个 Task 在生命周期中可能被多次 wake 但每次都是幂等的、最终 refcount 归零时整块 Cell 被 dealloc
6.17 Task 设计的几个常见误解
教学过程中发现读者对 Task 有几个常见误解,本节集中澄清:
误解 1:"Task = Future"
错。Task 和 Future 不是一回事:
- Future 是 trait——
async fn展开的状态机是一个 Future - Task 是 Tokio 的包装——它拥有一个 Future 作为字段,同时带 Header、Trailer 这些运行时元数据
一个 Future 可以不被 spawn 成 Task(比如在 .await 里作为子 Future 被父 Future poll)。只有通过 tokio::spawn 创建的东西才成为 Task。
误解 2:"spawn 是一次昂贵操作"
相对错。spawn 确实有成本:一次堆分配(≥128 字节 + Future 状态机)、一次 OwnedTasks 侵入式链表插入(要拿分片锁)、一次入队、以及 INITIAL_STATE 的写入。但把它拆开看,没有一步是重量级操作——没有系统调用、没有线程创建、没有栈分配。
对比其他运行时的"创建一个并发单位":Go 的 goroutine 至少要 2 KB 初始栈 + 调度器登记;操作系统线程更要几 MB 栈 + clone 系统调用。Tokio 的 spawn 只是一次 malloc 加几次原子操作,量级完全不同。
真要知道你自己机器上的确切数字,跑仓库自带的 benches/spawn.rs(cargo bench --bench spawn) —— 不要相信任何拍脑袋的纳秒数(包括本专栏的)。
误解 3:"把 Future 传给 spawn 后它会立刻开始跑"
可能错。spawn 保证 Future 被 schedule(入某个 worker 队列),但不保证 Future 立刻被 poll。如果当前所有 worker 都忙,spawn 的 Future 可能要等几毫秒甚至更久才被 poll。
但如果你 spawn 完立刻 .await 其他 Future 让出,第 5 章讲的 LIFO slot 会让刚 spawn 的 Task 成为当前 worker 下一个取到的任务——在 multi_thread 的 worker 线程上,当前任务这次 poll 一返回,run_task 就直接从 LIFO slot 取出它来 poll(前提是协作预算还有剩余,tokio/src/runtime/scheduler/multi_thread/worker.rs:617)。
大多数场景下 spawn 后的延迟可以忽略。但做延迟敏感系统时记住:spawn 不等于 立即执行。
误解 4:"JoinHandle.await 返回就表示 Task 已经 drop 了"
近似对,但要小心。.await 返回(成功或 JoinError)表明 Future 已 Ready 或被 cancel。但此时 Task 的内存一定还没被 dealloc——JoinHandle 自己还持有一份 refcount(OwnedTasks 那份也可能还没交还)。
真正 dealloc 发生在 refcount 归零——那是在 .await 返回之后,由最后一个持有者(JoinHandle drop,或 scheduler 侧释放)交出最后一份引用时才发生。大多数时候你不需要 care,但写 unsafe 代码或在意 metrics 精确性时记住这点。
6.18 一些有趣的延伸思考
在走向下一章前,留几个本章延伸出的有趣问题:
问题 1:Cell<T, S> 里 T 是 Future,这个 Future 的 size 怎么影响整个 Task 的 size?
如果 T 是一个深嵌套的 async fn,它的状态机可能几百字节甚至几 KB——整个 Cell 也就相应大。所以大 Future 的 spawn 开销也大(堆分配更多字节)。Tokio 1.40 的 tracing 埋点不记录 Future 大小(size.bytes 字段是更新的版本才加的),想观察可以在 spawn 前自己用 std::mem::size_of_val 打点。
问题 2:为什么 Tokio 没有"Task arena"(批量分配 Task 内存的 arena)? 常规 spawn 每次一个 malloc——看起来有优化空间:把 N 个 Task 批量分配在一个 arena,省 malloc 次数。但 Tokio 没这么做,因为:
- Task 生命周期高度不确定(可能几微秒也可能几小时),arena 难以回收
- 现代分配器(glibc malloc、jemalloc、mimalloc)对这种大小的分配本来就走线程本地缓存,几乎没有锁争抢
- 而且
Cell<T, S>的大小随T变化,arena 要按大小分档,复杂度不值得
问题 3:能不能把多个小 Future spawn 成一个"组合 Task"?
不能直接做,但可以通过 futures::join! 或 FuturesUnordered 把多个 Future 组合成一个大 Future,然后 spawn 一次(JoinSet 不行——它的每个 Future 仍各自 spawn 成一个 Task)。这样只有一次分配、一份 Header 开销。对于"有大量短生命周期小 Future"的场景,这是减少开销的标准技巧。
问题 4:Tokio 为什么不把 Header 的 state 再细分?
已经 6 个状态位 + refcount 了——每多一个状态位,refcount 就少一位(REF_COUNT_SHIFT 由 STATE_MASK 自动推出),32 位平台上尤其要掂量。state 字段已经非常紧凑;如果要加新状态(比如"被 trace 跟踪中"),通常通过在其他地方加字段或者复用现有位组合来表达。
问题 5:能不能从 *const Header 反推回 &Future?
不直接能——反推需要知道泛型 T 是什么。Harness<T, S> 在泛型单态化里知道 T,但 RawTask 是非泛型的只有 Header 指针。所以 Harness 是所有 Future 访问的唯一合法入口。这套分层确保了"非泛型代码处理调度、泛型代码处理 Future"的清晰边界。
这些问题每一个都触及 Tokio 设计的某一个边界——把这些边界想透,你对这个系统的理解就完整了。
6.19 从 Tokio 的 Task 设计能学到的可迁移模式
本章拆了 Tokio 的 Task 从头到尾。如果你从中抽象出可以迁移到别处的工程模式,至少有六条:
模式 1:hot / cold 数据分离
Header = hot(随时访问);Trailer = cold(JoinHandle 才用)。这种按访问频率分组字段的设计可以直接迁移到你设计的任何并发数据结构——比如一个连接池里连接对象、任何带 refcount 的对象。
模式 2:用 bit layout 合并原子字段
任何"状态 + refcount"或"两个状态位相互制约"的设计,考虑合并到一个 atomic。只要字段加起来不超过 usize 宽度(64 位平台 64 bit),就能这么干。你的代码会因此获得原子性免费。
模式 3:newtype 手柄封装
Harness<T, S> 只包一个 NonNull<Cell<T, S>> 但提供一整套操作 API——和裸指针一样大,却把 unsafe 都关在了它自己的方法里。这种"无额外开销的操作封装"让代码组织清晰而不付出运行时成本。你的类型系统里所有"就是个 *mut T 但我要让它只能通过特定 API 访问"的场景都可以这么做。
模式 4:vtable + offset 绕开泛型
Tokio 把泛型 T、S 的 offset 信息存在 vtable 里,让运行时代码不必单态化也能访问各个字段。这是 "type erasure" 的标准做法,比 Box<dyn Trait> 灵活:vtable 指针存在对象内部,外面传来传去的句柄只是一个瘦指针。自己写"需要抹去泛型但保留数据访问"的场景时值得借鉴。
模式 5:fetch_update_action 原子状态机
"读当前值 → 纯函数决定新值和副作用 → CAS 尝试 → 失败就重试"——这个模式是所有无锁 state machine 的心脏。Tokio 把它抽成了一个辅助函数——你自己写类似代码时也可以抽成这种形式。
模式 6:reference counting + state 的合并即生命周期管理
Tokio 不用 Rc / Arc 的常规路线(外面加一层)——而是把 refcount 嵌入到 state 里。这让 drop 的开销和状态转移融合在一个 CAS 里。如果你的对象生命周期和状态变化高度耦合,考虑这种合并设计。
这六条中任何一条用好都能让你的代码跨越"普通 Rust"进入"工业级 Rust"。Tokio 源码最大的价值不在于它怎么写的异步运行时——而在于它怎么把 Rust 类型系统和并发原语用到极致。
读完本章后,建议打开 tokio/src/runtime/task/core.rs 和 tokio/src/runtime/task/state.rs 原文通读一遍——Cell 三件套布局、Header 的 5 个字段、State bit layout 的所有状态转移、Harness 通过 vtable offset 访问 Cell、spawn → Cell::new → push Notified → schedule 的完整路径——配合本章拆解,源码会变得透明。
延伸阅读
- Tokio 源码:
tokio/src/runtime/task/core.rs—— Cell / Header / Core / Trailer 完整定义 - Tokio 源码:
tokio/src/runtime/task/state.rs—— State 完整 bit layout 和所有状态转移 - Tokio 源码:
tokio/src/runtime/task/harness.rs—— Task poll 全流程 - 《Rust 编译器与运行时揭秘》专栏第 5 章:内存布局与 niche 优化
- 《Vue 3 设计与实现》专栏第 5 章:ReactiveEffect 的 flags 状态机和 Dirty 标记,可与本章 State 的位布局对照阅读