Rust in Everything
基于 rayon-core 1.13.0

状态机与计数器

主线位置:一个线程睡下又醒来。这一章是主线 3 里所有参与者共同读写的那块状态:要睡的线程在这里登记,发布任务的线程在这里看「有没有人在睡、要不要叫」,叫醒方在这里把睡眠数减掉。
flowchart LR
  subgraph word["AtomicUsize(64 位平台)"]
    s["位 0–15:sleeping 线程数"]
    i["位 16–31:inactive 线程数"]
    j["位 32–63:JEC"]
  end
  look["start_looking / work_found"] -->|"inactive ±1"| i
  sl["sleep() 登记"] -->|"sleeping +1(CAS,同时确认 JEC 没变)"| s
  wk["wake_specific_thread"] -->|"sleeping -1"| s
  sy["announce_sleepy:JEC 奇数则 +1"] --> j
  nj["new_jobs:JEC 偶数则 +1"] --> j

四种状态

sleep/README.md 的「Thread states」一节定义了三种主状态和一个子状态:

状态含义在代码里怎么体现
active正在执行任务不在 wait_until_cold 的内层循环里
idle在找活:偷别人、看注入队列持有一个 IdleState,rounds < 32
sleepy仍在找活,但已经宣布「再找一轮就睡」IdleState.jobs_counter 不是 DUMMY
sleeping阻塞在自己的 Condvar 上is_blocked == true

idle、sleepy、sleeping 合称 inactive。线程的状态没有存在一个字段里,而是分散在三处:计数器(全局、原子)、IdleState(线程自己栈上的局部变量)、is_blocked(每线程一把锁保护的布尔值)。IdleState 的注释说得直白:「产生一个这种结构,就是线程处于空闲状态的证据」——start_looking 返回它,找到活时它被丢弃,其余操作都拿 &mut IdleState。

AtomicCounters:一个字装三个数

counters.rs 第 3–87 行:

pub(super) struct AtomicCounters {
    value: AtomicUsize,
}

/// Number of bits used for the thread counters.
#[cfg(target_pointer_width = "64")]
const THREADS_BITS: usize = 16;

#[cfg(target_pointer_width = "32")]
const THREADS_BITS: usize = 8;

const SLEEPING_SHIFT: usize = 0 * THREADS_BITS;
const INACTIVE_SHIFT: usize = 1 * THREADS_BITS;
const JEC_SHIFT: usize = 2 * THREADS_BITS;

/// Max value for the thread counters.
pub(crate) const THREADS_MAX: usize = (1 << THREADS_BITS) - 1;

const ONE_SLEEPING: usize = 1;
const ONE_INACTIVE: usize = 1 << INACTIVE_SHIFT;
const ONE_JEC: usize = 1 << JEC_SHIFT;

64 位平台:低 16 位 sleeping,再 16 位 inactive,高 32 位 JEC;32 位平台按 8 / 8 / 16 分。(结构体上方的注释举的例子是「THREADS_BITS 等于 10」,和现在的常量对不上,以常量为准。)

为什么要挤在一个字里:sleep 协议里有一步要求原子地同时做两件事——「确认 JEC 自从我宣布 sleepy 以来没变」和「把 sleeping 加一」。README 写的是「the thread atomically: checks the JEC … increments the number of sleeping threads」。三个数在同一个字里,一次 CAS 就做到了:try_add_sleeping_thread 拿 sleep() 刚读到的整个字作旧值,只要期间有人动过 JEC(或者任何一个计数),CAS 就失败,sleep() 重读一次再判断。发布任务的一方也是一次读就拿到 sleeping、inactive、JEC 三个数的一致快照。

THREADS_MAX 就是计数器字段能表示的最大值:64 位 65535,32 位 255。Sleep::new 里 assert!(n_threads <= THREADS_MAX),Registry::new 先把线程数和 max_num_threads() 取较小值(软上限,2022 年 d8e9059 加入,见 ThreadPoolBuilder)。

五个操作

