How does once_cell::sync::OnceBox enable heap-allocated one-time initialization for non-Sync types?

once_cell::sync::OnceBox provides thread-safe one-time initialization for heap-allocated values, allowing types that don't implement Sync to be stored in a global static by keeping the synchronization separate from the value itself. Unlike OnceLock<T> which requires T: Sync, OnceBox<T> only requires T: Send because the value is always accessed through references managed by the synchronization primitive, not shared directly between threads.

The Problem: Non-Sync Types in Statics

use std::sync::OnceLock;
 
// This FAILS to compile because RefCell is not Sync
static NOT_SYNC: OnceLock<std::cell::RefCell<Vec<String>>> = OnceLock::new();
 
fn main() {
    // Error: `RefCell<Vec<String>>` cannot be shared between threads safely
    // The trait `Sync` is not implemented for `RefCell<Vec<String>>`
}

OnceLock<T> requires T: Sync because multiple threads could access the value concurrently once initialized.

OnceBox to the Rescue

use once_cell::sync::OnceBox;
use std::cell::RefCell;
 
// This COMPILES because OnceBox only requires T: Send
static GLOBAL_CACHE: OnceBox<RefCell<Vec<String>>> = OnceBox::new();
 
fn main() {
    // Initialize once
    GLOBAL_CACHE.get_or_init(|| {
        RefCell::new(Vec::new())
    });
    
    // Use the cache
    if let Some(cache) = GLOBAL_CACHE.get() {
        cache.borrow_mut().push("item".to_string());
    }
}

OnceBox<T> works with T: Send (not T: Sync) because the value lives behind a pointer and access is controlled.

Why OnceBox Works with Non-Sync Types

use once_cell::sync::OnceBox;
 
// The key insight: OnceBox uses atomic operations for synchronization
// and the value is always accessed through the get() method
 
// When thread A initializes:
// 1. Atomically claims the right to initialize
// 2. Allocates the value on the heap (Box<T>)
// 3. Stores the pointer atomically
 
// When thread B reads:
// 1. Atomically reads the pointer
// 2. Either sees null (not initialized) or sees the pointer
// 3. If pointer exists, gets a reference to the value
 
// Thread safety comes from:
// - Atomic operations for the pointer (no data races on the pointer)
// - Box<T> provides stable heap location
// - get() returns &T, access is controlled by caller
 
// Why Send is sufficient:
// - Value is created on one thread and sent to heap
// - Other threads only read the pointer, then access through reference
// - The synchronization (OnceLock-like) is Sync
// - The value T only needs to move between threads (Send)

The synchronization happens at the OnceBox level, not the T level, so T only needs Send.

OnceBox vs OnceLock

use once_cell::sync::OnceBox;
use std::sync::OnceLock;
 
fn comparison() {
    // OnceLock<T>: Inline storage, requires T: Sync
    // - The value is stored directly in the OnceLock
    // - Multiple threads can have &T simultaneously
    // - Therefore T must be safe for concurrent access (Sync)
    
    // OnceBox<T>: Heap storage, requires T: Send  
    // - The value is stored behind a Box<T>
    // - Access is through get() which returns Option<&T>
    // - The OnceBox itself is Sync (atomic synchronization)
    // - T only needs to be Send (can move between threads)
    
    // Memory layout:
    // OnceLock<T>: Inline, sizeof(OnceLock<T>) >= sizeof(T)
    // OnceBox<T>: Pointer, sizeof(OnceBox<T>) is small (just a pointer + state)
}
 
// Compile-time comparison:
use std::cell::RefCell;
 
// This won't compile:
// static ONCE_LOCK: OnceLock<RefCell<i32>> = OnceLock::new();
 
// This compiles:
static ONCE_BOX: OnceBox<RefCell<i32>> = OnceBox::new();

OnceBox trades heap allocation for relaxed trait bounds.

Basic Usage

use once_cell::sync::OnceBox;
use std::cell::RefCell;
 
static STATE: OnceBox<RefCell<Vec<String>>> = OnceBox::new();
 
fn get_state() -> &'static RefCell<Vec<String>> {
    STATE.get_or_init(|| {
        RefCell::new(Vec::new())
    })
}
 
fn main() {
    // First access initializes
    let state = get_state();
    state.borrow_mut().push("First".to_string());
    
    // Subsequent accesses return the same instance
    let state2 = get_state();
    state2.borrow_mut().push("Second".to_string());
    
    // Both point to the same RefCell
    assert_eq!(state.borrow().len(), 2);
    assert_eq!(state2.borrow().len(), 2);
}

