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
- The Check: We perform an
Acquireload. If the pointer isn't null, we return the data immediately, which is fast and lock-free. - The Race: If null, we allocate the instance.
- The CAS: We attempt to swap the null pointer with our new one. If the
compare_exchangefails, 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.