158. OnceLock<T> and LazyLock<T, F> — The std Replacements for lazy_static!
This morning’s Atomic* handled “many threads, one scalar.” For “many threads, one value — computed once, then read forever” the std answer is two types: LazyLock<T, F> when you can name the initializer up front, OnceLock<T> when you only learn the value at runtime. Both make the old lazy_static! macro and the once_cell crate redundant.
The bad old days: lazy_static!
Until Rust 1.80 (mid-2024), a thread-safe lazy global meant a macro from a crate:
| |
It worked, but it pulled in a dependency, used macro magic to fake a static, and gave you a custom Deref wrapper instead of a normal type. Every dependency in your tree that wanted a lazy global was either pulling in lazy_static or the more modern once_cell crate.
The std way: LazyLock<T, F>
Stabilized in Rust 1.80, LazyLock is a normal type — no macro, no Deref trickery, just a static like any other:
| |
The closure runs the first time anything touches COUNTRIES, exactly once, and every thread that races to be that “first” gets the same value back. If two threads arrive together, one wins the init and the other blocks until it’s done — same contract lazy_static! always had, now in std.
When the initializer isn’t known at compile time: OnceLock<T>
LazyLock is great when you can write the initializer as a const fn-friendly closure. But what about config you only know after main() starts — a CLI flag, an env var, a parsed file? Stuffing that into a closure means either re-reading the env var at first use or capturing values you don’t have yet at static time. That’s OnceLock<T>’s job:
| |
set returns Err(value) if someone beat you to it — handy when multiple call sites might initialize and you want the first one to win without panicking.
The convenience method: get_or_init
The “check if set, init if not” dance is so common that OnceLock ships it as one call:
| |
This is what most “lazy global computed from runtime data” code actually wants. Functions can own their own OnceLock as a function-local static — no need to pollute the module namespace.
Which one when
| You have | Reach for |
|---|---|
| A closure that needs no runtime input | LazyLock<T, F> |
A value you’ll set later (from main, a builder, a DI container) | OnceLock<T> with set |
| Per-function memoization of an expensive computation | OnceLock<T> with get_or_init |
| Single-threaded version of the above | LazyCell / OnceCell |
LazyLock is the closer match to lazy_static!. OnceLock is the closer match to manual Mutex<Option<T>> patterns where you wanted “set once, read many.”
The trait bounds, briefly
Because these are Sync and live in a static, the contained T must be Send + Sync. The initializer closure for LazyLock must be Send. You won’t notice for String, HashMap, Vec, Arc<…> — but if you try to stuff an Rc<T> in there the compiler will (correctly) yell. The single-threaded versions, LazyCell and OnceCell, have no such bound — that’s the whole reason both pairs exist.
What you can finally delete
If a crate in your tree still has lazy_static = "1" or once_cell = "1" in its Cargo.toml, and your MSRV is 1.80 or newer, the migration is mechanical:
| |
| |
One fewer dependency, one less macro in the expansion, and the type that shows up in error messages is just LazyLock<T> — not some crate-private deref wrapper. Tomorrow’s bite picks up the thread on Arc<T> — what to reach for when “one global value” isn’t enough and you need shared ownership across threads.