Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
What will I learn
- You will learn how to spawn OS threads and wait for them with join handles;
- why you use
moveclosures to hand data to a thread, and how ownership makes data races impossible; - how to pass messages between threads with channels;
- how
ArcandMutexlet several threads share and mutate one value safely; - what "fearless concurrency" actually means, and why so many concurrency bugs become compile errors;
- how this compares to Python, where the GIL quietly serialises your threads anyway.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Rust toolchain (via rustup, from rustup.rs);
- The previous twelve episodes, especially ownership and the smart pointers
RcandRefCell; - The ambition to learn systems programming from the ground up.
Difficulty
- Beginner
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections
- Learn Rust Series (#8) - Traits & Generics
- Learn Rust Series (#9) - Modules & Crates
- Learn Rust Series (#10) - Lifetimes
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex (this post)
Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
Concurrency is where most languages quietly hand you a footgun: shared mutable state across threads, and a data race waiting patiently to ruin your afternoon. You write code that looks correct, it passes every test on your laptop, and then three weeks later it corrupts a value in production exactly once, on a Tuesday, and you never reproduce it again. Anyone who has debugged a real race knows the specific flavour of despair I am describing. Rust's headline claim is fearless concurrency, and having spent a lot of years fighting the alternative, I can tell you it is honestly not marketing. The very same ownership and borrowing rules that stopped dangling references back in episode 3 also make data races a compile error. Not a lint, not a warning, not a runtime check you might forget to enable -- your program simply does not build until the sharing is provably safe.
This episode is a tour of the everyday tools you actually reach for, and by the end you will watch the compiler catch a bug that would ship silently in almost any other language ;-) We take them in the natural order: first plain threads, then message passing, then genuinely shared state with Arc and Mutex. As always, though, we start with last episode's homework.
Solutions to Episode 12's exercises
Episode 12 was smart pointers -- Box, Rc and RefCell. There were three exercises, and here is how each lands with full code you can paste and run.
Exercise 1 asked you to build a Cons list of five integers, reusing the List enum from that episode, and write a recursive fn sum(list: &List) -> i32 that adds up every value by pattern-matching on Cons and Nil:
enum List {
Cons(i32, Box<List>),
Nil,
}
use self::List::{Cons, Nil};
fn sum(list: &List) -> i32 {
match list {
Cons(value, rest) => value + sum(rest),
Nil => 0,
}
}
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3,
Box::new(Cons(4, Box::new(Cons(5, Box::new(Nil))))))))));
println!("{}", sum(&list)); // 15
}
The key insight is that sum takes &List, a borrow, so the recursion walks the list without ever taking ownership or freeing anything. Each Cons(value, rest) arm adds its own value to the sum of everything after it, and the Nil arm returns 0 as the base case that stops the recursion. The Box is what makes the whole recursive type possible in the first place, exactly as we discussed last time -- without it the type would have infinite size.
Exercise 2 wanted you to create an Rc<String>, clone it three more times inside an inner { } block, print Rc::strong_count from inside the block, and then print the count again after the block ends to watch it fall back down:
use std::rc::Rc;
fn main() {
let original = Rc::new(String::from("shared"));
println!("start: {}", Rc::strong_count(&original)); // 1
{
let _a = Rc::clone(&original);
let _b = Rc::clone(&original);
let _c = Rc::clone(&original);
println!("inside block: {}", Rc::strong_count(&original)); // 4
} // _a, _b, _c dropped here
println!("after block: {}", Rc::strong_count(&original)); // 1
}
This is the reference count made visible. Inside the block there are four owners of the one string, so the count reads 4. The moment the block ends, the three clones go out of scope, each one decrements the count on its way out, and we are back to 1. Nobody freed anything by hand -- the count did the bookkeeping, and the String itself would only be freed at the instant the count hit zero.
Exercise 3 was the one that proves the point of RefCell: make an Rc<RefCell<i32>>, clone the handle, increment the value through one handle, then read it through the other and confirm both see the change:
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
let shared = Rc::new(RefCell::new(0));
let handle = Rc::clone(&shared);
*handle.borrow_mut() += 1; // mutate through the clone
println!("{}", shared.borrow()); // 1, seen through the original
}
Both shared and handle point at the same cell, so a mutation made through one is immediately visible through the other -- because there is only one integer, not two copies. That is the whole reason the Rc<RefCell<T>> pattern exists: Rc gives you the multiple handles, and RefCell lets you mutate through them. Right, homework cleared. On to threads!
Spawning a thread
You start a new operating-system thread with thread::spawn, handing it a closure that contains the work to do. The call returns a join handle, and calling join on that handle blocks the current thread until the spawned one has finished:
use std::thread;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("from the thread: {i}");
}
});
println!("from main");
handle.join().unwrap(); // wait here until the thread is done
println!("all done");
}
The important detail is the join. Without it, main might reach the end and tear down the entire process before the spawned thread has had a chance to run at all -- when main returns, the program exits, background threads and all. The join handle is how you say "wait right here until that work is complete", and it is your explicit synchronisation point. That unwrap on the end deserves a note too: if the spawned thread panics, join returns an Err, and unwrapping it turns the child's panic into a panic in the joining thread. In other words, a crash in a worker does not silently vanish -- you get told about it.
One thing that surprises newcomers: the two threads run genuinely in parallel on a multi-core machine, so the output order is not deterministic. Run it a few times and "from main" may print before, after, or tangled up with the thread's lines. That non-determinism is the entire nature of concurrency, and Rust does not pretend it away -- it just makes sure the non-determinism cannot corrupt your data.
Threads can return values
A join handle is not merely a "wait" signal. It also carries whatever the thread's closure returned. So a thread can go off, compute something expensive, and join hands the result straight back to you when it is ready:
use std::thread;
fn main() {
let handle = thread::spawn(|| {
(1..=100).sum::<u32>() // the closure's return value
});
let total = handle.join().unwrap(); // join gives it back
println!("the thread computed {total}"); // 5050
}
This is the simplest possible parallel computation: fire off some work, then collect the answer once it is done. The closure returns 5050, the sum of one to a hundred, and that value travels back out through join. Spread the same idea across several threads -- each summing a slice of a huge array, say -- and you have a genuine speed-up on multi-core hardware, with the main thread simply joining each handle and adding up the partial results. That naturally raises the harder question this whole episode circles around: what happens when threads need to share data rather than each own their own private piece? That is where ownership starts doing some heavy lifting.
Moving data into a thread
A spawned thread may outlive the scope that created it, so a closure that merely borrows a local variable would risk pointing at data that has already been dropped -- the dangling reference problem from episode 3, now with a thread attached. Rust refuses to allow it, and the fix is the move keyword, which transfers ownership of the captured data into the thread:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("thread received {data:?}"); // the thread now owns `data`
});
handle.join().unwrap();
// data is gone in main now: it was moved into the thread
}
Look at what just happened, because it is ownership doing concurrency safety for free. Because data was moved into the closure, main can no longer touch it -- try to use data after the spawn and the compiler stops you cold. And that is precisely the guarantee we want: if main cannot access the vector and the thread now owns it exclusively, there is simply no way for two threads to race on the same vector, because they do not both have it. The borrow checker you occassionally cursed back in episode 3 is now silently preventing an entire category of concurrency bug, using the exact same rules, no new machinery required. This is the theme of the whole episode in one example: the ownership model was never just about single-threaded memory safety -- it was always secretly a concurrency model too.
Channels: passing messages
Often the cleanest concurrent design is not to share memory at all, but to send messages between threads. There is a slogan the Rust and Go communities both love: "do not communicate by sharing memory; share memory by communicating". The standard library's mpsc module -- that stands for multiple producer, single consumer -- gives you a channel with two ends: a transmitter tx you send on, and a receiver rx you read from:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for i in 1..=3 {
tx.send(i).unwrap(); // send moves the value down the channel
}
});
for received in rx { // the receiver is an iterator; ends when tx drops
println!("got: {received}");
}
}
Two things make this safe and pleasant. First, the value is moved into send, so once you have sent something, the producing thread can no longer use it -- there is no lingering shared reference for anyone to race on. Ownership travels with the message. Second, the receiver rx implements Iterator, which is why we can loop over it directly with for; the loop yields each value as it arrives and ends automatically when the sending end is dropped and no more values can ever come. That "ends when the sender drops" behaviour is genuinely useful -- it means a for loop over a channel is a natural, race-free way to drain a producer until it is finished.
This message-passing style scales beautifully and sidesteps locks entirely, which is why it is often the first tool I reach for. If you have written any Go, the channel idea will feel instantly familiar -- Rust borrowed the concept wholesale and then made it memory-safe through ownership, so you cannot accidentally keep using a value after you have sent it away. You can also clone tx to get multiple producers all feeding one receiver, which is what the "multiple producer" in the name is promising.
Shared state: Arc and Mutex
Message passing covers a great deal, but sometimes you genuinely do need several threads to touch one value in place -- a shared counter, a shared cache, a shared configuration. For that, two pieces cooperate, and you almost always see them together as Arc<Mutex<T>>.
Arc<T> is the atomically reference-counted pointer. It is the thread-safe sibling of Rc from last episode: same idea of multiple owners of one value via a reference count, except the count is updated with atomic CPU instructions so that several threads incrementing and decrementing it cannot corrupt it. Mutex<T> provides mutual exclusion -- it is a lock, and only the thread currently holding that lock may reach the data inside. Put them together and the Arc lets many threads own the value while the Mutex ensures only one of them mutates it at any instant:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..10 {
let c = Arc::clone(&counter); // each thread gets its own owning handle
let handle = thread::spawn(move || {
let mut num = c.lock().unwrap(); // acquire the lock
*num += 1; // mutate the shared value
}); // lock released here as `num` (the guard) drops
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("final count: {}", *counter.lock().unwrap()); // 10
}
Walk through the mechanics, because every line is earning its place. Each iteration clones the Arc so the new thread gets its own owning handle to the shared counter -- remember, cloning an Arc bumps the atomic count, it does not copy the integer. Inside the thread, c.lock() blocks until this thread is the one holding the lock, and hands back a MutexGuard. We deref that guard with *num to reach the integer and increment it. Then, crucially, the guard num goes out of scope at the closing brace, its Drop runs, and the lock is automatically released -- there is no manual unlock to forget. The result is always ten, with no race, because the type system will not let you touch the integer without first holding the lock. In many languages that same "increment across ten threads" would occassionally lose an update and print 9 or 8; here it cannot, and you did not have to be clever to get it right.
Notice, by the way, that lock() returns a Result, which is why we unwrap. That is not bureaucratic noise: if a thread panics while holding the lock, the mutex becomes "poisoned", and every later lock() returns an Err to warn you that the protected data might be in a half-updated, inconsistent state. Rust surfaces that possibility in the type rather than letting you blunder on over corrupted data. It is the same "make the danger visible" philosophy we saw with error handling back in episode 6.
Why Rc and RefCell will not do here
You might reasonably ask why we did not simply reuse Rc<RefCell<T>> from last episode -- it also gives shared, mutable state, after all. The answer is that those two types are single-threaded on purpose. Rc's counter is a plain, non-atomic integer, so if two threads cloned or dropped the same Rc at the same moment, their increments could stomp on each other and corrupt the count -- leading to a value freed too early or never freed at all. Rust knows this, and it stops you at compile time, because Rc is deliberately not marked as safe to send between threads:
use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(5);
let d = Rc::clone(&data);
// thread::spawn(move || println!("{d}")); // would NOT compile: `Rc` is not Send
println!("{d}");
}
Uncomment that spawn line and the compiler refuses to build, pointing straight at a bound called Send that thread::spawn requires and that Rc does not satisfy. This is the entire idea of fearless concurrency compressed into one error message. Rust has two marker traits, Send (safe to move to another thread) and Sync (safe to share by reference across threads), and the compiler tracks them automatically for every type. Rc is neither; Arc is both. So the difference between a single-threaded tool and a thread-safe one is encoded directly in the types, and when you reach for the wrong one, the compiler does not shrug -- it routes you to the right one and tells you why. You do not need to memorise which types are thread-safe; you just try, and the borrow checker corrects you before the code ever runs.
Having said that, a word for the Python crowd, because this is where Rust genuinely leaps ahead. In Python, threads do share memory, but the Global Interpreter Lock (the famous GIL) means only one thread executes Python bytecode at a time. So your ten threads incrementing a counter never truly run in parallel -- they take turns, serialised by the interpreter, and you get no CPU speed-up from them at all on a multi-core machine (they are still useful for I/O waiting, just not for crunching numbers). Rust has no GIL. Those ten threads run on ten cores simultaneously, for real, and the compiler guarantees they cannot race. Real parallelism and safety at the same time -- that combination is quite some upgrade, and it is a big part of why people put up with the borrow checker in the first place ;-)
So what did we actually cover?
thread::spawnstarts a new OS thread from a closure and returns a join handle. Callingjoinblocks until that thread finishes, and it also hands back whatever the thread's closure returned (or the thread's panic, as anErr).- The
movekeyword transfers ownership of captured data into a thread, which is what makes it safe -- once the data is moved, the spawning scope cannot touch it, so no two threads can race on it. Ownership is the concurrency guarantee. - Channels (
std::sync::mpsc) let threads communicate by sending messages instead of sharing memory. The value is moved intosend, the receiver is an iterator, and the loop ends when the sender is dropped. This is often the cleanest design and avoids locks entirely. Arc<T>is the atomically reference-counted, thread-safe cousin ofRc, andMutex<T>is a lock giving one-writer-at-a-time access. TheArc<Mutex<T>>combination is the everyday pattern for shared, mutable state across threads, with the lock released automatically when the guard drops.RcandRefCellare single-threaded by design.Rcis notSend, so trying to move one into a thread is a compile error. The marker traitsSendandSynclet the compiler prove thread-safety for you and steer you toArcwhen you need it.
The through-line is the same one that has run through this whole series: Rust did not bolt concurrency safety on as an afterthought. It fell out, almost for free, from the ownership and borrowing rules we spent twelve episodes building. A data race is fundamentally two things happening to one piece of memory without coordination -- and "two things touching one piece of memory" is exactly what the borrow checker has been policing since episode 3. Point that same rulebook at threads and data races become as impossible to compile as dangling references. That is why Rust can call it fearless and actually mean it.
If you come from Python or JavaScript, the mental adjustment is that concurrency is no longer a category of bug you discover in production at 3am -- it is a category of bug you discover at compile time, in your editor, before you have even run the thing once. That trade is worth an enormous amount, and once you have felt it, going back feels a bit like driving without a seatbelt. Next time we start turning these primitives toward building real, structured programs rather than one-file demos, but one thing at a time ;-)
Exercises
Three exercises, gentle to chewier as always. Full solutions open the next episode -- play with them first, the compiler is a patient teacher.
- Spawn three threads, each of which prints its own number (0, 1 and 2). Collect their join handles in a
Vec, then join them all in a second loop. Run it a few times and notice the output order changes -- that is real parallelism, not a bug. - Build a channel where a spawned thread sends the numbers
1through5, and the main thread receives them, sums them, and prints the total (which should be15). Use thefor received in rxiterator style to drain the channel. - Take the
Arc<Mutex<i32>>counter from this episode and change it into anArc<Mutex<Vec<i32>>>. Spawn five threads, and have each one lock the mutex andpushits own id number into the shared vector. After joining them all, print the vector and confirm it contains all five ids (in some order).
De groeten, en tot de volgende keer! ;-)
Leave Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Zig Series (#124) - Consistent Hashing
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
- Learn AI Series (#143) - Continual Learning
- Learn Zig Series (#123) - LRU Cache
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn AI Series (#142) - AI Reasoning and Planning
- Learn Zig Series (#122) - Union-Find
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn AI Series (#141) - Robotics and Embodied AI
- Learn Zig Series (#121) - Topological Sort