Data Race
Data Race
Level 9 — Concurrency & Parallelism Simultaneous unsynchronized access where at least one is a write; impossible in safe Rust.
1. Prerequisites
std::thread::spawn— The function that introduces multiple threads (and thus the possibility of data races).- Ownership — The system Rust uses to prevent this bug.
Mutex<T>— The tool used to synchronize access and prevent data races.
2. Term Category
Undefined Behavior (Concurrency Flaw): A Data Race occurs when two or more concurrent threads access the exact same memory location without synchronization, and at least one access is a write.
3. Explanation
(1) Design Motivation — "Why does this concept exist?"
In languages like C and C++, unsynchronized memory mutation across threads produces undefined behavior—often resulting in non-reproducible "Heisenbugs", memory corruption, and security vulnerabilities.
Rust's ownership and borrowing model (&T aliased XOR &mut T exclusive) combined with Send and Sync auto traits guarantee at compile time that Data Races are impossible in Safe Rust.
(2) Reality Metaphor
Two blindfolded authors trying to edit the same sheet of paper simultaneously:
- Author A reads line 5 while Author B erases line 5 and writes new text over it.
- Author A reads half of the old string and half of the new string, resulting in corrupted gibberish.
(3) Rust Code Examples
Compile-Time Data Race Prevention in Safe Rust
use std::thread;
fn main() {
let mut data = vec![1, 2, 3];
// Thread A attempts to mutate data
// thread::spawn(move || { data.push(4); });
// Thread B attempts to read data at the same time
// thread::spawn(move || { println!("{:?}", data); }); // ❌ COMPILE ERROR! Borrow checker rejects aliased mutation!
}
4. Common Mistakes & Pitfalls
Mistake 1: Confusing Low-Level "Data Races" with High-Level "Race Conditions"
The mistake: Assuming Rust's data-race-freedom guarantees that logic timing bugs (race conditions) cannot occur.
Why it is wrong: Safe Rust prevents low-level memory corruption (data races). However, high-level logical race conditions (e.g. TOCTOU: checking balance then withdrawing in separate mutex locks) can still happen in Safe Rust.
Mistake 2: Assuming Multi-Step Atomic Operations Are Race-Condition Free
The mistake: Performing val.load() followed by val.store() across separate atomic calls and expecting atomic isolation.
Why it is wrong: Another thread can modify the atomic value between the .load() and .store() calls, creating a logical race condition.
Incorrect:
let current = atomic.load(Ordering::Relaxed);
atomic.store(current + 1, Ordering::Relaxed); // ❌ Race condition between load and store!
Fix:
atomic.fetch_add(1, Ordering::Relaxed); // Correct: Single atomic read-modify-write operation!
Mistake 3: Bypassing Borrow Checker Rules with unsafe Raw Pointers (*mut T) across Threads
The mistake: Dereferencing raw pointers concurrently inside unsafe blocks without synchronization.
Why it is wrong: Bypassing compile-time borrow checker checks re-introduces C-style data races and undefined behavior into Rust.
5. Practice Exercises
Exercise 1: High-Throughput Telemetry Aggregator (Lock-Free Data Race Prevention)
Scenario: Hundreds of concurrent worker threads process requests and must record telemetry metrics (total requests, error counts, active connections) without incurring heavy Mutex lock overhead or triggering unsynchronized data races.
Requirements:
- Implement
TelemetryAggregatorusingAtomicU64andAtomicI64. - Implement
record_request,connection_opened,connection_closed, andsnapshot. - Write unit tests validating lock-free multi-threaded metrics collection.
Answer
Implementation
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
#[derive(Debug, PartialEq, Eq)]
pub struct MetricsSnapshot {
pub total_requests: u64,
pub error_count: u64,
pub active_connections: i64,
}
pub struct TelemetryAggregator {
total_requests: AtomicU64,
error_count: AtomicU64,
active_connections: AtomicI64,
}
impl TelemetryAggregator {
pub fn new() -> Self {
Self {
total_requests: AtomicU64::new(0),
error_count: AtomicU64::new(0),
active_connections: AtomicI64::new(0),
}
}
pub fn record_request(&self, is_error: bool) {
self.total_requests.fetch_add(1, Ordering::Relaxed);
if is_error {
self.error_count.fetch_add(1, Ordering::Relaxed);
}
}
pub fn connection_opened(&self) {
self.active_connections.fetch_add(1, Ordering::Relaxed);
}
pub fn connection_closed(&self) {
self.active_connections.fetch_sub(1, Ordering::Relaxed);
}
pub fn snapshot(&self) -> MetricsSnapshot {
MetricsSnapshot {
total_requests: self.total_requests.load(Ordering::Acquire),
error_count: self.error_count.load(Ordering::Acquire),
active_connections: self.active_connections.load(Ordering::Acquire),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_concurrent_telemetry_aggregation() {
let aggregator = Arc::new(TelemetryAggregator::new());
let mut handles = vec![];
for thread_idx in 0..10 {
let agg = Arc::clone(&aggregator);
handles.push(thread::spawn(move || {
for iter in 0..100 {
agg.connection_opened();
let is_err = (thread_idx + iter) % 5 == 0;
agg.record_request(is_err);
agg.connection_closed();
}
}));
}
for handle in handles {
handle.join().unwrap();
}
let snap = aggregator.snapshot();
assert_eq!(snap.total_requests, 1000);
assert_eq!(snap.error_count, 200);
assert_eq!(snap.active_connections, 0);
}
}
Technical Explanation
- Unsynchronized mutation (
counter++) across threads causes data races and cache line corruption in languages like C/C++; Rust's borrow checker rejects shared&mut Tto prevent this at compile time. AtomicU64andAtomicI64wrap hardware atomic instructions, providing thread-safe interior mutability through shared references (&self).Ordering::Relaxedhandles metric increments without memory fences, whileOrdering::Acquiresynchronizes memory reads insnapshot().
Exercise 2: Multi-Account Financial Ledger (Eliminating TOCTOU Race Conditions)
Scenario: Safe Rust prevents data races (memory corruption) but does not automatically eliminate high-level race conditions (logical bugs like TOCTOU).
Requirements:
- Implement
AccountLedgerusingArc<Mutex<AccountState>>. - Implement
transfer_toacquiring both locks in a single atomic scope to eliminate TOCTOU bugs. - Order lock acquisitions by account ID (
self_id < target_id) to prevent AB-BA deadlocks. - Write unit tests validating transfer correctness and deadlock prevention.
Answer
Implementation
use std::sync::{Arc, Mutex};
use std::thread;
#[derive(Debug, PartialEq, Eq)]
pub enum TransactionError {
InsufficientFunds { balance: u64, requested: u64 },
SameAccount,
}
#[derive(Debug)]
pub struct AccountState {
pub id: u64,
pub balance: u64,
}
#[derive(Clone)]
pub struct AccountLedger {
state: Arc<Mutex<AccountState>>,
}
impl AccountLedger {
pub fn new(id: u64, initial_balance: u64) -> Self {
Self {
state: Arc::new(Mutex::new(AccountState {
id,
balance: initial_balance,
})),
}
}
pub fn id(&self) -> u64 {
self.state.lock().unwrap().id
}
pub fn balance(&self) -> u64 {
self.state.lock().unwrap().balance
}
pub fn transfer_to(&self, target: &AccountLedger, amount: u64) -> Result<(), TransactionError> {
let self_id = self.id();
let target_id = target.id();
if self_id == target_id {
return Err(TransactionError::SameAccount);
}
let (mut source_guard, mut target_guard) = if self_id < target_id {
let g1 = self.state.lock().unwrap();
let g2 = target.state.lock().unwrap();
(g1, g2)
} else {
let g2 = target.state.lock().unwrap();
let g1 = self.state.lock().unwrap();
(g1, g2)
};
if source_guard.balance < amount {
return Err(TransactionError::InsufficientFunds {
balance: source_guard.balance,
requested: amount,
});
}
source_guard.balance -= amount;
target_guard.balance += amount;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_concurrent_transfers_prevent_race_conditions() {
let acc1 = AccountLedger::new(1, 1000);
let acc2 = AccountLedger::new(2, 1000);
let mut handles = vec![];
for _ in 0..10 {
let a1 = acc1.clone();
let a2 = acc2.clone();
handles.push(thread::spawn(move || {
let _ = a1.transfer_to(&a2, 50);
}));
}
for _ in 0..10 {
let a1 = acc1.clone();
let a2 = acc2.clone();
handles.push(thread::spawn(move || {
let _ = a2.transfer_to(&a1, 30);
}));
}
for handle in handles {
handle.join().unwrap();
}
assert_eq!(acc1.balance(), 800);
assert_eq!(acc2.balance(), 1200);
}
}
Technical Explanation
- Data races refer to unsynchronized memory corruption; race conditions refer to logical timing/ordering errors.
- Holding locks for both accounts within a single critical section eliminates TOCTOU balance check races.
- Sorting lock acquisitions by account ID (
self_id < target_id) prevents AB-BA thread deadlocks.
Exercise 3: Dynamic Parallel Dispatch Pipeline with Auto-Trait Guards (Send & Sync)
Scenario: Building a thread-safe task processing engine where worker handles receive task items via channels and report processing status without data races.
Requirements:
- Implement
ParallelPipelineusingmpsc::channelandArc<Mutex<Receiver<Task>>>. - Cleanly shut down worker threads when task channels drop.
- Write unit tests validating parallel task execution.
Answer
Implementation
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
#[derive(Debug, PartialEq, Eq)]
pub enum TaskResult {
Success { task_id: u64, output: String },
Failure { task_id: u64, error: String },
}
pub struct Task {
pub id: u64,
pub payload: String,
}
pub struct ParallelPipeline {
worker_count: usize,
}
impl ParallelPipeline {
pub fn new(worker_count: usize) -> Self {
Self { worker_count }
}
pub fn execute_batch(&self, tasks: Vec<Task>) -> Vec<TaskResult> {
let (task_tx, task_rx) = mpsc::channel::<Task>();
let (result_tx, result_rx) = mpsc::channel::<TaskResult>();
let shared_rx = Arc::new(Mutex::new(task_rx));
let mut workers = vec![];
for _ in 0..self.worker_count {
let rx = Arc::clone(&shared_rx);
let tx = result_tx.clone();
workers.push(thread::spawn(move || loop {
let task = {
let lock = rx.lock().unwrap();
match lock.recv() {
Ok(t) => t,
Err(_) => break,
}
};
let result = if task.payload.contains("invalid") {
TaskResult::Failure {
task_id: task.id,
error: "Payload contains invalid data".to_string(),
}
} else {
TaskResult::Success {
task_id: task.id,
output: task.payload.to_uppercase(),
}
};
tx.send(result).unwrap();
}));
}
drop(result_tx);
for task in tasks {
task_tx.send(task).unwrap();
}
drop(task_tx);
for worker in workers {
worker.join().unwrap();
}
let mut results = vec![];
while let Ok(res) = result_rx.recv() {
results.push(res);
}
results
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parallel_pipeline_execution() {
let pipeline = ParallelPipeline::new(4);
let tasks = vec![
Task { id: 1, payload: "alpha_data".to_string() },
Task { id: 2, payload: "invalid_payload".to_string() },
Task { id: 3, payload: "beta_data".to_string() },
];
let results = pipeline.execute_batch(tasks);
assert_eq!(results.len(), 3);
}
}
Technical Explanation
SendandSyncauto traits guarantee thread safety, preventing single-threaded types (Rc,RefCell) from crossing thread boundaries.Arc<Mutex<Receiver<Task>>>allows multiple worker threads to safely pop task items from a shared channel.- Dropping
task_txcloses the channel and signals worker threads to exit cleanly uponErr(_).
6. Related Terms
Mutex<T>— The software tool that prevents Data Races by forcing threads to wait in a single-file line.AtomicTypes — The hardware tool that prevents Data Races by performing math in a single, uninterruptible cycle.
7. Key Takeaways
- A Data Race happens when two threads access the exact same memory simultaneously, and at least one is writing.
- Safe Rust guarantees that Data Races are impossible to compile.
- Rust prevents them using Ownership, Borrowing (
&mut),Send, andSync. - Data races (memory corruption) are distinct from Race Conditions (logical timing bugs).