Fearless Concurrency 🧵
Doing many things at once is where most languages get scary, and where Rust got its swagger. The trick: everything you've learned since Lesson 6 was secretly preparing you for this.
Why concurrency is feared
Your CPU has many cores; using them means running threads: several instruction streams at once. The danger: two threads touching the same data at the same time, a data race. Races are the worst kind of bug: invisible in testing, catastrophic at 3am, unreproducible by daylight. Most languages document "best practices" and wish you luck. Rust made the compiler responsible. If a program has a data race, it does not compile.
Spawning threads
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("side quest: step {i}");
thread::sleep(Duration::from_millis(50));
}
});
println!("main quest continues...");
handle.join().unwrap(); // wait for the side quest to finish
println!("all quests done!");
}
thread::spawn takes a closure (Lesson 14!) and runs it in parallel.
join() waits for it. Run it twice; the line order can differ.
That unpredictability is exactly why the next part matters.
Ownership meets threads: move
use std::thread;
fn main() {
let treasure = vec![1, 2, 3];
let handle = thread::spawn(move || { // move: closure takes ownership
println!("thread guards: {treasure:?}");
});
// println!("{treasure:?}"); // ❌ moved into the thread, hands off!
handle.join().unwrap();
}
Without move, the closure would borrow treasure,
but the compiler can't know how long the thread will live. Maybe longer than
main's variables! So it demands ownership transfer. Feel that? The
"annoying" ownership rules from Lesson 6 just quietly prevented a
use-after-free across threads, a bug class that has haunted C for fifty years.
Talking between threads: channels 📮
The Rust motto: "Do not communicate by sharing memory; share memory by communicating." A channel is a one-way pipe (many senders, one receiver):
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel(); // transmitter, receiver
for worker in 0..3 {
let tx = tx.clone(); // each worker gets a sender
thread::spawn(move || {
tx.send(format!("worker {worker} reporting in")).unwrap();
});
}
drop(tx); // close the original so the loop below can end
for message in rx { // receive until all senders gone
println!("📨 {message}");
}
}
Bonus elegance: send moves the value down the pipe
(ownership again!), so after sending, the worker can't secretly keep modifying
the message. The API makes cheating impossible, not just impolite.
Sharing state: Arc<Mutex<T>> 🔒
Sometimes threads genuinely need to update one shared value. Two tools, both of which you basically already know:
Mutex(mutual exclusion): a lock. One thread at a time may hold it and touch the data inside. It'sRefCell's hard-hat cousin: same "one writer" law, enforced with actual blocking.Arc(atomic reference counting):Rc's thread-safe twin, so many threads can co-own the Mutex.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut n = counter.lock().unwrap(); // take the lock...
*n += 1; // ...safely mutate...
})); // ...lock auto-released here
}
for h in handles {
h.join().unwrap();
}
println!("final count: {}", *counter.lock().unwrap()); // always 10
}
In C++ this program compiles happily without the lock and corrupts the count randomly. In Rust, try removing the Mutex and sharing a plain counter: the compiler rejects it. The phrase fearless concurrency isn't marketing: whole categories of concurrency bugs are compile-time errors, so refactoring threaded code stops being scary.
async/await (with runtimes like tokio): same safety
guarantees, different machinery. Learn threads first; async is a natural next
step once you're comfortable.
- Forgetting
moveon the closure; the compiler will tell you, with the fix. - Forgetting
join(): main ends, program exits, threads die mid-sentence. - Using
Rc/RefCellacross threads: refused at compile time; the thread-safe spellings areArc/Mutex.
Race... condition-free
Spawn two threads: one prints "tick 1..5", the other "tock 1..5", each sleeping 30ms between prints. Join both. Run it a few times. Does the interleaving change? Why is this randomness still safe?
Reveal solution
use std::thread;
use std::time::Duration;
fn main() {
let t1 = thread::spawn(|| for i in 1..=5 {
println!("tick {i}");
thread::sleep(Duration::from_millis(30));
});
let t2 = thread::spawn(|| for i in 1..=5 {
println!("tock {i}");
thread::sleep(Duration::from_millis(30));
});
t1.join().unwrap();
t2.join().unwrap();
}
The order varies (that's inherent to parallelism), but no data is shared, so no race exists. Unpredictable timing is fine; unpredictable data is the disease, and Rust cures it.
The worker pipeline
Spawn 4 workers that each compute a square (worker n sends
n * n) through a channel. Collect and sum the results in main.
(Mind the drop(tx) trick.)
Reveal solution
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for n in 1..=4 {
let tx = tx.clone();
thread::spawn(move || tx.send(n * n).unwrap());
}
drop(tx);
let total: i32 = rx.iter().sum(); // channels are iterators too!
println!("1+4+9+16 = {total}"); // 30
}
Shared crab census
Adapt the Arc<Mutex<...>> counter: make it an
Arc<Mutex<Vec<String>>>, have 5 threads each push
"crab #n", then print the final census. Predict the length.
Can you predict the order?
Reveal solution
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let census = Arc::new(Mutex::new(Vec::new()));
let mut handles = vec![];
for n in 1..=5 {
let census = Arc::clone(&census);
handles.push(thread::spawn(move || {
census.lock().unwrap().push(format!("crab #{n}"));
}));
}
for h in handles { h.join().unwrap(); }
println!("{:?}", census.lock().unwrap());
}
Length: always 5. Order: whoever grabbed the lock first (varies per run). Consistent data, flexible timing. Fearless.