Data races¶
Level: 201 · for C and C++ programmers
One line: Two threads incrementing one counter loses about half the increments, and the build that loses them is the debug one — turn on the optimizer and the same bug produces the right answer every time.
The program¶
#include <pthread.h>
#include <stdio.h>
static long tally = 0;
static void *count_ballots(void *arg) {
(void)arg;
for (int i = 0; i < 1000000; i++) {
tally += 1; /* read, add, write */
}
return NULL;
}
int main(void) {
pthread_t a, b;
pthread_create(&a, NULL, count_ballots, NULL);
pthread_create(&b, NULL, count_ballots, NULL);
pthread_join(a, NULL);
pthread_join(b, NULL);
printf("%ld\n", tally); /* 2000000, surely */
return 0;
}
What it did¶
-O0 1149618 1122178 1090881 1344056 1097819 1014115
-O2 2000000 2000000 2000000 2000000 2000000 2000000
tally += 1 is three machine steps — read, add, write — and the scheduler may run the other thread between any two of them. When it does, both threads read the same value, both add one, both write, and one increment is gone. At -O0 that happens about half the time, so a million increments each yields roughly a million total instead of two.
Then read the second row. Nothing was fixed. At -O2 the compiler hoisted the counter into a register for the whole loop and wrote it back once, which happens to make each thread's million-increment loop close to atomic, so the two writes usually do not interleave. The bug is still there — it is a property of the source, not of the binary — and it has stopped being visible in the build you ship while remaining visible in the build you debug. A team that sees the wrong number, switches to a release build to "check performance", and finds the number correct will conclude something about the debugger.
ThreadSanitizer is the right tool and it works:
WARNING: ThreadSanitizer: data race (pid=85935)
Write of size 8 at 0x000109e04000 by thread T2:
#0 count_ballots data_races.c:9
Previous write of size 8 at 0x000109e04000 by thread T1:
#0 count_ballots data_races.c:9
Location is global 'tally' at 0x000109e04000
It reports the race even on a run where the arithmetic came out right, which is the important property — it is checking the access pattern, not the answer. What it cannot do is see a path your test did not take.
Why the standard allows it¶
A data race is two threads accessing one memory location, at least one of them writing, with no synchronisation between them. The C and C++ memory models say a program containing one has undefined behaviour — not "a wrong number", undefined, which is what licenses the register-hoisting above and much stranger transformations besides. Nothing in the type system distinguishes tally from a variable only one thread touches, so nothing can object.
What Rust does instead¶
The counter cannot be shared without saying how it is synchronised. Arc<Mutex<i64>> is the ordinary way to say it, and Arc is the atomically counted pointer — the one that may cross a thread boundary:
let tally = Arc::new(Mutex::new(0i64));
let tally_here = Arc::clone(&tally); // one handle per thread
thread::spawn(move || {
for _ in 0..100_000 {
*tally_here.lock().unwrap() += 1;
}
});
The full program runs the whole two-thread count three times:
Verified output of data_races.rs — regenerated by tools/run_examples.py, never hand-typed.
Three identical answers, and they would be identical on any machine, because the arithmetic is no longer a race the scheduler gets to decide.
The refusal¶
Drop the Mutex and let both closures touch the i64 directly:
error[E0499]: cannot borrow `tally` as mutable more than once at a time
--> share_a_counter.rs:11:27
|
6 | let a = thread::spawn(|| {
| - -- first mutable borrow occurs here
8 | tally += 1;
| ----- first borrow occurs due to use of `tally` in closure
10 | });
| |______- argument requires that `tally` is borrowed for `'static`
11 | let b = thread::spawn(|| {
| ^^ second mutable borrow occurs here
The same borrow rule that stops iterator invalidation in a single-threaded loop is what stops this: one mutable borrow at a time, and a data race requires two. There is no separate thread-safety analysis — the concurrency guarantee falls out of the rule you already met on day one, which is the part worth carrying away.
The traits that finish the job are Send (this type may move to another thread) and Sync (this type may be shared with one). They are marker traits the compiler derives automatically, and they are why Rc is refused across a thread boundary while Arc is allowed.
What this does not buy you¶
"No data races" is much narrower than "no concurrency bugs", and this is the row of the benefits list most often overread. A data race is that one specific thing: two threads, one location, one writer, no synchronisation. Still entirely available to you:
- Deadlock — take two locks in different orders on two threads. See forgotten unlock.
- Lost updates across two locks — read under lock A, decide, write under lock B, and something changed in between.
- Ordering bugs in your own logic — every thread synchronised correctly, and the answer still depends on who finished first.
The claim is that the compiler removes the category where the hardware gives you an answer nobody wrote. The rest is still design.
The middle bullet, as a program¶
That one is worth running, because it needs no second lock and no mistake with the first. One account holding 100, two clerks, each told to withdraw 100 only if the money is there:
// CHECK — take the lock, read, hand it straight back.
let affordable = { *balance.lock().unwrap() >= 100 };
thread::sleep(Duration::from_millis(50)); // holding nothing
// ACT — take the lock again, on a fact that has since expired.
if affordable {
*balance.lock().unwrap() -= 100;
}
Verified output of still_a_race.rs — regenerated by tools/run_examples.py, never hand-typed.
Every read and every write happened under the lock. There is no data race in it: Send and Sync are satisfied, the borrow checker never objects, and ThreadSanitizer has nothing to report. The account is overdrawn anyway — because the lock protected each access and nobody asked it to protect the decision. Mutex guards a location; holding one across the check and the act is a thing you have to choose, and affordable is a copy of a fact that stopped being true while nothing was held.
That is the whole distinction: a data race is about memory, a race condition is about time. Rust removed the first from safe code and left the second exactly where every other language leaves it. Worth knowing that CWE-362 is titled for the second — Concurrent Execution using Shared Resource with Improper Synchronization ('Race Condition') — so a table that marks it "prevented by Rust" is reading the narrow guarantee onto the broad category, which is the most common way this page's subject gets overclaimed in print.
If you are coming from another language¶
- C++ —
std::atomic<long>fixes this program, andstd::mutexfixes the general case, so the mechanisms are the same. The difference is what happens when you forget: a plainlongshared between threads compiles in C++ and does not compile here, because the closure would have to capture a mutable reference that another closure also holds. TSan and the borrow checker answer the same question at opposite ends — one on the paths a test took, one on all of them. - Python — the GIL made data races rare rather than impossible, and the free-threaded builds now arriving remove even that accident. What transfers is
threading.Lockas a concept; what changes is that here the lock contains the data, so there is no unlocked path to the variable to forget about. - ABAP — you do not write threads, so this bug is not in your vocabulary; the nearest thing is two work processes updating one database row, where the protection is a lock object or the database's own isolation. The idea that carries over is that
Mutex<T>is the lock and the row in one object, which is the arrangementENQUEUEcannot express.
Practice¶
Increment one counter from eight threads. First try it the C way — a shared mut counter and a closure per thread — and record the two error codes. Say why neither is a threading-specific check.
Then write it twice more, with a Mutex and with an atomic, and confirm both totals are exact. Finish by naming the two traits that make this checkable at compile time, and stating precisely what Rust does not promise about concurrency.
Solution
data_races_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: the race you cannot write.
//!
//! rustc --edition 2024 data_races_kata.rs -o /tmp/drk && /tmp/drk
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
const THREADS: u32 = 8;
const EACH: u32 = 10_000;
fn main() {
println!("THE C SHAPE");
println!(" static int counter; ... counter++; /* in 8 threads */");
println!(" counter++ is load, add, store. Two threads can load the same");
println!(" value and both store one more than it, so increments vanish --");
println!(" and the DEBUG build loses them while -O2 may keep the value in a");
println!(" register and produce the right answer, which is the worst");
println!(" possible way to learn about a race.");
println!();
println!("WRITING IT IN RUST: THE PROGRAM DOES NOT EXIST");
println!(" let mut counter = 0u32;");
println!(" thread::spawn(|| counter += 1); <- E0373 / E0499");
println!(" A closure sent to another thread may outlive this frame, so it");
println!(" cannot borrow `counter`; and two of them cannot hold `&mut` to");
println!(" one value at all. The check is the ordinary borrow checker --");
println!(" there is no threading special case.");
println!();
println!("1. THE MUTEX VERSION");
let counter = Arc::new(Mutex::new(0u32));
let mut handles = Vec::new();
for _ in 0..THREADS {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..EACH { *c.lock().unwrap() += 1; }
}));
}
for h in handles { h.join().unwrap(); }
println!(" {THREADS} threads x {EACH} increments = {}", *counter.lock().unwrap());
println!(" Exact, every run. The data is INSIDE the mutex, so there is no");
println!(" way to reach it without locking -- unlike a C mutex, which is a");
println!(" separate object you have to remember to take.");
println!();
println!("2. THE ATOMIC VERSION");
let atomic = Arc::new(AtomicU32::new(0));
let mut handles = Vec::new();
for _ in 0..THREADS {
let a = Arc::clone(&atomic);
handles.push(thread::spawn(move || {
for _ in 0..EACH { a.fetch_add(1, Ordering::Relaxed); }
}));
}
for h in handles { h.join().unwrap(); }
println!(" same total, no lock: {}", atomic.load(Ordering::SeqCst));
println!(" fetch_add is one indivisible operation, so the load-add-store");
println!(" window that the C bug lives in does not exist.");
println!();
println!("WHAT MAKES THIS CHECKABLE AT ALL");
println!(" Two marker traits. `Send` means a value may move to another");
println!(" thread; `Sync` means &T may be shared between them. u32 is both,");
println!(" &mut u32 is not Sync, Rc is neither -- so `thread::spawn`");
println!(" requiring Send is what rejects the bad programs, at the type");
println!(" level, with no runtime cost and no sanitizer.");
println!();
println!(" Note what is NOT promised: Rust prevents data races, not race");
println!(" CONDITIONS. Two correctly-locked threads can still interleave");
println!(" in an order your logic did not expect. The guarantee is about");
println!(" memory, and it is the half that produces impossible values.");
assert_eq!(*counter.lock().unwrap(), THREADS * EACH);
assert_eq!(atomic.load(Ordering::SeqCst), THREADS * EACH);
}
Verified output of data_races_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
THE C SHAPE
static int counter; ... counter++; /* in 8 threads */
counter++ is load, add, store. Two threads can load the same
value and both store one more than it, so increments vanish --
and the DEBUG build loses them while -O2 may keep the value in a
register and produce the right answer, which is the worst
possible way to learn about a race.
WRITING IT IN RUST: THE PROGRAM DOES NOT EXIST
let mut counter = 0u32;
thread::spawn(|| counter += 1); <- E0373 / E0499
A closure sent to another thread may outlive this frame, so it
cannot borrow `counter`; and two of them cannot hold `&mut` to
one value at all. The check is the ordinary borrow checker --
there is no threading special case.
1. THE MUTEX VERSION
8 threads x 10000 increments = 80000
Exact, every run. The data is INSIDE the mutex, so there is no
way to reach it without locking -- unlike a C mutex, which is a
separate object you have to remember to take.
2. THE ATOMIC VERSION
same total, no lock: 80000
fetch_add is one indivisible operation, so the load-add-store
window that the C bug lives in does not exist.
WHAT MAKES THIS CHECKABLE AT ALL
Two marker traits. `Send` means a value may move to another
thread; `Sync` means &T may be shared between them. u32 is both,
&mut u32 is not Sync, Rc is neither -- so `thread::spawn`
requiring Send is what rejects the bad programs, at the type
level, with no runtime cost and no sanitizer.
Note what is NOT promised: Rust prevents data races, not race
CONDITIONS. Two correctly-locked threads can still interleave
in an order your logic did not expect. The guarantee is about
memory, and it is the half that produces impossible values.
See also¶
- Sharing across threads:
Arc— why the atomic counter is what makes it legal - Marker traits —
SendandSync, and what the compiler does with them - Forgotten unlock — the guard, and the deadlocks it does not prevent
- The bugs Rust is a reply to — the other eight
Po polsku¶
Wyścig danych (data race) psuje intuicję, którą wynosi się z zajęć: że build debugowy zachowuje się „normalnie", a optymalizacja co najwyżej przyspiesza. Tutaj jest odwrotnie — to wersja debugowa gubi mniej więcej połowę inkrementacji, a -O2 daje poprawną odpowiedź za każdym razem. Program z błędem zaczyna działać, kiedy się go zoptymalizuje, więc sprawdzenie „na szybko" utwierdza w przekonaniu, że wszystko gra.
Norma C i C++ nie mówi tu „policzy źle", tylko zachowanie niezdefiniowane (undefined behaviour) — a to znaczy, że kompilator ma prawo założyć, że taka sytuacja nie zachodzi, więc sensu traci cały program, nie jedna zmienna. Warto przy okazji trzymać po polsku rozróżnienie, które łatwo się zlewa: wyścig danych to niezsynchronizowany dostęp dwóch wątków do tej samej pamięci, a sytuacja wyścigu (race condition) to szersze pojęcie o kolejności zdarzeń. Rust wyklucza pierwsze i nie obiecuje drugiego. Widać to na programie wyżej: dwóch urzędników, jedno konto, każdy dostęp pod blokadą — i konto i tak schodzi na minus, bo blokada chroniła odczyt, a nie decyzję.
Odmowa przychodzi nie z debuggera o trzeciej nad ranem, tylko z typów: cechy Send i Sync rozstrzygają, co wolno przenieść i współdzielić między wątkami. Cena jest uczciwa i widoczna — zamiast gołej zmiennej piszesz Arc<Mutex<T>> i musisz to zrobić wprost.
Szukaj po polsku: wyścig danych · zachowanie niezdefiniowane · cechy Send i Sync · rust fearless concurrency · rust data race