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.
