Rust in Everything
基于 tokio 1.54.1

一个 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 两位。其余四个标志位和引用计数在每个状态里都可以变。

位的分配

task/state.rs 第 17 行 起:

/// 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 表示谁置位 / 清零
0RUNNING有线程正在 poll 或取消这个任务。它就是任务的锁poll 开始时置位,poll 结束时清零
1COMPLETEfuture 已结束并被 drop,输出已写入。置位后不再清零只置位一次
2NOTIFIED存在一个 Notified,任务在某个队列里或马上要进队列wake 置位,开始 poll 时清零
3JOIN_INTERESTJoinHandle 还在drop JoinHandle 时清零
4JOIN_WAKERJoinHandle 已经把 waker 存进 Trailer见 JoinHandle 一章
5CANCELLED任务要尽快取消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

第 117 行:

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

第 151 行:

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))
    })
}

四种结果:

结果条件调用方接着做什么
Cancelledpoll 期间被 abort 了状态不变(还持有 RUNNING),drop future,走完成流程
Okpoll 期间没人唤醒无。用来 poll 的那个 Notified 的引用在这里扣掉
OkNotifiedpoll 期间有人唤醒过引用 +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

第 184 行:

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。

📝NOTE
没有蠢问题

问: 别的转换都要 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_valtransition_to_notified_by_ref
正在运行置 NOTIFIED;扣掉 waker 的引用。不提交置 NOTIFIED。不提交
已完成,或已有 NOTIFIED扣掉 waker 的引用;若归零则释放不变
空闲且没有 NOTIFIED置 NOTIFIED,引用 +1,提交置 NOTIFIED,引用 +1,提交

「正在运行」这一行是整个设计的要点:唤醒一个正在被 poll 的任务,不会让它进队列,只是留个记号。poll 结束时由 transition_to_idle 看到记号,再由 poll 的那个线程负责把它放回队列。这样保证了同一时刻至多一个 Notified,任务不会在 poll 期间被另一个 worker 从队列里取走、并发 poll。

💡TIP
打个比方:诊室门上的灯和便条

任务是一间诊室。门上的灯(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 还没返回时就被释放了。

📝NOTE
冷知识:「不变」的那一格,1.48.0 起也要写一次

上表里 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 直接抢锁取消。

💬 关联讨论

发起讨论
加载中...