Rust in Everything
基于 tokio 1.54.1

本地队列

主线位置:任务主线的「任务进入队列」。在 worker 线程上 spawn 或唤醒的任务,先进 LIFO 槽,被挤出来的进这里。
flowchart LR
  subgraph buf[buffer:256 个槽,下标对 256 取模]
    direction LR
    s1[已被窃取者认领,正在拷贝]
    s2[可以 pop 或被偷]
    s3[空槽]
    s1 --- s2 --- s3
  end
  steal[head 高 32 位:steal] -.-> s1
  real[head 低 32 位:real] -.-> s2
  tail[tail] -.-> s3

源码在 multi_thread/queue.rs,600 行,没有一把锁。

📝NOTE
冷知识:tokio 曾经用的是 crossbeam 的 Chase-Lev 双端队列

2019 年 10 月以前,worker 的本地队列直接用 crossbeam 实现的 Chase-Lev deque。重写线程池的 #1657(随 0.2.0 发布)把它换掉了,PR 说明的理由是:Chase-Lev 支持的一些能力 tokio 用不上,却要为它们付开销。换上的就是本页这个固定大小的环形缓冲,「满了就把一半挪进全局队列」也是那时定下的。

结构

Inner:

pub(crate) struct Inner<T: 'static> {
    /// Concurrently updated by many threads.
    ///
    /// Contains two `UnsignedShort` values. The `LSB` byte is the "real" head of
    /// the queue. The `UnsignedShort` in the `MSB` is set by a stealer in process
    /// of stealing values. It represents the first value being stolen in the
    /// batch. The `UnsignedShort` indices are intentionally wider than strictly
    /// required for buffer indexing in order to provide ABA mitigation and make
    /// it possible to distinguish between full and empty buffers.
    ///
    /// When both `UnsignedShort` values are the same, there is no active
    /// stealer.
    ///
    /// Tracking an in-progress stealer prevents a wrapping scenario.
    head: AtomicUnsignedLong,

    /// Only updated by producer thread but read by many threads.
    tail: AtomicUnsignedShort,

    /// Elements
    buffer: Box<[UnsafeCell<MaybeUninit<task::Notified<T>>>; LOCAL_QUEUE_CAPACITY]>,
}

容量是 256(loom 测试里缩到 4)。在有 64 位原子的平台上,UnsignedShort 是 u32,UnsignedLong 是 u64;没有 64 位原子的平台退回 u16 / u32。

三个下标:

下标谁改含义
tail只有 owner下一个任务写到哪
real(head 低半)owner 的 pop、窃取者下一个能取的任务
steal(head 高半)只有窃取者正在被偷的那一批从哪开始。没人在偷时等于 real
💡TIP
打个比方:出餐口的一圈格子

厨房出餐口有 256 个格子围成一圈。厨师(owner)做好一道菜就放进 tail 指着的格子,自己闲下来时从 real 那头端走最早的一道,先放的先端。

隔壁的服务员(窃取者)没活干,就过来帮忙端走一半。他不是直接端,而是先在要端的那几格前面挂一块「已预订」的牌子(把 real 往前推),再一盘一盘慢慢端(拷贝),端完摘牌(steal 追上 real)。牌子挂着的时候,厨师不会往这些格子里放新菜,别的服务员看见牌子也会走开。

比方到这里为止:真格子只有 256 个编号,tokio 的下标却一直往上数到 2³² 才绕回来。这样做的原因见下面 ABA 那一条。

下标只增不减,到上限后回绕,读写槽位时才对 256 取模(& MASK)。用 u32 存 256 以内的下标,是为了两件事:

  • 分清空和满。 tail - head 的取值是 0 到 256,共 257 种,u8 装不下。
  • 防 ABA。 窃取者读到 head,被调度出去,回来时 CAS。如果下标只有 8 位,这期间 owner 正好推进了 256 的整数倍,CAS 会误以为什么都没变。用 32 位以后,要推进 2 的 32 次方才会撞上。源码在类型定义处引用了 issue #5041:有 64 位原子的平台就用更宽的整数,专门为了这一点。
📝NOTE
冷知识:16 位下标不到 1 毫秒就能绕一圈

