Skip to Content
Course content
Click on the "Edit" button in the top corner of the screen to edit your slide content.

There's a recurring pattern in almost every production app I've built: the "Global Configuration" problem. You have a set of settings—maybe a database URL, an API key, or a log level—that you load from a file or environment variables when the app starts. You only want to do this expensive I/O once, but you need those values accessible from anywhere in your codebase without passing a config object through every single function signature in your call stack.

The "Trust Me" Approach with static mut

When I first started with Rust, my instinct was to reach for static mut. It seems straightforward: create a global variable, mark it as mutable, and wrap it in an Option. In my head, I knew that I'd initialize it during the main setup and never touch it again. But Rust's borrow checker doesn't trust my intentions; it only sees that any access to a static mut is inherently unsafe because multiple threads could potentially race to read or write it.

static mut CONFIG: Option = None;

fn get_config() -> &'static Config {
    unsafe {
        // This is a disaster waiting to happen in a multi-threaded app
        CONFIG.as_ref().expect("Config not initialized")
    }
}

The problem here is that we've just bypassed the entire reason we use Rust. If two threads call an initialization function at the same time, you have a data race. Even if you're "sure" it only happens once, the compiler forces you to wrap every single access in an unsafe block. That’s a lot of noise for something that should be a basic architectural pattern.

Paying the Mutex Tax

To avoid unsafe, the next logical step is usually a Mutex. You wrap your Option in a Mutex, and now the compiler is happy. But now we've traded safety for a performance penalty. Every single time any part of your app needs to check a configuration value, it has to acquire a lock, even though the value never changes after the first five milliseconds of the program's life.

I've seen this in large codebases where a high-frequency loop calls a function that checks a config value. The lock contention becomes a legitimate bottleneck. It feels wrong to pay a synchronization tax on every read for a value that is effectively a constant after startup.

Initialization without the Overhead

This is where OnceLock (the thread-safe version of OnceCell) comes in. Think of it as a write-once, read-many container. It allows you to define a global variable that starts empty and is filled exactly once. The magic is that OnceLock handles the synchronization internally only during the initialization phase.

Once the value is set, subsequent calls to get() are essentially just pointer dereferences. You get the safety of a Mutex during the "write" and the speed of a static reference during the "read".

use std::sync::OnceLock;

static CONFIG: OnceLock<Config> = OnceLock::new();

fn get_config() -> &'static Config {
    CONFIG.get_or_init(|| {
        // This closure runs exactly once across the entire program
        Config::load_from_env()
    })
}

If you don't actually need to store a value, but you just need to ensure a specific piece of setup code—like initializing a logging framework or a telemetry exporter—runs exactly once, use std::sync::Once. It doesn't return a value; it just guarantees that the provided closure is executed once, regardless of how many threads attempt to call it.

I generally prefer OnceLock for state and Once for side-effects. It keeps the intent clear: "I need this piece of data" versus "I need this action to happen."




📋 Practical Task

Refactoring the Global Logger Registry

You have been handed a legacy module that manages a global list of active logger names. Currently, it uses a Mutex<Option<Vec<String>>>, which is causing performance degradation in the application's hot path due to lock contention during read-only access.

Your Task: Refactor the following code to use std::sync::OnceLock. Ensure that the get_loggers function no longer returns a MutexGuard, but instead returns a shared reference &'static [String].

use std::sync::Mutex;

struct LoggerRegistry {
    loggers: Vec<String>,
}

static REGISTRY: Mutex<Option<LoggerRegistry>> = Mutex::new(None);

fn init_registry(names: Vec<String>) {
    let mut lock = REGISTRY.lock().unwrap();
    *lock = Some(LoggerRegistry { loggers: names });
}

fn get_loggers() -> std::sync::MutexGuard<'static, Option<LoggerRegistry>> {
    REGISTRY.lock().unwrap()
}

fn main() {
    init_registry(vec!["Network".to_string(), "Database".to_string()]);
    let loggers = get_loggers();
    println!("Active loggers: {:?}", loggers.as_ref().unwrap().loggers);
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.