Rust in Everything
基于 rayon-core 1.13.0

睡眠与唤醒的握手

主线位置:一个线程睡下又醒来的前半。wait_until_cold 找不到活 → start_looking → no_work_found 前 32 轮 yield_now → 第 32 轮 announce_sleepy → 再找一轮 → sleep():get_sleepy → 锁 is_blocked → fall_asleep → 看 JEC → 登记 sleeping → fence(SeqCst) → 看注入队列 → Condvar::wait。

sequenceDiagram
  participant S as 要睡的线程 S
  participant C as AtomicCounters
  participant J as 注入队列 / 广播队列
  participant P as 外部发布方 P
  S->>C: 第 32 轮:announce_sleepy,记下 JEC = x(偶数)
  S->>J: 再找一轮,没找到
  S->>S: latch.get_sleepy,锁住 is_blocked,latch.fall_asleep
  S->>C: 读计数:JEC 仍是 x?是则 CAS:sleeping +1
  P->>J: push 任务
  P->>P: fence(SeqCst)
  P->>C: new_jobs:JEC 偶则 +1,读 sleeping
  S->>S: fence(SeqCst)
  S->>J: has_injected_jobs?
  alt 看到任务
    S->>C: 自己 sleeping -1,回去找活
  else 没看到
    S->>S: is_blocked = true,Condvar::wait(放锁)
    P->>S: 看到 sleeping > 0:wake_any_threads,锁 is_blocked,置 false,notify_one,sleeping -1
  end

入口:wait_until_cold 的内层循环

registry.rs 第 780–817 行(WorkerThread 主循环逐行讲过):

let mut idle_state = self.registry.sleep.start_looking(self.index);
while !latch.probe() {
    if let Some(job) = self.find_work() {
        self.registry.sleep.work_found();
        self.execute(job);
        // The job might have injected local work, so go back to the outer loop.
        continue 'outer;
    } else {
        self.registry
            .sleep
            .no_work_found(&mut idle_state, latch, || self.has_injected_job())
    }
}

每一「轮」= 一次 find_work(本地 deque、自己的广播队列、随机起点轮询所有别的线程、全局注入队列)+ 一次 no_work_found。latch 是这个线程正在等的那个 latch:join 里等 B 时是 B 的 SpinLatch,主循环里是自己的 terminate。最后一个参数是一个闭包,sleep 模块不认识 WorkerThread,「有没有注入的任务」要由调用方告诉它。

轮数:32 次让出,宣布,再一轮,睡

sleep/mod.rs 第 87–115 行:

pub(super) fn no_work_found(
    &self,
    idle_state: &mut IdleState,
    latch: &CoreLatch,
    has_injected_jobs: impl FnOnce() -> bool,
) {
    if idle_state.rounds < ROUNDS_UNTIL_SLEEPY {
        thread::yield_now();
        idle_state.rounds += 1;
    } else if idle_state.rounds == ROUNDS_UNTIL_SLEEPY {
        idle_state.jobs_counter = self.announce_sleepy();
        idle_state.rounds += 1;
        thread::yield_now();
    } else if idle_state.rounds < ROUNDS_UNTIL_SLEEPING {
        idle_state.rounds += 1;
        thread::yield_now();
    } else {
        debug_assert_eq!(idle_state.rounds, ROUNDS_UNTIL_SLEEPING);
        self.sleep(idle_state, latch, has_injected_jobs);
    }
}

ROUNDS_UNTIL_SLEEPY = 32,ROUNDS_UNTIL_SLEEPING = 33。数一下:

  • rounds 0–31:32 次 yield_now。
  • rounds 32:announce_sleepy(JEC 奇变偶,记下值),rounds 变 33,再 yield_now 一次。
  • rounds 33:进 sleep()。

所以一个线程从开始找活到尝试睡下,做了 34 次 find_work、33 次 thread::yield_now()。第三个分支(32 < rounds < 33)在现在的常量下永远走不到,宣布 sleepy 之后实际只多找了一轮,和 README「宣布后再搜索一次」的描述一致。

本机一次 std::thread::yield_now() 的耗时(sleep-idle yield-cost,单线程连续调用 10 万次,三次,1 / 5 分钟负载约 1.7 / 2.95)是 113–118 ns,33 次合计约 4 µs。find_work 在 12 线程的池里要轮询 11 个别人的 deque 加注入队列。两者加起来,一个找不到活的线程在睡前转多久,唤醒策略与实测按线程量了 CPU 时间。