1.22.0 以前,下标在所有平台上都是 u16,head 是 u32。2022 年 9 月有人提了 issue #5041:窃取者读完 tail、还没做 CAS 时被操作系统换下去,这期间 owner 恰好 pop 了 2¹⁶ 个任务、push 的又不够多,回来的 CAS 会成功,窃取者偷走的任务比队列里实际有的还多,这是未定义行为。提 issue 的人在自己的旧笔记本上量过,push 加 pop 2¹⁶ 个任务用不了 1 毫秒,一个线程被换下去 1 毫秒并不罕见。修复 #5042 很直接:有 64 位原子就把下标加宽到 u32,在 64 位平台上没有测到性能损失。

steal 和 real 打包在一个 u64 里(第 587 行),这样窃取者能一次 CAS 同时检查和修改两者:

fn unpack(n: UnsignedLong) -> (UnsignedShort, UnsignedShort) {
    let real = n & UnsignedShort::MAX as UnsignedLong;
    let steal = n >> (mem::size_of::<UnsignedShort>() * 8);

    (steal as UnsignedShort, real as UnsignedShort)
}

fn pack(steal: UnsignedShort, real: UnsignedShort) -> UnsignedLong {
    (real as UnsignedLong) | ((steal as UnsignedLong) << (mem::size_of::<UnsignedShort>() * 8))
}

owner 取任务:pop

Local::pop:

pub(crate) fn pop(&mut self) -> Option<task::Notified<T>> {
    let mut head = self.inner.head.load(Acquire);

    let idx = loop {
        let (steal, real) = unpack(head);

        // safety: this is the **only** thread that updates this cell.
        let tail = unsafe { self.inner.tail.unsync_load() };

        if real == tail {
            // queue is empty
            return None;
        }

        let next_real = real.wrapping_add(1);

        // If `steal == real` there are no concurrent stealers. Both `steal`
        // and `real` are updated.
        let next = if steal == real {
            pack(next_real, next_real)
        } else {
            assert_ne!(steal, next_real);
            pack(steal, next_real)
        };

        // Attempt to claim a task.
        let res = self
            .inner
            .head
            .compare_exchange_weak(head, next, AcqRel, Acquire);

        match res {
            Ok(_) => break real as usize & MASK,
            Err(actual) => head = actual,
        }
    };

    Some(self.inner.buffer[idx].with(|ptr| unsafe { ptr::read(ptr).assume_init() }))
}

owner 也从 real 这一头取,所以本地队列本身是先进先出的。「后进先出」的效果来自队列外面的 LIFO 槽,见 worker 主循环。

tail 只有 owner 写,所以 owner 读它用 unsync_load,不需要原子操作。head 会被窃取者同时修改,必须 CAS。有窃取者在拷贝时(steal != real),owner 只推进 real,不碰 steal。

owner 放任务:push_back_or_overflow

第 188 行:

let tail = loop {
    let head = self.inner.head.load(Acquire);
    let (steal, real) = unpack(head);

    // safety: this is the **only** thread that updates this cell.
    let tail = unsafe { self.inner.tail.unsync_load() };

    if tail.wrapping_sub(steal) < LOCAL_QUEUE_CAPACITY as UnsignedShort {
        // There is capacity for the task
        break tail;
    } else if steal != real {
        // Concurrently stealing, this will free up capacity, so only
        // push the task onto the inject queue
        overflow.push(task);
        return;
    } else {
        // Push the current task and half of the queue into the
        // inject queue.
        match self.push_overflow(task, real, tail, overflow, stats) {
            Ok(_) => return,
            // Lost the race, try again
            Err(v) => {
                task = v;
            }
        }
    }
};

self.push_back_finish(task, tail);

容量算的是 tail - steal,不是 tail - real。steal 到 real 之间的槽已经被窃取者认领,但它可能还没拷贝完;如果 owner 按 real 算容量,就可能在这些槽里写入新任务,覆盖掉还没被拷走的旧任务。

三个分支:

  1. 有空位:写进 tail 对应的槽,然后 tail.store(tail + 1, Release)。Release 保证窃取者看到新的 tail 时,槽里的数据也已经写好。
  2. 满了,但有窃取者正在偷:偷完就会空出一半,没必要搬家,只把这一个任务放进注入队列。
  3. 满了,没人在偷:调 push_overflow,把一半任务搬到注入队列。

满了:push_overflow 搬走后一半

第 253 行,核心是两步:

const NUM_TASKS_TAKEN: UnsignedShort = (LOCAL_QUEUE_CAPACITY / 2) as UnsignedShort;

// ...

// Claim all tasks.
if self
    .inner
    .head
    .compare_exchange_weak(pack(head, head), pack(tail, tail), Release, Relaxed)
    .is_err()
{
    // We failed to claim the tasks, losing the race. Return out of
    // this function and try the full `push` routine again. The queue
    // may not be full anymore.
    return Err(task);
}