操作谁调用原子操作内存序
add_inactive_thread线程开始找活(start_looking)fetch_add(ONE_INACTIVE)SeqCst
sub_inactive_thread线程找到活(work_found),返回「该再叫醒几个」fetch_sub(ONE_INACTIVE)SeqCst
try_add_sleeping_thread要睡的线程登记CAS 整个字SeqCst
sub_sleeping_thread叫醒方(wake_specific_thread);或者要睡的线程在最后检查里发现注入任务时自己减fetch_sub(ONE_SLEEPING)SeqCst
increment_jobs_event_counter_if(cond)sleepy 宣布方(条件:JEC 是奇数)、发布任务方(条件:JEC 是偶数)读 + 条件 CAS 循环SeqCst

三个不变量:

  • inactive ≥ sleeping。线程从 idle 变成 sleeping 时 inactive 不减,sleeping 加一;醒来回到 idle 时 sleeping 减一、inactive 不变;只有找到活才减 inactive。所以 awake_but_idle_threads() = inactive - sleeping 就是「醒着在找活的线程数」。几个函数里的 debug_assert! 检查的就是这条。
  • sleeping 由叫醒方减。wake_specific_thread 里的注释:线程从被通知到真正醒来有一段延迟,如果让它自己减,这段时间里别的发布方会以为还有人在睡、又去叫一次。叫醒方在 notify_one 之后立刻减,计数马上反映真实情况。
  • 计数只是估计,正确性不靠它。发布方根据计数决定叫不叫、叫几个;计数偏大只会多做一次无用的 wake_specific_thread(拿锁一看 is_blocked 是假),偏小可能少叫一个线程。真正保证「有活不会没人做」的是 睡眠与唤醒的握手 里那几道检查。

JEC:奇偶编码

JEC(jobs event counter,任务事件计数器)回答的是:「我宣布要睡之后,有没有人发布过新任务?」

