The compiler stops the race before the code runs
You are building a concurrent web server. Two requests arrive at the exact same millisecond. Both handlers try to increment a global request counter. In Python, you hope the GIL saves you or you slap a lock on it and pray you didn't forget one. In C++, you get a data race, the counter corrupts, and the bug appears only under load in production.
In Rust, you cannot write that code. The compiler refuses to build the binary. It knows two threads are attempting to mutate the same memory location without synchronization. It rejects the program before it ever runs. You get a clear error message pointing to the exact line where the race would happen.
This is not a runtime check. This is not a warning. Rust prevents data races at compile time by making the type system aware of threads. The language enforces rules that make it impossible to have two threads access the same mutable data simultaneously unless you use a synchronization primitive that the compiler trusts.
Ownership is the first line of defense
Rust's ownership system tracks who controls a value. Every value has exactly one owner. When the owner goes out of scope, the value is dropped. This rule applies across threads.
When you spawn a thread, the closure you pass to thread::spawn captures variables from the surrounding scope. The compiler checks those captures. If the closure tries to mutate a variable that the main thread also mutates, the compiler rejects the code.
use std::thread;
fn main() {
let mut counter = 0;
// Thread::spawn requires the closure to be 'static and Send.
// The closure captures `counter` by move because it mutates it.
let handle = thread::spawn(|| {
counter += 1;
});
// Error: `counter` was moved into the closure above.
// The main thread cannot access it anymore.
counter += 1;
handle.join().unwrap();
}
The compiler rejects this with E0382 (use of moved value). The variable counter is moved into the thread. The main thread loses access. This prevents a race by forcing you to choose: move the data to the thread, or share it safely.
If you try to share a reference instead, the compiler checks whether the type allows sharing across threads. Most types do not allow mutable sharing. If you attempt to pass a mutable reference to a thread, the compiler blocks you.
use std::thread;
fn main() {
let mut data = vec![1, 2, 3];
// Attempting to pass a mutable reference to a thread.
// The closure captures `&mut data`.
let handle = thread::spawn(|| {
data.push(4);
});
// Error: `data` does not live long enough.
// The thread might outlive the reference, leading to a dangling pointer.
// Additionally, `&mut Vec` is not Send.
handle.join().unwrap();
}
The compiler rejects this because references have lifetimes. A thread can outlive the scope that created the reference. Allowing a thread to hold a reference to stack data in the main thread would create a use-after-free bug. The compiler prevents this by requiring 'static lifetimes for spawned threads, which means the data must own itself or live for the entire program.
Ownership forces you to make explicit choices about data flow. You cannot accidentally share mutable state. The compiler makes you declare your intent.
Send and Sync: The thread-aware type system
Rust prevents data races using two marker traits: Send and Sync. These traits tell the compiler how a type behaves with threads.
Send means a type can be transferred to another thread. If a type implements Send, you can move it across a thread boundary. Sync means a type can be shared between threads via a reference. If a type implements Sync, you can pass &T to multiple threads.
The compiler derives these traits automatically for most types. A struct is Send if all its fields are Send. A struct is Sync if all its fields are Sync. This recursive derivation means complex types inherit thread safety from their components.
Some types are explicitly not Send or not Sync. Rc<T> is not Send because its reference counting uses non-atomic operations. If two threads increment the count simultaneously, the count can corrupt. RefCell<T> is not Sync because its runtime borrow checking is not atomic. Mutex<T> is Sync because it uses atomic operations to guard access.
The compiler uses these traits to enforce safety. thread::spawn requires the closure to be Send. This ensures the closure and all captured variables can safely move to another thread. If you try to spawn a thread with a non-Send type, the compiler rejects the code.
use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(vec![1, 2, 3]);
// Error: `Rc` is not `Send`.
// The compiler rejects this with E0277 (trait bound not satisfied).
let handle = thread::spawn(|| {
println!("{:?}", data);
});
handle.join().unwrap();
}
The error message points to Rc and explains that it does not implement Send. The compiler knows Rc has unsafe behavior in a multi-threaded context and blocks you.
Under the hood, all interior mutability in Rust goes through UnsafeCell. UnsafeCell<T> is the only type that allows mutable access through an immutable reference. The compiler marks UnsafeCell as not Sync. This means any type containing UnsafeCell cannot be shared across threads unless it adds synchronization.
Mutex<T> wraps UnsafeCell<T> but implements Sync. It does this by using atomic instructions to guard the cell. The compiler trusts Mutex because the implementation guarantees exclusive access. This design ensures that the compiler can verify thread safety by checking trait bounds, without needing to analyze the implementation of every type.
Trust the traits. If a type is not Send, there is a reason. The compiler has identified a potential race condition and is protecting you.
Real-world pattern: Arc and Mutex
When you need to share mutable data across threads, you combine Arc<T> and Mutex<T>. Arc<T> provides shared ownership with atomic reference counting. Mutex<T> provides mutual exclusion.
Arc stands for Atomic Reference Counted. It works like Rc, but the counter updates are atomic. This makes Arc safe to use across threads. Arc is Send and Sync.
Mutex stands for Mutual Exclusion. It allows only one thread to access the data at a time. When you lock a mutex, you get a guard that provides mutable access. When the guard drops, the mutex unlocks.
use std::sync::{Arc, Mutex};
use std::thread;
/// Shared counter protected by Arc and Mutex.
fn main() {
// Arc allows multiple owners across threads.
// Mutex ensures only one thread writes at a time.
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
// Arc::clone increments the reference count.
// This is a shallow clone, not a deep copy.
// Convention: Use Arc::clone(&x) to signal shallow clone.
let counter_clone = Arc::clone(&counter);
let handle = thread::spawn(move || {
// Lock the mutex to get mutable access.
// lock() returns a Result, unwrap panics on poison.
let mut num = counter_clone.lock().unwrap();
*num += 1;
// Guard drops here, mutex unlocks.
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
// All threads finished. Lock and read the result.
println!("Result: {}", *counter.lock().unwrap());
}
The code compiles and runs safely. Arc allows the counter to be shared. Mutex ensures that only one thread increments the counter at a time. The compiler verifies that Arc<Mutex<i32>> is Send and Sync. It checks that the lock guard provides exclusive access.
The move keyword on the closure forces the closure to take ownership of counter_clone. This ensures the thread owns its Arc handle. When the thread ends, the Arc drops, decrementing the reference count. When the last Arc drops, the Mutex and the inner value drop.
Convention aside: The community prefers Arc::clone(&x) over x.clone(). Both compile and do the same thing. The explicit form signals to readers that this is a shallow clone of the pointer, not a deep copy of the data. This distinction matters in Rust where clone often implies deep copying.
Use Arc<Mutex<T>> when you need shared mutable state. The compiler ensures you cannot access the data without locking. You cannot forget to lock. You cannot hold the lock across thread boundaries because the guard is not Send. The borrow checker enforces the locking protocol.
When the compiler lets you shoot yourself in the foot
Rust prevents data races in safe code. If your code compiles and uses only safe Rust, it is free of data races. This is a mathematical guarantee.
You can break this guarantee with unsafe. unsafe blocks allow you to bypass the borrow checker. You can dereference raw pointers, call unsafe functions, and access mutable statics. The compiler trusts you inside unsafe blocks. If you introduce a race condition, the compiler will not stop you.
use std::thread;
fn main() {
let data = Box::new(42);
let raw = Box::into_raw(data);
let handle = thread::spawn(|| {
unsafe {
// SAFETY: This is undefined behavior.
// Two threads write to the same raw pointer without synchronization.
// The compiler cannot prevent this race.
*raw = 100;
}
});
unsafe {
*raw = 200;
}
handle.join().unwrap();
}
This code compiles. It has a data race. The behavior is undefined. The program might crash, produce garbage, or appear to work. The compiler cannot analyze unsafe code for races. You take full responsibility.
The lesson is clear. unsafe removes the guardrails. Use unsafe only when you are implementing a safe abstraction. Wrap the unsafe code in a function that enforces safety invariants. Document the invariants in a // SAFETY: comment. Keep the unsafe block as small as possible.
The community calls this the "minimum unsafe surface" rule. Expose a safe API. Hide the unsafe implementation. This way, users of your code get the compiler's guarantees. You bear the burden of correctness inside the abstraction.
Treat the SAFETY comment as a proof. If you cannot write down the invariants that make the unsafe block correct, you do not have a proof. Do not write the code.
Choosing your synchronization strategy
Rust gives you several tools for concurrent data sharing. Pick the right tool based on your access patterns. The compiler will enforce the rules for whatever you choose.
Use Arc<T> when multiple threads need to read the same data and the data never changes. Arc provides shared ownership with zero synchronization overhead for reads. The data must be immutable, or you must use interior mutability with synchronization.
Use Arc<Mutex<T>> when threads need to write to shared data and you can serialize the writes. Mutex ensures exclusive access. Use this when writes are frequent or when the critical section is small. The lock overhead is low for short critical sections.
Use Arc<RwLock<T>> when reads vastly outnumber writes and you want concurrent read access. RwLock allows multiple readers or one writer. Use this when readers would block each other under a Mutex. Profile first. RwLock has higher overhead per operation than Mutex. It only wins when contention is high and reads dominate.
Use thread-local storage (std::thread::LocalKey) when each thread needs its own independent copy of state. Thread-local storage avoids synchronization entirely. Use this for caches, random number generators, or request-scoped data. The compiler ensures each thread accesses only its own copy.
Use Send-only types when you want to force data movement and prevent accidental sharing. Types like Rc and RefCell are not Send. This prevents you from passing them to threads. Use this to enforce ownership transfer. If a type should never be shared, make it !Send by containing a non-Send field.
Use Sync-only types when you want to allow sharing but prevent movement. This is rare. Most types are both Send and Sync or neither. You can mark a type as !Send but Sync if it contains data that can be shared but not moved. This pattern appears in low-level systems code.
Reach for plain references when lifetimes are simple. The compiler tracks borrows. If you can structure your code so that data is owned by a single thread and passed via references within that thread, you get zero overhead. Avoid Arc and Mutex when ownership transfer suffices.
The decision matrix is simple. Immutable sharing gets Arc. Mutable sharing gets Arc<Mutex<T>> or Arc<RwLock<T>>. Independent state gets thread-local storage. Ownership transfer gets move. The compiler enforces the choice.
If it compiles, the race is gone. Period. The borrow checker does not just check borrows. It checks threads. It checks lifetimes. It checks trait bounds. It builds a graph of data flow and verifies that no two threads can mutate the same memory without synchronization. You get this guarantee for free. You do not need to run a race detector. You do not need to hope for the best. The compiler proves the absence of data races.
Trust the borrow checker. It usually has a point. When it rejects your code, it found a race condition that would have haunted you in production. Fix the code to satisfy the compiler. The result is correct by construction.