Rust in Everything
基于 parking_lot 0.12.5

Once

主线位置:主线 1 的一个变体。Once 的慢路径就是 RawMutex::lock_slow 的同一个骨架(抢 LOCKED_BIT → 没人停车才自旋 → 置 PARKED_BIT → park),区别在于拿到锁的那个线程执行完闭包后不是「解锁」,而是一次性写成 DONE_BIT 并 unpark_all。

stateDiagram-v2
  direction LR
  New: 0 新建
  Run: LOCKED 闭包执行中
  RunP: LOCKED 加 PARKED 有人在等
  Done: DONE
  Poison: POISON
  New --> Run: CAS 抢到 LOCKED_BIT
  Poison --> Run: call_once_force 抢到,同时清 POISON
  Run --> RunP: 等待者置 PARKED_BIT 后停车
  Run --> Done: 闭包返回,swap(DONE_BIT)
  RunP --> Done: 闭包返回,swap 后 unpark_all
  Run --> Poison: 闭包 panic,PanicGuard swap(POISON_BIT)
  RunP --> Poison: 闭包 panic,swap 后 unpark_all

1 字节,4 位

once.rs:15-18:

const DONE_BIT: u8 = 1;
const POISON_BIT: u8 = 2;
const LOCKED_BIT: u8 = 4;
const PARKED_BIT: u8 = 8;

pub struct Once(AtomicU8);(77 行)。state() 把四位翻译成 OnceState 的四个值:Done 优先,其次 InProgress(LOCKED_BIT),再次 Poisoned,都没有就是 New(86-99 行)。注意 LOCKED_BIT 和 POISON_BIT 可以同时出现:call_once_force 在一个已毒化的 Once 上重新执行时(见下)。

文档列的与 std 的差异(58-64 行):1 字节而不是 1 个字;不要求 'static;「Relaxed memory barriers in the fast path」;自适应自旋。第三条和 0.12.5 的代码对不上:快路径是一次 load(Ordering::Acquire)(下一节),不是 Relaxed。

快路径:一次读

call_once:

#[inline]
pub fn call_once<F>(&self, f: F)
where
    F: FnOnce(),
{
    if self.0.load(Ordering::Acquire) == DONE_BIT {
        return;
    }

    let mut f = Some(f);
    self.call_once_slow(false, &mut |_| unsafe { f.take().unchecked_unwrap()() });
}

初始化完成之后,每次调用就是一次 Acquire 读加一次比较。Acquire 与执行闭包那个线程最后的 swap(DONE_BIT, Release) 配对,保证读到 DONE_BIT 的线程能看到闭包里的写入(文档里的「happens-before relation between the closure and code executing after the return」)。比较用的是 == DONE_BIT 而不是 & DONE_BIT:完成之后状态字恰好是 DONE_BIT,其余位全是 0(见下面的 swap)。

FnOnce 被包进 Option 再以 &mut dyn FnMut(OnceState) 传给非泛型的 call_once_slow:注释说这样慢路径只编译一份,而且没有办法不分配地经由虚调用调用一个 FnOnce(187-197 行)。

慢路径

call_once_slow 的循环和 RawMutex::lock_slow 几乎逐行对应,只多了开头两个判断:

// If another thread called the closure, we're done
if state & DONE_BIT != 0 {
    // An acquire fence is needed here since we didn't load the
    // state with Ordering::Acquire.
    fence(Ordering::Acquire);
    return;
}

// If the state has been poisoned and we aren't forcing, then panic
if state & POISON_BIT != 0 && !ignore_poison {
    // Need the fence here as well for the same reason
    fence(Ordering::Acquire);
    panic!("Once instance has previously been poisoned");
}

// Grab the lock if it isn't locked, even if there is a queue on it.
// We also clear the poison bit since we are going to try running
// the closure again.
if state & LOCKED_BIT == 0 {
    match self.0.compare_exchange_weak(
        state,
        (state | LOCKED_BIT) & !POISON_BIT,
        Ordering::Acquire,
        Ordering::Relaxed,
    ) {
        Ok(_) => break,
        Err(x) => state = x,
    }
    continue;
}
// ... spin if no PARKED_BIT, set PARKED_BIT, park(addr, validate = state == LOCKED | PARKED)

