Skip to content

← Atomics, Send/Sync and the Memory Model step 10 of 12

Hard End-to-End

Build a spinlock from AtomicBool and UnsafeCell

Write a real lock, from the two primitives everything else is made of.

pub struct SpinLock<T> { /* AtomicBool + UnsafeCell<T> */ }

impl<T> SpinLock<T> {
    pub fn new(value: T) -> Self;
    pub fn lock(&self) -> SpinLockGuard<'_, T>;
    pub fn into_inner(self) -> T;
}

pub fn spin_counter(increments: usize, workers: usize) -> usize

SpinLockGuard<'_, T> must implement Deref, DerefMut and Drop.

spin_counter shares a SpinLock<Vec<u64>> holding four counters between workers scoped threads; worker w performs increments increments, spreading them over the four slots. Return the total, which is increments * workers. Treat workers == 0 as 1.

The protected value is a Vec, not an integer, deliberately — a Vec has a destructor and a heap allocation, so a wrong Drop or a wrong Deref fails loudly instead of quietly.

Why Mutex<T> contains its data

You have been told that the data living inside the lock is a design insight. Building one is where you find out it is not a stylistic choice — it is forced.

UnsafeCell<T> is the only way in the entire language to get a &mut T from a &T. It is not a library type with a clever trick; the compiler knows about it and switches off the no-aliasing assumption for anything reachable through it. Cell, RefCell, Mutex, RwLock, atomic::* — every one of them is an UnsafeCell plus a discipline that makes it sound.

So a lock that did not contain its data would have nowhere to put the UnsafeCell, and no way to hand out the &mut. The guard is the only way out of the cell, and taking the lock is the only way to get a guard. That is the whole architecture, and it is why you cannot forget to lock in Rust.

The protocol

Lock:

while self.locked
    .compare_exchange_weak(false, true, Acquire, Relaxed)
    .is_err()
{
    spin_loop();
}

Unlock, in the guard’s Drop:

self.lock.locked.store(false, Release);

The Acquire/Release pair is not decoration. It is exactly what makes the previous holder’s writes visible to the next one. Whoever unlocks does a Release store; whoever locks does an Acquire read-modify-write that observes it; the happens-before edge that creates is what makes it sound to hand out a &mut T to data another thread just wrote. Weaken either and the lock still excludes correctly and stops publishing correctly — the worst kind of bug, and the one this grader cannot see.

Use compare_exchange_**weak** inside the loop: it may fail spuriously and you are looping anyway, so the strong form’s internal retry is pure waste. The failure ordering can be Relaxed, because a failed attempt tells you nothing you are going to act on.

The starter has two bugs and only one of them is a compile error

First, the gate:

error[E0277]: `UnsafeCell<Vec<u64>>` cannot be shared between threads safely
   = help: within `SpinLock<Vec<u64>>`, the trait `Sync` is not implemented
           for `UnsafeCell<Vec<u64>>`

UnsafeCell is deliberately not Sync — of course it is not, it is unsynchronised interior mutability by definition. Your lock is synchronised, and the compiler cannot know that. So you make the promise:

unsafe impl<T: Send> Sync for SpinLock<T> {}

Justify the bound before you write it. Why T: Send and not T: Sync? Because only one thread at a time can reach the data — that is what the lock is for — so the T is transferred between threads, never shared. Transfer is exactly what Send licenses. This is the same bound std::sync::Mutex carries, for the same reason, and it is worth being able to explain rather than copy. (RwLock needs T: Send + Sync precisely because several readers hold &T at once.)

Second, and the toolchain will not tell you: the starter’s lock() is test-then-set.

while self.locked.load(Acquire) { spin_loop(); }
self.locked.store(true, Release);

Two threads can both read false before either writes true, and both then believe they hold the lock. Checking and claiming must be one indivisible operation, which is precisely what compare_exchange is. This bug does not produce a data race the compiler can see — you already promised Sync — it produces lost updates and a wrong total.

Why a spinlock is a bad default

A blocked thread here does not sleep; it burns a core asking “is it free yet?” On an oversubscribed machine the thread holding the lock may be descheduled while every spinner keeps its core busy denying it a chance to run — the lock is held for a whole scheduling quantum and throughput collapses.

That is why the work here is bounded (workers <= 8, modest increments), and it is why real implementations spin briefly and then park. std::sync::Mutex does exactly that. Spinning is right when the critical section is a handful of instructions and contention is low; the moment either assumption fails, it is the worst option available.

The lints, honestly

clippy::missing_spin_loop and clippy::empty_loop do enforce std::hint::spin_loop() in a wait loop — but they were observed to fire on a local atomic and not when the atomic is reached through an Arc or a reference, which is the shape here. So the gate will not catch it for you. Write the hint anyway: it emits a pause/yield instruction that cuts power draw and speeds the eventual exit.

clippy::mut_from_ref is deny-by-default and would catch a fn get(&self) -> &mut T that hands out a &mut from a shared reference without a guard — the exact soundness hole this design avoids by making deref_mut take &mut self.

Loading visualization…