6.6 · Once, OnceLock, LazyLock, LazyCell: One-Time Initialization
Domain 6 — Concurrency and Parallelism Duration: ~15 minutes Library components:
std::sync::Once,std::sync::OnceLock,std::sync::LazyLock,std::cell::OnceCell,std::cell::LazyCell
Introduction
One of the most common needs in concurrent programs is to initialize a value exactly once, whichever thread asks first. Examples are global configurations, compiled regexes, lookup tables, and loggers. In the past, this needed external crates (lazy_static, once_cell). The standard library now has a family of five types that does all of this work.
This tutorial shows:
Once: runs a side effect exactly once (call_once,is_completed).OnceLock: stores a value exactly once and is thread-safe (get_or_init,set,get).LazyLock: a value together with its initializer closure, thread-safe. It hasget/get_mut/force_mut(1.94) andFrom<T>(1.96).OnceCellandLazyCell: the single-threaded counterparts instd::cell.- The decision matrix with four cells, and how Lazy poisoning differs from
Mutexpoisoning.
Once: Exactly One Side Effect
Once stores no value. It records only whether its closure ran. Each thread calls call_once, and the closure body runs exactly once. Callers that arrive during the execution block until it finishes. Thus the code after call_once can always rely on a complete setup:
static LOGGER_SETUP: Once = Once::new();
fn init_logging() {
LOGGER_SETUP.call_once(|| {
// Install the log sinks and open the files here.
// This body runs exactly once in the process.
});
// At this point the setup is ALWAYS complete, in each thread.
}
is_completed() tells you the state and starts nothing. Once is the correct tool when the thing that must occur once is an effect, not a value that you want to get. Examples are the registration of a callback and the initialization of a C library. When you do want a value, the other types of the family are better.
OnceLock: Exactly One Value
OnceLock<T> is a thread-safe slot that you can write once and read without limit. It is the standard library solution for a static that the program computes at runtime, in safe code:
// Config is a struct with the fields worker_threads: u32 and verbose: bool.
static CONFIG: OnceLock<Config> = OnceLock::new(); // empty at program start
fn config() -> &'static Config {
// The first call runs the closure and stores the value. Each later call returns it.
CONFIG.get_or_init(|| Config { worker_threads: 4, verbose: false })
}
If several threads call get_or_init at the same time, one thread runs the closure and the other threads block for a short time. Then all the threads get a reference to the same value. The example binary asserts that the initialization counter is exactly 1 after 6 threads race.
The value can also come from outside, with no closure:
let chosen_port: OnceLock<u16> = OnceLock::new();
assert_eq!(chosen_port.set(8080), Ok(())); // the first set stores the value
assert_eq!(chosen_port.set(9090), Err(9090)); // too late: set returns the rejected value
assert_eq!(chosen_port.get(), Some(&8080)); // get never initializes
set makes the result of the race explicit for the thread that succeeds and for the threads that fail. get reads the state and does not initialize the value. OnceLock can get its value at a later time, and this is the difference between OnceLock and LazyLock.
06_19_once_and_oncelock.rs prints:
Once closure ran 1 time(s)
OnceLock closure ran 1 time(s)
global config: Config { worker_threads: 4, verbose: false }
port locked in: Some(8080)
get() on empty OnceLock: None
All assertions passed.
LazyLock: The Closure Is Part of the Type
LazyLock<T> contains the value and its initializer. The call sites only deref it. They cannot see that an initialization occurs:
// The closure is part of the static. Nothing runs until the first use.
static KEYWORDS: LazyLock<HashMap<&'static str, u32>> = LazyLock::new(|| {
HashMap::from([("fn", 1), ("let", 2), ("impl", 3), ("match", 4)])
});
// In any place and in any thread: the first deref builds the map, exactly once.
// `get` here is HashMap::get, which the deref makes available.
assert_eq!(KEYWORDS.get("match"), Some(&4));
The 1.94 and 1.96 additions complete the explicit control. All of these are associated functions, called as LazyLock::f(&value), so that they do not conflict with the methods of the deref target:
LazyLock::get(&l)(1.94) returnsOption<&T>. It reads the state and does not force the initialization.LazyLock::force(&l)(1.80) initializes the value now and returns&T. A deref does this implicitly.LazyLock::force_mut(&mut l)andget_mut(1.94) let you change the value in place. The&mutproves exclusive access, so no synchronization occurs.LazyLock::from(value)(1.96) makes a pre-initialized Lazy, and its closure never runs. Use it for tests and dependency injection: give fixture data to a field that is usually lazy.
Poisoning is different from Mutex poisoning. If the initializer panics, the Lazy stays poisoned, and each subsequent access panics too. The first access consumed the FnOnce closure, so the Lazy cannot run it again. Mutex has a PoisonError recovery API (compare Tutorial 6.2), but a Lazy has no recovery API.
Keep Lazy initializers infallible. If the initialization can fail, use OnceLock::get_or_init, which you can retry with a new closure. A pattern in the style of get_or_try_init is a second alternative (the get_or_try_init method itself is still unstable in 1.99).
06_20_lazylock_lazycell.rs prints:
before first use: builds = 0
after 4 racing readers: builds = 1
get -> None before force, Some after
force_mut grew the cache in place: ["seed", "grown"]
LazyLock::from: pre-initialized, no closure involved
LazyCell forced via deref: "--------"
poisoned LazyLock: first access panicked, second panics too
All assertions passed.
OnceCell and LazyCell: The Single-Thread Variants
std::cell::OnceCell and std::cell::LazyCell have the same API as their sync counterparts, but they are !Sync. The compiler rejects code that shares them between threads. In exchange, they do no synchronization: no atomics and no blocking, only a flag check.
The typical use is memoization in a struct field, where the initializer needs &self. A closure that you store at construction cannot borrow the document that it belongs to. Thus the field is a OnceCell, not a LazyCell:
struct Document {
text: String,
word_count: OnceCell<usize>, // computed at most once, on demand
}
impl Document {
fn word_count(&self) -> usize {
// The first call counts the words and stores the result. Each later call reads it.
// The closure borrows self.text, and it needs only &self.
*self.word_count.get_or_init(|| self.text.split_whitespace().count())
}
}
LazyCell is the correct type when you do know the closure at construction. An example is a lookup table of a parser that only some inputs need:
let ascii_upper: LazyCell<Vec<char>> = LazyCell::new(|| ('A'..='Z').collect());
// The first use builds the table. If the program never uses it, the closure never runs.
The two types have get and get_mut. OnceCell also has set and take. LazyCell also has force_mut (1.94) and From<T> (1.96). These are the same operations that the sync types have, without the thread safety and without its cost.
The Four-Cell Decision Matrix
Two questions select the type:
Figure: Choosing an Init Type
| Value provided later | Closure at construction | |
|---|---|---|
Single-threaded (std::cell) | OnceCell<T> | LazyCell<T> |
Thread-safe (std::sync) | OnceLock<T> | LazyLock<T> |
Practical defaults:
- A global that each thread reads:
static X: LazyLock<T>. - A global that gets its value from
main(CLI arguments, a loaded configuration):static X: OnceLock<T>andset. - A memoized field for each instance: a
OnceCellin the struct. - A lazily-built helper that one scope owns:
LazyCell. - An effect, not a value:
Once.
06_21_cell_matrix.rs has one scenario for each cell of the matrix. It prints:
A OnceCell: word count memoized = 9
B LazyCell: table built on demand, len = 26
C OnceLock: session id = alpha (varies)
D LazyLock: filtered tokens = ["art", "war", "way", "zen"]
All assertions passed.
Summary
| Concept | Key point |
|---|---|
Once::call_once | Exactly one execution. Concurrent callers block until it is complete. |
OnceLock::get_or_init | One thread computes the value. All threads get the same &T. |
OnceLock::set | Initialization from outside. Err(value) returns the rejected value. |
get (all types) | Reads the state and does not initialize. None means that no initialization occurred. |
LazyLock | The type stores the closure. The call sites only deref. |
force (1.80), force_mut and get_mut (1.94) | Explicit initialization through &, and mutation with no lock through &mut |
From<T> (1.96) | A pre-initialized LazyCell or LazyLock. The closure never runs. |
| Lazy poisoning | A panic in the initializer makes all later accesses panic. There is no recovery, which is different from Mutex. |
OnceCell/LazyCell | The same API for one thread. !Sync, with no synchronization cost. |
| The matrix | Two questions (threads, closure at construction) select one of four types. Once is for effects. |
Code Examples
| File | Description |
|---|---|
06_19_once_and_oncelock.rs | call_once, a race on get_or_init, set/get, exactly-once assertions |
06_20_lazylock_lazycell.rs | Lazy statics, force_mut/get_mut, From<T>, poisoning behavior |
06_21_cell_matrix.rs | All four quadrants in one program, from a memoized field to a global table |