Concurrency in Rust
Write safe concurrent code with threads, Arc<Mutex<T>>, channels (mpsc), Send/Sync traits, and Rayon.
Spawning Threads
Rust’s threading model maps directly to OS threads. Each thread is an independent unit of execution with its own stack. The type system enforces that data crossing thread boundaries is safe — you cannot accidentally share a non-thread-safe type across threads. The compiler will reject the code at compile time, not crash at runtime.
use std::thread;
use std::time::Duration;
fn main() {
// thread::spawn takes a closure and runs it on a new OS thread
let handle = thread::spawn(|| {
for i in 1..=5 {
println!("spawned thread: {}", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..=3 {
println!("main thread: {}", i);
thread::sleep(Duration::from_millis(1));
}
// join() waits for the thread to finish and propagates any panic
handle.join().unwrap();
}
thread::spawn returns a JoinHandle. Call .join() to wait for the thread and propagate panics.
Moving Data into Threads
Threads require owned data. If a thread borrowed a variable, the variable’s owner might drop it while the thread is still running — a use-after-free. The move keyword on a closure transfers ownership of all captured variables into the closure, ensuring the thread owns everything it uses and the data lives long enough.
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5];
// move transfers ownership of data into the closure
// Without move, the borrow would dangle if main() returned first
let handle = thread::spawn(move || {
let sum: i32 = data.iter().sum();
println!("sum: {}", sum);
sum // threads can return a value via JoinHandle
});
let result = handle.join().unwrap();
println!("thread returned: {}", result);
}
Shared State: Arc<Mutex<T>>
When multiple threads need to read and write the same data, you need two things: shared ownership (Arc) and mutual exclusion (Mutex). Arc<T> (Atomically Reference Counted) is a thread-safe reference-counted pointer — cloning it gives you another handle to the same heap allocation. Mutex<T> ensures only one thread accesses the inner value at a time by blocking others until the lock is released.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Arc allows multiple threads to share ownership of the Mutex
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
// Clone the Arc to get another handle to the same Mutex
let counter = Arc::clone(&counter);
let h = thread::spawn(move || {
let mut num = counter.lock().unwrap(); // acquire the lock
*num += 1;
// lock is automatically released when `num` goes out of scope (RAII)
});
handles.push(h);
}
for h in handles {
h.join().unwrap();
}
println!("final count: {}", *counter.lock().unwrap()); // 10
}
Deadlock Prevention
Rust does not prevent deadlocks at compile time, but good practices keep them rare: keep lock scopes as small as possible, always acquire multiple locks in the same order across all threads, and prefer channels for communication over shared mutable state.
Channels — Message Passing
Channels implement the “share memory by communicating” philosophy. Instead of multiple threads reaching into shared state, one thread produces a value and passes ownership to another via a channel. This eliminates the class of bugs where multiple threads observe inconsistent intermediate state. std::sync::mpsc provides Multiple Producer, Single Consumer channels.
use std::sync::mpsc;
use std::thread;
fn main() {
// tx = transmitter (sender), rx = receiver
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let messages = vec!["hello", "from", "thread"];
for msg in messages {
tx.send(msg).unwrap(); // ownership of msg transfers through the channel
}
// tx is dropped here — the channel closes, rx will stop blocking
});
// Iterate until the channel is closed (all senders dropped)
for msg in rx {
println!("{}", msg);
}
}
Multiple Producers
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for id in 0..3 {
// Clone the sender for each producer thread — all share one receiver
let tx = tx.clone();
thread::spawn(move || {
tx.send(format!("message from thread {}", id)).unwrap();
});
}
// Drop the original sender so rx knows when all clones are gone
drop(tx);
for msg in rx {
println!("{}", msg);
}
}
sync_channel — Bounded Channel
A bounded channel applies backpressure: the sender blocks when the buffer is full, preventing a fast producer from overwhelming a slow consumer. This is the right choice for producer-consumer pipelines where you want to limit memory usage.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::sync_channel(2); // buffer holds at most 2 items
thread::spawn(move || {
for i in 0..5 {
println!("sending {}", i);
tx.send(i).unwrap(); // blocks when buffer is full — provides backpressure
}
});
thread::sleep(std::time::Duration::from_millis(50));
for val in rx {
println!("received {}", val);
}
}
Send and Sync Marker Traits
Send and Sync are the compile-time mechanism that makes Rust’s concurrency guarantees possible. They are automatically derived for most types and cannot be implemented incorrectly — the compiler verifies the rules. Any type that violates the rules (like Rc<T> which uses non-atomic reference counting) is automatically excluded from cross-thread use, and the error appears at the call site rather than as a runtime crash.
Send: Safe to transfer ownership to another thread.Sync: Safe to share a reference across threads (&TisSend).
fn requires_send<T: Send>(_: T) {}
fn requires_sync<T: Sync>(_: T) {}
fn main() {
// String is Send — fine to move to another thread
let s = String::from("hello");
requires_send(s);
// Rc<T> is NOT Send — it uses non-atomic reference counting
// Uncommenting the next two lines would be a compile error:
// let rc = std::rc::Rc::new(1);
// requires_send(rc); // ERROR: Rc<i32> cannot be sent between threads safely
}
Most types are automatically Send + Sync. Notable exceptions:
Rc<T>— notSend(useArc<T>)Cell<T>/RefCell<T>— notSync(useMutex<T>)- Raw pointers
*const T/*mut T— neither
RwLock<T> — Multiple Readers, Single Writer
Mutex grants exclusive access on every lock. If the data is read far more often than written, this is unnecessarily restrictive. RwLock allows any number of concurrent readers while still enforcing that writes are exclusive — a significant throughput improvement for read-heavy workloads like caches and configuration stores.
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let data = Arc::new(RwLock::new(vec![1, 2, 3]));
// Multiple readers can hold read locks simultaneously
let mut handles = vec![];
for _ in 0..3 {
let data = Arc::clone(&data);
handles.push(thread::spawn(move || {
let read = data.read().unwrap(); // shared read lock
println!("read: {:?}", *read);
}));
}
for h in handles { h.join().unwrap(); }
// Write lock is exclusive — waits for all readers to finish
data.write().unwrap().push(4);
println!("after write: {:?}", data.read().unwrap());
}
Atomic Types
For simple numeric operations like counters and flags, atomics are faster than a mutex because they use hardware-level atomic CPU instructions rather than OS locks. There is no blocking, no context switching, and no heap allocation. The Ordering parameter controls the memory visibility guarantees — SeqCst is the safest choice when in doubt.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
fn main() {
let counter = Arc::new(AtomicUsize::new(0));
let mut handles = vec![];
for _ in 0..10 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
// fetch_add is a single atomic CPU instruction — no lock needed
c.fetch_add(1, Ordering::SeqCst);
}));
}
for h in handles { h.join().unwrap(); }
println!("{}", counter.load(Ordering::SeqCst)); // 10
}
Rayon — Data Parallelism
Rayon makes data parallelism easy: swap .iter() for .par_iter() and Rayon automatically divides the work across all available CPU cores using a work-stealing thread pool. No manual thread management, no channels, no mutexes — just a one-word change to your iterator chain.
[dependencies]
rayon = "1"
use rayon::prelude::*;
fn is_prime(n: u64) -> bool {
if n < 2 { return false; }
if n == 2 { return true; }
if n % 2 == 0 { return false; }
let limit = (n as f64).sqrt() as u64;
!(3..=limit).step_by(2).any(|i| n % i == 0)
}
fn main() {
// Sequential — single core
let primes_seq: Vec<u64> = (2..100_000)
.filter(|&n| is_prime(n))
.collect();
// Parallel — all cores, identical output, one method change
let primes_par: Vec<u64> = (2u64..100_000)
.into_par_iter() // the only change from the sequential version
.filter(|&n| is_prime(n))
.collect();
println!("found {} primes", primes_par.len());
// Parallel map-reduce — sum of squares
let sum: u64 = (1u64..=1_000_000)
.into_par_iter()
.map(|x| x * x)
.sum();
println!("sum of squares: {}", sum);
}
Rayon also provides parallel sort:
use rayon::prelude::*;
fn main() {
let mut data: Vec<i32> = (0..1_000_000).map(|x| 1_000_000 - x).collect();
data.par_sort(); // parallel sort — significantly faster on large collections
println!("sorted: {} .. {}", data[0], data[data.len() - 1]);
}