Doing Many Things at Once — Threads and Channels
Concurrency without data races, because the compiler won't let you write one
For fifty years, “make it concurrent” and “keep it correct” pulled in opposite directions. The moment two threads touched the same data, a whole new species of bug arrived — bugs that vanish when you add a print statement and reappear in production at 3am.
Rust’s claim is almost rude in its simplicity: the worst of those bugs will not compile. Not “is discouraged.” Will not compile.
A modern computer has many CPU cores, all able to run code at the same instant. A program that uses them — a web server answering thousands of requests, a game stepping physics and audio and rendering at once — is concurrent. Concurrency is enormously useful and famously dangerous, because two pieces of code touching the same data at the same time can corrupt it in ways that depend on timing and so are nearly impossible to reproduce.
Rust’s promise is fearless concurrency: the exact same ownership and borrowing rules you already learned for single-threaded code (lessons 4 and 5) also rule out data races between threads. You still have to think about other concurrency bugs. But the worst class — two threads scribbling over each other’s memory — is simply not expressible in safe Rust.
A few words you’ll need
- Thread: one running sequence of instructions. Every program starts with one thread (the “main” thread). You can ask the operating system for more, each running its own code in parallel.
- OS thread: a thread the operating system gives you. It costs a little to create (some stack memory, some bookkeeping) but it can genuinely run on another core. Rust’s
std::thread::spawnmakes one. - Data race: two threads reach the same memory at the same time and at least one is writing. The result is undefined — garbage, a crash, a security hole. This is the bug Rust deletes.
- Deadlock: two threads each waiting for something the other holds, so neither moves. A logic bug, not a memory bug — Rust does not prevent it (no language does, in general).
- Channel: a one-way pipe between threads. One end sends values; the other receives them. Threads coordinate by passing messages instead of sharing memory.
How ownership becomes thread safety
Back in lesson 5 you met the borrow checker’s core rule: at any moment a value can have many readers, or one writer, but never both. That rule is exactly what stops a data race — a data race is “two accessors, one of them writing.” So Rust didn’t need a new mechanism for threads. It reused the one you already fought.
Two marker traits carry that rule across thread boundaries:
Send— a type isSendif a value of it can be safely moved to another thread. Almost everything isSend. The famous exception isRc<T>, the cheap single-thread reference counter from lesson 6: its count isn’t safe to bump from two threads, soRcis deliberately notSend.Sync— a type isSyncif it’s safe to share by reference across threads (precisely:&TisSend). AMutex<T>isSync; a plainCell<T>is not.
You almost never implement these by hand — the compiler derives them from your fields automatically. You meet them in API bounds. Here’s the real signature of thread::spawn:
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
Read the F: Send + 'static part as a sentence: “the closure you hand me must be safe to move to another thread, and must not borrow anything from the calling thread that could disappear out from under it.” Break either rule and the build fails. The data-race-shaped program never reaches main.
Try it: spawn a thread and join it
thread::spawn takes a closure and runs it on a fresh OS thread. It hands back a handle you can .join() to wait for that thread and collect whatever it returned. Hit Run — notice the main thread prints its line without waiting, then .join() blocks until the worker is done:
The worker returns 15 (that’s 1+2+3+4+5), and .join() is how main gets it. The .unwrap() handles the rare case that the thread panicked — join returns a Result, exactly like the error handling from lesson 10.
The error that teaches the rule
Here’s the heart of the lesson. The program below tries to use a String from main inside a spawned thread. It looks innocent. Hit Run and read the error:
You get **error[E0373]: closure may outlive the current function, but it borrows \greeting`**. The compiler's reasoning: the spawned thread might keep running after main's frame is gone, so a *borrow* of greeting` could outlive the value itself — a use-after-free in waiting. It refuses, and even suggests the fix.
That fix is the keyword move. Putting move in front of the closure changes capture from borrow to take ownership: the String is moved into the thread, which now owns it outright, and the lifetime problem evaporates.
Same code, one word added, and it compiles. This is the whole pattern: move is how you hand data across a thread boundary instead of lending it. Because ownership moves, main can no longer touch greeting — and so two threads can never both hold it. The data race isn’t caught; it was never representable.
Channels: coordinate without sharing
Moving a single value into one thread is fine, but real programs need a stream of values flowing between threads. That’s a channel. The standard library’s is std::sync::mpsc — multi-producer, single-consumer. You get two ends: a sender tx and a receiver rx.
The spawned thread sends; main receives by iterating the receiver, which simply ends when the sender is dropped:
Notice the move again: the closure takes ownership of tx. Each value is moved into send and moved back out at rx — at no point do two threads hold the same String. That’s the CSP idea made concrete: the data is never shared, only handed off.
The “multi-producer” part means you can clone the sender so several threads feed one receiver. Here four workers each compute a square and send it; main collects them. Because the threads finish in an unpredictable order, we sort before printing so the output is stable:
That prints squares: [0, 1, 4, 9]. The drop(tx) matters: rx keeps yielding until every sender is gone, so we drop the original (the clones live in the threads and vanish as those finish).
When you truly need shared state: Arc<Mutex<T>>
Sometimes many threads really must read and write one piece of data. The standard combination is Arc<Mutex<T>>:
Arc<T>is an atomic reference counter — likeRcfrom lesson 6, but its count is safe to update from multiple threads (that’s the “atomic” part), soArcisSend. It lets several threads share ownership of one heap value.Mutex<T>wraps a value and only lets you reach it through.lock(), which hands back a guard. One thread holds the guard at a time; everyone else waits their turn.
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0));
// ...inside each thread:
let mut n = counter.lock().unwrap();
*n += 1;
// the lock releases automatically when `n` (the guard) is dropped
The guard releases the lock when it goes out of scope — through the same Drop mechanism as everything else in lesson 4. You cannot forget to unlock; there’s no unlock call to forget. (In C, a forgotten unlock is a classic deadlock; Rust deletes that mistake too.)
Why Rc isn't Send but Arc is — the one-line difference
Rc<T> and Arc<T> are almost the same type. Both keep a count of how many handles point at a shared value and free it when the count hits zero. The only meaningful difference is how that count is updated. Rc uses an ordinary += 1, which is fast but not safe if two threads do it at once — two increments can interleave and lose a count, leading to a premature free (a use-after-free). Arc uses an atomic increment, a CPU instruction that’s indivisible even across cores, at a small speed cost.
Here’s the beautiful part: you don’t have to remember any of this. Rc is marked not-Send, so the moment you try to move one into thread::spawn, the compiler stops you and effectively says “use Arc.” The type system turns a subtle, timing-dependent memory bug into a named, fix-at-compile-time error. That’s the entire trick of Send and Sync — encode “safe to cross threads?” as a property the compiler checks, so the dangerous version never builds.
A short history of message-passing
Tony Hoare publishes Communicating Sequential Processes: concurrent programs should talk over channels, not poke at shared memory.
Erlang, built at Ericsson for telephone switches, makes message-passing between lightweight processes the whole programming model — and stays up for years.
Go ships with channels and goroutines as first-class features, popularizing CSP for a generation of server programmers.
Rust 1.0 arrives with fearless concurrency: the borrow checker makes data races a compile error, and Send/Sync police which types may cross threads.
Where to go from here
This lesson covered OS threads — the primitive. Larger Rust services often use async instead: a single OS thread juggling thousands of tasks by suspending each at the right moment (a network read, a timer). The words change — Future instead of Thread, .await instead of .join(), runtimes like tokio instead of thread::spawn — but the ownership rules are identical. Once Send, Sync, and Arc<Mutex<T>> live in your head, async feels far less foreign.
Async is its own substantial topic and isn’t in this curriculum yet. For now, threads plus channels are enough to write real concurrent programs that don’t race.
Key takeaways
- A thread is one running sequence of code; many threads can run on many cores at once.
thread::spawnruns a closure on a new OS thread;.join()waits for it and collects its return value.- A closure that uses outside data across a thread boundary needs
move— borrowing fails witherror[E0373]because the thread could outlive the borrow. Send= safe to move between threads;Sync= safe to share by reference. Both are auto-derived;Rcis notSend, butArcis.- Channels (
std::sync::mpsc) pass typed messages so data is transferred, never shared — the usual clean choice. - For genuinely shared mutable state, use
Arc<Mutex<T>>; the guard unlocks on drop, so you can’t forget. - Data races are a compile error. Deadlocks and logic races are still yours to avoid — but the worst class of concurrency bug is gone.
Every other language asks you to be careful with threads and quietly punishes you when you slip. Rust asks you to be careful with ownership — and then hands you concurrency for free, because the rule that stops a use-after-free is the same rule that stops a data race.
One mechanism, two guarantees. That is what “fearless” means: not that you stop thinking, but that the compiler refuses to let you ship the bug you were afraid of.