How does once_cell::sync::OnceLock::get_or_init ensure thread-safe one-time initialization without blocking?

OnceLock::get_or_init uses an atomic state machine to coordinate initialization across threads, allowing concurrent readers to proceed without blocking while ensuring exactly one thread performs initialization. The implementation relies on a three-state atomic flag—uninitialized, initializing, and initialized—combined with careful memory ordering to provide safe concurrent access. Unlike std::sync::Once which blocks threads during initialization, OnceLock allows threads to read already-initialized values immediately and uses std::sync::Once internally only for the initialization coordination. This design minimizes contention after initialization completes, making subsequent reads essentially lock-free.

Basic OnceLock Usage

use once_cell::sync::OnceLock;
use std::collections::HashMap;
 
static CONFIG: OnceLock<HashMap<String, String>> = OnceLock::new();
 
fn get_config() -> &'static HashMap<String, String> {
    CONFIG.get_or_init(|| {
        println!("Initializing config...");
        let mut config = HashMap::new();
        config.insert("host".to_string(), "localhost".to_string());
        config.insert("port".to_string(), "8080".to_string());
        config
    })
}
 
fn main() {
    let config = get_config();
    println!("Host: {}", config.get("host").unwrap());
    
    let config2 = get_config();
    println!("Port: {}", config2.get("port").unwrap());
    
    // "Initializing config..." prints only once
    // Second call returns existing reference
}

get_or_init ensures initialization runs exactly once, returning a reference to the stored value.

The Three-State Atomic Model

use once_cell::sync::OnceLock;
use std::sync::atomic::{AtomicU8, Ordering};
 
// Conceptual model: OnceLock uses atomic state tracking
// State values:
// 0 = UNINITIALIZED - no value stored, initialization not started
// 1 = INITIALIZING   - initialization in progress
// 2 = INITIALIZED    - value stored and ready
 
struct ConceptualOnceLock<T> {
    state: AtomicU8,
    value: std::mem::MaybeUninit<T>,
    // Internal: std::sync::Once for blocking coordination
}
 
impl<T> ConceptualOnceLock<T> {
    fn get_or_init<F>(&self, f: F) -> &T
    where
        F: FnOnce() -> T,
    {
        loop {
            // Fast path: already initialized
            let state = self.state.load(Ordering::Acquire);
            if state == 2 {
                // SAFETY: Initialized state guarantees value is stored
                unsafe {
                    return &*self.value.as_ptr();
                }
            }
            
            // Try to claim initialization
            if self.state.compare_exchange(
                0,  // UNINITIALIZED
                1,  // INITIALIZING
                Ordering::AcqRel,
                Ordering::Acquire,
            ).is_ok() {
                // We won the race - initialize
                let value = f();
                unsafe {
                    self.value.as_mut_ptr().write(value);
                }
                self.state.store(2, Ordering::Release);
                
                return unsafe { &*self.value.as_ptr() };
            }
            
            // State is INITIALIZING - need to wait
            // Real implementation uses std::sync::Once for blocking
        }
    }
}
 
// The actual OnceLock is more sophisticated:
// - Uses std::sync::Once for blocking during initialization
// - Avoids spinning with efficient blocking primitives
// - Handles panic during initialization

The atomic state ensures exactly one thread performs initialization while others wait or read the completed value.

Non-Blocking Reads After Initialization

use once_cell::sync::OnceLock;
use std::sync::Arc;
use std::thread;
 
static CACHE: OnceLock<Vec<u64>> = OnceLock::new();
 
fn main() {
    // Initialize once
    CACHE.get_or_init(|| {
        println!("Computing expensive cache...");
        (0..1000).collect()
    });
    
    // Now spawn threads that read
    let handles: Vec<_> = (0..10)
        .map(|_| {
            thread::spawn(|| {
                // This is essentially lock-free!
                // Just an atomic load and pointer dereference
                let cache = CACHE.get().unwrap();
                println!("Thread read cache length: {}", cache.len());
            })
        })
        .collect();
    
    for handle in handles {
        handle.join().unwrap();
    }
    
    // After initialization, get_or_init is also non-blocking:
    // It checks the atomic state first (Acquire load)
    // If INITIALIZED, returns reference immediately
}

