What is the difference between once_cell::unsync::Lazy and once_cell::sync::Lazy for single-threaded contexts?

The once_cell crate provides two Lazy types that share the same initialization behavior but differ in thread-safety guarantees: unsync::Lazy for single-threaded contexts and sync::Lazy for multi-threaded scenarios. The unsync variant is simpler and faster because it omits atomic operations and synchronization primitives, while the sync variant uses OnceCell with atomic operations to ensure thread-safe initialization. For single-threaded applications, unsync::Lazy provides the same lazy initialization semantics without the synchronization overhead, making it the appropriate choice when thread safety is unnecessary.

Basic unsync::Lazy Usage

use once_cell::unsync::Lazy;
 
static CONFIG: Lazy<String> = Lazy::new(|| {
    // Called once, first time CONFIG is accessed
    println!("Initializing config");
    std::fs::read_to_string("config.txt").unwrap_or_default()
});
 
fn main() {
    println!("Before access");
    let config = &*CONFIG; // Initialization happens here
    println!("Config: {}", config);
    let config2 = &*CONFIG; // No re-initialization
}

unsync::Lazy initializes the value on first access, storing it for subsequent accesses.

Basic sync::Lazy Usage

use once_cell::sync::Lazy;
 
static GLOBAL_CONFIG: Lazy<String> = Lazy::new(|| {
    println!("Initializing global config");
    std::fs::read_to_string("config.txt").unwrap_or_default()
});
 
fn main() {
    std::thread::spawn(|| {
        let config = &*GLOBAL_CONFIG;
    });
    
    let config = &*GLOBAL_CONFIG;
}

sync::Lazy is safe to access from multiple threads, ensuring initialization happens exactly once.

Implementation Differences

// unsync::Lazy - simplified implementation
pub struct Lazy<T, F = fn() -> T> {
    cell: OnceCell<T>,  // No atomic operations
    init: Cell<Option<F>>,
}
 
// sync::Lazy - simplified implementation
pub struct Lazy<T, F = fn() -> T> {
    cell: OnceCell<T>,  // Uses atomics internally
    init: Mutex<Option<F>>,  // Thread-safe initialization
}

unsync::Lazy uses Cell for the initialization closure; sync::Lazy uses Mutex.

Thread Safety Trade-offs

use once_cell::sync::Lazy;
use std::sync::Arc;
 
// sync::Lazy is required for statics accessed from multiple threads
static SHARED_CACHE: Lazy<Vec<String>> = Lazy::new(|| {
    vec!["initialized".to_string()]
});
 
fn worker_thread() {
    let cache = &*SHARED_CACHE; // Thread-safe access
}
 
fn main() {
    let handles: Vec<_> = (0..10)
        .map(|_| std::thread::spawn(worker_thread))
        .collect();
    
    for handle in handles {
        handle.join().unwrap();
    }
}

sync::Lazy ensures only one thread initializes the value even with concurrent access.

use once_cell::unsync::Lazy;
 
// unsync::Lazy in single-threaded context
fn single_threaded() {
    let config = Lazy::new(|| {
        std::fs::read_to_string("config.txt").unwrap_or_default()
    });
    
    let value = &*config;
    let again = &*config; // Reuses same value
    
    println!("{}", value);
}

unsync::Lazy is appropriate when no cross-thread access occurs.

Performance Characteristics

use once_cell::{sync::Lazy, unsync};
 
fn benchmark_overhead() {
    // unsync::Lazy - no atomic operations
    let unsync_lazy = unsync::Lazy::new(|| 42);
    
    // sync::Lazy - uses atomic operations for thread safety
    let sync_lazy: Lazy<i32> = Lazy::new(|| 42);
    
    // Access overhead comparison:
    // unsync::Lazy: Single memory check, no atomics
    // sync::Lazy: Atomic check with Release/Acquire ordering
}

unsync::Lazy avoids atomic operations that have overhead even on single-threaded access.

Interior Mutability

use once_cell::unsync::Lazy;
 
fn mutable_initialization() {
    // unsync::Lazy allows interior mutability in init
    let mut counter = 0;
    let lazy = Lazy::new(|| {
        counter += 1; // Can mutate local state
        counter * 2
    });
    
    // sync::Lazy cannot mutate captured variables
    // across thread boundaries safely
}

