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);
}
}
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));
}
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);
}
}
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();
}
}
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);
}