22.6 Sharing Data Between Threads

Rust's standard library provides multiple thread-safe data types such as mutexes, read-write locks, condition variables, and atomic types for different shared-data scenarios.
How immutable data can be shared between threads using only Arc<T> (without the need for mutexes) was introduced in Chapter 19, where we discussed various types of Rust's smart pointers.

22.6.1 Mutex and Arc

To allow safe sharing of mutable data among multiple threads, you can combine Arc<T> (atomic reference counting) with Mutex<T> (mutual exclusion):

  • Arc<T>: An atomic reference-counted pointer, enabling multiple owners of the same data.
  • Mutex<T>: Permits only one thread at a time to access the protected data, preventing data races.

Mutex<T> is analogous to RefCell<T> but is thread-safe and suitable for concurrent contexts. When multiple threads need to mutate shared data, you typically wrap your data in an Arc<Mutex<T>>, clone the Arc, and move each clone to a separate thread:

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

fn main() {
    let shared_count = Arc::new(Mutex::new(0));
    let mut handles = vec![];
    for _ in 0..5 {
        let counter = Arc::clone(&shared_count);
        let handle = thread::spawn(move || {
            for _ in 0..10 {
                let mut guard = counter.lock().expect("A thread holding the lock panicked.");
                *guard += 1;
            }
        });
        handles.push(handle);
    }
    for handle in handles {
        handle.join().expect("One of the spawned threads has panicked.");
    }
    println!("Final count = {}", *shared_count.lock().unwrap()); // Expected: 50
}

While Box and Arc signify heap allocation, a Mutex is solely about locking.

Key points:

  • Arc::clone(&x) increments the atomic reference count, giving each thread its own handle to the same underlying data.
  • counter.lock() blocks until the mutex can be obtained and returns a Result<MutexGuard<T>, _>. This guard ensures exclusive access to the data while it is in scope. If another thread panicked while holding the lock, lock() returns an error. The MutexGuard acts like a reference to the protected data. Once the guard goes out of scope or is dropped, the lock is automatically released.
  • handle.join() returns Result<T, Box<dyn Any + Send + 'static>>. It is Ok if the thread completes normally and Err if it panics.

In C++, mutexes (also called locks) are used as well. However, the lock and the data it protects are typically separate objects. The programmer uses calls like mutex.Acquire() and mutex.Release() to mark the start and end of critical sections where data shared by multiple threads is modified. While one thread executes such a critical section, all other threads must wait before entering sections protected by the same mutex.
Separating the mutex instance from the data it protects, as well as manually acquiring and releasing the lock, can easily introduce serious errors.

22.6.2 RwLock (Read-Write Lock)

A read-write lock (RwLock<T>) behaves similarly to a Mutex<T>, but it allows multiple simultaneous readers or a single exclusive writer:

  • Any number of threads can hold the read lock concurrently, provided no thread is writing.
  • Only one thread can hold the write lock at a time, and no readers can hold the lock during a write.

RwLock can boost performance in read-heavy scenarios:

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

fn main() {
    let data = Arc::new(RwLock::new(vec![1, 2, 3]));
    let data_reader = Arc::clone(&data);
    let handle_reader = thread::spawn(move || {
        let read_guard = data_reader.read().expect("Failed to acquire read lock");
        println!("Reader sees: {:?}", *read_guard);
        // read_guard goes out of scope here
    });
    let data_writer = Arc::clone(&data);
    let handle_writer = thread::spawn(move || {
        let mut write_guard = data_writer.write().expect("Failed to acquire write lock");
        write_guard.push(4);
        println!("Writer appended '4'");
    });
    handle_reader.join().expect("Reader thread panicked");
    handle_writer.join().expect("Writer thread panicked");
    println!("Final data: {:?}", data.read().expect("Failed to acquire final read lock"));
}

22.6.3 Condition Variables

A condition variable (Condvar) is a synchronization primitive used to coordinate multiple threads by letting them wait until a specific condition is true. Condition variables typically work alongside a mutex to safely access shared state and signal other threads when a condition changes.

Below is a simple example showing how a condition variable can synchronize threads:

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

fn main() {
    // Create an Arc holding a tuple of a Mutex and a Condvar.
    let pair = Arc::new((Mutex::new(false), Condvar::new()));
    let pair2 = Arc::clone(&pair);
    // Spawn a thread that waits for a condition to be met.
    let waiter = thread::spawn(move || {
        let (lock, cvar) = &*pair2;
        let mut started = lock.lock().expect("Failed to lock the mutex");
        // Wait until the condition is true, releasing the lock while waiting.
        while !*started {
            started = cvar.wait(started).expect("Failed to wait on the condition variable");
        }
        println!("Condition met, proceeding...");
    });
    // Simulate some work in the main thread before notifying.
    thread::sleep(std::time::Duration::from_millis(500));
    {
        let (lock, cvar) = &*pair;
        let mut started = lock.lock().expect("Failed to lock the mutex");
        *started = true; // Signal that the condition is now true.
        cvar.notify_one(); // Wake up one waiting thread.
    }
    waiter.join().expect("Failed to join the waiter thread");
}