// Add back the first half of tasks.
self.inner
    .tail
    .store(tail.wrapping_add(NUM_TASKS_TAKEN), Release);

第一步 CAS 把 head 直接推到 tail,相当于一次认领全部 256 个任务,窃取者从这一刻起看到的是空队列。第二步把 tail 再往后推 128。因为下标对 256 取模,新的 [tail, tail + 128) 正好落在原来 [head, head + 128) 那 128 个槽上:前一半任务原地没动,就这样又回到了队列里。后一半(head + 128 起的 128 个)由 BatchTaskIter 读出来,连同新任务一起,用 push_batch 一次挂进注入队列,总共 129 个。

为什么搬后一半,不搬前一半?源码注释给了理由:worker 从注入队列取任务时,总是放进本地队列的前一半(见全局注入队列)。所以后一半里的任务一定不是刚从注入队列拿回来的。只搬后一半,就不会出现同一个任务在两个队列之间来回搬、一次也没被 poll 的情况。

📝NOTE
冷知识:1.52.0 之前搬的是前一半

搬后一半是 2026 年 4 月的 #8029 才改的(1.52.0)。之前 push_overflow 搬前一半,PR 指出这在极端情况下会违反 tokio 文档承诺的公平性:如果另一个任务不停地 spawn 或唤醒大量任务,同一个任务可能被一次次搬回注入队列。

改成搬后一半以后,作者给出了一个证明。把等待中的任务分成三个桶:本地队列前一半、注入队列、本地队列后一半。任务被唤醒后可能落进任何一个桶,但之后的每个操作都只会把它往更低的桶挪,或者减少排在它前面的任务数。所以一个被唤醒的任务,最多经过「本地队列容量 + 注入队列最大长度 × global_queue_interval」次操作就会被 poll。

搬家期间有一小段时间,前一半任务对窃取者是看不见的:head 已经推到了 tail,新的 tail 还没写。注释说明了为什么这不要紧:这时来偷的 worker 会看到空队列,可能去睡觉;但紧接着有 129 个任务进了注入队列,调用方 schedule_local 随后会叫醒一个睡着的 worker,它会再来找活。

实验:第 258 个任务

用一个 worker 的运行时,在 worker 上一口气 spawn n 个空任务,然后看注入队列的长度(global_queue_depth 是稳定的指标 API):

#[tokio::main(flavor = "multi_thread", worker_threads = 1)]
async fn main() {
  for n in [257, 258, 300] {
    let depth = tokio::spawn(async move {
      for _ in 0..n {
        tokio::spawn(async {});
      }
      tokio::runtime::Handle::current().metrics().global_queue_depth()
    })
    .await
    .unwrap();
    println!("spawned {n} on a worker -> global queue depth {depth}");
    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
  }
}
spawned 257 on a worker -> global queue depth 0
spawned 258 on a worker -> global queue depth 129
spawned 300 on a worker -> global queue depth 129

每 spawn 一个任务,新任务进 LIFO 槽,槽里原来那个进本地队列。所以 spawn n 个之后,LIFO 槽里有 1 个,本地队列里要放 n - 1 个:

spawn 个数本地队列要放结果
257256正好放满,不溢出,注入队列为 0
258257放第 257 个时队列已满:后一半 128 个加上这一个,共 129 个进注入队列
300299溢出一次后本地队列剩 128 个,再放 42 个没满,注入队列还是 129

300 这一行可以对一下账:LIFO 槽 1 个,本地队列 128 + 42 = 170 个,注入队列 129 个,加起来正好 300。

❗IMPORTANT
动脑筋:386、387、513 呢?

还是上面那个程序,把 n 换成 386、387、513。注入队列的深度各是多少?先在纸上推,再看本页末尾的答案。

提示:溢出一次之后,本地队列剩 128 个,要再放多少个才会第二次溢出?

窃取:steal_into

另一个 worker 偷任务时,调目标 worker 的 Steal::steal_into,把任务直接偷进自己的本地队列(第 417 行)。真正的搬运在 steal_into2,分三步。

第一步:认领。 一次 CAS 把 real 往前推 n,steal 不动:

let (src_head_steal, src_head_real) = unpack(prev_packed);
let src_tail = self.0.tail.load(Acquire);

// If these two do not match, another thread is concurrently
// stealing from the queue.
if src_head_steal != src_head_real {
    return 0;
}