Once initialized, all accesses are fast atomic reads with no synchronization overhead.

Concurrent Initialization Attempts

use once_cell::sync::OnceLock;
use std::sync::Arc;
use std::thread;
 
static DATA: OnceLock<String> = OnceLock::new();
 
fn main() {
    let handles: Vec<_> = (0..5)
        .map(|i| {
            thread::spawn(move || {
                let value = DATA.get_or_init(|| {
                    println!("Thread {} initializing", i);
                    // Simulate expensive initialization
                    thread::sleep(std::time::Duration::from_millis(100));
                    format!("Initialized by thread {}", i)
                });
                value
            })
        })
        .collect();
    
    for handle in handles {
        let result = handle.join().unwrap();
        println!("Got: {}", result);
    }
    
    // Output:
    // "Thread X initializing" (exactly once)
    // "Got: Initialized by thread X" (5 times, same value)
    // Only one thread performs initialization
    // Others block briefly until complete
}

Multiple threads racing to initialize results in exactly one winner; others wait for completion.

Interior Mutability Pattern

use once_cell::sync::OnceLock;
use std::cell::UnsafeCell;
 
// OnceLock provides safe interior mutability for one-time initialization
// The UnsafeCell is only written during initialization, after which it's immutable
 
struct LazyCache<T> {
    cell: OnceLock<T>,
}
 
impl<T> LazyCache<T> {
    fn new() -> Self {
        LazyCache {
            cell: OnceLock::new(),
        }
    }
    
    fn get_or_init<F>(&self, f: F) -> &T
    where
        F: FnOnce() -> T,
    {
        self.cell.get_or_init(f)
    }
}
 
// Usage: immutable interface with lazy interior mutation
fn main() {
    let cache: LazyCache<Vec<i32>> = LazyCache::new();
    
    // cache is immutable, but initialization mutates internal state
    let data = cache.get_or_init(|| {
        println!("Computing...");
        vec
![1, 2, 3, 4, 5]
    });
    
    let data2 = cache.get_or_init(|| {
        println!("This won't print");
        vec
![999]; // Never called
    });
    
    assert_eq!(data, data2);  // Same reference
}

OnceLock enables lazy initialization through an immutable interface, hiding the interior mutability.

Panic Handling During Initialization

use once_cell::sync::OnceLock;
use std::panic;
use std::thread;
 
static DATA: OnceLock<String> = OnceLock::new();
 
fn main() {
    // First attempt: panic
    let result1 = panic::catch_unwind(|| {
        DATA.get_or_init(|| {
            println!("First initialization attempt");
            panic!("Initialization failed!");
        })
    });
    assert!(result1.is_err());
    
    // After panic, OnceLock allows retry
    // Internal state reset to UNINITIALIZED
    let result2 = DATA.get_or_init(|| {
        println!("Second initialization attempt");
        "Success".to_string()
    });
    
    println!("Got: {}", result2);
    
    // Note: The actual behavior depends on OnceLock version
    // Some versions "poison" after panic
    // Check your version's documentation
}

If initialization panics, OnceLock typically allows retry—the state remains uninitialized.

Memory Ordering Guarantees

use once_cell::sync::OnceLock;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
 
static READY: OnceLock<bool> = OnceLock::new();
static COUNTER: AtomicUsize = AtomicUsize::new(0);
 
fn writer_thread() {
    // Initialize data
    COUNTER.store(42, Ordering::Relaxed);
    READY.get_or_init(|| true);
}
 
fn reader_thread() {
    // Wait for initialization
    READY.get_or_init(|| true);
    
    // Memory ordering guarantees:
    // - We see the initialized value
    // - We also see all writes that happened before initialization
    // - COUNTER is guaranteed to be 42 (with proper ordering)
    
    let value = COUNTER.load(Ordering::Relaxed);
    println!("Counter: {}", value);
}
 
fn main() {
    thread::scope(|s| {
        s.spawn(writer_thread);
        s.spawn(reader_thread);
    });
}

get_or_init provides Acquire-Release semantics, ensuring visibility of pre-initialization writes.

Comparison with std::sync::Once