和 crossbeam 的 Backoff 对比:Backoff 是「同一件事再试一次」之间的等待,前几步用 spin_loop 自旋 1、2、4…64 次,之后才 yield_now,第 11 次后提示调用方去阻塞;rayon 这里每一轮都是一次完整的找活,两轮之间无条件 yield_now,没有自旋,轮数固定。rayon 不依赖 Backoff。

sleep():五道关卡

sleep/mod.rs 第 117–194 行:

fn sleep(
    &self,
    idle_state: &mut IdleState,
    latch: &CoreLatch,
    has_injected_jobs: impl FnOnce() -> bool,
) {
    let worker_index = idle_state.worker_index;

    if !latch.get_sleepy() {
        return;
    }

    let sleep_state = &self.worker_sleep_states[worker_index];
    let mut is_blocked = sleep_state.is_blocked.lock().unwrap();
    debug_assert!(!*is_blocked);

    // Our latch was signalled. We should wake back up fully as we
    // will have some stuff to do.
    if !latch.fall_asleep() {
        idle_state.wake_fully();
        return;
    }

    loop {
        let counters = self.counters.load(Ordering::SeqCst);

        // Check if the JEC has changed since we got sleepy.
        debug_assert!(idle_state.jobs_counter.is_sleepy());
        if counters.jobs_counter() != idle_state.jobs_counter {
            // JEC has changed, so a new job was posted, but for some reason
            // we didn't see it. We should return to just before the SLEEPY
            // state so we can do another search and (if we fail to find
            // work) go back to sleep.
            idle_state.wake_partly();
            latch.wake_up();
            return;
        }

        // Otherwise, let's move from IDLE to SLEEPING.
        if self.counters.try_add_sleeping_thread(counters) {
            break;
        }
    }

    // Successfully registered as asleep.

    // We have one last check for injected jobs to do. This protects against
    // deadlock in the very unlikely event that
    //
    // - an external job is being injected while we are sleepy
    // - that job triggers the rollover over the JEC such that we don't see it
    // - we are the last active worker thread
    std::sync::atomic::fence(Ordering::SeqCst);
    if has_injected_jobs() {
        // If we see an externally injected job, then we have to 'wake
        // ourselves up'. (Ordinarily, `sub_sleeping_thread` is invoked by
        // the one that wakes us.)
        self.counters.sub_sleeping_thread();
    } else {
        // If we don't see an injected job (the normal case), then flag
        // ourselves as asleep and wait till we are notified.
        //
        // (Note that `is_blocked` is held under a mutex and the mutex was
        // acquired *before* we incremented the "sleepy counter". This means
        // that whomever is coming to wake us will have to wait until we
        // release the mutex in the call to `wait`, so they will see this
        // boolean as true.)
        *is_blocked = true;
        while *is_blocked {
            is_blocked = sleep_state.condvar.wait(is_blocked).unwrap();
        }
    }

    // Update other state:
    idle_state.wake_fully();
    latch.wake_up();
}
关卡检查失败时挡住的是
1latch.get_sleepy():UNSET → SLEEPY直接返回(latch 已 SET,外层循环 probe 后退出)等的事已经发生
2拿 is_blocked 锁—为下面所有步骤建立「叫醒方必须等我」的前提
3latch.fall_asleep():SLEEPY → SLEEPINGwake_fully,返回1、3 之间 latch 被设置
4JEC 是否还是宣布时记下的值;没变就 CAS 登记 sleepingwake_partly(rounds = 32,回到宣布之前),latch.wake_up,返回宣布之后有人发布了任务(内部或外部)
5fence(SeqCst) 后 has_injected_jobs():全局注入队列或本线程广播队列非空自己减 sleeping,wake_fully,回去找活外部注入的任务,包括 JEC 回绕恰好掩盖了它的情况

关卡 4 失败时只回到「宣布之前」:下一次 no_work_found 时 rounds == 32,立刻重新宣布 sleepy、再找一轮,而不是从头转 32 轮。README 的解释:JEC 变了说明有新任务,再找一次多半能找到;找不到就马上再试着睡。