get_or_init returns a reference after ensuring initialization.

Common Non-Sync Types

use once_cell::sync::OnceBox;
use std::cell::{RefCell, Cell};
use std::rc::Rc;
 
// RefCell - interior mutability without Sync
static REF_CELL: OnceBox<RefCell<i32>> = OnceBox::new();
 
// Cell - copy types with interior mutability
static COUNTER: OnceBox<Cell<usize>> = OnceBox::new();
 
// Rc - reference counted (not Arc)
// Rc is Send if T: Send (because it can move to another thread)
// But Rc is NOT Sync (cannot share &Rc between threads)
static SHARED_RC: OnceBox<Rc<String>> = OnceBox::new();
 
fn main() {
    REF_CELL.get_or_init(|| RefCell::new(0));
    COUNTER.get_or_init(|| Cell::new(0));
    SHARED_RC.get_or_init(|| Rc::new("shared".to_string()));
    
    // Use the values
    if let Some(r) = REF_CELL.get() {
        *r.borrow_mut() += 1;
    }
}

RefCell, Cell, and Rc are common non-Sync types that work with OnceBox.

Lazy Initialization Patterns

use once_cell::sync::OnceBox;
use std::cell::RefCell;
use std::time::Instant;
 
static EXPENSIVE: OnceBox<RefCell<Vec<u8>>> = OnceBox::new();
 
fn get_expensive_data() -> &'static RefCell<Vec<u8>> {
    EXPENSIVE.get_or_init(|| {
        println!("Computing expensive data...");
        // Simulate expensive computation
        let data: Vec<u8> = (0..1000).collect();
        RefCell::new(data)
    })
}
 
fn main() {
    // First call - computes
    let data1 = get_expensive_data();
    println!("First access: {}", data1.borrow().len());
    
    // Second call - returns existing
    let data2 = get_expensive_data();
    println!("Second access: {}", data2.borrow().len());
    
    // Both references point to same data
    assert!(std::ptr::eq(data1, data2));
}

Lazy initialization with OnceBox ensures computation happens only once.

Configuration Pattern

use once_cell::sync::OnceBox;
use std::cell::RefCell;
 
struct Config {
    database_url: String,
    max_connections: usize,
    timeout_ms: u64,
}
 
impl Config {
    fn from_env() -> Self {
        Config {
            database_url: std::env::var("DATABASE_URL")
                .unwrap_or_else(|_| "localhost:5432".to_string()),
            max_connections: std::env::var("MAX_CONNECTIONS")
                .ok()
                .and_then(|s| s.parse().ok())
                .unwrap_or(10),
            timeout_ms: std::env::var("TIMEOUT_MS")
                .ok()
                .and_then(|s| s.parse().ok())
                .unwrap_or(5000),
        }
    }
}
 
// Global configuration stored in RefCell for runtime modification
static CONFIG: OnceBox<RefCell<Config>> = OnceBox::new();
 
fn get_config() -> &'static RefCell<Config> {
    CONFIG.get_or_init(|| {
        RefCell::new(Config::from_env())
    })
}
 
fn main() {
    // Read configuration
    let url = get_config().borrow().database_url.clone();
    println!("Database URL: {}", url);
    
    // Modify configuration (RefCell allows this)
    get_config().borrow_mut().max_connections = 20;
}

Configuration can be initialized lazily and modified at runtime through RefCell.

Thread-Safe Access Patterns

use once_cell::sync::OnceBox;
use std::cell::RefCell;
 
static DATA: OnceBox<RefCell<Vec<i32>>> = OnceBox::new();
 
fn thread_safe_access() {
    // Initialize once
    DATA.get_or_init(|| RefCell::new(Vec::new()));
    
    // Multiple threads can access, but RefCell ensures single-threaded access
    // at a time for mutations
    
    std::thread::scope(|s| {
        s.spawn(|| {
            // Each thread gets a reference to the RefCell
            if let Some(r) = DATA.get() {
                // This borrow_mut will panic if another thread holds a borrow
                // RefCell is not thread-safe for concurrent access!
                // Use with caution in single-threaded contexts or
                // when you know access is sequential
                r.borrow_mut().push(1);
            }
        });
        
        s.spawn(|| {
            if let Some(r) = DATA.get() {
                // Read-only access is safe if no writes happen
                // But this will panic if the other thread has borrow_mut
                // let len = r.borrow().len();
            }
        });
    });
}

OnceBox provides thread-safe initialization, but RefCell access still has the same rules—borrow and borrow_mut will panic if concurrent.

