Rust: Using `OnceLock` for Safe One‑Shot Initialization
Introduction
I often find myself reaching for a lazy, thread‑safe way to initialize a global value without paying the cost of a full‑fledged `Mutex` or `RwLock`. In a logging subsystem, for example, the file handle or the HTTP client should be created exactly once, even when multiple threads request it simultaneously. The idiomatic solution in modern Rust is `std::sync::OnceLock`. It gives you a safe, zero‑cost one‑time initialization primitive that works just like the classic `Once` but also stores the initialized value directly.
When a One‑Shot Init Becomes a Daily Habit
Consider a background service that needs a shared database connection pool. Creating a pool is expensive—opening connections, performing health checks, and configuring timeouts. You want each worker thread to obtain the same pool instance without re‑initializing it. The old pattern was to wrap the pool in a `Mutex
The Technique: `OnceLock` in Action
Below is a production‑ready snippet that demonstrates how to expose a global `HttpClient` lazily. The example also includes a small helper to illustrate error handling when initialization fails.
use std::sync::OnceLock;
// Global storage for the HTTP client.
static CLIENT: OnceLock = OnceLock::new();
/// Returns the shared HTTP client, creating it on first use.
fn get_client() -> &'static HttpClient {
// `get_or_init` runs the closure only the first time it is called.
// If the closure panics, the panic is propagated and the value is left uninitialized.
CLIENT.get_or_init(|| {
// Simulate expensive initialization.
println!("Initializing HTTP client...");
HttpClient::new()
})
}
struct HttpClient {
base_url: String,
}
impl HttpClient {
fn new() -> Self {
// In a real program you would read configuration, establish connections, etc.
Self {
base_url: "https://api.example.com".to_string(),
}
}
fn get(&self, path: &str) {
let url = format!("{}/{}", self.base_url, path);
println!("GET {}", url);
}
}
fn main() {
// Multiple threads can call this safely.
std::thread::spawn(|| get_client().get("/users"));
std::thread::spawn(|| get_client().get("/posts"));
// The main thread also uses the same instance.
get_client().get("/comments");
}
The key line is `CLIENT.get_or_init(|| { … })`. `OnceLock` internally uses a `Once` to guarantee that the closure executes only once, regardless of how many threads race to call it. After the first successful initialization, the stored value is returned directly. No extra `Mutex` wrapper is needed, which saves both memory and lock contention.
Why `OnceLock` Beats the Old Patterns
- Zero‑overhead after init. The cell holds the value directly; there is no indirection through an `Option` or a `Mutex`.
- Thread‑safety baked in. The API guarantees safe access from any thread without extra synchronization code.
- Simpler error handling. If the initialization closure returns an error, the `OnceLock` remains uninitialized and subsequent calls will attempt re‑initialization. This behavior is explicit and avoids hidden panics.
- Fusion with `Option`. You can still check whether the value is present with `CLIENT.get()`, which returns an `Option<&T>`. This is useful for optional global resources.
Remember: `OnceLock` is designed for *one‑shot* initialization. If you need to re‑initialize the value later (e.g., after configuration changes), you should reach for a different synchronization primitive like `Mutex
>` or a custom interior‑mutability pattern.
Production Considerations
When I ship code that uses `OnceLock`, I always pair it with a thorough test suite. The first test ensures that the initialization runs only once, even when multiple threads invoke the accessor concurrently. A second test verifies that the returned reference is the same across threads. Here is a quick unit‑test snippet that you can drop into your crate:
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::thread;
#[test]
fn client_is_shared() {
let handles: Vec<_> = (0..10)
.map(|_| thread::spawn(get_client))
.collect();
let clients: Vec<_> = handles
.into_iter()
.map(|h| h.join().unwrap())
.collect();
// All references must point to the same memory address.
let first = clients[0] as *const HttpClient;
for client in &clients[1..] {
assert_eq!(first, *client as *const HttpClient);
}
}
}
Notice that `get_client` returns a `&'static HttpClient`. The test spawns threads that call the function directly, which returns a reference. The references are compared by pointer address, confirming that the underlying `OnceLock` held a single instance.
Legacy Support with `once_cell
If you are maintaining code that targets Rust versions before 1.70, you can add the `once_cell` crate and use its `sync::Lazy` type. The pattern is almost identical, and many existing projects already have it as a dependency. Switching later to `OnceLock` is straightforward because the API surface is similar.
use once_cell::sync::Lazy;
static CLIENT: Lazy = Lazy::new(|| {
println!("Initializing HTTP client (once_cell)...");
HttpClient::new()
});
fn get_client() -> &'static HttpClient {
&CLIENT
}
Conclusion
One‑time initialization is a common need in server‑side Rust code, and `std::sync::OnceLock` gives you a clean, zero‑cost solution right out of the box. By moving the expensive setup into a closure that runs exactly once, you avoid race conditions, eliminate unnecessary locking, and keep your code readable. Whether you are building a logging facade, an HTTP client, or any global resource, `OnceLock` should be your first choice. Try it in your next project, and you’ll notice the difference in both performance and ergonomics.