Same code, three runtimes
Two threads, one counter, one increment each per iteration. Three languages, three outcomes.
- Python. The GIL serialises the increments at runtime. The counter ends correct, but threading does not parallelise the work.
- C++. The program compiles and runs. The counter loses updates non-deterministically — the Shared Counter lab measures 74% loss on Apple M1 Pro, 4 threads.
- Rust. The program does not compile. The borrow checker rejects the code before it ever runs.
The contrast is the point: Rust moves the bug from a runtime trace to a compile-error message. The same lost-update behaviour Shared Counter exhibits would be a cannot borrow as mutable more than once rejection in Rust.
The failing Rust version
rust
// Won't compile. The borrow checker rejects the program.
use std::thread;
fn main() {
let mut counter: i64 = 0;
let mut handles = vec![];
for _ in 0..4 {
// No `move`: the closure captures `counter` by mutable reference.
// Four closures cannot each hold &mut counter at the same time.
handles.push(thread::spawn(|| {
for _ in 0..1_000_000 {
counter += 1;
}
}));
}
for h in handles { h.join().unwrap(); }
}
// rustc:
// error[E0373]: closure may outlive the current function, but it borrows
// `counter`, which is owned by the current function.
// error[E0499]: cannot borrow `counter` as mutable more than once at a time.
// note: `thread::spawn` requires the closure to be `Send + 'static`;
// a borrow of a stack-local variable is neither.
//
// The bug Rust prevents: the same lost-update race Shared Counter shows in C++.
// The aliasing-XOR-mutation rule applies across threads exactly as it applies
// within one thread — four `&mut counter` cannot coexist.
Why this rule eliminates data races
A data race requires three conditions: (1) two threads access the same memory, (2) at least one access is a write, (3) no synchronisation orders them. Rust's rule blocks conditions (1) and (2) jointly — two threads cannot simultaneously hold mutable references to the same data, because the borrow checker rejects the second &mut.
This is a type-system proof, not a runtime check. The compiler verifies the property statically; the runtime never sees the bad state. The formal-methods work behind this (the RustBelt project, Jung et al. POPL 2018) proves that safe Rust programs are free of data races by construction. The proof is mechanised in Coq.
The correct version — Arc<Mutex<T>>
rust
// Compiles. Correct under any thread schedule.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0i64));
let mut handles = vec![];
for _ in 0..4 {
let counter = Arc::clone(&counter); // shared ownership across threads
handles.push(thread::spawn(move || {
for _ in 0..1_000_000 {
let mut guard = counter.lock().unwrap();
*guard += 1; // critical section
} // guard released here (RAII)
}));
}
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap()); // exactly 4_000_000
}
Send and Sync — the type-system encoding
Two marker traits encode thread-safety in Rust's type system.
Send — a type is Send if ownership can transfer across thread boundaries. Most types are Send automatically. Rc<T> (single-threaded reference counting) is not Send because its refcount is non-atomic; using it across threads would be a data race.
Sync — a type is Sync if &T (a shared reference) can cross threads. Mutex<T> is Sync because the lock makes concurrent access race-safe. Cell<T> is not Sync because its interior mutation is non-atomic.
The trait bounds appear on thread::spawn: the closure must be Send + 'static. This is the compile-time check that the data flowing into the new thread is race-safe. The borrow checker enforces the rule on references; Send and Sync enforce it across thread boundaries.
The Rust hump
The first 4 weeks of Rust are about learning that the borrow checker is right and old habits are wrong. C++ programmers reach for shared mutation by reflex; Rust requires every such pattern to either go through Arc<Mutex<T>> (runtime cost, like a C++ shared_ptr + lock) or be redesigned around ownership transfer (no runtime cost, different mental model).
The hump is real and the curve is steep — write 5 lines of code, get 30 lines of compiler error. The pattern that works: read the error, treat the borrow checker as correct, and adopt Rust's framing. After 4 weeks the feedback loop is fast. After 8 weeks the ownership intuition transfers back to C++ — equivalent code written by a Rust-trained programmer contains fewer aliasing bugs.
Make this compile
Below is a Rust function that does not compile. The compiler error names the rule violation. The task: read it, name what would have happened if Rust let it compile, and propose the smallest fix.
fn split_and_sort(v: &mut Vec<i32>) {
let (first, second) = (&v[..v.len()/2], &v[v.len()/2..]);
v.sort(); // ❌ borrow error
process(first);
process(second);
}
(a) Which rule does this violate? (b) What runtime bug would happen if Rust let it compile? (c) Propose the smallest fix that preserves the function's intent.
Expected: (a) Aliasing-XOR-mutation. `first` and `second` are shared references (`&[i32]`) into `v`; `v.sort()` requires a `&mut Vec<i32>`. Shared and mutable references cannot coexist on the same data. (b) `sort` reorders elements; `first` and `second` are aliases into the original layout, so after `sort()` they would silently point at different elements than the caller expected — iterator invalidation in disguise. (c) Sort first, then take the slices: `v.sort(); let (first, second) = v.split_at(v.len()/2);`. If the original element positions must be preserved, copy the slice contents into owned `Vec<i32>` values before sorting.