unsync::Lazy can capture mutable local references; sync::Lazy cannot.

Initialization Guarantees

use once_cell::unsync::Lazy;
 
struct ExpensiveInit {
    data: Vec<u8>,
}
 
impl ExpensiveInit {
    fn new() -> Self {
        // Expensive computation
        std::thread::sleep(std::time::Duration::from_millis(100));
        Self { data: vec![0; 1024] }
    }
}
 
fn deferred_initialization() {
    let lazy = Lazy::new(|| ExpensiveInit::new());
    
    // Initialization deferred until first access
    println!("Not initialized yet");
    
    let _ = &*lazy; // Now initialized
    
    // No re-initialization on subsequent access
    let _ = &*lazy;
}

Both variants guarantee exactly one initialization call.

Error Handling

use once_cell::unsync::Lazy;
use std::fs;
 
enum InitError {
    Io(std::io::Error),
}
 
fn fallible_init() {
    // Lazy cannot return Result - initialization must succeed
    let config: Lazy<String> = Lazy::new(|| {
        // Panics if file doesn't exist
        fs::read_to_string("config.txt").expect("config required")
    });
    
    // For fallible initialization, use Lazy<Option<T>> or OnceCell
}

Both variants require initialization to succeed; panics propagate to the caller.

Local Static Variables

use once_cell::unsync::Lazy;
 
fn function_with_static() {
    // Local static using unsync::Lazy
    static LOCAL_CACHE: Lazy<Vec<i32>> = Lazy::new(|| {
        (0..100).collect()
    });
    
    let cache = &*LOCAL_CACHE;
}
 
fn main() {
    function_with_static();
    function_with_static(); // Reuses same cache
}

unsync::Lazy can be used for local statics that are only accessed from the main thread.

Non-Send Types

use once_cell::unsync::Lazy;
use std::rc::Rc;
 
fn non_send_types() {
    // unsync::Lazy can hold non-Send types
    let lazy: Lazy<Rc<i32>> = Lazy::new(|| {
        Rc::new(42)
    });
    
    let rc = &*lazy;
    let rc2 = Rc::clone(rc);
    
    // sync::Lazy cannot hold non-Send types
    // because it must be accessible from any thread
}

unsync::Lazy can store types that don't implement Send.

Interior Mutability in Initialization

use once_cell::unsync::Lazy;
 
fn capture_mutable() {
    let mut state = 0;
    
    let lazy = Lazy::new(|| {
        state += 1; // Mutates captured variable
        state
    });
    
    // sync::Lazy cannot do this because
    // captured values must be Send
}

unsync::Lazy allows capturing mutable references to local variables.

When to Use Each

use once_cell::{sync::Lazy, unsync::Lazy};
 
// Use sync::Lazy for:
// - Statics accessed from multiple threads
// - Types that implement Send
// - When thread safety is required
static GLOBAL_STATE: Lazy<Vec<String>> = Lazy::new(|| vec![]);
static CONFIG: Lazy<Config> = Lazy::new(|| Config::load());
 
// Use unsync::Lazy for:
// - Single-threaded contexts
// - Local variables that don't escape the thread
// - Types that don't implement Send
fn local_context() {
    let local_cache: unsync::Lazy<Vec<i32>> = Lazy::new(|| {
        (0..100).collect()
    });
    
    let config: unsync::Lazy<Config> = Lazy::new(|| {
        Config::from_file("local.json")
    });
}

Choose sync::Lazy when thread safety matters; unsync::Lazy for single-threaded contexts.

Conversion Between Variants

use once_cell::{sync::Lazy, unsync::Lazy};
 
fn conversion_example() {
    // Cannot directly convert between sync and unsync
    
    // If you need both, create separately:
    let unsync: unsync::Lazy<String> = unsync::Lazy::new(|| "local".to_string());
    
    // For thread-safe context:
    let sync: Lazy<String> = Lazy::new(|| "global".to_string());
    
    // Or restructure to use OnceCell directly
    use once_cell::sync::OnceCell;
    let cell: OnceCell<String> = OnceCell::new();
}

