Rust in Everything
基于 crossbeam-deque 0.8.8

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

(第 1211–1355 行)

  • 下标 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

第 1402 行:

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();
            }
        }
    }
}
  1. CAS tail 认领一个槽。多个 push 者在这里竞争,失败就 Backoff::spin 后重试(Backoff)。
  2. 认领到块里最后一个槽(offset == 62)的人负责装下一个块:先换 tail.block,再把 tail.index 直接跳过第 64 个位置,最后把新块挂到旧块的 next 上。新块在 CAS 之前就分配好了(offset + 1 == BLOCK_CAP 时预分配),把其他 push 者在第 64 个位置上等待的时间压到最短。没抢到的那个人预分配的块,在函数返回时随 Option<Box<Block>> 一起释放。
  3. 写任务,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;
}
  1. 读 head;如果正停在第 64 个位置(别人正在换块),snooze 等。
  2. 没有 HAS_NEXT 时要确认队列不空:SeqCst fence 后读 tail,两者相等就是 Empty。发现 tail 已经在后面的块里,就在新 head 上打 HAS_NEXT,之后同一块里的窃取者都不用再读 tail 了。这是 Injector 里三道 SeqCst fence 中的一道。
  3. CAS head 认领。用的是 compare_exchange_weak,失败(包括假失败)都返回 Retry。
  4. 认领到块里最后一个槽的人负责把 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说明
101 + 4同一块,(10 + 1) / 2 = 5
621 + 30同一块,(62 + 1) / 2 = 31
63、64、100、2001 + 32tail 已在下一块,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:CAS head 认领 → 等 WRITE → 读 → 置 READ。认领和写入分两步,所以中途被挂起的 push 者会让读这个槽的人等着。
  • 块的释放不用 epoch:读完最后一个槽的线程发起,遇到没读完的槽打 DESTROY 交给那个读者接着做。
  • 块用 alloc_zeroed 直接在堆上建,修掉了大 T 时的栈溢出(0.8.6)。
  • 批量窃取不跨块;steal_batch_and_pop 对 Injector 的上限是 33(源码 TODO 说略快),实测只有 tail 已在下一块时才拿得到 33 个。
  • rayon 只对 Injector 用单个 steal(),并且把它放在找活的最后一步。

💬 关联讨论

发起讨论
加载中...