Course contentsShow
← Rust
Lesson 1049 of 1,9109. Smart Pointers and Interior MutabilityFree lesson

Thread-Safe Initialization

Implement singleton or lazy-static patterns using atomic synchronization.

In this lesson, you will learn how to create global, lazily-initialized data that remains safe to access across multiple threads.

The Singleton Challenge

In Rust, global variables (often called statics) must be initialized at compile-time with a constant expression. However, many real-world objects—like database connections or complex configuration trees—cannot be created at compile-time. If you want a global instance of such an object, you must perform "lazy initialization," where the object is created only when it is first accessed.

Doing this in a multi-threaded environment is tricky: if two threads reach an uninitialized variable at the same time, they might both attempt to construct it, leading to data races or double-initialization.

Atomic Initialization

To solve this, we use an Atomic flag (or an atomic pointer) to track the state of our object. The pattern involves checking if the object exists; if not, we use a compare-and-swap (CAS) operation to "claim" the responsibility of initializing the data.

Consider this conceptual implementation:

use std::sync::atomic::{AtomicPtr, Ordering};
use std::ptr;

static INSTANCE: AtomicPtr<MyConfig> = AtomicPtr::new(ptr::null_mut());

fn get_config() -> &'static MyConfig {
    let ptr = INSTANCE.load(Ordering::Acquire);
    if !ptr.is_null() {
        return unsafe { &*ptr };
    }
    
    // Create and leak the instance to achieve 'static lifetime
    let new_config = Box::into_raw(Box::new(MyConfig::load()));
    
    // Compare-and-Swap to ensure only one thread wins
    if let Err(actual) = INSTANCE.compare_exchange(
        ptr::null_mut(), new_config, Ordering::AcqRel, Ordering::Acquire
    ) {
        // Another thread won, drop our local attempt
        unsafe { drop(Box::from_raw(new_config)); }
        unsafe { &*actual }
    } else {
        unsafe { &*new_config }
    }
}

How It Works

  1. The Check: We perform an Acquire load. If the pointer isn't null, we return the data immediately, which is fast and lock-free.
  2. The Race: If null, we allocate the instance.
  3. The CAS: We attempt to swap the null pointer with our new one. If the compare_exchange fails, it means another thread successfully initialized the global state while we were busy allocating. We simply discard our duplicate, clean up, and use the existing one.

This approach ensures that your complex object is initialized exactly once, thread-safely, and with minimal overhead.

Key Takeaway: Thread-safe initialization relies on atomic operations to ensure that only one thread wins the race to construct a shared resource, preventing data races and ensuring consistent global state.