use once_cell::sync::OnceLock;
use std::sync::Once;
use std::cell::UnsafeCell;
 
// std::sync::Once approach
static mut ONCE_VALUE: UnsafeCell<Option<String>> = UnsafeCell::new(None);
static ONCE: Once = Once::new();
 
fn get_with_once() -> &'static str {
    ONCE.call_once(|| {
        // SAFETY: Single-threaded initialization guaranteed by Once
        unsafe {
            (*ONCE_VALUE.get()) = Some("initialized".to_string());
        }
    });
    
    // SAFETY: After call_once returns, value is initialized
    unsafe {
        (*ONCE_VALUE.get()).as_ref().unwrap()
    }
}
 
// OnceLock approach: much cleaner
static LOCK_VALUE: OnceLock<String> = OnceLock::new();
 
fn get_with_lock() -> &'static str {
    LOCK_VALUE.get_or_init(|| "initialized".to_string())
}
 
fn main() {
    println!("Once: {}", get_with_once());
    println!("Lock: {}", get_with_lock());
}
 
// Key differences:
// 1. OnceLock returns a reference directly
// 2. OnceLock stores the value, Once doesn't
// 3. OnceLock requires no unsafe code
// 4. OnceLock has better ergonomics

OnceLock combines Once's synchronization with storage, eliminating unsafe code.

Comparison with lazy_static!

// lazy_static! macro approach
use lazy_static::lazy_static;
 
lazy_static! {
    static ref LAZY_MAP: std::collections::HashMap<String, i32> = {
        let mut map = std::collections::HashMap::new();
        map.insert("a".to_string(), 1);
        map
    };
}
 
// OnceLock approach
use once_cell::sync::OnceLock;
use std::collections::HashMap;
 
static ONCE_MAP: OnceLock<HashMap<String, i32>> = OnceLock::new();
 
fn get_map() -> &'static HashMap<String, i32> {
    ONCE_MAP.get_or_init(|| {
        let mut map = HashMap::new();
        map.insert("a".to_string(), 1);
        map
    })
}
 
fn main() {
    // Both provide lazy initialization
    println!("lazy_static: {:?}", *LAZY_MAP);
    println!("OnceLock: {:?}", get_map());
    
    // Differences:
    // - OnceLock is in std (Rust 1.70+)
) as part of lazy_static crate
    // - OnceLock has simpler API
    // - lazy_static supports more complex initialization patterns
    // - OnceLock has slightly better performance
}

OnceLock is simpler and now in std; lazy_static! offers more macro features.

Thread-Local vs Sync

use once_cell::sync::OnceLock;
use once_cell::unsync::OnceCell;
 
// Sync version: thread-safe, can be static
static GLOBAL: OnceLock<String> = OnceLock::new();
 
// Unsync version: not thread-safe, for local use
// Only `unsync::OnceCell`, not `OnceLock`
 
fn main() {
    // OnceLock: thread-safe
    let sync_cell: OnceLock<i32> = OnceLock::new();
    sync_cell.get_or_init(|| 42);
    
    // OnceCell: not thread-safe, but lighter weight
    let unsync_cell: once_cell::unsync::OnceCell<i32> = OnceCell::new();
    unsync_cell.get_or_init(|| 42);
    
    // Use OnceLock for:
    // - Static variables
    // - Shared state across threads
    // - Lazy static initialization
    
    // Use OnceCell for:
    // - Local variables
    // - Single-threaded contexts
    // - Slightly better performance when sync not needed
}

sync::OnceLock is thread-safe; unsync::OnceCell avoids synchronization overhead when not needed.

Integration with Global State

use once_cell::sync::OnceLock;
use std::sync::Arc;
 
struct DatabasePool {
    connections: Vec<String>,
}
 
impl DatabasePool {
    fn new() -> Self {
        println!("Creating database pool...");
        DatabasePool {
            connections: vec
!["conn1", "conn2", "conn3"]
,
        }
    }
    
    fn get_connection(&self) -> &str {
        self.connections.first().unwrap()
    }
}
 
static DB_POOL: OnceLock<Arc<DatabasePool>> = OnceLock::new();
 