关卡 5 失败时 README 说的是「回到 sleepy 之前的状态,就像 JEC 变了一样」,代码却是 wake_fully()(轮数归零)。这是上游文档与代码的出入,以代码为准:发现注入任务的线程会从头开始找活,找到就执行,找不到要再转满 33 轮。

关卡都过了才 *is_blocked = true 进 Condvar::wait,while *is_blocked 循环挡住虚假唤醒:只有叫醒方把它置回 false 才算真的被叫醒。醒来后 wake_fully(轮数归零,回到 idle)、latch.wake_up(SLEEPING → UNSET,latch 已被设置则不动)。

为什么先拿锁再登记

关卡 2 在关卡 3、4 之前拿锁,一直持有到 Condvar::wait 才放。叫醒方(wake_specific_thread)必须拿同一把锁才能看 is_blocked:

fn wake_specific_thread(&self, index: usize) -> bool {
    let sleep_state = &self.worker_sleep_states[index];

    let mut is_blocked = sleep_state.is_blocked.lock().unwrap();
    if *is_blocked {
        *is_blocked = false;
        sleep_state.condvar.notify_one();
        self.counters.sub_sleeping_thread();
        true
    } else {
        false
    }
}

于是叫醒方只会看到两种状态之一:

  • S 还没拿锁,或者已经在某道关卡放弃、放了锁:is_blocked == false,叫醒方什么也不做。S 醒着,后面的关卡(或下一轮 find_work)会自己发现新情况。
  • S 已经过了关卡 4(登记了 sleeping):叫醒方被挡在锁外,直到 S 进 wait 放锁,此时 is_blocked 已经是 true,叫醒方把它改成 false、notify_one、替 S 减 sleeping。

不存在「S 登记了 sleeping、叫醒方看到 is_blocked == false 走了、S 再置 true 睡下」这种交错。源码注释(第 180–184 行)说的就是这一点。

三种丢唤醒,各由哪里挡

要防的总是同一个形状(README「The jobs event counter and deadlock」一节):S 找活没找到;P 发布了任务,但没看到有人在睡,没叫;S 睡下。按任务的来源分三种:

1. S 等的 latch 被设置

join 的 B 被偷走后,主人 S 在 B 的 latch 上 wait_until,窃取者跑完 B 后 Latch::set。这种情况不经过计数器,靠的是 latch 自身的四态:swap(SET) 和 S 的 get_sleepy / fall_asleep 两个 CAS 在同一个原子变量上排成全序,swap 读到 SLEEPING 才去 wake_specific_thread,再加上关卡 2 的锁。三种交错在 Latch 家族的 CoreLatch 一节逐个列过。

2. 内部任务:别的 worker 往自己 deque 里 push

WorkerThread::push 之后调 new_internal_jobs,直接进 new_jobs,没有 fence。

  • P 的 new_jobs 发生在 S 宣布之后:S 宣布时把 JEC 变成偶数,P 看到偶数就加一,S 在关卡 4 看到 JEC 变了,回去再找。JEC 是同一个原子变量上的读-改-写,这一步不会漏。
  • P 的 new_jobs 发生在 S 宣布之前:P 看到的 JEC 可能是奇数,不写;也没看到 sleeping,不叫。S 宣布后再找的那一轮可能看不到 P 刚推的任务(P 推任务和 S 偷任务之间没有 fence)。S 会睡下。

第二种情况是允许发生的。README:「如果新任务来自线程池内部,这种竞态危害不大,意味着处理任务的 worker 比应有的少,但不会死锁」。P 是一个活着的 worker,它推了任务,之后自己总会弹出来执行(或者在 join 里等它的时候执行)。代价只是少了一个帮手。

3. 外部任务:池外线程注入

Registry::inject 把任务推进全局 Injector,调 new_injected_jobs:

pub(super) fn new_injected_jobs(&self, num_jobs: u32, queue_was_empty: bool) {
    // This fence is needed to guarantee that threads
    // as they are about to fall asleep, observe any
    // new jobs that may have been injected.
    std::sync::atomic::fence(Ordering::SeqCst);

    self.new_jobs(num_jobs, queue_was_empty)
}

这里不能接受「少一个帮手」:注入方 P 不是 worker,不会自己执行这个任务。如果所有 worker 都按第 2 种情况的交错睡下,任务就永远留在队列里,in_worker_cold 的调用方在 LockLatch 上永远等下去。