Explanation

  1. Shared State: A boolean guarded by a Mutex, paired with a Condvar, and wrapped in an Arc. This allows sharing between threads.
  2. Waiting with wait(): When a thread calls cvar.wait(guard), it releases the lock and blocks until it's notified. Upon waking, it re-acquires the lock.
  3. Spurious Wakeups: The while loop re-checks the condition after waking, ensuring correctness even if a thread wakes up unexpectedly.
  4. notify_one() vs. notify_all(): notify_one() wakes a single waiting thread. notify_all() wakes all waiting threads. Use whichever best fits your concurrency logic.

22.6.4 Rust's Atomic Types

In many concurrent scenarios, you may need operations that appear 'indivisible' (atomic) to all threads. Incrementing an integer, for example, involves multiple steps—reading, modifying, and writing the value. If another thread interjects, it can cause a race condition. Atomic types ensure operations happen atomically, preventing such interference.

For low-level lock-free concurrency or performance-critical code, Rust provides atomic types in the std::sync::atomic module, such as AtomicBool, AtomicUsize, and AtomicPtr. These types guarantee that reads and writes happen atomically, but they do not automatically provide higher-level synchronization like mutual exclusion.

Instead of the usual arithmetic and logical operators, atomic types expose methods that perform atomic operations—individual loads, stores, exchanges, and arithmetic operations—ensuring they happen as a single unit, even if other threads are also performing atomic operations on the same memory location.

Atomics have minimal overhead. Atomic operations never use system calls. A load or store often compiles to a single CPU instruction.

Example: Using a Global Atomic Counter

use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

static GLOBAL_COUNTER: AtomicUsize = AtomicUsize::new(0);

fn main() {
    let mut handles = vec![];
    for _ in 0..5 {
        let handle = thread::spawn(|| {
            for _ in 0..10 {
                GLOBAL_COUNTER.fetch_add(1, Ordering::Relaxed);
            }
        });
        handles.push(handle);
    }
    for handle in handles {
        handle.join().unwrap();
    }
    println!("Global counter: {}", GLOBAL_COUNTER.load(Ordering::SeqCst));
}

Here, GLOBAL_COUNTER is a static global variable accessible by all threads. Because it's atomic, multiple threads can safely increment it without risking data races.

A possible use case for atomics is informing child threads about a state change. For example, in a chess program, the main thread might set an atomic boolean to indicate that the human opponent gave up or lost patience, forcing the engine to make its move. You would share that atomic flag between threads by wrapping it in an Arc<AtomicBool>, then passing clones to the child threads.

Memory Orderings

Rust's atomic operations let you specify memory ordering to determine how they interact with other memory operations:

  • Relaxed: Fastest but imposes no ordering constraints on other operations.
  • Acquire/Release/AcqRel: Provide stronger partial ordering guarantees.
  • SeqCst: Imposes a strict global ordering across all sequentially consistent operations (the strongest guarantee).

Choosing the right ordering is crucial. In many simple counter scenarios, Relaxed is enough. However, more complex synchronization often benefits from Acquire, Release, or SeqCst to ensure correctness.

Rust atomics currently follow the same rules as C++20 atomics. For more information, see the nomicon.

22.6.5 Scoped Threads (Rust 1.63+)

Scoped threads, introduced in Rust 1.63, make it possible to spawn threads that borrow data from the parent thread's stack safely. These threads are guaranteed to complete before the scope ends, preventing dangling references.

use std::thread;

fn main() {
    let mut a = vec![1, 2, 3];
    let mut x = 0;
    thread::scope(|s| {
        s.spawn(|| {
            println!("hello from the first scoped thread");
            // Borrow `a` here — safe because the lifetime is tied to the scope.
            dbg!(&a);
        });
        s.spawn(|| {
            println!("hello from the second scoped thread");
            // Mutably borrow `x`. This is allowed because no other thread
            // uses it at the same time.
            x += a[0] + a[2];
        });
        println!("hello from the main thread");
    });
    // All scoped threads have finished at this point.
    a.push(4);
    assert_eq!(x, a.len());
}

Scoped threads can be joined inside of the spanned scope, similar to ordinary threads. spawn returns an instance of a ScopedJoinHandle on which we can call join() to get the optional return value of the executed closure or an error if the thread panicked:

use std::thread;

fn main() {
    thread::scope(|s| {
        let t = s.spawn(|| {
            panic!("oh no");
        });
        assert!(t.join().is_err());
    });
    
    thread::scope(|s| {
        let t = s.spawn(|| {
            47
        });
        println!("{}", t.join().unwrap()); // 47
    });
}

Note: Before Rust 1.63, scoped threads were provided by external crates like crossbeam.

Advantages of Scoped Threads

  • Safe Borrowing: Threads can borrow stack data without Arc or 'static lifetimes.
  • Automatic Joining: All spawned threads finish before the scope exits, preventing use-after-free.
  • Ergonomics: Reduces boilerplate for short-lived parallel tasks.

Keep in mind that only one thread can hold a mutable reference to a variable at a time. If multiple threads need to modify the same data, you still need synchronization (e.g., a mutex).