OnceBox with Mutex for True Thread Safety

use once_cell::sync::OnceBox;
use std::sync::Mutex;
 
// If you need thread-safe mutation, combine OnceBox with Mutex
// Note: If T is already Sync (like Mutex), you could use OnceLock instead
// But OnceBox still works and might be preferred for API consistency
 
static THREAD_SAFE_DATA: OnceBox<Mutex<Vec<String>>> = OnceBox::new();
 
fn get_thread_safe() -> &'static Mutex<Vec<String>> {
    THREAD_SAFE_DATA.get_or_init(|| {
        Mutex::new(Vec::new())
    })
}
 
fn main() {
    std::thread::scope(|s| {
        s.spawn(|| {
            let mut data = get_thread_safe().lock().unwrap();
            data.push("from thread 1".to_string());
        });
        
        s.spawn(|| {
            let mut data = get_thread_safe().lock().unwrap();
            data.push("from thread 2".to_string());
        });
    });
    
    let data = get_thread_safe().lock().unwrap();
    println!("Items: {:?}", *data);
}

For truly concurrent access, use OnceBox<Mutex<T>> or just OnceLock<T> if T: Sync.

OnceBox vs LazyLock

use once_cell::sync::OnceBox;
use std::sync::LazyLock;
 
fn comparison() {
    // LazyLock (Rust 1.80+) requires T: Sync
    // static LAZY: LazyLock<RefCell<i32>> = LazyLock::new(|| RefCell::new(0));
    // Error: RefCell<i32> is not Sync
    
    // OnceBox works with T: Send
    static ONCE_BOX: OnceBox<RefCell<i32>> = OnceBox::new();
    
    // LazyLock is built into std, more ergonomic
    static LAZY_SYNC: LazyLock<Vec<i32>> = LazyLock::new(|| vec![1, 2, 3]);
    
    // OnceBox requires heap allocation
    // OnceLock/LazyLock store value inline
    
    // When to use OnceBox:
    // 1. T is Send but not Sync
    // 2. You need heap allocation (very large T)
    // 3. You need compatibility with once_cell crate
    
    // When to use LazyLock:
    // 1. T is Sync
    // 2. You want std library solution
    // 3. You want inline storage
}

Use OnceBox when T: Send but not T: Sync.

Real-World Example: Lazy Regex Cache

use once_cell::sync::OnceBox;
use std::cell::RefCell;
use std::collections::HashMap;
use regex::Regex;
 
// Cache of compiled regexes - RefCell for runtime mutation
static REGEX_CACHE: OnceBox<RefCell<HashMap<&'static str, Regex>>> = OnceBox::new();
 