慢路径里的读都是 Relaxed,看到 DONE_BIT 或 POISON_BIT 时补一个 Acquire 栅栏,效果等同于用 Acquire 读。抢到 LOCKED_BIT 时顺手清掉 POISON_BIT:能走到这里且带着 POISON_BIT 的,只可能是 call_once_force。

停车的 timed_out 回调是 unreachable!():Once 从不带超时停车。醒来后不看令牌,回到循环开头重新读状态:被叫醒只意味着「执行者结束了」,结局是 DONE 还是 POISON 要自己看。

执行闭包:PanicGuard

275-309 行:

struct PanicGuard<'a>(&'a Once);
impl<'a> Drop for PanicGuard<'a> {
    fn drop(&mut self) {
        // Mark the state as poisoned, unlock it and unpark all threads.
        let once = self.0;
        let state = once.0.swap(POISON_BIT, Ordering::Release);
        if state & PARKED_BIT != 0 {
            let addr = once as *const _ as usize;
            unsafe {
                parking_lot_core::unpark_all(addr, DEFAULT_UNPARK_TOKEN);
            }
        }
    }
}

let guard = PanicGuard(self);
let once_state = if state & POISON_BIT != 0 {
    OnceState::Poisoned
} else {
    OnceState::New
};
f(once_state);
mem::forget(guard);

// Now unlock the state, set the done bit and unpark all threads
let state = self.0.swap(DONE_BIT, Ordering::Release);
if state & PARKED_BIT != 0 {
    let addr = self as *const _ as usize;
    unsafe {
        parking_lot_core::unpark_all(addr, DEFAULT_UNPARK_TOKEN);
    }
}
  • 成功:swap(DONE_BIT) 一次把 LOCKED_BIT、PARKED_BIT 都清掉,只留 DONE_BIT;旧值里有 PARKED_BIT 就 unpark_all。
  • 闭包 panic:unwind 时 PanicGuard::drop 把状态 swap 成 POISON_BIT,同样叫醒所有等待者。等待者醒来看到 POISON_BIT:call_once 的等待者跟着 panic,call_once_force 的等待者会去抢 LOCKED_BIT 再试一次。
  • 闭包传进来的 OnceState 只可能是 New 或 Poisoned(快照取自抢锁那一刻)。

和 Mutex 不同,Once 用 unpark_all 一次叫醒全部等待者:它们醒来后只读一下状态就走,不需要互斥。

与 std 的对照

std 的 Once 在 macOS 上是 sys/sync/once/queue.rs(等待节点链表,8 字节),在 Linux 等平台上是 futex.rs(4 字节)(once/mod.rs:10-36)。std 的毒化报错和 parking_lot 一字不差(once/queue.rs:167)。OnceLock 在内部用的是 call_once_force(sync/once_lock.rs:562-580),所以初始化闭包 panic 不会让它卡住:下一次 get_or_init 会再试。LazyLock 则会毒化(sync/lazy_lock.rs:415)。

parking_lot Oncestd Oncestd OnceLock<u64>std LazyLock<u64>
本机大小181616
Linux 大小(编译期断言)1416—
初始化 panic 后再调用panic「Once instance has previously been poisoned」同左重新执行初始化panic「LazyLock instance has previously been poisoned」
绕过毒化call_once_forcecall_once_force不需要无

实验 E10

