Summary
A developer attempted to implement a custom SpinLock in Rust based on Mara Bos’s book to protect shared mutable state across threads. The code resulted in memory safety issues (segmentation faults or undefined behavior) because the raw SpinLock was used to guard an UnsafeCell containing a String without adhering to Rust’s strict concurrency safety rules. Specifically, the SpinLock implementation lacked an unsafe impl<T: Send> Sync for SpinLock<T> guarantee, and the usage site failed to enforce Send bounds or proper synchronization semantics for the UnsafeCell.
Root Cause
The immediate cause of the crash is unsafe synchronization of a non-Send type (specifically, an UnsafeCell<String> not properly wrapped) and missing Sync implementation for the custom lock.
- Missing
Syncfor the Lock: TheSpinLockstruct defined in the module does not implementSync. WhileAtomicBoolisSync, the lock primitive itself needs to be explicitly markedSyncto allow sharing across threads via&SpinLock. Without this,std::thread::scopecannot safely pass a reference to it. - Improper
UnsafeCellUsage: TheSyncWrapperattempts to makeUnsafeCell<String>Sync. However, simply wrappingUnsafeCelland addingunsafe impl Syncis a blanket promise to the compiler. If that promise is broken (e.g., the underlying data is notSendor the synchronization primitive is insufficient), you get undefined behavior. - Type Constraints:
StringisSend+Sync. However,UnsafeCell<T>is notSync(because it allows interior mutability). TheSyncWrapperbypasses this, but theSpinLockimplementation provided was too primitive to correctly manage the Drop and Send semantics ofStringin a panic-safe manner, leading to potential data races or double-frees.
Why This Happens in Real Systems
Engineers often “cargo cult” synchronization primitives from low-level languages (C/C++) into Rust, assuming AtomicBool + spin_loop is enough.
- The “It Compiles” Fallacy: Rust’s strict compiler blocks safe concurrency bugs. When engineers use
unsafe, they bypass these checks. If they get theunsafelogic wrong (like missing bounds), the compiler generates code that assumes the safety rules were followed. - Low-Level Optimizations: Developers implement spinlocks to avoid OS overhead. However, spinlocks are notoriously hard to get right in safe Rust because they require precise memory ordering and thread parking logic.
- Undefined Behavior is Silent: Unlike a panic, unsafe concurrency bugs (data races) do not always crash immediately. They cause silent corruption or random crashes later, making them hard to debug.
Real-World Impact
- Segmentation Faults: The most likely symptom of this code is an immediate segfault when the threads try to push to the
StringinsideUnsafeCell, because the memory layout is being accessed incorrectly or theStringpointer is dangling. - Data Races: Even if it runs, two threads might overwrite memory locations if the
Acquire/Releaseordering is insufficient (thoughswapwithAcquire/Releaseis usually sufficient for a simple lock, the lack ofSyncprevents the compiler from verifying thread safety). - Deadlocks: While this specific example uses a timeout, naive spinlocks often fail to implement proper futex or thread parking, leading to CPU spinning and system unresponsiveness under load.
Example or Code (if necessary and relevant)
To fix the SpinLock to be usable and safe, it must properly handle thread wake-up (futex) and implement Sync/Send correctly. Below is a corrected version of the SpinLock that uses parking_lot logic or standard library primitives to be actually useful, rather than just spinning. Note: The original code’s UnsafeCell usage was incorrect; the fix requires wrapping the data in Mutex or implementing the lock correctly to own the data.
Here is a valid, working implementation of a SpinLock (using std::sync::atomic and thread::yield_now for portability, though parking_lot is preferred in production):
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
pub struct SpinLock {
locked: AtomicBool,
}
impl SpinLock {
pub const fn new() -> Self {
SpinLock {
locked: AtomicBool::new(false),
}
}
pub fn lock(&self) {
// Strict memory ordering is crucial.
while self
.locked
.swap(true, Ordering::Acquire)
{
// Ideally, use thread::park() here, but a spin loop with yield is:
thread::yield_now();
}
}
pub fn unlock(&self) {
self.locked.store(false, Ordering::Release);
}
}
// CRITICAL: Without this, you cannot share &SpinLock across threads.
unsafe impl Sync for SpinLock {}
unsafe impl Send for SpinLock {}
How Senior Engineers Fix It
Senior engineers avoid custom synchronization primitives unless absolutely necessary.
- Use Standard Primitives: Replace the custom
SpinLockwithstd::sync::Mutex. It handles OS-level futex waiting, poisoning, and correct memory ordering automatically. - Encapsulate Unsafe: If a spinlock is needed for performance, they wrap it in a safe API. They ensure the
UnsafeCellis private and the struct guaranteesSynconly if it holdsSenddata. - Use
parking_lot: In the Rust ecosystem, theparking_lotcrate providesMutexandRwLockthat are faster, smaller, and don’t require poisoning, avoiding the pitfalls ofstd::syncand raw spinlocks. - Prevent
UnsafeCellLeakage: They would not exposeUnsafeCell<String>. Instead, they would define a struct that contains the lock and the data, ensuring that access to the data always happens through the lock.
Why Juniors Miss It
- Misunderstanding
SendandSync: Juniors often think “shared state” just means a reference and a lock. They don’t realize thatSendmoves ownership between threads, whileSyncallows shared references (&T) between threads. - The “It Just Works” Illusion: They copy a minimal
AtomicBoollock example from a blog or book without understanding that the example was likely a teaching tool on atomics, not a production-ready lock. - Ignoring
UnsafeCellInvariants: They treatUnsafeCellas a magic bypass formutrequirements, forgetting that it removes compiler protection entirely. They assume the lock makes it safe, but without the compiler enforcing the lock usage (via a safe wrapper), it is easy to forget to lock it.