本地队列
主线位置:任务主线的「任务进入队列」。在 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 行,没有一把锁。
2019 年 10 月以前,worker 的本地队列直接用 crossbeam 实现的 Chase-Lev deque。重写线程池的 #1657(随 0.2.0 发布)把它换掉了,PR 说明的理由是:Chase-Lev 支持的一些能力 tokio 用不上,却要为它们付开销。换上的就是本页这个固定大小的环形缓冲,「满了就把一半挪进全局队列」也是那时定下的。
结构
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 |
厨房出餐口有 256 个格子围成一圈。厨师(owner)做好一道菜就放进 tail 指着的格子,自己闲下来时从 real 那头端走最早的一道,先放的先端。
隔壁的服务员(窃取者)没活干,就过来帮忙端走一半。他不是直接端,而是先在要端的那几格前面挂一块「已预订」的牌子(把 real 往前推),再一盘一盘慢慢端(拷贝),端完摘牌(steal 追上 real)。牌子挂着的时候,厨师不会往这些格子里放新菜,别的服务员看见牌子也会走开。
下标只增不减,到上限后回绕,读写槽位时才对 256 取模(& MASK)。用 u32 存 256 以内的下标,是为了两件事:
- 分清空和满。
tail - head的取值是 0 到 256,共 257 种,u8装不下。 - 防 ABA。 窃取者读到
head,被调度出去,回来时 CAS。如果下标只有 8 位,这期间 owner 正好推进了 256 的整数倍,CAS 会误以为什么都没变。用 32 位以后,要推进 2 的 32 次方才会撞上。源码在类型定义处引用了 issue #5041:有 64 位原子的平台就用更宽的整数,专门为了这一点。
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
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
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 算容量,就可能在这些槽里写入新任务,覆盖掉还没被拷走的旧任务。
三个分支:
- 有空位:写进
tail对应的槽,然后tail.store(tail + 1, Release)。Release保证窃取者看到新的tail时,槽里的数据也已经写好。 - 满了,但有窃取者正在偷:偷完就会空出一半,没必要搬家,只把这一个任务放进注入队列。
- 满了,没人在偷:调
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 的情况。
搬后一半是 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 个数 | 本地队列要放 | 结果 |
|---|---|---|
| 257 | 256 | 正好放满,不溢出,注入队列为 0 |
| 258 | 257 | 放第 257 个时队列已满:后一半 128 个加上这一个,共 129 个进注入队列 |
| 300 | 299 | 溢出一次后本地队列剩 128 个,再放 42 个没满,注入队列还是 129 |
300 这一行可以对一下账:LIFO 槽 1 个,本地队列 128 + 42 = 170 个,注入队列 129 个,加起来正好 300。
还是上面那个程序,把 n 换成 386、387、513。注入队列的深度各是多少?先在纸上推,再看本页末尾的答案。
窃取: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。
问: 为什么偷一次要两次 CAS?一次 CAS 把 steal 和 real 一起往前推,不是更快吗?
答: 一次推完,这些槽就立刻「还」给了 owner。owner 是按 tail - steal 算容量的,steal 一往前走,它就认为这些槽空了,可能马上往里写新任务,而窃取者这时还没把旧任务拷出去,旧任务就被覆盖了。分两次 CAS,中间这段时间 steal != real,这些槽算「已认领、未搬完」:owner 的容量算不到它们,其他窃取者看到 steal != real 也会直接放弃。
steal_into 在外面还有两处处理:
- 偷之前检查自己的队列,如果已经超过半满就不偷了。偷来的最多是 128 个,这样保证放得下。
- 偷来的最后一个任务不放进自己的队列,直接返回给调用者马上运行,
tail只推进n - 1。
四种操作,谁和谁会冲突
| 操作 | 谁调 | 改 head | 改 tail |
|---|---|---|---|
pop | owner | CAS 推进 real(没人在偷时连 steal 一起推) | 不改 |
push_back_or_overflow | owner | 只有溢出时 CAS 一次 | Release 写 |
push_overflow | owner | CAS 认领全部 | 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 个数 | 本地队列要放 | 溢出次数 | 注入队列 |
|---|---|---|---|
| 386 | 385 | 1 | 129 |
| 387 | 386 | 2 | 258 |
| 513 | 512 | 2 | 258 |
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 槽。