一个 usize 的状态机
主线位置:任务主线上每一个箭头。进队列、被 poll、挂起、被唤醒、完成,每一步都是对
Header::state的一次原子修改。
stateDiagram-v2 [*] --> Idle: spawn(NOTIFIED=1,引用 3) Idle --> Running: transition_to_running(清 NOTIFIED) Running --> Idle: transition_to_idle(Pending) Running --> Complete: transition_to_complete(Ready / panic / 取消) Idle --> Running: transition_to_shutdown(置 CANCELLED) Complete --> [*]: 引用计数归零,dealloc
图里的 Idle、Running、Complete 只看 RUNNING 和 COMPLETE 两位。其余四个标志位和引用计数在每个状态里都可以变。
位的分配
/// The task is currently being run.
const RUNNING: usize = 0b0001;
/// The task is complete.
///
/// Once this bit is set, it is never unset.
const COMPLETE: usize = 0b0010;
/// Extracts the task's lifecycle value from the state.
const LIFECYCLE_MASK: usize = 0b11;
/// Flag tracking if the task has been pushed into a run queue.
const NOTIFIED: usize = 0b100;
/// The join handle is still around.
const JOIN_INTEREST: usize = 0b1_000;
/// A join handle waker has been set.
const JOIN_WAKER: usize = 0b10_000;
/// The task has been forcibly cancelled.
const CANCELLED: usize = 0b100_000;
/// All bits.
const STATE_MASK: usize = LIFECYCLE_MASK | NOTIFIED | JOIN_INTEREST | JOIN_WAKER | CANCELLED;
/// Bits used by the ref count portion of the state.
const REF_COUNT_MASK: usize = !STATE_MASK;
/// Number of positions to shift the ref count.
const REF_COUNT_SHIFT: usize = REF_COUNT_MASK.count_zeros() as usize;
/// One ref count.
const REF_ONE: usize = 1 << REF_COUNT_SHIFT;
REF_COUNT_SHIFT 算出来是 6,REF_ONE 是 64。在 64 位平台上,引用计数有 58 位。
| 位 | 名字 | 为 1 表示 | 谁置位 / 清零 |
|---|---|---|---|
| 0 | RUNNING | 有线程正在 poll 或取消这个任务。它就是任务的锁 | poll 开始时置位,poll 结束时清零 |
| 1 | COMPLETE | future 已结束并被 drop,输出已写入。置位后不再清零 | 只置位一次 |
| 2 | NOTIFIED | 存在一个 Notified,任务在某个队列里或马上要进队列 | wake 置位,开始 poll 时清零 |
| 3 | JOIN_INTEREST | JoinHandle 还在 | drop JoinHandle 时清零 |
| 4 | JOIN_WAKER | JoinHandle 已经把 waker 存进 Trailer | 见 JoinHandle 一章 |
| 5 | CANCELLED | 任务要尽快取消 | abort 或运行时关闭时置位 |
| 6.. | 引用计数 | 每个 Task、Notified、JoinHandle、Waker… 各占一个 |
初始状态(第 61 行):
const INITIAL_STATE: usize = (REF_ONE * 3) | JOIN_INTEREST | NOTIFIED;
三个引用对应 new_task 造出的 Task、Notified、JoinHandle。刚 spawn 的任务马上要进队列,所以 NOTIFIED 已经是 1。换成数字是 3 × 64 + 8 + 4 = 204,二进制 0b11_001100。
为什么状态和引用计数挤在一个原子里
看一个具体的决策:一个 waker 被 wake()(按值,消耗自己)。它要做的是:
- 任务空闲且没有
Notified:置NOTIFIED,造一个新的Notified交给调度器,引用计数 +1。 - 任务已经有
Notified,或者已经完成:什么也不用提交,但 waker 自己的引用要 -1。如果减到 0,还要负责释放内存。
如果状态和引用计数是两个原子变量,「看到已有 Notified」和「把自己的引用减掉」之间,另一个线程可能已经 poll 完任务、清掉了 NOTIFIED、把它的引用也减掉了。两个线程各看到一半,结果可能是谁都没释放(泄漏),或者都去释放(double free)。
放在同一个 usize 里,「读状态、做决定、改状态、改计数」就是一次 compare-and-swap:要么整体成功,要么基于新值重来。tokio 把这个模式抽成了 fetch_update_action:
fn fetch_update_action<F, T>(&self, mut f: F) -> T
where
F: FnMut(Snapshot) -> (T, Option<Snapshot>),
{
let mut curr = self.load();
loop {
let (output, next) = f(curr);
let next = match next {
Some(next) => next,
None => return output,
};
let res = self.val.compare_exchange(curr.0, next.0, AcqRel, Acquire);
match res {
Ok(_) => return output,
Err(actual) => curr = Snapshot(actual),
}
}
}
闭包拿到一个快照 Snapshot(就是 usize 的拷贝),返回「要做什么」和「新状态」。新状态是 None 表示不用改,直接返回动作。每个 transition_to_* 都是一个这样的闭包,返回值是一个 #[must_use] 的枚举,调用方必须按它的指示去做:提交、释放,或者什么都不做。
开始 poll:transition_to_running
pub(super) fn transition_to_running(&self) -> TransitionToRunning {
self.fetch_update_action(|mut next| {
let action;
assert!(next.is_notified());
if !next.is_idle() {
// This happens if the task is either currently running or if it
// has already completed, e.g. if it was cancelled during
// shutdown. Consume the ref-count and return.
next.ref_dec();
if next.ref_count() == 0 {
action = TransitionToRunning::Dealloc;
} else {
action = TransitionToRunning::Failed;
}
} else {
// We are able to lock the RUNNING bit.
next.set_running();
next.unset_notified();
if next.is_cancelled() {
action = TransitionToRunning::Cancelled;
} else {
action = TransitionToRunning::Success;
}
}
(action, Some(next))
})
}
调用方手里是一个 Notified,所以开头断言 NOTIFIED 为 1。然后分两种情况:
- 任务不空闲(正在被别人 poll,或者已经完成):这个
Notified作废,引用减一。正常调度里,任务被唤醒时如果正在运行,NOTIFIED只会置位、不会再造一个Notified(见下文),所以这条分支主要出现在关闭阶段:任务已经被shutdown完成,队列里残留的Notified被取出来时走到这里。 - 任务空闲:置
RUNNING,拿到锁;同时清掉NOTIFIED。清掉它是为了检测「poll 期间又被唤醒了」:poll 结束时如果发现NOTIFIED又变成 1,就知道要再调度一次。如果CANCELLED已经是 1,返回Cancelled,调用方不 poll,直接取消。
poll 返回 Pending:transition_to_idle
pub(super) fn transition_to_idle(&self) -> TransitionToIdle {
self.fetch_update_action(|curr| {
assert!(curr.is_running());
if curr.is_cancelled() {
return (TransitionToIdle::Cancelled, None);
}
let mut next = curr;
let action;
next.unset_running();
if !next.is_notified() {
// Polling the future consumes the ref-count of the Notified.
next.ref_dec();
if next.ref_count() == 0 {
action = TransitionToIdle::OkDealloc;
} else {
action = TransitionToIdle::Ok;
}
} else {
// The caller will schedule a new notification, so we create a
// new ref-count for the notification. Our own ref-count is kept
// for now, and the caller will drop it shortly.
next.ref_inc();
action = TransitionToIdle::OkNotified;
}
(action, Some(next))
})
}
四种结果:
| 结果 | 条件 | 调用方接着做什么 |
|---|---|---|
Cancelled | poll 期间被 abort 了 | 状态不变(还持有 RUNNING),drop future,走完成流程 |
Ok | poll 期间没人唤醒 | 无。用来 poll 的那个 Notified 的引用在这里扣掉 |
OkNotified | poll 期间有人唤醒过 | 引用 +1 造一个新 Notified,交给调度器的 yield_now 放回队列 |
OkDealloc | 没人唤醒,而且扣掉后引用为 0 | 释放内存。所有 waker、JoinHandle 都没了,这个任务再也不会被唤醒 |
OkDealloc 这一支在 tokio 自己的调度器里走不到:存活的任务总被 OwnedTasks 链表(或阻塞池的 UnownedTask)持有一个引用,poll 期间不会归零。它是为了让状态机在任何引用组合下都正确而留的。
反过来想,链表的这个引用也意味着:一个返回 Pending 却没把 waker 交给任何人的 future,JoinHandle 又已经 drop 了,它不会被释放,而是一直挂在链表上,直到运行时关闭。用 num_alive_tasks 可以看到它:
#[tokio::main]
async fn main() {
let metrics = tokio::runtime::Handle::current().metrics();
// pending() 从不保存传给它的 waker
drop(tokio::spawn(std::future::pending::<()>()));
tokio::task::yield_now().await;
println!("alive: {}", metrics.num_alive_tasks()); // alive: 1
}
完成:transition_to_complete 和 transition_to_terminal
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)
}
这里没有 CAS 循环。调用方持有 RUNNING,已知 RUNNING=1, COMPLETE=0,要变成 RUNNING=0, COMPLETE=1,这两位同时取反就行,一条 fetch_xor 搞定,其他位不受影响。返回的快照里带着这一刻的 JOIN_INTEREST 和 JOIN_WAKER,决定接下来是丢掉输出还是叫醒 JoinHandle。
问: 别的转换都要 CAS 循环,这里一条 fetch_xor 就够了?如果这时另一个线程正好在 wake(置 NOTIFIED)或者 drop JoinHandle(清 JOIN_INTEREST),它的修改会不会被覆盖掉?
答: 不会。fetch_xor 是一条原子的读-改-写,它在内存里当时的值上翻转这两位,别的线程同时做的修改都保留下来。CAS 循环解决的是另一类问题:新值取决于对旧值的判断。比如 transition_to_running 要先看任务是否空闲,再决定是置 RUNNING 还是扣引用,扣完还要看是不是 0。这种「先看再改」写不成一条固定的位运算,只能拿快照算出新值,再用 CAS 确认这期间没人动过。transition_to_complete 不需要判断:调用方持有 RUNNING,这两位的当前值是确定的,改法和其他位无关。下面的 transition_to_terminal 也一样,一条 fetch_sub 写完,再看返回的旧值判断自己是不是最后一个引用。
完成后,任务还要从 OwnedTasks 移除,再把剩下的引用一次扣掉:transition_to_terminal(count) 用一条 fetch_sub(count * REF_ONE),返回值告诉调用方是不是最后一个引用。count 是 1 还是 2,在 poll 与唤醒 一章里讲。
唤醒:by_val 和 by_ref 两个版本
Waker 有两种唤醒方式:wake(self) 消耗 waker,wake_by_ref(&self) 不消耗。对应两个转换:
| 当前状态 | transition_to_notified_by_val | transition_to_notified_by_ref |
|---|---|---|
| 正在运行 | 置 NOTIFIED;扣掉 waker 的引用。不提交 | 置 NOTIFIED。不提交 |
已完成,或已有 NOTIFIED | 扣掉 waker 的引用;若归零则释放 | 不变 |
空闲且没有 NOTIFIED | 置 NOTIFIED,引用 +1,提交 | 置 NOTIFIED,引用 +1,提交 |
「正在运行」这一行是整个设计的要点:唤醒一个正在被 poll 的任务,不会让它进队列,只是留个记号。poll 结束时由 transition_to_idle 看到记号,再由 poll 的那个线程负责把它放回队列。这样保证了同一时刻至多一个 Notified,任务不会在 poll 期间被另一个 worker 从队列里取走、并发 poll。
任务是一间诊室。门上的灯(RUNNING)亮着,表示医生正在看这个病人(有线程在 poll)。护士(waker)来报告新情况:灯灭着,就去前台给病人挂号排队(置 NOTIFIED,引用 +1,提交一个 Notified);灯亮着,就不去挂号,只在门上贴一张便条(只置 NOTIFIED)。门上已经有便条,或者已经挂过号,后来的护士什么都不用做。
医生进门时先把门上的旧便条撕掉(transition_to_running 清 NOTIFIED),所以出门时看到的便条一定是看诊期间贴的。看到便条,医生自己去前台再挂一个号(OkNotified,交给 yield_now)。
比方到这里为止:真便条可以贴好几张、各写各的事,NOTIFIED 只有一位,贴三张和贴一张没有区别,也不记是谁贴的。poll 与唤醒一章的「动脑筋」会让你数一数。
by_val 版本在「提交」时也是 +1 而不是直接把 waker 的引用转给 Notified:调用方先拿新引用去提交,提交返回后再扣掉自己的。源码注释说明了原因:schedule 可能当场 drop 掉传进去的 Notified(比如运行时正在关闭),如果没有自己这个引用撑着,任务可能在 schedule 还没返回时就被释放了。
上表里 by_ref 碰到「已有 NOTIFIED」时状态不变。1.48.0 之前,这条分支和「已完成」一起返回 None,fetch_update_action 只做了一次 load 就返回,不写原子变量。2025 年 9 月的 issue #7589 报告:一个 channel 库在 Miri 的弱内存模拟下死锁,future 被 wake_by_ref 之后再也没有被 poll;issue 里写明 x86_64 上复现不了,只出现在弱内存平台上。
修复 #7622(1.48.0,同一天也回移到了 1.47.2 和 1.43.3)的说明是:在 Miri 或 POWER 这类平台上,load 可能读到旧值,而这条直接返回 None 的分支在竞争下可能导致死锁。改法是把「已有 NOTIFIED」单独拆出来,返回 Some(snapshot),用一次 CAS 写回同样的值。现在源码里的注释写的就是这个理由:wake_by_ref() 要和任务开始执行建立 synchronize-with,这次 Release 写和 transition_to_running 里的 Acquire 配对。「已完成」那一支仍然返回 None。
取消:transition_to_notified_and_cancel 和 transition_to_shutdown
abort 走 transition_to_notified_and_cancel:已完成或已取消就什么都不做;正在运行就置 CANCELLED(和 NOTIFIED),等 poll 结束时由 transition_to_idle 发现;空闲就置 CANCELLED,必要时提交一个 Notified,让某个 worker 去执行取消。
运行时关闭走 transition_to_shutdown:
pub(super) fn transition_to_shutdown(&self) -> bool {
let mut prev = Snapshot(0);
let _ = self.fetch_update(|mut snapshot| {
prev = snapshot;
if snapshot.is_idle() {
snapshot.set_running();
}
// If the task was not idle, the thread currently running the task
// will notice the cancelled bit and cancel it once the poll
// completes.
snapshot.set_cancelled();
Some(snapshot)
});
prev.is_idle()
}
区别在于谁来 drop future:abort 可能在任意线程调用,而 !Send 的任务(LocalSet 上的)只能在它自己的线程上 drop,所以 abort 只置位、交给调度器;shutdown 由运行时自己在正确的线程上调用,任务空闲时直接抢下 RUNNING,当场 drop。
引用计数:和 Arc 一样的取舍
ref_inc 用 Relaxed,溢出时 abort 进程:
pub(super) fn ref_inc(&self) {
// ...
let prev = self.val.fetch_add(REF_ONE, Relaxed);
// If the reference count overflowed, abort.
if prev > isize::MAX as usize {
process::abort();
}
}
和标准库 Arc::clone 的道理相同:能增加引用的人手里已经有一个引用,对象不可能在这时被释放,所以不需要同步;而减引用用 AcqRel,最后一个减到 0 的线程要能看到其他线程之前对任务的所有写入,才能安全释放。
小结
- 一个
AtomicUsize:低 6 位是RUNNING、COMPLETE、NOTIFIED、JOIN_INTEREST、JOIN_WAKER、CANCELLED,其余是引用计数,REF_ONE是 64。初始值 204:3 个引用,加NOTIFIED、JOIN_INTEREST。 - 状态和引用计数放在一起,是为了让「看状态、做决定、扣引用、判断是否释放」成为一次 CAS。
fetch_update_action把这个模式统一起来,返回#[must_use]的动作枚举。 RUNNING是任务的锁,开始 poll 时同时清NOTIFIED;poll 期间的唤醒只置NOTIFIED,由 poll 线程在结束时重新调度,保证同一时刻至多一个Notified。transition_to_complete用一条fetch_xor同时翻转两位;abort只置位、交给调度器,shutdown直接抢锁取消。