fn get_db() -> Arc<DatabasePool> {
    Arc::clone(DB_POOL.get_or_init(|| {
        Arc::new(DatabasePool::new())
    }))
}
 
fn main() {
    // Multiple functions can access the same pool
    fn handler1() {
        let db = get_db();
        println!("Handler1: {}", db.get_connection());
    }
    
    fn handler2() {
        let db = get_db();
        println!("Handler2: {}", db.get_connection());
    }
    
    handler1();
    handler2();
    
    // "Creating database pool..." prints only once
    // Both handlers share the same pool instance
}

OnceLock is ideal for global state that needs lazy, thread-safe initialization.

Blocking vs Spin Behavior

use once_cell::sync::OnceLock;
use std::thread;
use std::time::Instant;
 
static SLOW: OnceLock<String> = OnceLock::new();
 
fn main() {
    let start = Instant::now();
    
    // Thread 1: Slow initialization
    let h1 = thread::spawn(|| {
        SLOW.get_or_init(|| {
            println!("Thread 1: Starting slow init");
            thread::sleep(std::time::Duration::from_millis(100));
            println!("Thread 1: Finished slow init");
            "slow data".to_string()
        })
    });
    
    // Thread 2: Tries to read during Thread 1's initialization
    let h2 = thread::spawn(|| {
        thread::sleep(std::time::Duration::from_millis(50));
        println!("Thread 2: Attempting to read");
        let value = SLOW.get_or_init(|| {
            println!("Thread 2: Initializing (won't run)");
            "thread 2 data".to_string()
        });
        println!("Thread 2: Got '{}'", value);
        value
    });
    
    h1.join().unwrap();
    h2.join().unwrap();
    
    println!("Total time: {:?}", start.elapsed());
}
 
// Thread 2 blocks efficiently (uses OS synchronization)
// It doesn't spin-wait for the initialization
// Once initialization completes, Thread 2 proceeds immediately

During initialization, waiting threads block efficiently using std::sync::Once internals, not spinning.

get() vs get_or_init()

use once_cell::sync::OnceLock;
 
static MAYBE_VALUE: OnceLock<String> = OnceLock::new();
 
fn main() {
    // get(): Returns reference if initialized, None if not
    let before = MAYBE_VALUE.get();
    println!("Before: {:?}", before);  // None
    
    // get_or_init(): Initializes if needed, always returns reference
    let value = MAYBE_VALUE.get_or_init(|| "initialized".to_string());
    println!("After init: {}", value);
    
    // Now get() returns Some
    let after = MAYBE_VALUE.get();
    println!("After: {:?}", after);  // Some("initialized")
    
    // get_mut(): Returns mutable reference if exclusively held
    if let Some(inner) = MAYBE_VALUE.get_mut() {
        inner.push(" modified");
    }
    
    // get() now shows the modification
    println!("Modified: {:?}", MAYBE_VALUE.get());
}
 
// Use get() when:
// - You want to check if initialized without triggering init
// - You have a separate initialization path
// - You want Option for conditional access
 
// Use get_or_init() when:
// - You want guaranteed initialization
// - You have a closure to provide the value
// - You want a reference unconditionally

get() returns Option<&T>; get_or_init() guarantees a &T by initializing if needed.

into_value() for Ownership Transfer

use once_cell::sync::OnceLock;
 
fn main() {
    let cell: OnceLock<Vec<i32>> = OnceLock::new();
    
    // Initialize
    cell.get_or_init(|| vec
![1, 2, 3]);
 
    // get() returns reference
    let reference: &Vec<i32> = cell.get().unwrap();
    println!("Reference: {:?}", reference);
    
    // into_value() consumes OnceLock and returns ownership
    // Only available on owned OnceLock, not static
    let owned: Vec<i32> = cell.into_value().unwrap();
    println!("Owned: {:?}", owned);
    
    // Now cell is consumed, can't use it anymore
    // into_value() fails if not initialized
}

into_value() extracts the initialized value, consuming the OnceLock and transferring ownership.

Real-World Pattern: Lazy Configuration

use once_cell::sync::OnceLock;
use std::collections::HashMap;
use std::env;
 
