Data Races
A data race occurs when two or more threads access the same memory location concurrently, at least one of the accesses is a write, and at least one of the accesses is unsynchronized (neither protected by a mutual-exclusion lock nor performed using atomic instructions). In Rust (as in C and C++), a data race is immediate undefined behavior—and Safe Rust guarantees at compile time that data races are impossible.
The Three Ingredients Required for a Data Race
All three conditions must hold simultaneously for a data race to occur. Safe Rust breaks at least one of these three conditions on every memory access:
The Data Race Triangle (Break ANY Edge to Prevent Data Races):
(1) Same Memory Location Accessed by >= 2 Threads
/ \
/ \
/ \
(2) At Least One Access Is a Write -------- (3) Unsynchronized (Non-Atomic / Unlocked)
| Condition Required for a Data Race | How Safe Rust Prevents It |
|---|---|
| 1. Shared across threads | Types without Send/Sync (Rc<T>, Cell<T>, RefCell<T>) cannot cross thread boundaries (E0277) |
| 2. Concurrent write + read/write | Borrow checker enforces many &T (read-only) XOR one &mut T (exclusive write); &mut T cannot alias |
| 3. Unsynchronized mutation | Shared mutation across threads is only possible through Sync types (Atomic*, Mutex<T>, RwLock<T>) that synchronize all accesses |
Compiler Rejection of an Unsynchronized Cross-Thread Write (E0277 / E0382)
If two threads attempt to increment the same variable without synchronization, Safe Rust rejects the program at compile time:
- Attempting to capture
&mut countin two threads fails withE0499(cannot borrow count as mutable more than once at a time). - Attempting to share
Cell<u64>across threads fails withE0277(Cell<u64> cannot be shared between threads safely):
// ❌ does not compile
use std::cell::Cell;
use std::thread;
fn main() {
let counter = Cell::new(0_u64);
thread::scope(|s| {
// Error E0277: `&Cell<u64>` is NOT `Send` because `Cell<u64>` is `!Sync`!
s.spawn(|| counter.set(counter.get() + 1));
s.spawn(|| counter.set(counter.get() + 1));
});
}
error[E0277]: `Cell<u64>` cannot be shared between threads safely
--> src/main.rs:9:17
|
9 | s.spawn(|| counter.set(counter.get() + 1));
| ----- ^^ `Cell<u64>` cannot be shared between threads safely
| |
| required by a bound introduced by this call
|
= help: the trait `Sync` is not implemented for `Cell<u64>`
= note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` or `std::sync::atomic::AtomicU64` instead
= note: required for `&Cell<u64>` to implement `Send`
Replacing Cell<u64> with AtomicU64 (as suggested directly by the compiler help note!) makes the concurrent increments lock-free and completely free of data races:
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
fn main() {
let counter = AtomicU64::new(0);
thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| {
for _ in 0..1_000 {
counter.fetch_add(1, Ordering::Relaxed);
}
});
}
});
println!("race-free atomic total: {}", counter.load(Ordering::Relaxed));
}
race-free atomic total: 4000
Data Races vs. General Race Conditions
It is important to distinguish a data race (low-level undefined behavior on unsynchronized memory bytes) from a logical race condition (high-level non-deterministic ordering of synchronized operations):
| Property | Data Race | Logical Race Condition |
|---|---|---|
| Level | Memory model / hardware byte access | Application business logic / lock ordering |
| Causes Undefined Behavior (UB)? | Yes (tearing, register caching, optimizer corruption) | No (memory remains valid and well-defined) |
| Possible in Safe Rust? | Impossible (prevented at compile time) | Possible (e.g., releasing a lock between a check and an update — TOCTOU) |
| How to prevent | Enforced automatically by Send, Sync, and borrow checker |
Hold a single MutexGuard across the entire check-and-update transaction, or use atomic compare_exchange |