ST
StateTrace
Visual Quant & Low-Latency Systems Lab
GitHub
Curriculum/rust-borrow-checker-vs-race

Rust Borrow Checker vs Race

memory model·L2 · idiom
Replacesthe belief that race conditions can only be prevented at runtime.

Rust's borrow checker rejects programs with data races at compile time by enforcing one rule — at any moment, a piece of data has either one mutable reference or any number of shared references, never both. Two threads cannot simultaneously mutate the same data; the program does not compile. The trade is a learning curve (the Rust hump) for elimination of an entire bug class.

The one rule

Rust's borrow checker enforces a single rule on every reference: at any moment, a piece of data has either one mutable reference (&mut T) or any number of shared references (&T) — never both. This is the aliasing XOR mutation rule. It applies uniformly: it prevents iterator invalidation in single-threaded code AND prevents data races in multi-threaded code. The same compile-time check produces both safety properties.

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.

Prerequisites
Unlocks
Bridges
  • ownership-rulesmodel → implementation
    Ownership turns exclusive mutation into a compile-time property instead of a runtime locking convention. The same L0 atom unlocks Send/Sync traits and Arc<Mutex<T>> patterns in Stage 7.
    Where this shows up
    • `&mut T` is the type-level proof that no other reference exists
    • Move semantics drop the source binding, preventing use-after-free
    • `Send` + `Sync` derive from ownership; data races become a type error
  • send-sync-traitsimplementation → model
    Send and Sync encode which values can cross threads and which shared references remain race-safe — the type-system encoding of the borrow checker's concurrency rules.
Done state

Evidence the learner produces, checks that confirm it.

Evidence
  • artifactTwo Rust programs written on the learner's machine: (1) a deliberately-racy version that does not compile (capture the rustc error message), (2) an `Arc<Mutex<T>>` or `AtomicI64` version that compiles and produces `final_count == 4_000_000` reliably across runs.
  • observable behaviorReads a rustc borrow-checker error and translates it into 'the runtime bug this prevents' — usually iterator invalidation, use-after-free, or a data race.
Checks
  • manualStates the aliasing-XOR-mutation rule in one sentence, without using the words 'safe' or 'unsafe'. Reference: at any moment, a piece of data has either one `&mut` reference or any number of `&` references, never both.
  • manualDistinguishes `Send` from `Sync` and gives a type that has one but not the other. Reference: `Rc<T>` is neither `Send` nor `Sync`; `Cell<T>` is `Send` but not `Sync`; `Mutex<T>` is both; primitive integer types are both. The distinction: `Send` is about transferring ownership across threads, `Sync` is about sharing `&T` references across threads.
  • manualGiven the lab's `split_and_sort` rustc error, names (a) the rule violated, (b) the runtime bug Rust prevents, (c) the smallest fix. (Reference answer in the lab's expectedObservation.)
References