<< Home

Chapter 11: Concurrency and Parallelism

Rust gives you predictable concurrency by enforcing ownership and borrowing rules at compile time. Threads communicate through channels, shared state is guarded with locks or atomics, and marker traits such as Send and Sync tell the compiler what may move across threads. When tasks scale beyond manual threading, libraries such as rayon offer data parallelism with minimal boilerplate.

Threads and channels

The standard library provides std::thread for spawning threads and std::sync::mpsc for message passing. A thread runs a closure on a new call stack; channels send values from one thread to another without sharing mutable memory.

Spawning work in threads

thread::spawn takes ownership of a closure and returns a JoinHandle. Calling join waits for the thread to finish and yields its result or an error.

use std::thread;

fn main() {
  let handle = thread::spawn(|| {
    let mut n = 0;
    for _ in 0..1_000 { n += 1; }
    n
  });

  let result = handle.join().unwrap();
  assert_eq!(result, 1_000);
}

Message passing with channels

Channels use a sender and receiver. The sender clones freely, while the receiver is unique. Sending moves values into the channel; receiving yields them one at a time.

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

fn main() {
  let (tx, rx) = mpsc::channel();

  thread::spawn(move || {
    for i in 0..3 { tx.send(format!("msg {i}")).unwrap(); }
  });

  for msg in rx {
    println!("{}", msg);
  }
}
💡 Channels avoid shared mutable state. If two threads only need to exchange data, a channel is simpler and often faster to reason about.

Send and Sync explained

Send means a type may be moved across threads. Sync means a type may be referenced from multiple threads at the same time. These traits are auto implemented for most types; the compiler infers them from their fields.

Send for ownership transfer

If a type implements Send, you may move it into a thread. Basic primitives and most smart pointers implement Send.

fn spawn_with_value(v: String) {
  std::thread::spawn(move || {
    println!("got {}", v);
  });
}

Sync for shared references

A type is Sync when &T may be shared across threads. Types that contain Cell or RefCell are not Sync because they allow mutation without locks.

// &usize is Sync, so Arc<usize> is shareable across threads
use std::sync::Arc;
use std::thread;

fn main() {
  let n = Arc::new(42_usize);
  let n2 = n.clone();
  thread::spawn(move || println!("{}", n2));
}
⚠️ You almost never implement Send or Sync by hand. Manual implementations require unsafe code; rely on the compiler to infer the traits unless you have a very specific low level use case.

Shared state with Mutex and RwLock

When threads must mutate the same data, wrap the value in a lock. Mutex permits one writer at a time; RwLock permits multiple readers or one writer. Both provide guards that release automatically through Drop.

Coordinating writes with Mutex

A Mutex<T> stores a value inside a lock. Accessing the value requires locking it; the guard holds a mutable reference until it is dropped.

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

fn main() {
  let shared = Arc::new(Mutex::new(0_i32));

  let workers: Vec<_> = (0..4).map(|_| {
    let s = shared.clone();
    thread::spawn(move || {
      for _ in 0..500 { *s.lock().unwrap() += 1; }
    })
  }).collect();

  for w in workers { w.join().unwrap(); }
  assert_eq!(*shared.lock().unwrap(), 2000);
}

Read heavy workloads with RwLock

RwLock supports multiple readers or one writer. It is useful when many threads read and updates are rare.

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

fn main() {
  let data = Arc::new(RwLock::new(vec![1, 2, 3]));

  {
    let read = data.read().unwrap();
    assert_eq!(read.len(), 3);
  }

  {
    let mut write = data.write().unwrap();
    write.push(4);
  }
}
💡 Locks add overhead. If many tasks only read immutable data, consider cloning shared values or using channels to avoid locking.

Atomics and Arc

Atomic types provide lock free operations on primitive values. They guarantee safe concurrent updates by using hardware atomic instructions. Use them when you need simple counters or flags without allocating a lock.

Atomic counters

Atomic integers support operations such as fetch_add, swap, and compare_exchange. Choose a memory ordering that matches your needs; SeqCst is the simplest and most consistent.

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

fn main() {
  let count = Arc::new(AtomicUsize::new(0));
  let mut handles = vec![];

  for _ in 0..4 {
    let c = count.clone();
    handles.push(thread::spawn(move || {
      for _ in 0..1_000 { c.fetch_add(1, Ordering::SeqCst); }
    }));
  }

  for h in handles { h.join().unwrap(); }
  assert_eq!(count.load(Ordering::SeqCst), 4_000);
}

Atomic flags and coordination

You can build simple signals with atomic booleans. These flags coordinate work between threads without storing additional state.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

fn main() {
  let flag = Arc::new(AtomicBool::new(false));

  let f2 = flag.clone();
  thread::spawn(move || {
    thread::sleep(Duration::from_millis(200));
    f2.store(true, Ordering::SeqCst);
  });

  while !flag.load(Ordering::SeqCst) {
    std::thread::yield_now();
  }
}
⚠️ Atomics are powerful and easy to misuse. When synchronisation involves more than simple flags or counters, use Mutex or RwLock to avoid subtle ordering bugs.

Data parallelism with rayon

Rayon parallelises iterator based computations. It splits data into chunks, processes them in parallel, then recombines the results. You write the same iterator chains you would in a single thread, except you call par_iter instead of iter.

Parallel iterators

Parallel iterators behave like ordinary iterators but schedule work across a thread pool. Rayon handles load balancing internally.

use rayon::prelude::*;

fn main() {
  let data: Vec<u64> = (1..=1_000_000).collect();
  let sum: u64 = data.par_iter().copied().sum();
  println!("sum = {}", sum);
}

Parallel transformations

You can combine map, filter, reduce, and other adapters exactly as you would with sequential iterators.

use rayon::prelude::*;

fn main() {
  let squares: Vec<u64> = (1..=20_000)
    .into_par_iter()
    .map(|n| n * n)
    .filter(|n| n % 3 == 0)
    .collect();

  assert!(squares.len() > 0);
}
💡 Rayon excels at pure computations. When work involves heavy I/O or fine grained locking, ordinary threads or async designs often scale more predictably.