No direct conversion exists; choose the appropriate variant from the start.

Common Patterns

use once_cell::unsync::Lazy;
 
// Pattern: Lazy-loaded configuration
struct AppConfig {
    database_url: String,
    api_key: String,
}
 
impl AppConfig {
    fn load() -> Self {
        Self {
            database_url: std::env::var("DATABASE_URL").unwrap_or_default(),
            api_key: std::env::var("API_KEY").unwrap_or_default(),
        }
    }
}
 
// Pattern: Cached computation
fn cached_computation() {
    let expensive_result: Lazy<Vec<u8>> = Lazy::new(|| {
        (0..1000).map(|x| (x % 256) as u8).collect()
    });
    
    // Only computed once even if function called multiple times
    let result = &*expensive_result;
}
 
// Pattern: Lazy regex compilation
use regex::Regex;
 
fn lazy_regex() {
    static PATTERN: Lazy<Regex> = Lazy::new(|| {
        Regex::new(r"\d+").unwrap()
    });
    
    let text = "abc123def";
    if PATTERN.is_match(text) {
        println!("Found digits");
    }
}

Common patterns apply to both variants, differing only in thread safety.

Real-World Example: Application Config

use once_cell::unsync::Lazy;
use std::collections::HashMap;
 
struct Config {
    settings: HashMap<String, String>,
}
 
impl Config {
    fn load() -> Self {
        let mut settings = HashMap::new();
        settings.insert("host".to_string(), "localhost".to_string());
        settings.insert("port".to_string(), "8080".to_string());
        Self { settings }
    }
}
 
fn main() {
    // Single-threaded application uses unsync::Lazy
    let config: Lazy<Config> = Lazy::new(Config::load);
    
    let host = config.settings.get("host").unwrap();
    println!("Host: {}", host);
    
    // Subsequent access is fast
    let port = config.settings.get("port").unwrap();
    println!("Port: {}", port);
}

For CLI tools and single-threaded apps, unsync::Lazy avoids unnecessary synchronization.

Real-World Example: Multi-threaded Server

use once_cell::sync::Lazy;
use std::sync::Arc;
 
struct DatabasePool {
    connections: Vec<String>,
}
 
impl DatabasePool {
    fn new() -> Self {
        Self {
            connections: vec!["conn1".to_string(), "conn2".to_string()],
        }
    }
}
 
static DB_POOL: Lazy<Arc<DatabasePool>> = Lazy::new(|| {
    Arc::new(DatabasePool::new())
});
 
fn handle_request() {
    // Multiple threads can access safely
    let pool = Arc::clone(&*DB_POOL);
    // ... use pool
}
 
fn main() {
    // Server spawns multiple threads
    for _ in 0..10 {
        std::thread::spawn(|| {
            handle_request();
        });
    }
}

For servers and multi-threaded applications, sync::Lazy ensures safe concurrent access.

Synthesis

Key differences:

Aspect unsync::Lazy sync::Lazy
Thread safety Not thread-safe Thread-safe
Initialization closure Cell<Option<F>> Mutex<Option<F>>
Atomic operations None Uses atomics
Send bound on T Not required Required
Performance Faster access Slower due to atomics
Use case Single-threaded Multi-threaded

When to use:

Context Choice
Global static in multi-threaded app sync::Lazy
Local variable in function unsync::Lazy
Types not implementing Send unsync::Lazy
CLI tool, single-threaded unsync::Lazy
Web server, multi-threaded sync::Lazy

Key insight: The unsync::Lazy and sync::Lazy types provide identical lazy initialization semantics but differ in thread-safety mechanisms. unsync::Lazy uses Cell for interior mutability without atomic operations, making it faster for single-threaded contexts. sync::Lazy uses atomic operations and Mutex to ensure exactly-once initialization across concurrent access, incurring synchronization overhead. The unsync variant can store non-Send types like Rc and capture mutable references to local variables, while sync::Lazy requires Send types. For single-threaded applications like CLI tools or when the lazy value is confined to one thread, unsync::Lazy provides the same lazy initialization without unnecessary synchronization costs.