Injector
主线位置:任务主线的入口。不在工作线程上的代码提交任务时
push进Injector;工作线程本地队列空了,就从这里steal或steal_batch_and_pop一批到自己的Worker里。
flowchart LR
subgraph b0["块 0(63 个槽)"]
direction LR
r0["已读"] --- r1["待读:head 指这里"] --- r2["已写"]
end
subgraph b1["块 1"]
direction LR
w0["已写"] --- w1["空:tail 指这里"] --- w2["……"]
end
b0 -->|"next"| b1
push["push:CAS tail,写槽,置 WRITE 位"] -.-> w1
steal["steal:CAS head,等 WRITE 位,读槽,置 READ 位"] -.-> r1
结构
// Bits indicating the state of a slot:
// * If a task has been written into the slot, `WRITE` is set.
// * If a task has been read from the slot, `READ` is set.
// * If the block is being destroyed, `DESTROY` is set.
const WRITE: usize = 1;
const READ: usize = 2;
const DESTROY: usize = 4;
// Each block covers one "lap" of indices.
const LAP: Index = 64;
// The maximum number of values a block can hold.
const BLOCK_CAP: usize = LAP as usize - 1;
// How many lower bits are reserved for metadata.
const SHIFT: usize = 1;
// Indicates that the block is not the last one.
const HAS_NEXT: Index = 1;
struct Slot<T> {
task: UnsafeCell<MaybeUninit<T>>,
state: AtomicUsize,
}
struct Block<T> {
next: AtomicPtr<Block<T>>,
slots: [Slot<T>; BLOCK_CAP],
}
struct Position<T> {
index: AtomicIndex,
block: AtomicPtr<Block<T>>,
}
pub struct Injector<T> {
head: CachePadded<Position<T>>,
tail: CachePadded<Position<T>>,
_marker: PhantomData<T>,
}
- 下标
index是AtomicIndex,0.8.8 起在有 64 位原子的平台上是u64(和Worker的i64同一个提交,#1321)。最低 1 位(SHIFT = 1)留作元数据,index >> 1才是逻辑位置。 - 每 64 个逻辑位置是一「圈」(lap),对应一个块;块里只有 63 个槽,第 64 个位置不存东西,当作「这块用完了、下一块还没装上」的等待点。
head.index的最低位是HAS_NEXT:表示head所在的块后面已经有块了,窃取者可以跳过「和tail比较」这一步。tail.index的最低位不用。head和tail各自CachePadded,推的人和偷的人不互相拖累。
和同仓库的 SegQueue、channel 的 list flavor 是同一个结构(list:无界块链表),常量不同:那两个是 LAP = 32、BLOCK_CAP = 31,这里是 64 / 63。还有一处不同:那两个的第一个块在第一次 push 时才分配,Injector 在 new()(Default,第 1360 行)里就分配好。
块直接分配在堆上。 Block::new 用 alloc_zeroed 分配后 Box::from_raw(第 1269 行),全零正好是合法的初始状态(空指针、状态 0、MaybeUninit)。以前是 Box::new(Block::new()),先在栈上构造 63 个槽再搬到堆上,T 很大时会栈溢出。channel 的 #1146 / #1147 先修了 list flavor,2024 年 12 月 761d0b67(「Port #1146 & #1147 to deque::Injector and queue::SegQueue」)移植过来,随 0.8.6 发布,并加了测试 stack_overflow:push 一个 32 KiB 的结构体(63 个就是约 2 MiB)。
push
pub fn push(&self, task: T) {
let backoff = Backoff::new();
let mut tail = self.tail.index.load(Ordering::Acquire);
let mut block = self.tail.block.load(Ordering::Acquire);
let mut next_block = None;
loop {
// Calculate the offset of the index into the block.
let offset = ((tail >> SHIFT) % LAP) as usize;
// If we reached the end of the block, wait until the next one is installed.
if offset == BLOCK_CAP {
backoff.snooze();
tail = self.tail.index.load(Ordering::Acquire);
block = self.tail.block.load(Ordering::Acquire);
continue;
}
// If we're going to have to install the next block, allocate it in advance in order to
// make the wait for other threads as short as possible.
if offset + 1 == BLOCK_CAP && next_block.is_none() {
next_block = Some(Block::<T>::new());
}
let new_tail = tail + (1 << SHIFT);
// Try advancing the tail forward.
match self.tail.index.compare_exchange_weak(
tail,
new_tail,
Ordering::SeqCst,
Ordering::Acquire,
) {
Ok(_) => unsafe {
// If we've reached the end of the block, install the next one.
if offset + 1 == BLOCK_CAP {
let next_block = Box::into_raw(next_block.unwrap());
let next_index = new_tail.wrapping_add(1 << SHIFT);
self.tail.block.store(next_block, Ordering::Release);
self.tail.index.store(next_index, Ordering::Release);
(*block).next.store(next_block, Ordering::Release);
}
// Write the task into the slot.
let slot = (*block).slots.get_unchecked(offset);
slot.task.get().write(MaybeUninit::new(task));
slot.state.fetch_or(WRITE, Ordering::Release);
return;
},
Err(t) => {
tail = t;
block = self.tail.block.load(Ordering::Acquire);
backoff.spin();
}
}
}
}
- CAS
tail认领一个槽。多个 push 者在这里竞争,失败就Backoff::spin后重试(Backoff)。 - 认领到块里最后一个槽(
offset == 62)的人负责装下一个块:先换tail.block,再把tail.index直接跳过第 64 个位置,最后把新块挂到旧块的next上。新块在 CAS 之前就分配好了(offset + 1 == BLOCK_CAP时预分配),把其他 push 者在第 64 个位置上等待的时间压到最短。没抢到的那个人预分配的块,在函数返回时随Option<Box<Block>>一起释放。 - 写任务,
fetch_or(WRITE, Release)发布。
认领和写入是分开的两步。认领了槽但还没写的 push 者如果被调度走,窃取者 CAS 到这个槽后会在 wait_write 里 snooze 等它。所以 Injector 并不是严格意义上的无锁(lock-free):一个线程停在两步之间,读到这个槽的线程也会停住。
steal
第 1477 行,分四步:
let mut new_head = head + (1 << SHIFT);
if new_head & HAS_NEXT == 0 {
atomic::fence(Ordering::SeqCst);
let tail = self.tail.index.load(Ordering::Relaxed);
// If the tail equals the head, that means the queue is empty.
if head >> SHIFT == tail >> SHIFT {
return Steal::Empty;
}
// If head and tail are not in the same block, set `HAS_NEXT` in head.
if (head >> SHIFT) / LAP != (tail >> SHIFT) / LAP {
new_head |= HAS_NEXT;
}
}
// Try moving the head index forward.
if self
.head
.index
.compare_exchange_weak(head, new_head, Ordering::SeqCst, Ordering::Acquire)
.is_err()
{
return Steal::Retry;
}
- 读
head;如果正停在第 64 个位置(别人正在换块),snooze等。 - 没有
HAS_NEXT时要确认队列不空:SeqCst fence 后读tail,两者相等就是Empty。发现tail已经在后面的块里,就在新head上打HAS_NEXT,之后同一块里的窃取者都不用再读tail了。这是Injector里三道 SeqCst fence 中的一道。 - CAS
head认领。用的是compare_exchange_weak,失败(包括假失败)都返回Retry。 - 认领到块里最后一个槽的人负责把
head移到下一块(wait_next等 push 者把next挂上);然后wait_write等槽被写好,读出任务。
和 Worker 不同,Injector 的槽是先认领、后读的,同一个槽不会有两个线程同时读写,所以这里没有 volatile 那种「技术上的 UB」,用的是普通的 ptr::read。
谁来释放块:READ 与 DESTROY
Injector 不用 epoch。块的释放靠槽上的两个状态位协商(Block::destroy 第 1299 行):
unsafe fn destroy(this: *mut Block<T>, count: usize) {
// It is not necessary to set the `DESTROY` bit in the last slot because that slot has
// begun destruction of the block.
for i in (0..count).rev() {
let slot = (*this).slots.get_unchecked(i);
// Mark the `DESTROY` bit if a thread is still using the slot.
if slot.state.load(Ordering::Acquire) & READ == 0
&& slot.state.fetch_or(DESTROY, Ordering::AcqRel) & READ == 0
{
// If a thread is still using the slot, it will continue destruction of the block.
return;
}
}
// No thread is using the block, now it is safe to destroy it.
drop(Box::from_raw(this));
}
- 读完块里最后一个槽的线程发起销毁,从后往前检查前面的槽:都已经
READ了就直接释放。 - 碰到还没读完的槽(认领了但还在
wait_write或在拷贝),就给它打上DESTROY,然后撒手不管。 - 那个慢的读者读完后
fetch_or(READ),发现DESTROY已置位,就接着从自己这个位置往前检查、负责释放。
每个读者读完都 fetch_or(READ, AcqRel) 一次;读到最后一个槽的那个读者不用置 READ,因为是它在发起销毁。这套协议在 channel 的 list flavor 和 SegQueue 里一模一样,它们和 Injector 是同一份设计的三个副本。
批量窃取
steal_batch_with_limit(第 1612 行)一次 CAS 把 head 推过一批,然后逐槽 wait_write、读出来、直接写进目标 Worker 的 buffer(按目标的 flavor 决定正序还是倒序),最后 Release fence + 写目标的 back。
一批有多大,取决于 head 和 tail 在不在同一块:
| 情况 | 本次推进 advance |
|---|---|
head 已有 HAS_NEXT,或 tail 在后面的块 | min(BLOCK_CAP - offset, limit):最多偷到本块末尾 |
tail 在同一块 | min((len + 1) / 2, limit):一半,向上取整 |
一次批量窃取不会跨块,最多偷到块尾。
steal_batch_and_pop 对 Injector 用的上限是 MAX_BATCH + 1 = 33,和 Stealer 不同(第 1779 行):
pub fn steal_batch_and_pop(&self, dest: &Worker<T>) -> Steal<T> {
// TODO: we use `MAX_BATCH + 1` as the hard limit for Injecter as the performance is slightly
// better, but we may change it in the future to be compatible with the same method in Stealer.
self.steal_batch_with_limit_and_pop(dest, MAX_BATCH + 1)
}
+ 1 是 2019 年 1 月 af8ce60f(「Tweak batch size」)引入的,2022 年 #903 加 *_with_limit 方法时补上了这条 TODO,说明理由是「性能略好」。实测(exp-crossbeam/deque-retry/ 的 injector,第一次调用 steal_batch_and_pop):
| push 个数 | 返回 1 个 + 搬进 dest | 说明 |
|---|---|---|
| 10 | 1 + 4 | 同一块,(10 + 1) / 2 = 5 |
| 62 | 1 + 30 | 同一块,(62 + 1) / 2 = 31 |
| 63、64、100、200 | 1 + 32 | tail 已在下一块,min(63, 33) = 33 |
同一块里最多 63 个,一半向上取整是 32,所以 33 只在 tail 已经进入后面的块时才达得到。
len 与 is_empty
is_empty 用 SeqCst 读 head 和 tail 比较。len(第 2003 行)要循环:读 tail、读 head、再读一次 tail,两次 tail 相同才算数;然后把落在第 64 个位置上的下标往后挪一格、减去跨过的块数(每块有一个不存东西的位置)。高并发 push 时 tail 一直在变,len 可能要转很多圈。sqlx 卷里关于 ArrayQueue::len() 的那条注释说的是同一类问题,ArrayQueue 与 SegQueue 解释。
谁在用
- rayon:
Registry里的injected_jobs: Injector<JobRef>,不在工作线程上调用install、spawn等提交的任务进这里(Registry::inject,第 428 行)。工作线程取的时候只用单个steal(),Retry就循环(registry.rs第 450–458 行),没有用批量窃取;而且 rayon 的find_work把它放在最后:先本地、再偷别的线程、最后才看注入队列(第 833–841 行)。 - tokio 的全局注入队列是另一种做法:一把
Mutex加一条侵入式链表,next 指针存在任务头里,批量取任务时持锁一次搬多个(tokio 卷:全局注入队列)。
小结
Injector是块链表:每块 63 个槽,下标每 64 个一圈,第 64 个位置用来等下一块装好;head下标的最低位HAS_NEXT让窃取者跳过和tail的比较。- push:CAS
tail认领 → 必要时装下一块(新块提前分配)→ 写槽 → 置WRITE。steal:CAShead认领 → 等WRITE→ 读 → 置READ。认领和写入分两步,所以中途被挂起的 push 者会让读这个槽的人等着。 - 块的释放不用 epoch:读完最后一个槽的线程发起,遇到没读完的槽打
DESTROY交给那个读者接着做。 - 块用
alloc_zeroed直接在堆上建,修掉了大T时的栈溢出(0.8.6)。 - 批量窃取不跨块;
steal_batch_and_pop对Injector的上限是 33(源码 TODO 说略快),实测只有tail已在下一块时才拿得到 33 个。 - rayon 只对
Injector用单个steal(),并且把它放在找活的最后一步。