12. Concurrency
📋 Jump to Takeaways🎁 In most languages, concurrency bugs hide until production. In Rust, data races are impossible — the type system catches them at compile time. No runtime cost, no sanitizer flags, no prayer. The compiler simply refuses to build code that could corrupt shared memory. This is "fearless concurrency."
Spawning Threads
Use std::thread::spawn to create a new OS thread. It takes a closure that becomes the thread's entry point.
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("spawned thread: {}", i);
thread::sleep(Duration::from_millis(100));
}
});
for i in 1..=3 {
println!("main thread: {}", i);
thread::sleep(Duration::from_millis(100));
}
handle.join().unwrap(); // wait for spawned thread to finish
// Output interleaves — both threads run concurrently
}thread::spawn returns a JoinHandle. Calling .join() blocks until that thread completes and returns its result (or propagates a panic).
Move Closures for Threads
Spawned threads might outlive the scope that created them. Rust forces you to move data into the thread closure so ownership is clear.
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
// data is now owned by this thread
let sum: i32 = data.iter().sum();
println!("sum: {}", sum); // sum: 6
});
// println!("{:?}", data); // ERROR: data was moved
handle.join().unwrap();
}Without move, the compiler rejects the code because it can't guarantee data lives long enough. This is the ownership system preventing a use-after-free at compile time.
Channels: Message Passing
Channels let threads communicate by sending messages. Rust's standard library provides mpsc — multiple producer, single consumer.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let messages = vec!["hello", "from", "thread"];
for msg in messages {
tx.send(msg).unwrap();
}
});
// rx.recv() blocks until a message arrives
for received in rx {
println!("got: {}", received);
}
// got: hello
// got: from
// got: thread
}The rx iterator ends when all senders are dropped. You can clone tx to have multiple producers sending to the same receiver.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
let tx2 = tx.clone();
thread::spawn(move || { tx.send("from thread 1").unwrap(); });
thread::spawn(move || { tx2.send("from thread 2").unwrap(); });
for msg in rx {
println!("{}", msg);
}
// Order depends on scheduling — both messages arrive
}Shared State: Arc and Mutex
When multiple threads need to read and write the same data, use Arc<Mutex<T>>. Mutex provides mutual exclusion (only one thread locks at a time). Arc is an atomically reference-counted pointer that allows shared ownership across threads.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("final count: {}", *counter.lock().unwrap()); // final count: 5
}You can't use a plain Rc across threads — the compiler rejects it because Rc doesn't implement Send. You can't use Mutex without Arc across threads because the mutex would be dropped when the spawning scope ends. The type system guides you to the correct combination.
Send and Sync Traits
Rust's thread safety guarantees come from two marker traits:
Send— a type can be transferred to another thread. Almost everything isSend.Sync— a type can be referenced from multiple threads.TisSyncif&TisSend.
These are auto-implemented by the compiler. Types like Rc<T> are not Send, and Cell<T> is not Sync. You never implement them manually in normal code — the compiler checks them for you.
use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(5);
// This won't compile:
// thread::spawn(move || {
// println!("{}", data);
// });
// ERROR: `Rc<i32>` cannot be sent between threads safely
// Fix: use Arc instead
use std::sync::Arc;
let data = Arc::new(5);
thread::spawn(move || {
println!("{}", data); // 5
}).join().unwrap();
}The compiler enforces Send and Sync at every thread boundary. You cannot accidentally share non-thread-safe data.
Channels vs Mutex: When to Use Which
| Use Case | Prefer |
|---|---|
| One thread produces, another consumes | Channel |
| Multiple writers updating shared state | Arc<Mutex<T>> |
| Pipeline / streaming data | Channel |
| Shared config or cache | Arc<Mutex<T>> or Arc<RwLock<T>> |
| Simple coordination (done signal) | Channel |
Channels enforce a "share by communicating" philosophy. Mutexes enforce a "communicate by sharing" model. When in doubt, start with channels — they're harder to deadlock.
Rayon: Easy Parallelism
The rayon crate turns sequential iterators into parallel ones with a single method change. It handles thread pool management and work-stealing automatically.
// Add to Cargo.toml: rayon = "1"
use rayon::prelude::*;
fn main() {
let numbers: Vec<i64> = (1..=1_000_000).collect();
// Just change .iter() to .par_iter()
let sum: i64 = numbers.par_iter().sum();
println!("sum: {}", sum); // sum: 500000500000
// Parallel map + filter
let results: Vec<i64> = numbers
.par_iter()
.filter(|&&x| x % 2 == 0)
.map(|x| x * x)
.collect();
println!("even squares count: {}", results.len());
}Rayon is the go-to choice for data parallelism. It uses all available CPU cores and requires no manual thread management.
Async/Await and Tokio
For I/O-bound work (network requests, file operations, databases), Rust offers async/await. Unlike threads, async tasks don't each require an OS thread — thousands can run on a small thread pool.
// Add to Cargo.toml: tokio = { version = "1", features = ["full"] }
#[tokio::main]
async fn main() {
let task1 = tokio::spawn(async {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
"task 1 done"
});
let task2 = tokio::spawn(async {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
"task 2 done"
});
let result1 = task1.await.unwrap();
let result2 = task2.await.unwrap();
println!("{}, {}", result1, result2); // task 1 done, task 2 done
}Async Rust is a large topic on its own. The key points: async fn returns a Future, .await yields control until the future completes, and you need a runtime like Tokio or async-std to execute futures. Use threads for CPU-bound parallelism and async for I/O-bound concurrency.
Practical Example: Parallel Web Scraper Pattern
Here's a pattern combining channels and threads for concurrent work:
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn process_item(id: u32) -> String {
thread::sleep(Duration::from_millis(50)); // simulate work
format!("result-{}", id)
}
fn main() {
let (tx, rx) = mpsc::channel();
let items: Vec<u32> = (1..=4).collect();
for item in items {
let tx = tx.clone();
thread::spawn(move || {
let result = process_item(item);
tx.send(result).unwrap();
});
}
drop(tx); // drop original sender so rx iterator ends
let results: Vec<String> = rx.into_iter().collect();
println!("{:?}", results); // ["result-1", "result-3", "result-2", "result-4"] (order varies)
}Notice we drop(tx) — the original sender — so the receiver knows when all senders are gone and stops iterating.
Key Takeaways
thread::spawncreates OS threads; usemoveclosures to transfer ownershipJoinHandle::join()waits for a thread to finish and propagates panics- Channels (
mpsc) enable safe message passing between threads Arc<Mutex<T>>enables safe shared mutable state across threadsSendandSyncare compiler-enforced — you can't accidentally share unsafe types- Channels suit producer/consumer patterns; mutexes suit shared caches and counters
- Rayon makes data parallelism trivial with
.par_iter() - Async/await handles I/O concurrency without one thread per connection
- Rust guarantees no data races at compile time — "fearless concurrency" is real