Rust - Fearless Concurrency

Concurrent programming in traditional languages (C, C++, Go) often leads to subtle bugs that elude testing: data races, deadlocks, and torn reads.

Rust's concurrency model—often termed Fearless Concurrency—leverages the ownership and type systems to prove thread safety at compile time.

Data Races vs Race Conditions

To understand Rust's guarantees, it is critical to distinguish between these two concepts:

  • Data Race: Two or more threads concurrently access the same memory location, at least one of the accesses is a write, and there is no synchronization (e.g. mutex or memory barrier).
    • Rust's Guarantee: Safe Rust mathematically prevents data races at compile time. A program with a data race cannot compile.
  • Race Condition: A high-level logical timing bug (for example, Thread A checks an account balance, and before it can withdraw, Thread B withdraws the funds).
    • Logical race conditions depend on application business logic and cannot be eliminated purely by a type system.

The Foundation: Send and Sync Marker Traits

Thread safety in Rust is built on two built-in marker traits in std::marker:

pub unsafe auto trait Send {}
pub unsafe auto trait Sync {}
Marker Trait Semantics Invariant
Send Indicates ownership of a type can be transferred across thread boundaries. Safe to move value to another thread.
Sync Indicates it is safe to share references (&T) between multiple threads concurrently. T: Sync if and only if &T: Send.

Auto-Traits

Send and Sync are auto-traits:

  • If all fields of a struct implement Send and Sync, the compiler automatically derives Send and Sync for that struct.
  • If a type contains even a single field that is not thread-safe, the entire type automatically loses that trait.
       Type System Thread-Safety Matrix
+-------------------+----------+----------+-------------------------------------+
| Type              | Send?    | Sync?    | Reason                              |
+-------------------+----------+----------+-------------------------------------+
| `i32`, `String`   | Yes      | Yes      | Immutable or uniquely owned.        |
| `Rc<T>`           | **No**   | **No**   | Non-atomic reference counting.      |
| `Arc<T>`          | Yes      | Yes      | Atomic reference counting.          |
| `RefCell<T>`      | Yes      | **No**   | Non-atomic borrow tracking counter. |
| `Mutex<T>`        | Yes      | Yes      | Synchronizes access via OS mutex.   |
| `*const T`        | **No**   | **No**   | Raw pointers bypass borrow checker. |
+-------------------+----------+----------+-------------------------------------+

Why Rc<T> Cannot Cross Threads

If two threads hold cloned references to an Rc<T>, both threads could concurrently increment or decrement the reference count. Because Rc<T> uses plain integers (usize) rather than atomic operations, a CPU instruction pipeline could interleave reads and writes, resulting in a torn counter and an eventual premature free (Use-After-Free).

In Rust, attempting to pass an Rc<T> to std::thread::spawn fails during compilation:

error[E0277]: `Rc<i32>` cannot be sent between threads safely
  --> src/main.rs:6:19
   |
 6 |     thread::spawn(move || {
   |     ------------- ^^^^^^^ `Rc<i32>` cannot be sent between threads safely
   |     |
   |     required by a bound introduced by this call

The error is caught at build time, with zero runtime tracking or performance penalty.

Thread-Safe Shared State: Arc<Mutex<T>>

In C++ and Go, a mutex is an independent object placed next to the data it protects:

// Go / C++ pattern: Data and lock are decoupled
var mu sync.Mutex
var data int

mu.Lock()
data += 1
mu.Unlock() // Easy to forget or access `data` without `mu`!

In Rust, a Mutex<T> wraps and owns the data:

use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
    let counter_clone = Arc::clone(&counter);
    let handle = thread::spawn(move || {
        // Acquire lock: returns a MutexGuard
        let mut num = counter_clone.lock().unwrap();
        *num += 1;
        // Lock automatically released when `num` (MutexGuard) goes out of scope (RAII)
    });
    handles.push(handle);
}

for handle in handles {
    handle.join().unwrap();
}

println!("Result: {}", *counter.lock().unwrap());

Why Rust's Mutex Prevents Bugs Under the Hood

  1. No Access Without Locking: You cannot read or mutate the inner value T without calling .lock(). The method returns a MutexGuard<'_, T>, which implements Deref and DerefMut to expose T.
  2. Automatic Unlock (RAII): When the MutexGuard drops out of scope, its destructor unlocks the underlying OS mutex. Forgotten unlocks are impossible.
  3. Lock Poisoning: If a thread holding the lock panics, the mutex becomes "poisoned". Subsequent callers are alerted via Err(PoisonError) so they do not inadvertently operate on corrupted state.

Hardware Memory Ordering & Atomics

For lock-free systems programming, Rust provides std::sync::atomic types (AtomicBool, AtomicUsize, AtomicPtr) with explicit hardware memory orderings matching the C++20 memory model:

use std::sync::atomic::{AtomicBool, Ordering};

static READY: AtomicBool = AtomicBool::new(false);
static mut DATA: u64 = 0;

// Thread 1 (Producer)
unsafe {
    DATA = 42;
    // Release barrier: all previous writes become visible
    READY.store(true, Ordering::Release);
}

// Thread 2 (Consumer)
// Acquire barrier: synchronizes with the matching Release store
if READY.load(Ordering::Acquire) {
    unsafe {
        println!("Received: {}", DATA); // Guaranteed to see 42
    }
}

Memory Ordering Semantics

  • Relaxed: Guarantees atomicity of the single operation, but allows the compiler and CPU to reorder instructions. Useful for simple counters.
  • Acquire: Combined with load operations. Ensures that subsequent memory reads and writes cannot be reordered before this load.
  • Release: Combined with store operations. Ensures that all previous memory writes in the current thread are committed and visible to other threads before this store completes.
  • AcqRel: Used in read-modify-write operations (compare_exchange, fetch_add), acting simultaneously as an Acquire load and a Release store.
  • SeqCst (Sequential Consistency): The default and strictest ordering. Enforces a single global total order across all threads, inserting hardware memory fence instructions (such as MFENCE on x86 or DMB on ARM).

Message Passing

Following the principle of communicating sequential processes, Rust provides channels in std::sync::mpsc (Multiple Producer, Single Consumer) and high-performance crates like crossbeam-channel:

use std::sync::mpsc;
use std::thread;

let (tx, rx) = mpsc::channel();

thread::spawn(move || {
    let msg = String::from("packet payload");
    // Ownership of `msg` is moved into the channel!
    tx.send(msg).unwrap();
    // `msg` is no longer accessible here (compile error if used)
});

let received = rx.recv().unwrap();
println!("Got: {}", received);

Because send(msg) takes ownership of the value, the sending thread can never modify or read msg after sending it. Memory aliasing is cleanly transferred, eliminating concurrent access without runtime locking.

Recommended Reading
Sponsored
Programming Rust
Systems Rust
Programming Rust
By Jim Blandy, Jason Orendorff, Leonora TindallView on Amazon