方法(exp-parking_lot/once,不开 deadlock_detection):

  • ① 快路径:初始化完成后循环 2 亿次 call_once(std Once::call_once、OnceLock::get 同样),外加一个空循环作基准;程序内部 5 轮取中位数,整个程序跑 3 次。
  • ② 并发:12 个线程用 Barrier 同时对一个新 Once 调 call_once,闭包里计数;2000 轮,每轮计数必须恰好是 1。OnceLock::get_or_init 同样做一遍。反向验证:同一轮里再做一次不加锁的「先读标志、再计数、再写标志」,它应当出现计数大于 1 的轮次。
  • ③ 毒化:闭包 panic 后再调 call_once、call_once_force,与 std Once、OnceLock、LazyLock 对照。
  • ④ 等待中毒化:一个线程在闭包里睡 100 ms 后 panic,期间另外 4 个线程在同一个 Once(或 OnceLock)上等。

结果:

① 快路径(22:37,负载 2.67 / 2.98;3 次运行的中位数,每次运行本身是 5 轮的中位数;括号是 3 次的范围):

ns / 次
parking_lot Once::call_once(已完成)0.346(0.344–0.354)
std Once::call_once(已完成)0.344(0.340–0.346)
std OnceLock::get0.422(0.420–0.437)
空循环(只有 black_box(i))0.300(0.298–0.300)

三者都只比空循环多 0.04–0.12 ns:这是紧循环里的吞吐,读操作和循环本身重叠执行,量到的不是单次延迟。能说的只是「同量级、都接近免费」,parking_lot 的 Acquire 读与 std 没有可测的差别。

② 2000 轮 × 12 线程:parking_lot Once 和 OnceLock 计数不为 1 的轮次都是 0;不加锁的那一组有 104 轮计数大于 1,说明这个测法能抓到重复执行。

③ 毒化(原文):

pl first call_once: Some("init failed"); state now Poisoned
pl second call_once: Some("Once instance has previously been poisoned"); state now Poisoned
  pl call_once_force closure sees Poisoned (poisoned() = true)
pl after call_once_force: state Done
std Once first: Some("init failed"); is_completed false
std Once second: Some("Once instance has previously been poisoned")
  std call_once_force closure sees is_poisoned() = true
OnceLock first get_or_init: Some("init failed"); get() = None
OnceLock second get_or_init: Ok(7)
LazyLock first deref: Some("init failed")
LazyLock second deref: Some("LazyLock instance has previously been poisoned")

call_once_force 是反向验证:它看到 Poisoned,执行成功后状态变成 Done。

④ 等待中毒化:parking_lot 的 4 个等待者全部 panic「Once instance has previously been poisoned」——它们被 PanicGuard 叫醒后看到 POISON_BIT。OnceLock 的 4 个等待者里有一个重新执行了初始化(计数 1),4 个都拿到了同一个值。

结论:在需要「初始化失败可以重试」的地方,OnceLock 的语义和 parking_lot Once 不同,迁移时要注意(坑 once-poisoned)。OnceLock、LazyLock 稳定以后,新代码里需要 parking_lot Once 的场合不多:它的优势只剩 1 字节和不要求 'static(std 的 Once::new 同样是 const fn,也不要求 'static,这一条在今天已经不算差别)。

小结

  • Once = 一个 AtomicU8 的四位:DONE、POISON、LOCKED、PARKED。完成后状态恰好是 DONE_BIT,快路径是一次 Acquire 读加 == 比较(文档说是 Relaxed,与代码不符)。
  • 慢路径和 RawMutex::lock_slow 同一个骨架:抢 LOCKED_BIT(call_once_force 同时清 POISON_BIT)、没人停车才自旋、置 PARKED_BIT 后停车;醒来重读状态。
  • 执行者成功时 swap(DONE_BIT),panic 时 PanicGuard swap(POISON_BIT),两种情况都 unpark_all。
  • 毒化后 call_once panic,等待中的线程也一起 panic;call_once_force 能看到 Poisoned 并重试。std Once、LazyLock 也毒化,OnceLock 不毒化、下次重试。
  • E10(本机):初始化后的 call_once 与 std 同为约 0.35 ns / 次(紧循环吞吐);12 线程并发 2000 轮都只执行一次;毒化语义与 std Once 相同、与 OnceLock 不同。

💬 关联讨论

发起讨论
加载中...