挡住它的是关卡 5 和这里的 fence。README「Using seq-cst fences to prevent deadlock」一节的证明梗概:注入方依次做 PushJob(写 J,Release)、PushFence、ReadSleepers(读计数 C);要睡的线程依次做 IncSleepers(CAS 写 C)、SleepFence、ReadJob(读 J,Acquire)。两个 SeqCst fence 在全序里总有先后:

  • PushFence 在前:SleepFence 之后的 ReadJob 一定看到 PushJob,S 在关卡 5 发现任务,不睡。
  • SleepFence 在前:PushFence 之后的 ReadSleepers 一定看到 IncSleepers,P 知道有人在睡,new_jobs 去叫(叫几个的规则见 唤醒策略与实测)。

两者至少发生一个,所以不会出现「P 没看到有人睡、S 也没看到任务」。这正是 C++ 内存模型里 SeqCst fence 的典型用法(crossbeam 卷的内存序一章讲过同一个「两边各一个 fence」的形状,crossbeam-deque 的 pop / steal 也是它)。只用 Acquire / Release 做不到:README 指出内存模型给的保证是「如果你看到了 A,就一定看到 A 之前的那些」,但不保证「你一定看到 A」。

为什么有了 JEC 还要关卡 5

在大多数交错里,JEC 就够了:P 在 S 宣布之后发布,S 在关卡 4 看到 JEC 变了。源码注释把关卡 5 的作用说成防一种「非常不可能」的情况:外部注入发生在 S sleepy 期间,这次注入恰好使 JEC 回绕到 S 记下的值,而 S 又是最后一个活着的 worker。JEC 在 64 位平台上有 32 位,回绕到同一个值需要 2³² 次递增,而奇偶编码下每次递增都要求「上一次是对方写的」,也就是 2³¹ 轮「有线程宣布 sleepy、有人发布任务」的交替,要在 S 两次读 JEC 之间(关卡 4 前后几百纳秒)完成,实际不可能;32 位平台上 JEC 只有 16 位,难度小得多。按上面的证明梗概,fence 配对对外部任务的保证不依赖 JEC,所以关卡 5 实际兜住的是所有「外部任务 + JEC 没帮上忙」的交错,回绕只是其中最极端的一种(这是按代码和证明推出来的,注释本身只提回绕)。

关卡 5 是 2020-04 的 1222cbe「check one last time for injected jobs before sleep」加的,和「允许 JEC 在任意点回绕」(908e137)同一天前后。2022 年 be12615 把它扩展到本线程的广播队列,那次修复的分析见 broadcast。

内部任务为什么不加 fence

new_internal_jobs 比 new_injected_jobs 少一个 fence(SeqCst),而它在每次 WorkerThread::push 时都会调用,包括每一次 join。这是 README 明确的取舍:「推到内部队列的任务不需要 fence,因为在那种情况下漏看任务是可以接受的」。热路径上的每次 join 只多一次 SeqCst 的读(JEC 是奇数时)或一次 CAS(偶数时),不付 fence 的代价。

醒来之后

被叫醒的线程从 sleep() 返回时 rounds = 0,回到 wait_until_cold 的内层循环:先 probe 自己的 latch,再 find_work。找到活就 work_found,找不到就重新转 33 轮再睡。它不知道自己为什么被叫醒,也不需要知道:叫醒只是「去看一眼」的提示。唤醒策略与实测里实测的「被叫醒却没活干的线程各自空转几十微秒再睡回去」,就是这一段。

小结

  • 找不到活的线程做 34 次 find_work、33 次 thread::yield_now() 后进 sleep();第 32 轮宣布 sleepy,之后只多找一轮。
  • sleep() 五道关卡:latch 已设置(两处)、JEC 变了、注入队列或广播队列非空;全部通过才 is_blocked = true 进 Condvar::wait。
  • 关卡 2 先拿 is_blocked 锁再登记,叫醒方要拿同一把锁,所以只会看到「还醒着」或「已经在等」两种状态。
  • 内部任务允许被漏看(发布者自己会执行,只是少个帮手),不加 fence;外部注入不能漏,注入方与睡前检查各一个 SeqCst fence,保证至少一方看到另一方。
  • JEC 回绕是关卡 5 注释里写的理由;fence 配对对外部任务的保证并不依赖 JEC。

💬 关联讨论

发起讨论
加载中...