Rust in Everything
基于 rayon-core 1.13.0

一个线程睡下又醒来

主线位置:主线 3 的全程。本页只画状态和关键行,逐行的论证在 05 sleep 模块;latch 的四个状态在 Latch 家族。

一个 worker 什么时候会去睡?任何「等一个 latch」的地方:主循环在等自己的 terminate latch(WorkerThread 主循环),join 在等 B 的 latch(一次 join)。两者都进 wait_until_cold,找不到活时交给 sleep 模块。睡眠是每次等待的一部分,不是线程的全局状态:latch 被 set 时,等待者必须被叫醒并离开。

两套状态

sleep 模块同时维护两样东西:

全池共享的计数器 AtomicCounters:一个 AtomicUsize 里打包三段(64 位平台:低 16 位 sleeping 线程数、中 16 位 inactive 线程数、高 32 位 JEC,counters.rs:56-77)。inactive = 正在找活的(idle)+ 睡着的;JEC(jobs event counter)是一个只增不减的计数器,偶数表示最近一次递增来自「将要睡的线程」,奇数表示来自「发布任务的一方」。

每个等待者自己的 latch:CoreLatch 的四个状态 UNSET → SLEEPY → SLEEPING,以及任何时候都可能被别人置为 SET(latch.rs:58-69)。

stateDiagram-v2
  [*] --> active: 执行任务
  active --> idle: start_looking,inactive+1
  idle --> active: 找到活或 latch 已 SET,work_found,inactive-1,再叫醒至多 2 个
  idle --> sleepy: 第 32 轮 announce_sleepy,JEC 奇数则 +1 变偶数
  sleepy --> idle: JEC 变了,wake_partly 回到 sleepy 前
  sleepy --> sleeping: sleep,latch SLEEPY 再 SLEEPING,sleeping+1,Condvar wait
  sleeping --> idle: 被 notify,wake_fully,latch 回 UNSET
  sleepy --> active: latch 已 SET,等待结束

睡下去

wait_until_cold 的内层循环每找一轮没找到,就调一次 no_work_found:

        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(sleep/mod.rs:56-57)。按轮数走一遍:

  1. 第 0–31 轮:每轮 find_work 一次(本地 → 偷 → 注入队列),没有就 std::thread::yield_now()。共 32 次让出 CPU,没有用 crossbeam 的 Backoff(对照 Backoff)。

  2. 第 32 轮:announce_sleepy:JEC 若是奇数(最近有人发过任务)就 +1 变偶数,记下这个值。轮数变成 33。

  3. 第 33 轮:再 find_work 一次。这一轮是关键:宣布 sleepy 之后发布的任务,要么在这一轮被找到,要么会让 JEC 变化。

  4. 进入 sleep,依次过五道关:

    • latch.get_sleepy():CAS UNSET → SLEEPY。失败说明 latch 已被 set,直接返回,等待结束。
    • 锁住自己的 is_blocked: Mutex<bool>。
    • latch.fall_asleep():CAS SLEEPY → SLEEPING。失败同上。
    • 读计数器:JEC 和第 32 轮记下的不一样,说明期间有人发过任务而自己没看到,wake_partly 把轮数退回 32,回去再找。否则 CAS 把 sleeping +1。
    • fence(SeqCst) 后最后看一眼全局注入队列和本线程的广播队列;有东西就自己把 sleeping -1,不睡。

    全部通过,才 *is_blocked = true 并 condvar.wait。

源码里第三个分支(rounds < ROUNDS_UNTIL_SLEEPING)按上面的轮数永远走不到:第 32 轮之后轮数直接是 33;wake_partly 把轮数设回 32,下一轮又进第二个分支。这是按代码推出来的,没有实测。

叫醒

发布任务的一方有两个入口:worker 往本地 deque push 时调 new_internal_jobs,外部 inject 时调 new_injected_jobs(多一个 fence(SeqCst))。两者都进 new_jobs:

  1. JEC 若是偶数(有线程正在变 sleepy)就 +1 变奇数。这一步让那个线程在 sleep 的第四道关看到「JEC 变了」。
  2. 没有睡着的线程:直接返回。这是常见情况,代价只是一次原子读(或一次 CAS)。
  3. 有睡着的线程:队列原本非空,叫醒 min(新任务数, 睡着数) 个;队列原本为空,只在「醒着但空闲的线程数 < 新任务数」时叫醒差额。
  4. wake_any_threads 从 0 号线程开始逐个试 wake_specific_thread:锁住对方的 is_blocked,是 true 就置 false、notify_one,并由唤醒方把 sleeping -1(注释说这样做是为了不让后来者误以为还有人睡着、白叫一次)。

还有一个定向的入口:latch 被 set 时,CoreLatch::set 发现旧状态是 SLEEPING,就 notify_worker_latch_is_set(index),只叫醒那一个等待者(一次 join 第 8 步)。

醒来的线程回到 idle:wake_fully 把轮数清零,latch.wake_up() 把 SLEEPING 改回 UNSET(若 latch 还没被 set),然后重新开始 32 轮找活。离开 idle 时(找到活,或者等的 latch 已被 set),work_found 把 inactive -1,并按「还在睡的线程数,最多 2」再叫醒几个(sleep/mod.rs:80-85)——一个线程醒来干活,就再拉起至多两个,唤醒像链式反应一样扩散。

为什么要这么复杂

如果发布任务时每次都去 notify,那么 join 的每次 push 都要碰一把锁;如果从不 notify,睡着的线程就永远醒不来。rayon 的做法是用 JEC 的奇偶把两边的「意图」记在同一个原子变量里:

  • 发布方只在有人正在变 sleepy(JEC 为偶数)时才需要改 JEC,只在有人已经睡着(sleeping > 0)时才需要拿锁。
  • 将要睡的一方在宣布 sleepy 之后、真正睡之前,再检查一次 JEC:期间有任何发布,JEC 一定变过。

剩下的漏洞(例如 JEC 恰好回绕、外部注入和睡眠同时发生)由两个 SeqCst fence 和睡前最后一次看注入队列堵上。这套论证是 05 sleep 模块 的主体。空闲时的 CPU 占用、余温和唤醒延迟的实测(E3)也在那里。

每一步在哪一章

步本卷
wait_until_cold 的双层循环、find_workWorkerThread 主循环
CoreLatch 四态、get_sleepy / fall_asleep / wake_up / setLatch 家族
计数器布局、JEC 奇偶、五道关、两个 fence、唤醒策略、实测05 sleep 模块
CachePadded<WorkerSleepState>crossbeam:CachePadded

小结

  • 睡眠属于「一次等待」:等 terminate 的主循环和等 B 的 join 走同一条路。
  • 找不到活:32 轮 yield_now,第 32 轮宣布 sleepy(JEC 奇变偶),再找一轮,然后经五道检查在自己的 Condvar 上睡下。
  • 发布任务:JEC 偶变奇;没人睡就返回;有人睡就按队列原来是否为空决定叫醒几个,从 0 号线程叫起;唤醒方负责把 sleeping 计数减一。
  • latch 被 set 时只定向叫醒那一个等待者;醒来干活的线程再叫醒至多两个。

💬 关联讨论

发起讨论
加载中...