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: a parking_lot mutex and a plain Cell<bool>. Which one is used is decided once, from -Zthreads, and every call site is monomorphized against it. A single-threaded rustc therefore executes lock(), 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

rust
// 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:

rust
// 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:

rust
// 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:

rust
// 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:

rust
// 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:

rust
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::NoSync path 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 plain Cell<bool> is a correct lock.
  • The Mode::Sync path 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:

rust
// compiler/rustc_data_structures/src/sync.rs:73-91

Where it lives

  • compiler/rustc_data_structures/src/sync.rs — the global mode and the is_dyn_thread_safe check.
  • compiler/rustc_data_structures/src/sync/lock.rs — the dual-mode Lock<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.

RustInternalsCompilerPerformanceConcurrency