The Lock That Disappears: How rustc Deletes Every Atomic From a Single-Threaded Build
rustc's sharded caches and locks compile down to a Cell<bool> and no atomics at all when -Zthreads=1, because the mode is chosen once and encoded in the type system.
TL;DR: rustc’s internal
Lock<T>is a union of two implementations: aparking_lotmutex and a plainCell<bool>. Which one is used is decided once, from-Zthreads, and every call site is monomorphized against it. A single-threaded rustc therefore executeslock(),unlock()and every shard lookup with zero atomic instructions — the compiler deletes them entirely.
If you have ever profiled rustc, you have noticed something odd: the
single-threaded build is disproportionately fast compared to -Zthreads=2.
Part of that is obvious (no synchronization at all). Part of it is subtler:
rustc doesn’t just avoid the locks, it removes the instructions.
The union
// compiler/rustc_data_structures/src/sync/lock.rs:69-88
The type is a union of a Cell<bool>-based no-op lock and a real mutex, and
the mode is a compile-time constant baked into the monomorphization:
// compiler/rustc_data_structures/src/sync.rs:73-91
The global mode is a runtime AtomicU8, but it is read once, at
construction time, and the decision is then encoded in the type system by
picking between two monomorphizations.
The sharded map degenerates to one lock
This is where it pays off. The sharded hash map used for the query caches picks its representation at construction:
// compiler/rustc_data_structures/src/sharded.rs:18-42
/// An array of cache-line aligned inner locked structures with convenience methods.
/// A single field is used when the compiler uses only one thread.
pub enum Sharded<T> {
Single(Lock<T>),
Shards(Box<[CacheAligned<Lock<T>>; SHARDS]>),
}
...
pub fn new(mut value: impl FnMut() -> T) -> Self {
if is_dyn_thread_safe() {
return Sharded::Shards(Box::new(
[(); SHARDS].map(|()| CacheAligned(Lock::new(value()))),
));
}
Sharded::Single(Lock::new(value()))
}Two things happen here. The 32 shards — each cache-line aligned, so that two threads hashing into different shards do not fight over the same cache line — are not even allocated in the single-threaded case. And the locking mode is not a branch at runtime; it is a branch at compile time.
Then the interesting part, the lock acquisition itself:
// compiler/rustc_data_structures/src/sharded.rs:92-115
pub fn lock_shard_by_index(&self, i: usize) -> LockGuard<'_, T> {
match self {
Self::Single(single) => {
// Synchronization is disabled so use the `lock_assume_no_sync` method optimized
// for that case.
unsafe { single.lock_assume(Mode::NoSync) }
}
Self::Shards(shards) => {
// Synchronization is enabled so use the `lock_assume_sync` method optimized
// for that case.
unsafe { shards.get_unchecked(i & (SHARDS - 1)).0.lock_assume(Mode::Sync) }
}
}
}Note the two different functions: lock_assume(Mode::NoSync) and
lock_assume(Mode::Sync). Because Mode is a type parameter, each
instantiation is a separate function with a separate body. In the
single-threaded build, lock_assume(Mode::NoSync) does:
// compiler/rustc_data_structures/src/sync/lock.rs:140-160
— a Cell<bool> check and a store. No compare_exchange, no lock cmpxchg,
no AtomicBool::load. LLVM deletes it because the whole thing is provably
uncontended in that instantiation.
And the shard index is masked rather than divided:
shards.get_unchecked(i & (SHARDS - 1))Because SHARDS is a power of two, the modulo is a mask, and get_unchecked
removes the bounds check — the mask already guarantees the index is in
range.
Why the unsafe is sound
The unsafe blocks above look alarming, and the answer is worth spelling
out, because it is the kind of reasoning that makes this pattern acceptable
in a compiler:
- The
Mode::NoSyncpath is only taken when the compiler was built with a single thread. In that configuration no other thread can be inside the critical section, so a plainCell<bool>is a correct lock. - The
Mode::Syncpath is only taken when there are other threads, and it uses the real mutex. - The choice happens once, in
Sharded::new, and the value is immutable afterwards.
This is “unsafe code with a runtime-selected implementation”, which is
strictly better than Box<dyn Lock> — the latter would cost an indirect
call on every lock acquisition, and, more importantly, would defeat
inlining across the whole cache layer.
The payoff
The interesting thing about this design is that it is not a micro-optimization of a rare path. Cache shard locks are taken on every query cache lookup that misses, in every phase of the compiler. Removing the atomics from that path removes them from the compiler’s hot loops entirely.
It also explains a familiar observation: -Zthreads=1 rustc builds are
sometimes more than twice as fast as -Zthreads=2, which is more than
you would expect from removing lock contention alone. Part of the gap is the
scheduler, part is the atomics, part is that Sharded::Single has one cache
line of hot data instead of 32.
The same trick in the allocator
While we are here: the mode is not read from the environment at runtime for
every operation — it is baked into the type. The is_dyn_thread_safe()
function reads a global AtomicU8 once, and everything downstream is a
compile-time decision:
// compiler/rustc_data_structures/src/sync.rs:73-91
Where it lives
compiler/rustc_data_structures/src/sync.rs— the global mode and theis_dyn_thread_safecheck.compiler/rustc_data_structures/src/sync/lock.rs— the dual-modeLock<T>.compiler/rustc_data_structures/src/sharded.rs— the sharded map that collapses to a single lock.
Takeaway
Rust’s type system makes an unusual trick possible: the same source code can compile into a version with locks and a version without them, chosen at build time, with zero runtime dispatch and zero cost in the version you do not want. That is a level of specialization that most languages cannot express at all, and it is why a single-threaded rustc feels like a different program from a multi-threaded one.