最直接的做法是每发布一个任务就把计数器加一,要睡的线程睡前比较一下有没有变。README 说这「在实践中太贵」(链到 #746 里的一条评论,本地没有原文;推测原因是每次 push 都要对同一个缓存行做一次原子写),于是改成奇偶切换(counters.rs 第 23–53 行):

pub(super) struct JobsEventCounter(usize);

impl JobsEventCounter {
    pub(super) const DUMMY: JobsEventCounter = JobsEventCounter(usize::MAX);

    /// The JEC "is sleepy" if the last thread to increment it was in the
    /// process of becoming sleepy. This is indicated by its value being *even*.
    /// When new jobs are posted, they check if the JEC is sleepy, and if so
    /// they incremented it.
    pub(super) fn is_sleepy(self) -> bool {
        (self.as_usize() & 1) == 0
    }

    /// The JEC "is active" if the last thread to increment it was posting new
    /// work. This is indicated by its value being *odd*. When threads get
    /// sleepy, they will check if the JEC is active, and increment it.
    pub(super) fn is_active(self) -> bool {
        !self.is_sleepy()
    }
}

两条规则:

  • 线程宣布 sleepy(announce_sleepy):JEC 是奇数就加一变偶数;是偶数就不动。不管哪种,记下最终的值。
  • 发布任务(new_jobs):JEC 是偶数就加一变奇数;是奇数就不动。

increment_jobs_event_counter_if 实现这两条:

pub(super) fn increment_jobs_event_counter_if(
    &self,
    increment_when: impl Fn(JobsEventCounter) -> bool,
) -> Counters {
    loop {
        let old_value = self.load(Ordering::SeqCst);
        if increment_when(old_value.jobs_counter()) {
            let new_value = old_value.increment_jobs_counter();
            if self.try_exchange(old_value, new_value, Ordering::SeqCst) {
                return new_value;
            }
        } else {
            return old_value;
        }
    }
}

一个时间线(两个线程 S1、S2 先后要睡,P 发布任务):

步骤事件JEC说明
0初始0(偶)新池,没人 sleepy 过,但 0 是偶数
1P 发布任务1(奇)偶 → 奇
2P 又发布 9 个任务1已经是奇数,只读不写
3S1 宣布 sleepy2(偶)奇 → 偶,S1 记下 2
4S2 宣布 sleepy2已经是偶数,不写,S2 也记下 2
5P 发布任务3(奇)偶 → 奇
6S1、S2 睡前检查3 ≠ 2都发现「我宣布之后有新任务」,回去再找一轮

步骤 2 就是省下来的那部分:在没有线程宣布要睡的这段时间里,无论发布多少任务,new_jobs 只做一次 SeqCst 的读,不写共享缓存行。当池子很忙(没有线程闲到宣布 sleepy)时,这几乎是每次 push 的情况。代价是 JEC 不再「计数」,只记录「最后一个写它的是哪一方」,因此它只能回答「宣布之后有没有发布过任务」,回答不了「发布了几个」。

这个方案来自 2020-05-13 的 a0efb4a「transition to even/odd scheme」,提交说明:「这避免了在每个新任务上都递增 JEC,同时(我相信)仍然避免死锁」。两天后 c3a5304 修正了 is_sleepy 的位测试。

DUMMY = usize::MAX 表示「当前没有记下的值」(不 sleepy)。JEC 字段最多 32 位(64 位平台),真实的 JEC 值永远不会等于 usize::MAX;sleep() 里用 debug_assert!(idle_state.jobs_counter.is_sleepy()) 确认进来时已经宣布过(DUMMY 是奇数,过不了这个断言)。

回绕

JEC 在字的最高位,increment_jobs_counter 用 wrapping_add(ONE_JEC):溢出的进位直接从字的顶端丢掉,不会影响低位的两个计数。注释:「它占最高位,所以不会溢出到别的计数,只是自己回绕」。1.8.1 之前这里用的是普通加法,debug 构建里溢出就 panic(e768237,#800);32 位平台上 JEC 只有 16 位,65536 次递增就回绕一圈,所以发布说明说「在 32 位目标上更容易触发」。

回绕带来一个理论上的漏洞:线程宣布 sleepy 时记下 JEC = x,睡前检查时 JEC 恰好又回到 x(中间经过了整整 2³² 次奇偶切换),它就会误以为没有新任务。握手一章讲这种情况的后果和那道补救检查。

每线程的睡眠状态

sleep/mod.rs 第 14–66 行:

pub(super) struct Sleep {
    /// One "sleep state" per worker. Used to track if a worker is sleeping and to have
    /// them block.
    worker_sleep_states: Vec<CachePadded<WorkerSleepState>>,

    counters: AtomicCounters,
}

#[derive(Default)]
struct WorkerSleepState {
    /// Set to true when the worker goes to sleep; set to false when
    /// the worker is notified or when it wakes.
    is_blocked: Mutex<bool>,

    condvar: Condvar,
}

const ROUNDS_UNTIL_SLEEPY: u32 = 32;
const ROUNDS_UNTIL_SLEEPING: u32 = ROUNDS_UNTIL_SLEEPY + 1;

每个线程一把锁、一个条件变量,叫醒谁就只碰谁的那一对,这是 2020 年新方案相对旧方案(全池一个 Condvar,notify_all)的核心变化。Mutex / Condvar 来自 crate::sync,默认就是 std::sync,开启 web_spin_lock feature 时换成 wasm_sync。

CachePadded 让相邻线程的睡眠状态不落在同一条缓存行上(crossbeam 卷的 CachePadded)。本机(aarch64 macOS,exp-rayon/sizes/)实测:std::sync::Mutex<bool> 16 字节、Condvar 16 字节,WorkerSleepState 32 字节,套上 CachePadded 后 128 字节、128 字节对齐。12 个线程的池,这一组状态占 1.5 KiB。

counters 本身没有 CachePadded:Sleep 里只有它一个热的原子,它和 worker_sleep_states 的 Vec 头(指针、容量、长度,建池后不再改)放在一起,不存在两个热字段互相干扰的问题(推测:上游没有说明)。

小结

  • 线程状态分散在三处:全局计数器(sleeping、inactive、JEC)、线程栈上的 IdleState(轮数和记下的 JEC)、每线程一把锁保护的 is_blocked。
  • 三个数打包在一个 AtomicUsize 里(64 位:16 / 16 / 32),一次 CAS 就能「确认 JEC 没变并登记睡眠」;计数器位宽决定 THREADS_MAX(65535 / 255)。
  • inactive 包括 sleeping;sleeping 由叫醒方减,计数只是估计,正确性靠握手。
  • JEC 奇偶编码:宣布 sleepy 时奇变偶,发布任务时偶变奇;没人宣布要睡时,发布任务只需一次读。JEC 用 wrapping_add,回绕留下一个极小的漏洞,由握手的最后一道检查兜底。
  • 每线程一对 Mutex<bool> + Condvar,CachePadded 后本机 128 字节。

💬 关联讨论

发起讨论
加载中...