Skip to content

← Atomics, Send/Sync and the Memory Model step 8 of 12

Medium Primitives

OnceLock: one initialisation, however many threads race for it

Have workers threads all race to initialise the same global, and prove that exactly one of them won.

pub fn init_once(workers: usize) -> (u64, usize)

Two statics:

static CELL: OnceLock<u64> = OnceLock::new();
static ATTEMPTS: AtomicUsize = AtomicUsize::new(0);

Every worker calls CELL.get_or_init(|| { ATTEMPTS.fetch_add(1, Relaxed); 42 }). Return (*CELL.get().unwrap(), ATTEMPTS.load(Relaxed)). Treat workers == 0 as 1.

The answer is (42, 1) for every worker count, and that is the whole point: a deterministic assertion over a genuinely racy execution. Sixteen threads all arrive at the same line at the same instant, and the initialiser body runs exactly once. Not “almost always once” — once, guaranteed by the type.

That framing is worth stealing. When a piece of concurrent code has a property that holds regardless of scheduling, assert the property, and you have a test that means something.

OnceLock<T>

fn get(&self) -> Option<&T>
fn set(&self, value: T) -> Result<(), T>            // Err gives your value back
fn get_or_init<F: FnOnce() -> T>(&self, f: F) -> &T
fn wait(&self) -> &T                                 // block until initialised

All of these take &self — no mut anywhere — so a OnceLock works perfectly as a static. Internally it is a Once plus an UnsafeCell: the first caller into get_or_init runs the closure while every other caller blocks, and when it finishes they all get a shared reference to the one value.

This is the correct answer to “I need a global that is expensive to build”, and it replaces lazy_static! and once_cell::sync::OnceCell outright. It landed in std in 1.70.

OnceLock never poisons. If the initialiser panics, the cell is simply still empty and the next caller may try again.

LazyLock<T, F> is not the same, and the difference is nasty

LazyLock (1.80) is OnceLock plus the initialiser, run on first Deref:

static CONFIG: LazyLock<Config> = LazyLock::new(|| load_config());
// CONFIG.timeout   <- initialises here, first time only

It is more ergonomic and you should usually prefer it. But say the asymmetry out loud, because people assume the pair behave alike:

A panic in a LazyLock initialiser poisons it irrecoverably. Not “until cleared” — there is no clear_poison, there is no retry. Every subsequent access panics for the lifetime of the process. OnceLock in the same situation is merely still empty.

So if your initialiser can fail, OnceLock with an explicit retry is the honest choice, and LazyLock is for initialisers that cannot fail.

The starter’s bug is a const

warning: a `const` item should not be interior mutable
         [clippy::declare_interior_mutable_const]

A const is not a variable — it is a value inlined at every use site. So const CELL: OnceLock<u64> creates a fresh, empty cell everywhere CELL appears. Nothing is ever shared. get_or_init initialises a temporary that is discarded on the next line, CELL.get() is forever None, and the counter never leaves zero.

A static is one object, at one address, that everything refers to. That is what you need, and it works with no ceremony because OnceLock::new and AtomicUsize::new are const fn — the same reason static M: Mutex<Vec<u8>> = Mutex::new(Vec::new()) needs no macro.

Clippy flags the declaration and the borrow separately, and rustc has its own const_item_interior_mutations. Several independent lints agreeing is a strong signal that the code is not merely unidiomatic.

::: question You need a RefCell<Vec<u8>> scratch buffer in a threaded program. RefCell is not Sync, so it cannot be a static. What now? thread_local!, which is the honest answer to “how do I use a non-Sync type in a threaded program”.

thread_local! {
    static SCRATCH: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}

SCRATCH.with_borrow_mut(|buf| buf.push(1));

Each thread gets its own instance, created on first access and dropped when the thread ends. Nothing is shared, so Sync is never required — the problem is dissolved rather than solved.

Two practical notes. Access goes through LocalKey::with (or with_borrow / with_borrow_mut for a RefCell), because handing out a plain &'static reference would outlive the thread. And write the initialiser as const { ... } when you can: it makes access cheaper by removing the lazy-initialisation check, and clippy::missing_const_for_thread_local is a warn-by-default performance lint that will tell you so.

The trade-off is the obvious one — per-thread state is invisible to other threads, so it suits caches, buffers and scratch space, not anything that must be observed globally. :::

A note on real-world context

clippy::non_std_lazy_statics flags lazy_static! and once_cell in code that could use std. It is pedantic, and since this grader has no external crates it cannot fire here at all. Mentioned because you will meet both crates in existing code, and the migration to OnceLock/LazyLock is usually mechanical.

Loading visualization…