struct Config {
    settings: HashMap<String, String>,
    debug: bool,
}
 
impl Config {
    fn from_env() -> Self {
        let mut settings = HashMap::new();
        
        // Read from environment or files
        if let Ok(val) = env::var("APP_HOST") {
            settings.insert("host".to_string(), val);
        } else {
            settings.insert("host".to_string(), "localhost".to_string());
        }
        
        if let Ok(val) = env::var("APP_PORT") {
            settings.insert("port".to_string(), val);
        } else {
            settings.insert("port".to_string(), "8080".to_string());
        }
        
        let debug = env::var("APP_DEBUG").is_ok();
        
        Config { settings, debug }
    }
}
 
static CONFIG: OnceLock<Config> = OnceLock::new();
 
fn config() -> &'static Config {
    CONFIG.get_or_init(Config::from_env)
}
 
fn main() {
    // Configuration loaded on first access
    let cfg = config();
    println!("Host: {:?}", cfg.settings.get("host"));
    println!("Debug: {}", cfg.debug);
    
    // Subsequent calls return same reference
    let cfg2 = config();
    assert!(std::ptr::eq(cfg, cfg2));
}

Lazy configuration loading with OnceLock ensures single initialization and thread-safe access.

Real-World Pattern: Computed Constant

use once_cell::sync::OnceLock;
use std::collections::HashSet;
 
struct WordDictionary {
    words: HashSet<&'static str>,
}
 
impl WordDictionary {
    fn new() -> Self {
        println!("Building word dictionary...");
        let words: HashSet<&'static str> = include_str!("words.txt")
            .lines()
            .collect();
        println!("Dictionary has {} words", words.len());
        WordDictionary { words }
    }
    
    fn contains(&self, word: &str) -> bool {
        self.words.contains(word)
    }
}
 
static WORDS: OnceLock<WordDictionary> = OnceLock::new();
 
fn is_valid_word(word: &str) -> bool {
    WORDS.get_or_init(WordDictionary::new).contains(word)
}
 
fn main() {
    // Dictionary built on first call
    println!("Valid: {}", is_valid_word("hello"));
    
    // Subsequent calls use cached dictionary
    println!("Valid: {}", is_valid_word("world"));
    println!("Valid: {}", is_valid_word("rust"));
}

Expensive computations can be deferred and cached with OnceLock.

Synthesis

Thread-safety mechanism:

// State machine:
// UNINITIALIZED -> INITIALIZING -> INITIALIZED
//     |              |               |
//     v              v               v
//   try_init      wait/return     return ref
//     |              |               |
//     +--init------->+               |
//                    +--complete---->+
 
// get_or_init flow:
// 1. Atomic load (Acquire) of state
// 2. If INITIALIZED: return reference immediately
// 3. If UNINITIALIZED: try compare_exchange to INITIALIZING
//    - Success: run init closure, store value, set INITIALIZED
//    - Failure: another thread won, block until INITIALIZED
// 4. Block using std::sync::Once until complete

Key characteristics:

Aspect Behavior
Initialization Exactly once, thread-safe
Post-init reads Lock-free atomic load
During init Efficient blocking, not spinning
Panic handling State reset, allows retry
Memory ordering Acquire-Release semantics

Performance profile:

  • First call: synchronization overhead, runs initialization
  • Subsequent calls: atomic load + pointer dereference
  • Memory: one allocation for the value
  • Contention: minimal after initialization

When to use:

  • Lazy static initialization
  • Global configuration
  • Computed constants
  • Singleton patterns
  • Expensive one-time computations

When alternatives might be better:

  • Eager initialization (compile-time constants)
  • Thread-local state (use unsync::OnceCell)
  • Dynamic re-initialization (use RwLock or Mutex)

Key insight: OnceLock::get_or_init achieves thread-safe initialization through a combination of atomic state checking for the fast path and std::sync::Once for the slow path. After initialization completes, every subsequent call performs just an atomic load followed by a pointer dereference—essentially lock-free. This design amortizes the synchronization cost: you pay for coordination only during the one-time initialization, after which reads are as fast as possible. The API is safe, requires no unsafe code from users, and integrates naturally with Rust's ownership system through lifetime-qualified references.