Locking in Rust
Published 18 September 2026
Started as a question about Once, turned into a tour of the whole locking shelf in
std::sync. Companion to A primer on Rust.
The short version¶
If you only want the takeaway from the conversation that kicked this post off:
Onceis “run this closure exactly once, no matter how many threads call it.” It holds no data — it’s just synchronisation state.OnceLock<T>is “initialise this value once, then hand everyone a&T.” It stores the value.LazyLock<T>isOnceLock<T>with the initialiser baked in at the declaration, so callers just deref the static and never think about initialisation at all.- For mutable shared state you want
Mutex<T>orRwLock<T>, usually behind anArc. - For a single counter or flag, skip locks entirely and use an atomic.
The rest of this post is why each of those exists.
Rust puts the data inside the lock¶
The first thing to unlearn from C or Java: in Rust a lock is not a thing you remember to take before touching a variable. The lock owns the variable.
There is no way to read that 0 without going through counter.lock(). The discipline
that in C lives in a comment above the declaration (“callers must hold foo_mutex”) is,
here, a type. You cannot forget it, because there’s no unlocked path to the data.
Mutex<i32>
│
├── lock state (who holds it, who's waiting)
└── the i32 itself ← only reachable through a guard
Mutex: one writer at a time¶
lock() blocks until the mutex is free, then gives you a MutexGuard<T>, which derefs to
&mut T and releases the lock when it drops.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut n = counter.lock().unwrap();
*n += 1;
}));
}
for h in handles {
h.join().unwrap();
}
println!("{}", *counter.lock().unwrap()); // 10
}
Two things in there are worth naming.
The Arc is doing a different job from the Mutex. Arc answers “how do ten threads all
get to refer to the same value?” — shared ownership. Mutex answers “how do they take
turns mutating it?” — mutual exclusion. You need both, which is why Arc<Mutex<T>> is such
a common sight.
The unwrap() is about poisoning. If a thread panics while holding the lock, the data
behind it might be half-updated, so every later lock() returns Err(PoisonError) to warn
you. unwrap() says “if that happened, I’d rather crash too.” If you know your data is
still consistent after a panic, lock().unwrap_or_else(|e| e.into_inner()) takes the guard
out of the error and carries on.
The guard’s drop is what unlocks, so scope matters:
{
let mut n = counter.lock().unwrap();
*n += 1;
} // released here
expensive_unrelated_work(); // runs without holding the lock
Holding a guard across something slow — a network call, a long computation — is the single most common way to turn a correct program into a slow one.
RwLock: many readers or one writer¶
When reads vastly outnumber writes, Mutex serialises work that didn’t need serialising.
RwLock<T> splits the API in two:
use std::sync::RwLock;
let table = RwLock::new(vec![1, 2, 3]);
{
let r = table.read().unwrap(); // many of these can coexist
println!("{}", r.len());
}
{
let mut w = table.write().unwrap(); // exclusive
w.push(4);
}
It is not free. A read lock does more bookkeeping than a mutex acquire, and writers can be
starved by a steady stream of readers depending on the platform’s implementation. The rule
of thumb: reach for RwLock when reads dominate and the critical section is long enough
that the extra bookkeeping is noise. For a short read of a small value, Mutex often wins.
Once: run something exactly once¶
This is the primitive that started the conversation. Once guarantees that a closure runs
exactly once, even if several threads race into it.
use std::sync::Once;
static INIT: Once = Once::new();
fn initialize() {
INIT.call_once(|| {
println!("Initializing...");
});
}
fn main() {
initialize();
initialize();
initialize();
}
Output:
Three calls, one print. Across threads it behaves the same way:
use std::sync::Once;
use std::thread;
static INIT: Once = Once::new();
fn setup() {
INIT.call_once(|| {
println!("Initialization happened");
});
}
fn main() {
let mut handles = vec![];
for _ in 0..10 {
handles.push(thread::spawn(|| setup()));
}
for h in handles {
h.join().unwrap();
}
}
Still one line of output. The property that makes it useful isn’t just “runs once” — it’s that the losers of the race block until the winner finishes, and then observe the completed initialisation.
Thread 1 ──┐
Thread 2 ──┤
Thread 3 ──┼──> INIT.call_once(...) ──> initialization happens once
Thread 4 ──┤
Thread 5 ──┘
Without that second half you’d have the classic double-checked-locking bug: a thread sees the “initialised” flag set, proceeds, and reads a value the initialising thread hasn’t finished writing.
The thing to notice is what Once doesn’t hold:
No T anywhere. It’s synchronisation state and nothing else. If your initialisation
produces a value, you’re on your own for storing it — which is exactly the gap the next
type fills.
OnceLock: initialise a value once¶
OnceLock<T> is “run this once and keep the result.”
use std::sync::OnceLock;
static CONFIG: OnceLock<String> = OnceLock::new();
fn config() -> &'static String {
CONFIG.get_or_init(|| {
println!("Loading config...");
"production".to_string()
})
}
fn main() {
println!("{}", config());
println!("{}", config());
}
Output:
The first call builds the String; every call after that just returns a reference to it.
Once
└── call_once(|| initialize())
OnceLock<T>
├── get_or_init(|| create_T())
└── stores the resulting T
get_or_init is the usual entry point, but the other methods matter in practice:
get()returnsOption<&T>— “is it ready yet?” without initialising.set(value)returnsResult<(), T>— hands your value back if someone beat you to it.get_or_init(f)is the one you want 95% of the time.
This is what people used to pull in lazy_static or once_cell for. Since Rust 1.70 it’s
in std, and unlike the old lazy_static! macro there’s no hidden Deref type involved —
CONFIG is just a static of a normal type.
LazyLock: the version you’ll actually write¶
OnceLock still makes every call site say how to initialise. If there’s only one sensible
answer, put it at the declaration instead:
use std::sync::LazyLock;
use std::collections::HashMap;
static TABLE: LazyLock<HashMap<&str, u32>> = LazyLock::new(|| {
println!("building table");
HashMap::from([("a", 1), ("b", 2)])
});
fn main() {
println!("{}", TABLE["a"]); // builds it here
println!("{}", TABLE["b"]); // already built
}
LazyLock<T> derefs to T, so TABLE["a"] reads like a plain global. Initialisation
happens on first deref, still exactly once, still thread-safe. Stable since 1.80, and it’s
the closest thing Rust has to “a global with an initialiser”.
Picking between the three is mostly mechanical:
| You want | Use |
|---|---|
| a side effect to happen once | Once |
| a value built once, initialiser varies or needs runtime input | OnceLock<T> |
| a value built once from a fixed expression | LazyLock<T> |
The middle row is the one people miss. If your config path comes from main and only then
gets stored, LazyLock can’t see it — OnceLock plus a set() early in main is the
shape you want.
When not to lock at all¶
A lock is the general answer, and general answers cost something. Two cheaper ones come up constantly.
Atomics, when the shared state is a single integer or flag:
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
COUNTER.fetch_add(1, Ordering::Relaxed);
No guard, no poisoning, no blocking. Relaxed is fine when the counter is only ever read
for its own sake; the moment the counter’s value is meant to signal that other memory is
ready, you need Acquire/Release and should think carefully rather than guess.
Channels, when threads are handing work along rather than sharing it:
use std::sync::mpsc;
use std::thread;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
tx.send("done").unwrap();
});
println!("{}", rx.recv().unwrap());
If you find yourself locking a Vec just so one thread can push and another can drain, a
channel is the thing you were building by hand.
The ways this goes wrong¶
Deadlock by lock ordering. Thread A takes lock 1 then lock 2; thread B takes 2 then 1. Both stop forever. Rust’s type system does not save you here — the borrow checker knows about data races, not deadlocks. The fix is the old one: pick a global order for your locks and always take them in that order.
Deadlock by re-entry. std::sync::Mutex is not reentrant. Locking it twice on the same
thread deadlocks, and that’s easy to do accidentally when one locked method calls another.
Holding a guard across .await. In async code, a MutexGuard held across an await
point is both a correctness hazard and usually a compile error (the future stops being
Send). Either end the guard’s scope before the await:
let value = {
let g = state.lock().unwrap();
g.clone()
}; // guard dropped here
do_async_thing(value).await;
or use tokio::sync::Mutex, whose guard is await-safe. Don’t reach for the async mutex by
default though — it’s slower, and most async code only needs the lock for a few
non-awaiting lines.
Temporaries that live longer than you think. if let Some(x) = map.lock().unwrap().get(k)
keeps the guard alive for the whole if let body in older editions. Bind the guard to a
name when the lifetime matters.
What I’d tell myself a week ago¶
Oncehas no data.OnceLock<T>has data.LazyLock<T>has data and the recipe. That one sentence is most ofstd::sync’s lazy-init story.Arcis for sharing,Mutexis for mutating.Arc<Mutex<T>>isn’t redundant.- The guard is the lock. Where it drops is where you unlock, so pay attention to scopes.
- Poisoning is a feature, not noise —
unwrap()on a lock is a deliberate “panic if someone else panicked”, not laziness. - Before adding a
Mutex, check whether an atomic or a channel already says what you mean.