Introduction
Some data only needs to be initialized once and then shared immutably across threads. Rust provides a family of one-time initialization primitives — Once, OnceLock, LazyCell, and LazyLock — each suited to a different use case. Barrier serves a different purpose: synchronizing a group of threads so they all start work at the same moment.
Key Concepts
- Once: A synchronization primitive that runs a closure exactly once, even when called from multiple threads simultaneously. Subsequent calls to
call_onceare no-ops. - Once::wait(): Stabilized in Rust 1.86, this method blocks until some thread has completed the
call_onceinitialization, without running the closure itself. Useful for observer threads that need the initialization to be done but should not trigger it. - OnceLock<T>: A thread-safe cell that can be written to exactly once. Combines
Oncewith storage for a value of typeT. Common for lazy global initialization. - OnceLock::wait(): Also stabilized in Rust 1.86, blocks until the
OnceLockhas been initialized and returns a reference to the value. Unlikeget_or_init(), it does not accept a closure — it only observes. - LazyCell<T>: Stabilized in Rust 1.80,
LazyCell(instd::cell) provides lazy initialization for single-threaded use. It stores a closure and evaluates it on first access. It is!Syncand cannot be shared between threads. - LazyLock<T>: Stabilized in Rust 1.80,
LazyLock(instd::sync) is the thread-safe counterpart ofLazyCell. It evaluates its closure exactly once on first access, even under concurrent access from multiple threads. - Barrier: Blocks a group of
nthreads until all of them have calledwait(), then releases them all simultaneously. Useful for phased computations where all threads must finish one phase before any can start the next.
Real World Context
Global logger initialization, database connection pool setup, and regex compilation are all classic use cases for OnceLock and LazyLock — they should happen once, lazily, and be safe under concurrent access. Barrier is common in scientific and simulation code where you need all worker threads to synchronize between computation steps (e.g., each iteration of a physics simulation must complete before the next begins).
Deep Dive
Once and call_once
Once guarantees that the closure passed to call_once executes exactly once, no matter how many threads call it.
rustuse std::sync::Once; static INIT: Once = Once::new(); fn initialize() { INIT.call_once(|| { println!("this runs exactly once"); // expensive setup: open files, connect to database, etc. }); } // Call from many threads — only the first one runs the closure initialize(); // prints initialize(); // no-op initialize(); // no-op
The key takeaway is that call_once is idempotent: no matter how many times you call it, the closure runs exactly once and all subsequent calls are fast no-ops.
Once::wait() (Rust 1.86+)
The wait() method blocks the calling thread until some other thread has completed the initialization via call_once. It does not run the closure itself — it only waits for it to finish.
rustuse std::sync::Once; use std::thread; static INIT: Once = Once::new(); static mut CONFIG: Option<String> = None; let initializer = thread::spawn(|| { INIT.call_once(|| { // Safety: we are the only thread writing, inside call_once unsafe { CONFIG = Some("production".to_string()); } }); }); // Observer thread: wait for init to complete, don't run the closure let observer = thread::spawn(|| { INIT.wait(); // blocks until call_once finishes // Safety: initialization is guaranteed complete unsafe { println!("config: {:?}", CONFIG.as_ref().unwrap()); } }); initializer.join().unwrap(); observer.join().unwrap();
Here, wait() lets the observer thread block until call_once has completed without being responsible for running the initialization itself. This cleanly separates the "who initializes" concern from the "who needs the data" concern.
OnceLock<T>
OnceLock combines Once with typed storage. It is the modern, safe replacement for the Once + static mut pattern shown above.
rustuse std::sync::OnceLock; static CONFIG: OnceLock<String> = OnceLock::new(); fn get_config() -> &'static str { CONFIG.get_or_init(|| { println!("initializing config..."); "production".to_string() }) } println!("{}", get_config()); // initializes and returns "production" println!("{}", get_config()); // returns cached "production"
Notice how OnceLock combines the Once synchronization with typed storage, eliminating the need for unsafe and static mut. The get_or_init method handles both the first-time initialization and subsequent cache hits.
OnceLock::wait() (Rust 1.86+) blocks until the value is initialized and returns a reference, without triggering initialization itself.
rustuse std::sync::OnceLock; use std::thread; static DB_POOL: OnceLock<String> = OnceLock::new(); let init_thread = thread::spawn(|| { DB_POOL.get_or_init(|| { thread::sleep(std::time::Duration::from_millis(100)); "postgres://localhost/mydb".to_string() }); }); // This thread waits for initialization but does not trigger it let observer = thread::spawn(|| { let pool = DB_POOL.wait(); // blocks until initialized println!("observer sees: {pool}"); }); init_thread.join().unwrap(); observer.join().unwrap();
This pattern is particularly useful in server startup: one thread initializes the database pool while other threads wait for it to become available before handling requests.
LazyCell<T> (Single-Threaded)
LazyCell (in std::cell) evaluates its closure on first access. It is !Sync, meaning it cannot be shared between threads — it is designed for single-threaded lazy initialization.
rustuse std::cell::LazyCell; let lazy = LazyCell::new(|| { println!("computing..."); 92 }); println!("before access"); // "computing..." has NOT been printed yet println!("value: {}", *lazy); // prints "computing..." then "value: 92" println!("value: {}", *lazy); // prints "value: 92" — closure not called again
The closure is evaluated lazily on first dereference and cached for all subsequent accesses. Since LazyCell is !Sync, it cannot be placed in a static or shared between threads.
LazyLock<T> (Thread-Safe)
LazyLock (in std::sync) is the thread-safe version. It is ideal for global statics that should be initialized lazily.
rustuse std::sync::LazyLock; static GLOBAL_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| { regex::Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap() }); fn is_date(s: &str) -> bool { GLOBAL_REGEX.is_match(s) // initialized on first call }
This is the most common use of LazyLock: initializing a global static value lazily and safely. The regex is compiled only once, on the first call to is_date, and every subsequent call reuses the cached compiled regex.
LazyLock::force() explicitly triggers initialization without dereferencing. This is useful when you want to ensure the value is initialized at a specific point in your program.
As of Rust 1.94, LazyCell and LazyLock both provide additional stable accessor methods: get() returns Option<&T> to check whether the value has been initialized without triggering initialization, and force_mut() provides mutable access to the initialized value. These methods give you fine-grained control over lazy values without needing unstable features.
rustuse std::sync::LazyLock; let lazy = LazyLock::new(|| String::from("hello")); // Check without initializing assert!(LazyLock::get(&lazy).is_none()); // Force initialization LazyLock::force(&lazy); assert!(LazyLock::get(&lazy).is_some());
The get() method is useful for checking initialization status without triggering it, which is handy in diagnostics or conditional logic where you want to know if a lazy value has already been computed.
Barrier
Barrier blocks threads until a specified number have arrived, then releases them all at once. This is useful for phased parallel algorithms.
rustuse std::sync::{Arc, Barrier}; use std::thread; let num_threads = 4; let barrier = Arc::new(Barrier::new(num_threads)); let handles: Vec<_> = (0..num_threads) .map(|i| { let barrier = Arc::clone(&barrier); thread::spawn(move || { println!("thread {i}: doing phase 1 work"); // Simulate work thread::sleep(std::time::Duration::from_millis(100 * i as u64)); println!("thread {i}: waiting at barrier"); let result = barrier.wait(); // All threads reach here at the same time println!("thread {i}: starting phase 2"); // Exactly one thread is designated the "leader" if result.is_leader() { println!("thread {i}: I am the leader!"); } }) }) .collect(); for h in handles { h.join().unwrap(); }
Each thread does its phase 1 work independently, then calls barrier.wait(). The barrier blocks each thread until all four have arrived, at which point they are all released simultaneously to begin phase 2.
The wait() method returns a BarrierWaitResult. Exactly one thread per barrier release is designated the "leader" (via is_leader()), which is useful for performing a single cleanup or aggregation step before the next phase.
Barriers are reusable. After all threads pass through, the barrier resets and can be used again for the next phase.
rustuse std::sync::{Arc, Barrier}; use std::thread; let barrier = Arc::new(Barrier::new(3)); let handles: Vec<_> = (0..3) .map(|i| { let barrier = Arc::clone(&barrier); thread::spawn(move || { for phase in 0..3 { println!("thread {i}: working on phase {phase}"); barrier.wait(); // syncs after each phase } }) }) .collect(); for h in handles { h.join().unwrap(); }
This demonstrates that barriers are reusable: after all three threads pass through, the barrier resets automatically and can synchronize the next phase without any additional setup.
Common Pitfalls
- Using
LazyCellacross threads —LazyCellis!Syncand will cause a compile error if placed in astaticor shared between threads. UseLazyLockfor thread-safe lazy initialization. - Barrier with wrong count — If you create a
Barrier::new(n)but onlyn-1threads callwait(), all of them will block forever. The count must exactly match the number of threads that will synchronize.
Best Practices
- Prefer
LazyLockoverOnceLockfor statics —LazyLockcombines storage and initialization in one declaration and evaluates automatically on first access. UseOnceLockwhen initialization needs to be triggered from a specific call site or when you needwait(). - Use
Barrierfor phased parallel algorithms — When all threads must complete step N before any can start step N+1, a barrier is the clearest and most efficient synchronization primitive.
Summary
Once::call_once()runs a closure exactly once;Once::wait()(Rust 1.86) blocks until initialization completes without running the closure.OnceLock<T>provides typed one-time initialization withget_or_init()andwait()(Rust 1.86).LazyCell<T>(Rust 1.80,std::cell) is for single-threaded lazy init;LazyLock<T>(Rust 1.80,std::sync) is the thread-safe variant.LazyLock::force()triggers initialization explicitly. Rust 1.94 adds stableget()andforce_mut()accessors to bothLazyCellandLazyLock.Barriersynchronizes N threads at a rendezvous point and designates one as the leader.
Code Examples
use std::sync::{Arc, Barrier, LazyLock};
use std::thread;
// LazyLock: thread-safe global initialization
static GLOBAL_CONFIG: LazyLock<Vec<String>> = LazyLock::new(|| {
println!("Initializing config (runs once)...");
vec!["key=value".to_string(), "debug=true".to_string()]
});
fn main() {
let barrier = Arc::new(Barrier::new(3));
let handles: Vec<_> = (0..3)
.map(|i| {
let barrier = Arc::clone(&barrier);
thread::spawn(move || {
// All threads can read GLOBAL_CONFIG (initialized on first access)
println!("Thread {i}: config = {:?}", &*GLOBAL_CONFIG);
// Barrier: wait for all threads before proceeding
barrier.wait();
println!("Thread {i}: all threads synced, starting phase 2");
})
})
.collect();
for h in handles {
h.join().unwrap();
}
}