// Number of available tasks to steal
let n = src_tail.wrapping_sub(src_head_real);
let n = n - n / 2;

// ...

// Update the real head index to acquire the tasks.
let steal_to = src_head_real.wrapping_add(n);
assert_ne!(src_head_steal, steal_to);
next_packed = pack(src_head_steal, steal_to);

// Claim all those tasks. This is done by incrementing the "real"
// head but not the steal. By doing this, no other thread is able to
// steal from this queue until the current thread completes.
let res = self
    .0
    .head
    .compare_exchange_weak(prev_packed, next_packed, AcqRel, Acquire);

n - n / 2 是向上取整的一半:队列里有 1 个任务也能偷走 1 个。

第二步:拷贝。 把 [steal, real) 之间的 n 个任务逐个 ptr::read 出来,ptr::write 进自己队列的 tail 之后。这一步不用任何原子操作:这些槽已经被认领,owner 的 pop 只会从 real 往后取,push 按 steal 算容量,不会写进这些槽;其他窃取者看到 steal != real 会直接放弃。

第三步:收尾。 再一次 CAS 把 steal 推到和 real 相等,表示偷完了。这次 CAS 可能因为 owner 同时在 pop(改了 real)而失败,所以放在循环里重试,每次都把 steal 设成当时的 real。

📝NOTE
没有蠢问题

问: 为什么偷一次要两次 CAS?一次 CAS 把 steal 和 real 一起往前推,不是更快吗?

答: 一次推完,这些槽就立刻「还」给了 owner。owner 是按 tail - steal 算容量的,steal 一往前走,它就认为这些槽空了,可能马上往里写新任务,而窃取者这时还没把旧任务拷出去,旧任务就被覆盖了。分两次 CAS,中间这段时间 steal != real,这些槽算「已认领、未搬完」:owner 的容量算不到它们,其他窃取者看到 steal != real 也会直接放弃。

steal_into 在外面还有两处处理:

  • 偷之前检查自己的队列,如果已经超过半满就不偷了。偷来的最多是 128 个,这样保证放得下。
  • 偷来的最后一个任务不放进自己的队列,直接返回给调用者马上运行,tail 只推进 n - 1。

四种操作,谁和谁会冲突

操作谁调改 head改 tail
popownerCAS 推进 real(没人在偷时连 steal 一起推)不改
push_back_or_overflowowner只有溢出时 CAS 一次Release 写
push_overflowownerCAS 认领全部Release 写回一半
steal_into2其他 worker两次 CAS:先推 real,再追平 steal不改

owner 和窃取者只在 head 上竞争,每次竞争都是一次 CAS,失败就基于新值重来。tail 只有一个写者。这就是「单生产者、多消费者」的环形队列能做到无锁的原因:难的部分只有一个变量。

动脑筋答案

把实验里的数组换成 [386, 387, 513],同一台机器(M2 Max、rustc 1.99.0、--release)连跑 3 次,结果都一样:

spawned 386 on a worker -> global queue depth 129
spawned 387 on a worker -> global queue depth 258
spawned 513 on a worker -> global queue depth 258

推法:spawn n 个,本地队列要放 n - 1 次。下面说的「第几次」都是本地队列的放入次数。第 257 次放入触发第一次溢出,连同这个任务搬走 129 个,本地剩 128。再放 128 次(第 258 到第 385 次)本地又满了,第 386 次放入触发第二次溢出,注入队列再加 129 个。

spawn 个数本地队列要放溢出次数注入队列
3863851129
3873862258
5135122258

513 这一行:第二次溢出后本地剩 128 个,再放 126 次(第 387 到第 512 次),本地到 254 个,没满,所以还是 258。规律是:第一次溢出要 257 次放入,之后每 129 次放入再溢出一次。

小结

  • 每个 worker 一个容量 256 的环形缓冲。tail 只有 owner 写;head 打包了 steal 和 real 两个 32 位下标,所有竞争都在它上面用 CAS 解决。
  • 32 位下标用来分清空和满、防 ABA;读写槽位时才对 256 取模。
  • 窃取分两次 CAS:先推 real 认领一半,拷贝完再让 steal 追上来。认领期间 owner 按 steal 算容量,不会覆盖还没拷走的槽。
  • 满了就把后一半 128 个任务连同新任务一起搬到注入队列,前一半通过把 tail 往后推 128 原地保留。
  • 本地队列本身是先进先出的,后进先出来自它外面的 LIFO 槽。

💬 关联讨论

发起讨论
加载中...