fn get_regex(pattern: &'static str) -> Result<&'static Regex, regex::Error> {
    let cache = REGEX_CACHE.get_or_init(|| {
        RefCell::new(HashMap::new())
    });
    
    // Check cache first
    {
        let cache_ref = cache.borrow();
        if let Some(regex) = cache_ref.get(pattern) {
            // Return reference to cached regex
            // Note: This is safe because we never remove from cache
            // and pattern is 'static
            return Ok(regex);
        }
    }
    
    // Compile and cache
    let regex = Regex::new(pattern)?;
    cache.borrow_mut().insert(pattern, regex);
    
    // Get reference to inserted value
    Ok(cache.borrow().get(pattern).unwrap())
}

Pattern: OnceBox<RefCell<HashMap>> for mutable global caches.

Real-World Example: Single-Threaded State Machine

use once_cell::sync::OnceBox;
use std::cell::RefCell;
 
// State machine that must only be accessed from one thread at a time
// (e.g., from a dedicated event loop thread)
struct StateMachine {
    state: State,
    transitions: u64,
}
 
enum State {
    Idle,
    Running { task_id: u64 },
    Paused,
}
 
impl StateMachine {
    fn new() -> Self {
        StateMachine {
            state: State::Idle,
            transitions: 0,
        }
    }
}
 
// OnceBox allows lazy initialization
// RefCell allows mutation without Sync (but single-threaded access only!)
static STATE_MACHINE: OnceBox<RefCell<StateMachine>> = OnceBox::new();
 
fn initialize_state_machine() {
    STATE_MACHINE.get_or_init(|| RefCell::new(StateMachine::new()));
}
 
// Must only be called from the designated thread
fn process_event(event: Event) {
    let sm = STATE_MACHINE.get().expect("State machine not initialized");
    let mut sm = sm.borrow_mut();
    
    // Process event...
    sm.transitions += 1;
}

OnceBox enables patterns where Sync is intentionally not needed.

Memory Layout

use once_cell::sync::OnceBox;
use std::mem::size_of;
 
fn memory_layout() {
    // OnceBox stores a pointer + synchronization state
    println!("OnceBox<T> size: {}", size_of::<OnceBox<Vec<u8>>>());
    // Typically 8-16 bytes (pointer + atomic state)
    
    // Value is allocated on heap when initialized
    // Box<T> has overhead of:
    // - Heap allocation
    // - Pointer indirection on every access
    
    // Trade-off:
    // - Smaller static memory footprint
    // - Heap allocation on first access
    // - Pointer indirection on every access
    
    // Compare to OnceLock (inline storage):
    // - Larger static memory (sizeof(T))
    // - No heap allocation
    // - Direct access (no indirection)
}

OnceBox trades heap allocation for relaxed trait bounds and smaller static footprint.

get vs get_or_init

use once_cell::sync::OnceBox;
use std::cell::RefCell;
 
static DATA: OnceBox<RefCell<i32>> = OnceBox::new();
 
fn access_methods() {
    // get() returns Option<&T>
    // None if not initialized, Some(&T) if initialized
    match DATA.get() {
        Some(r) => {
            println!("Already initialized: {}", r.borrow());
        }
        None => {
            println!("Not yet initialized");
        }
    }
    
    // get_or_init(|| T) returns &T
    // Initializes if needed, then returns reference
    let r = DATA.get_or_init(|| RefCell::new(42));
    println!("Value: {}", r.borrow());
    
    // get_mut() returns Option<&mut T>
    // Only available if you have &mut OnceBox
    // Not useful for statics (which are immutable)
    
    // into_inner() returns Option<Box<T>>
    // Consumes OnceBox, returns ownership
    // Also not useful for statics
}

Use get() to check initialization, get_or_init() for lazy initialization.

Summary Table

fn summary() {
    // | Type          | Storage | Trait Bounds    | Use Case                    |
    // |---------------|---------|-----------------|----------------------------|
    // | OnceLock<T>   | Inline  | T: Sync         | Sync types in statics      |
    // | OnceBox<T>    | Heap    | T: Send         | Non-Sync types in statics  |
    // | LazyLock<T>   | Inline  | T: Sync         | Lazy static (std)          |
    // | Lazy<T>       | Inline  | T: Sync         | Lazy static (once_cell)    |
    
    // | Method        | Returns        | Behavior                        |
    // |---------------|----------------|---------------------------------|
    // | get()         | Option<&T>     | Check if initialized            |
    // | get_or_init() | &T             | Initialize if needed            |
    // | get_mut()     | Option<&mut T> | Mutable access (rarely useful) |
    // | into_inner()  | Option<Box<T>> | Take ownership (rarely useful)  |
    
    // | Pattern                      | Trait Bounds Required    |
    // |------------------------------|--------------------------|
    // | OnceBox<RefCell<T>>          | T: Send                  |
    // | OnceBox<Cell<T>>             | T: Send                  |
    // | OnceBox<Rc<T>>               | T: Send                  |
    // | OnceBox<Mutex<T>>            | T: Send (Mutex provides Sync) |
    // | OnceBox<Box<dyn Trait>>      | Trait: Send + 'static    |
}

Synthesis

Quick reference:

use once_cell::sync::OnceBox;
use std::cell::RefCell;
 
// Global mutable state with non-Sync type
static GLOBAL: OnceBox<RefCell<Vec<String>>> = OnceBox::new();
 
fn main() {
    // Initialize once (thread-safe)
    let data = GLOBAL.get_or_init(|| RefCell::new(Vec::new()));
    
    // Access and modify (RefCell rules apply!)
    data.borrow_mut().push("item".to_string());
    println!("Items: {:?}", data.borrow());
}

Key insight: OnceBox<T> enables storing non-Sync types in global statics by keeping synchronization and storage separate. The OnceBox itself is Sync (it uses atomic operations for thread-safe initialization), but it only requires T: Send because the value is heap-allocated and accessed through controlled references. This is crucial for types like RefCell<T>, Cell<T>, and Rc<T> which provide interior mutability without thread safety—OnceBox gets them safely initialized in a global, and then it's the caller's responsibility to use them correctly (single-threaded access for RefCell, etc.). The heap allocation (Box<T>) means OnceBox has a small memory footprint in the static (just a pointer) but incurs heap allocation and pointer indirection. For T: Sync, prefer OnceLock<T> or LazyLock<T> for inline storage without indirection. Use OnceBox specifically when you need global lazy initialization for Send types that aren't Sync.