Appearance
第6章 Task:轻量级任务的生命周期
"A Task is a Future with a passport: it knows who it is, where it lives, and when it is allowed to die." —— 笔者
本章要点
- Tokio Task 是一块连续的
#[repr(C)]堆内存,由Cell<T, S>定义:Header(hot,约 64 字节,精确填满一个 cache line)+ Core(scheduler + Future 本体)+ Trailer(cold 数据,JoinHandle 通信槽) 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 循环完成——状态位和 refcount 永远原子同步 JoinHandle::await的幕后是JOIN_WAKER位 + Trailer 里的 waker 槽——这个机制让 Task 完成时能幂等地唤醒 handle 持有者spawn的完整路径:create_task→Cell::new一次分配(64-80 字节 + Future 大小)→schedule(Notified)→ 进入 worker 队列。整个过程一次堆分配,零后续分配
6.1 Task 在内存里长什么样:Cell 的三件套
打开 tokio/src/runtime/task/core.rs,Tokio 1.40 里一个 Task 的本体是 Cell<T, S> 结构体。原样贴出:
rust
// 来源: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 需要随时被其他 worker 访问(偷任务时要读 queue_next、wake 时要写 state),而 Future 在 poll 时只能被当前 worker 独占访问。混在一起会污染 cache line——两个 worker 对同一 cache line 的读写会触发 cache coherence 协议,性能骤降。
Tokio 的设计是让 Header 占一个完整 cache line(64 字节) ——其他 worker 的并发操作不会触碰 Core 所在的后续内存。这是 false sharing 的经典防御。你用 pahole 工具看 Header 的布局会发现字段加起来正好 56-64 字节,精心设计的大小。
6.2 Header:Task 的神经中枢
rust
// 来源:tokio/src/runtime/task/core.rs
#[repr(C)]
pub(crate) struct Header {
pub(super) state: State,
pub(super) queue_next: UnsafeCell<Option<NonNull<Header>>>,
pub(super) vtable: &'static Vtable,
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_terminal - 引用计数加减 →
ref_inc/ref_dec
6.5 节会完整拆开这个字段。
queue_next: UnsafeCell<Option<NonNull<Header>>> —— 链表指针
当 Task 在全局注入队列(inject)里等待被 worker 取走时,它在一个无锁单链表里——这个字段就是那个链表的 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)。
rust
// 来源:tokio/src/runtime/task/raw.rs
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 / scheduler_offset / id_offset。这些是从 Header 起始地址到各个字段的字节偏移。
因为 Cell<T, S> 是泛型,不同 T、S 组合的 Core 大小不同,因此不同类型的 Task,Trailer 的位置也不同。但从 Header 指针访问 Trailer 的代码必须单态化(因为 RawTask 只存一个 Header 指针、不带泛型信息)。
解决办法:在 Cell 的 new 过程中计算出具体偏移量,存进 vtable 的这些字段。取 Trailer 就是:
rust
// 简化自 tokio/src/runtime/task/core.rs
impl Header {
pub(super) unsafe fn get_trailer(header: NonNull<Header>) -> NonNull<Trailer> {
let offset = header.as_ref().vtable.trailer_offset;
let ptr = header.as_ptr().cast::<u8>().add(offset);
NonNull::new_unchecked(ptr.cast::<Trailer>())
}
}Header 指针 + offset → 目标指针。这就是为什么 vtable 要"塞下偏移量"——让运行时代码在不知道 T、S 的情况下也能访问 Cell 的任何字段。
这种 "静态分派 + 运行时偏移"的组合是 Tokio 最巧妙的设计之一。静态分派保证泛型代码被编译器单态化出高效代码;运行时偏移保证外层逻辑只需要 *mut Header、不用到处传泛型 T、S。两者合一,既有泛型的灵活、又有非泛型代码的简洁。
这个模式在 Linux kernel 里叫 "container_of" 模式(用一个成员指针反推 struct 指针);在 C++ 里叫 offsetof trick;在 Rust 生态里 intrusive collections(如 linked-list crate、intrusive-collections crate) 全是这种思路。Tokio 的 OwnedTasks 名册内部就是一个侵入式链表——Trailer.owned 是链表节点,通过 offset 反推到 Header。
vtable 本身怎么分配
有一个细节值得注意:每个 Cell<T, S> 的 vtable 都是 &'static Vtable。静态引用意味着 vtable 必须在整个程序生命周期都存在。
Tokio 通过 Rust 的 static 初始化机制——每个具体的 (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 的承载
rust
// 来源:tokio/src/runtime/task/core.rs
#[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"stage: CoreStage<T>—— Future 本体或 Output(见下)
CoreStage —— Future 和 Output 共享一块内存
rust
// 来源:tokio/src/runtime/task/core.rs
pub(super) struct CoreStage<T: Future> {
stage: UnsafeCell<Stage<T>>,
}
// Stage 定义在同模块
enum Stage<T: Future> {
Running(T),
Finished(Result<T::Output, JoinError>),
Consumed,
}Task 生命周期中有三个阶段:
- Running:Future 还没跑完,
stage里装着 Future 本体 - Finished:Future 跑完了,
stage里装着Result<Output, JoinError>,等 JoinHandle 来收 - Consumed:输出已经被 JoinHandle 取走,
stage是空的,Task 等死
enum 的三个 variant 共享同一块内存。Future 的大小 + Output 的大小取较大者——在阶段切换时原地替换,不用额外分配。
这是一个极其节约内存的设计:一个 async fn 返回 String,Future 状态机可能有几百字节,Output 只有 24 字节(String 是 ptr + len + cap),Tokio 会在 Future 跑完后"压缩"这块内存,把 Output 塞进原来 Future 所在的位置。
6.4 Trailer:JoinHandle 的通信槽
rust
// 来源:tokio/src/runtime/task/core.rs
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<Option<NonNull<Header>>>,两个一共 16 字节waker: UnsafeCell<Option<Waker>>—— JoinHandle 在.await时把自己的 Waker 存在这里;Task 完成时通过这个 Waker 唤醒 JoinHandle 的持有者hooks: TaskHarnessScheduleHooks—— 调度回调(before_spawn/after_termination)的存储槽
为什么叫 "Trailer"(尾部):因为它在 Cell 的最后,对应冷数据(访问频率低)。JoinHandle 完成时才用它,和 runtime 调度的 hot 路径无关。内存放在这里不影响 Header 的 cache line——再次是防 false sharing 的设计。
6.5 State:一个 AtomicUsize 塞下的整个协议
这是整个 Task 最精巧的字段。打开 state.rs,先看定义:
rust
// 来源:tokio/src/runtime/task/state.rs
pub(super) struct State {
val: AtomicUsize,
}就一个 AtomicUsize。但它的 bit layout 极其讲究——一个 usize(64 位平台上 64 位)装下了:
rust
// 来源:tokio/src/runtime/task/state.rs
const RUNNING: usize = 0b0001; // bit 0
const COMPLETE: usize = 0b0010; // bit 1
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。
高位:引用计数
rust
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—— 状态位的反面就是引用计数位REF_COUNT_SHIFT—— 引用计数的起始 bit(64 位平台就是 6,因为低 6 位是状态)REF_ONE = 1 << REF_COUNT_SHIFT—— "一个引用"对应的数值(64 位平台上 = 64)
加减引用计数就是 val.fetch_add(REF_ONE, ...) / val.fetch_sub(REF_ONE, ...)——不会影响状态位(因为 REF_ONE 的低 6 位是 0)。
INITIAL_STATE = (REF_ONE * 3) | JOIN_INTEREST | NOTIFIED
新建 Task 时 state = 3 份引用 + JOIN_INTEREST + NOTIFIED。
为什么是 3 份引用?三份分别是:
- JoinHandle 持有一份(调用
spawn返回的那个) OwnedTasks名册持有一份(作为"全局活动 Task 列表")- 即将被 schedule 的 Notified Task 持有一份(push 到 worker 队列的那个)
spawn 时三方同时需要 refcount,所以起始值就是 3。
这种"合并 state + refcount"的设计是现代并发数据结构的标志性做法——Linux kernel 的 refcount_t、C++ folly 的 futures、JVM 对象头 —— 底层都是这种 bit layout。
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 字段各占一个 cache line,加起来 128 字节;合一个只占 8 字节
- 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。例如:
rust
// 默认 #[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 循环
所有 state 变化都通过一个内部辅助函数 fetch_update_action 实现——它本质上是一个 CAS 循环:
rust
// 简化示意:所有 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_weak(current, new_val, AcqRel, Acquire) {
Ok(_) => return result,
Err(actual) => current = actual, // race, retry
}
}
}每次状态转移的步骤:
- 读当前值
- 用闭包决定"应该转成什么" + "返回给调用者什么 action"
- CAS 尝试更新——失败则循环重试
整个过程是原子的——状态位和 refcount 作为一个整体被更新。
案例一:transition_to_running
原样(3.5 节已引用过):
rust
// 来源:tokio/src/runtime/task/state.rs
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 的上下文,可以看得更清楚:
rust
// 来源:tokio/src/runtime/task/state.rs
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 完会看到、重入 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 的简洁实现
rust
// 来源:tokio/src/runtime/task/state.rs
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)。只有最后 ref_dec 到 0 时需要 Release 屏障保证其他线程的写入可见
- 溢出检查 →
process::abort:如果 refcount 超过isize::MAX(理论上 64 位平台几乎不可能达到),整个进程 abort——这是防止 refcount wrap 到 0 导致双重释放的经典技巧,和 Rust 标准库 Arc 一致
案例三:transition_to_terminal —— Task 完成的闭环
Future 返回 Ready 时 Task 进入终态。这段转移同时处理 JOIN_WAKER 的唤醒逻辑:
rust
// 简化自 tokio/src/runtime/task/state.rs
pub(super) fn transition_to_terminal(&self, complete: bool) -> usize {
// 返回 "当前 snapshot 的 refcount",用于决定要不要 dealloc
let snapshot = self.fetch_update_action(|mut snapshot| {
if complete {
snapshot.set_complete();
}
// 清 RUNNING 位
snapshot.unset_running();
// 如果 JOIN_INTEREST 还在且 JOIN_WAKER 有效:需要 wake 持有 JoinHandle 的 Task
// 否则 JOIN_INTEREST 和 JOIN_WAKER 也可以清掉
let ret = snapshot.ref_count();
(ret, Some(snapshot))
});
snapshot
}一次 CAS 完成:设置 COMPLETE、清 RUNNING、可能清 JOIN_WAKER——然后外层 Harness 代码根据 JOIN_WAKER 决定是否 wake trailer.waker。
为什么不在这个 CAS 里直接 wake?因为 waker.wake() 可能触发任意代码(比如 wake 的 Task 又触发 spawn),而我们在 fetch_update_action 的闭包里——闭包应该快速返回、不能有复杂副作用(否则 CAS 失败时闭包可能被多次调用,副作用会重复)。
这是一个常见的无锁编程陷阱——Tokio 的处理很规范:状态转移在原子里做,副作用在原子外做。CAS 成功后外层代码根据转移返回的 action 决定做什么。
6.7 Task 的完整生命周期
把前 6 节拼起来,一个 Task 从生到死:
Step 1:spawn
rust
let handle = tokio::spawn(async { compute() });- 调
tokio::spawn→Handle::spawn→spawn_inner→new_task Cell::new(future, scheduler, task_id)—— 一次堆分配,大小 = sizeof(Header) + sizeof(Core) + sizeof(Trailer) + Future 大小- 初始化 Header.state =
INITIAL_STATE(refcount=3 + JOIN_INTEREST + NOTIFIED) - 把 Task 放进 scheduler 的
OwnedTasks名册(一个引用) - 把 Notified Task 放进 worker 的本地队列或 LIFO slot(一个引用)
- 返回 JoinHandle(一个引用)——三引用合计
Step 2:首次 poll
- worker 从队列拿到 Notified Task → 调
Harness::poll 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)
transition_to_terminal设置 COMPLETE- output 写入 CoreStage::Finished
- 如果 JOIN_WAKER 设置了(JoinHandle 之前 .await 过),触发 Trailer.waker 唤醒 JoinHandle 持有者
- JOIN_INTEREST 清除(JoinHandle 已经在 .await 里被 wake)
- refcount -1(scheduler-notified 的那份消费掉)
Step 6:JoinHandle.await 取走 output
- JoinHandle 的 Future 实现再次 poll → 看到 COMPLETE → 调 vtable.try_read_output
- CoreStage 从 Finished 变 Consumed
- refcount -1(JoinHandle 的那份)
Step 6½:COMPLETE 状态下的 CoreStage 替换
这一步最有意思——也展示了 Rust 零成本抽象的一个高光时刻。
Future 返回 Ready(output) 时,Harness::complete 做:
rust
// 简化示意
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 方案:原地在 800 字节内存里装 24 字节 Output(前 24 字节有效,后面忽略)→ Task 整个释放时一次性回收
省掉了 Output 的单独堆分配——一个 Task 一辈子只在 spawn 那一次分配堆。
Step 7:OwnedTasks 清理
- scheduler 遍历 OwnedTasks 看到 COMPLETE + Consumed → remove → refcount -1
- refcount 归零 → vtable.dealloc → 释放整块 Cell 内存
整个 Task 一次堆分配、零额外分配。refcount 从 3 到 2 到 1 到 0,每一步都是原子的 CAS 或 fetch_sub。
6.7½ Harness:Task 的"操作手柄"
Harness<T, S> 是 Tokio 源码里所有 Task 泛型逻辑的集中地。它的定义简洁:
rust
// 来源:tokio/src/runtime/task/harness.rs
pub(super) struct Harness<T: Future, S: 'static> {
cell: NonNull<Cell<T, S>>,
}只一个字段——指向 Cell 的裸指针。Harness 本身不占运行时开销(ZST 包装),但它把 Cell 的所有单态化操作集中到一处:
rust
impl<T: Future, S: Schedule> Harness<T, S> {
pub(super) fn poll(self) { /* 完整 Future 驱动逻辑 */ }
pub(super) fn schedule(&self) { /* 把自己推给 scheduler */ }
pub(super) fn dealloc(self) { /* 释放 Cell */ }
pub(super) fn wake_by_val(&self) { /* 第 3 章看过 */ }
pub(super) fn wake_by_ref(&self) { /* 第 3 章看过 */ }
pub(super) fn drop_reference(&self) { /* refcount -1 + 可能 dealloc */ }
pub(super) fn try_read_output(&self, ...) { /* JoinHandle 取 output */ }
pub(super) fn shutdown(self) { /* shutdown 强制结束 */ }
}vtable 里的每个函数指针最终都落到 Harness 的某个方法。这种"ZST + 丰富方法"的设计在 Rust 生态里常见——ManuallyDrop<T>、NonNull<T>、Pin<P> 都是类似结构。
Harness::poll 的完整路径
harness.rs 的 poll 函数(简化):
rust
// 简化自 tokio/src/runtime/task/harness.rs
pub(super) fn poll(self) {
let res = match self.state().transition_to_running() {
TransitionToRunning::Success => self.poll_inner(),
TransitionToRunning::Cancelled => { /* 直接走 cancel 流程 */ }
TransitionToRunning::Failed => return,
TransitionToRunning::Dealloc => {
self.dealloc();
return;
}
};
match res {
Some(PollFuture::Complete(result)) => {
self.complete(result);
}
Some(PollFuture::Dealloc) => self.dealloc(),
Some(PollFuture::Notified) => { /* 继续重 poll */ }
_ => {}
}
}5 个分支处理了 transition_to_running 的所有可能结果。然后 poll_inner 实际跑 Future::poll。最后根据 Future 的返回做相应的 state transition。
这就是 Task 的"心跳"——每次 worker 调用 vtable.poll,走的就是这段代码。一个 Task 在生命周期中可能被调这段代码几次到几千次(取决于它 pending 多少次)——每次都是这 20 行。
6.8 JoinHandle:如何"等 Task 完成"
rust
pub struct JoinHandle<T> {
raw: RawTask,
_p: PhantomData<T>,
}
impl<T: 'static> Future for JoinHandle<T> {
type Output = Result<T, JoinError>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// ... 简化
unsafe {
(self.raw.header().vtable.try_read_output)(
self.raw.ptr,
output_ptr,
cx.waker(),
)
}
}
}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——需要 CAS 保证两件事不会错过。Tokio 的 state.rs 里有一个专门的 transition_to_join_wake/transition_to_terminal_with_waker 组合,用 CAS 处理这种 race,通过 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 时取消",用 AbortHandle 或 AbortOnDropHandle(后者是 tokio-util 里的包装器)。
drop JoinHandle 会发生什么
drop(handle) 的副作用:
- 清除 JOIN_INTEREST 位(Task 完成时不用 wake handle 了,因为没人等)
- 清除 JOIN_WAKER 位(如果 handle 之前 .await 过)
- refcount -1(handle 那份 refcount)
如果此时 Task 还没完成,它继续跑。完成后因为 JOIN_INTEREST=0,output 会被直接 drop(不再有人要取)。这是合理的行为——没人等,就没必要保留输出。
6.8½ Task abort 和 cancellation 的机制
JoinHandle::abort() 或 AbortHandle::abort() 被调用时,Tokio 做什么?
答案是——它几乎什么都不做,只做一件最轻的事:设置 state 里的 CANCELLED 位。具体代码(简化自 state.rs):
rust
pub(super) fn transition_to_notified_and_cancel(&self) -> ... {
self.fetch_update_action(|mut snapshot| {
snapshot.set_cancelled();
if snapshot.is_idle() {
snapshot.set_notified();
snapshot.ref_inc();
(Submit, Some(snapshot))
} else {
// running 中,下次 poll 会看到 CANCELLED
(DoNothing, Some(snapshot))
}
})
}abort 不会"强制立刻停止"正在 poll 的 Future——它标记 CANCELLED,然后等 Task 下次被 poll 时 transition_to_running 会返回 Cancelled、Harness 进入 cancel 流程、drop 整个 Future 状态机(也就是"取消"的本质)。
如果 Task 当前正在 poll 一段纯 CPU 循环(没有 .await),它不会响应 abort——直到它下一次 .await 时才被取消。这就是 JoinHandle::abort() 不保证立刻停止的根本原因。
这是协作式调度的又一体现:Task 必须自己让出执行权,Tokio 不会抢占。对比 Go 的 context.Context + 定期检查也是类似——只是 Go 代码习惯手动查 ctx.Err(),Rust async 因为 .await 频繁,检查发生得更自然。
abort 的幂等性
abort() 可以被调任意多次,都是幂等的——因为它只是"或"一个 bit 到 state 里。已经 CANCELLED 的 Task 再被 abort 没有任何副作用。
这个幂等性让 abort 在并发环境下极其安全:两个线程同时 abort 同一个 Task,不会有 race 导致的双重 drop 或其他异常。
6.8¾ spawn 真实代码的完整路径
从用户视角的 tokio::spawn(fut) 到一个 Task 真的进入 scheduler 队列——这整条路径只有大约几百行代码,但它触达了本章前几节讲的所有机制。简化版:
rust
// 顶层 API
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where F: Future + Send + 'static, F::Output: Send + 'static {
Handle::current().spawn(future)
}
// Handle::spawn
impl Handle {
pub fn spawn<F>(&self, future: F) -> JoinHandle<F::Output> where /* bounds */ {
self.spawn_named(future, None)
}
fn spawn_named<F>(&self, future: F, name: Option<&str>) -> JoinHandle<F::Output>
where /* bounds */ {
let id = task::Id::next();
let (handle, notified) = self.inner.spawn(future, id);
// notified 被立即 schedule
self.inner.schedule(notified);
handle
}
}
// Runtime 的 spawn 内部
impl scheduler::Handle {
fn spawn<F>(&self, future: F, id: task::Id) -> (JoinHandle<F::Output>, Notified<Self>)
where /* bounds */ {
let (task_owned, notified, handle) = task::spawn(future, self.clone(), id);
self.shared.owned.push_back(task_owned); // 放进 OwnedTasks 名册
(handle, notified)
}
}
// task::spawn
pub fn spawn<T, S>(future: T, scheduler: S, id: Id)
-> (Task<S>, Notified<S>, JoinHandle<T::Output>)
where T: Future + ..., S: Schedule {
// 分配 Cell
let cell = Cell::new(future, scheduler, id, INITIAL_STATE);
let raw = RawTask::from_cell(cell);
// 三份 refcount 对应三个返回值
(Task { raw, _p: PhantomData }, Notified(Task { ... }), JoinHandle { raw, _p: PhantomData })
}5 层调用,一次 Cell::new 堆分配——整个 spawn 完成。返回的三个对象(OwnedTask、NotifiedTask、JoinHandle)共享同一块内存(三份 refcount)。这就是"spawn 轻量级"的机械本质。
6.9 Task 和调度器的双向绑定
Core 里的 scheduler: S 字段是反向引用——Task 知道自己属于哪个 scheduler。这让 Waker::wake 触发时,可以从 Task Header 找到 scheduler、把 Task push 回队列:
rust
// 第 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.9½ 内存布局实测:一个真实 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 优化)- 对齐填充 = 0-32 字节(取决于 cache line 对齐要求)
小计:32-64 字节。Tokio 通常把 Header 对齐到 64 字节 cache line(通过 #[repr(align(64))] 或类似机制),实际常常是 64 字节。
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(Output)) + tag
小计:16 字节 + Future 大小。Future 大小取决于 async fn 复杂度:
- 一个
async fn noop() {}展开 = 约 1-2 字节(只有状态 tag) - 一个调了几层
.await的 async fn = 几十到几百字节 - 一个含大 buffer 的复杂 async fn = 可能几 KB
Trailer
owned: linked_list::Pointers<Header>= 16 字节(prev + next)waker: UnsafeCell<Option<Waker>>= 24 字节(Option<Waker>= 24 字节)hooks: TaskHarnessScheduleHooks= 几字节(一般是两个Option<Arc<...>>,约 16 字节)
小计:约 56 字节。
总计
Task 总开销 ≈ 64 (Header) + 16 (Core 基础) + 56 (Trailer) + Future 大小 ≈ 136 字节 + Future。
对比一下:
- Go goroutine:栈 2 KB 起(初始栈)
- JVM Thread:栈 1 MB 起
- Linux pthread:栈 8 MB 起
- Tokio Task:~136 字节 + Future
这是 3-4 个数量级的差距。这就是为什么你能在 Tokio 里 spawn 几十万个 Task 而内存不爆——它们每个只占大几十字节。
6.10 和这个系列的其他书的关联
本章讲的 "单 atomic 装多字段 + bit layout + CAS 循环" 是整个现代并发编程的基石设计模式。《Rust 编译器与运行时揭秘》第 5 章(内存布局与 Niche 优化) 里讲的 Option<NonNull<T>> 打包、enum 的 niche——和本章的 state bit layout 同一类思维:最大化利用类型系统和内存布局承载信息。
Task 的 #[repr(C)] + offset 计算的设计,和**《Rust 编译器与运行时揭秘》第 5 章** 里讲的 struct 内存对齐规则直接相关。看过那章后,你能一眼看出 Header 的每个字段为什么按那个顺序排(对齐 + cache line)。
《Vue 3 设计与实现》第 12 章(生命周期与调度) 里 Vue 的 ReactiveEffect 也是一个"状态机 + 引用计数"结构——effect 被 dirty 标记的机制和本章 Task 的 NOTIFIED 位机制高度相似。同一个工程问题(异步 state machine + 外部触发器),在前端响应式和后端运行时给出了同构的解答。
6.10¾ 一个易被忽视的工程细节:Loom 覆盖的 Task 状态空间
Tokio 的 Task state.rs 文件有一个 loom.rs 配对测试文件。它穷举所有线程交错 + 所有状态组合来验证状态机的正确性。粗略估计:
- state 的可能 snapshot 有 2^6 状态位组合 × 各种 refcount ≈ 几十万种
- 一次操作(wake / poll / drop)可以在任意位置被其他线程打断
- 两三个并发线程的交错组合 ≈ 上亿种
手写测试不可能覆盖所有组合。Loom 通过模型检查系统性地走过每一种可能,所以 Tokio 的 Task state 协议能被认为是"数学上正确"的——任何线程交错下都不会出现 UB、双重释放、死锁、wakeup loss。
这是 Rust 生态里罕见的正确性保证水平。对比一般项目的"跑过 1000 次压测没崩就认为稳定",Loom 给的是穷举验证。Tokio 作为基础设施软件,必须达到这个水平——上层几百个业务库依赖它不出 bug。
作为读者,这意味着什么?意味着你读 Tokio state.rs 的代码时,不用质疑它对不对——你可以把精力放在理解它为什么这么对。这是读源码的理想状态。
6.11 本章小结
带走三件事:
- Task 是一次堆分配、
#[repr(C)]三件套(Header + Core + Trailer)。Header 是 hot cache line、Core 装 Future、Trailer 放 JoinHandle 通信槽 - State 用一个 AtomicUsize 装下 6 个状态位 + 引用计数。所有状态转移通过
fetch_update_action的 CAS 循环原子完成——状态位和 refcount 永不分裂 - 初始 refcount = 3(JoinHandle + OwnedTasks + Notified Task)、一个 Task 在生命周期中可能被多次 wake 但每次都是幂等的、最终 refcount 归零时整块 Cell 被 dealloc
6.10½ 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 确实有堆分配(~136 字节 + Future 大小)、原子入队、refcount 初始化——但和几乎所有其他异步语言的"创建并发单位"操作比:
- Go goroutine:至少 2 KB 栈分配 + scheduler 注册
- JVM CompletableFuture:对象分配 + 可能的 ExecutorService 注册
- Python asyncio task:对象分配 + loop 注册 + coroutine frame 分配
Tokio 的 spawn 是这几家里最轻的。在现代 CPU 上大约 200-500 纳秒。可以高频率 spawn——每秒几十万次都不会成为瓶颈。
误解 3:"把 Future 传给 spawn 后它会立刻开始跑"
可能错。spawn 保证 Future 被 schedule(入某个 worker 队列),但不保证 Future 立刻被 poll。如果当前所有 worker 都忙,spawn 的 Future 可能要等几毫秒甚至更久才被 poll。
但如果你 spawn 完立刻 .await 其他 Future 让出,则:
- LIFO slot 机制会让刚 spawn 的 Task 几乎立刻被当前 worker 选中 poll
- 延迟几百纳秒到几微秒
大多数场景下 spawn 后的延迟是可忽略的。但做延迟敏感系统时记住:spawn 不等于 立即执行。
误解 4:"JoinHandle.await 返回就表示 Task 已经 drop 了"
近似对,但要小心。.await 返回(成功或 JoinError)表明 Future 已 Ready 或被 cancel。但此时 Task 的内存可能还没被 dealloc——因为 refcount 可能还 > 0(OwnedTasks 还持有一份)。
真正 dealloc 发生在 refcount 归零——这可能在 .await 返回后几微秒到几十微秒。大多数时候你不需要 care,但写 unsafe 代码或 metrics 精确性 care 时记住这点。
6.10⅞ 一些有趣的延伸思考
在走向下一章前,留几个本章延伸出的有趣问题:
问题 1:Cell<T, S> 里 T 是 Future,这个 Future 的 size 怎么影响整个 Task 的 size? 如果 T 是一个深嵌套的 async fn,它的状态机可能几百字节甚至几 KB——整个 Cell 也就相应大。所以大 Future 的 spawn 开销也大(堆分配更多字节)。生产环境可以用 tokio-console 的 task size histogram 观察。
问题 2:为什么 Tokio 没有"Task arena"(批量分配 Task 内存的 arena)? 常规 spawn 每次一个 malloc——看起来有优化空间:把 N 个 Task 批量分配在一个 arena,省 malloc 次数。但 Tokio 没这么做,因为:
- Task 生命周期高度不确定(可能几微秒也可能几小时),arena 难以回收
- 大多数工作负载的 malloc 开销已经足够小(glibc malloc 或 jemalloc 命中线程缓存 ~20-50 纳秒)
- 带来的复杂度不值得
问题 3:能不能把多个小 Future spawn 成一个"组合 Task"? 不能直接做,但可以通过 JoinSet 或 futures::join! 把多个 Future 组合成一个大 Future,然后 spawn 一次。这样只有一次分配、一份 Header 开销。对于"有大量短生命周期小 Future"的场景,这是减少开销的标准技巧。
问题 4:Tokio 为什么不把 Header 的 state 再细分? 已经 6 个状态位 + refcount 了——再多的话位宽不够。state 字段已经非常紧凑;如果要加新状态(比如"被 trace 跟踪中"),通常通过在其他地方加字段或者复用现有位组合来表达。
问题 5:能不能从 *const Header 反推回 &Future?不直接能——反推需要知道泛型 T 是什么。Harness<T, S> 在泛型单态化里知道 T,但 RawTask 是非泛型的只有 Header 指针。所以 Harness 是所有 Future 访问的唯一合法入口。这套分层确保了"非泛型代码处理调度、泛型代码处理 Future"的清晰边界。
这些问题每一个都触及 Tokio 设计的某一个边界——把这些边界想透,你对这个系统的理解就完整了。
6.11½ 从 Tokio 的 Task 设计能学到的可迁移模式
本章拆了 Tokio 的 Task 从头到尾。如果你从中抽象出可以迁移到别处的工程模式,至少有六条:
模式 1:hot / cold 数据分离
Header = hot(随时访问);Trailer = cold(JoinHandle 才用)。这种按访问频率分组字段的设计可以直接迁移到你设计的任何并发数据结构——比如一个连接池里连接对象、任何带 refcount 的对象。
模式 2:用 bit layout 合并原子字段
任何"状态 + refcount"或"两个状态位相互制约"的设计,考虑合并到一个 atomic。只要字段加起来不超过 usize 宽度(64 位平台 64 bit),就能这么干。你的代码会因此获得原子性免费。
模式 3:ZST 手柄封装
Harness<T, S> 只包一个指针但提供一整套操作 API。**这种"无开销的操作封装"**让代码组织清晰而不付出运行时成本。你的类型系统里所有"就是个 *mut T 但我要让它只能通过特定 API 访问"的场景都可以这么做。
模式 4:vtable + offset 绕开泛型
Tokio 把泛型 T、S 的 offset 信息存在 vtable 里,让运行时代码可以单态化处理。这是 "type erasure" 的标准做法,比 Box<dyn Trait> 灵活得多,也省一层堆分配。自己写"需要抹去泛型但保留数据访问"的场景时值得借鉴。
模式 5:fetch_update_action 原子状态机
"读当前值 → 纯函数决定新值和副作用 → CAS 尝试 → 失败就重试"——这个模式是所有无锁 state machine 的心脏。Tokio 把它抽成了一个辅助函数——你自己写类似代码时也可以抽成这种形式。
模式 6:reference counting + state 的合并即生命周期管理
Tokio 不用 Rc / Arc 的常规路线(外面加一层)——而是把 refcount 嵌入到 state 里。这让 drop 的开销和状态转移融合在一个 CAS 里。如果你的对象生命周期和状态变化高度耦合,考虑这种合并设计。
这六条中任何一条用好都能让你的代码跨越"普通 Rust"进入"工业级 Rust"。Tokio 源码最大的价值不在于它怎么写的异步运行时——而在于它怎么把 Rust 类型系统和并发原语用到极致。
下一章我们回到另一个调度器——current_thread(单线程 runtime)+ LocalSet(当前线程 spawn)。你会看到多线程 runtime 和单线程 runtime 在 Task 结构上的共享之处、以及 LocalSet 为什么能 spawn !Send 的 Future。理解了单线程 runtime,你就拿到了整个 Tokio 的完整图景。
读完本章,再去 GitHub 看一眼
强烈建议你读完本章后,打开 tokio/src/runtime/task/core.rs 和 tokio/src/runtime/task/state.rs 原文浏览一遍。
此时你已经有了完整的 mental model:
- 你知道 Cell 的三件套布局
- 你知道 Header 的 5 个字段各自干什么
- 你知道 State 的 bit layout 和所有状态转移的 action 语义
- 你知道 Harness 怎么通过 vtable offset 访问 Cell 的每个字段
- 你知道 spawn → Cell::new → push Notified → schedule 的完整路径
带着这份 mental model 去读真实源码,你会有一种"原来我已经全懂了"的爽感——这是读书和读源码结合的理想节奏。本书每一章都值得你用这种方式消化:先读本书建立框架,再读源码补充细节、验证理解。
这样读完 20 章后,你不仅理解了 Tokio,更重要的是你有了独立阅读任何复杂 Rust 运行时代码的能力——因为你已经在自己脑子里跑过一个极精妙系统的完整 mental model。这种能力会陪你走过下一个十年,远超过 Tokio 本身的生命周期。
延伸阅读
- 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 设计与实现》第 12 章:ReactiveEffect 的状态机和 dirty 标记对比