← all lessons
Concurrency · #11 of 13

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

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:

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.

Portrait of computer scientist Tony Hoare
Tony Hoare · b. 1934 British computer scientist who, in 1978, formalized Communicating Sequential Processes — the idea that concurrent programs should coordinate by passing messages, not by sharing memory. Rust's slogan “don't communicate by sharing memory; share memory by communicating” is CSP in one line. The same idea powers Erlang and Go's channels.

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:

spawn + join editable · real rustc
Open in Playground ↗ ready

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 panickedjoin 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:

borrowing across a thread boundary — fails editable · real rustc
Open in Playground ↗ ready

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.

add move — the thread now owns the data editable · real rustc
Open in Playground ↗ ready

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::mpscmulti-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:

mpsc: send from a worker, receive in main editable · real rustc
Open in Playground ↗ ready

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:

many producers, one consumer editable · real rustc
Open in Playground ↗ ready

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>>:

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

1978

Tony Hoare publishes Communicating Sequential Processes: concurrent programs should talk over channels, not poke at shared memory.

1986

Erlang, built at Ericsson for telephone switches, makes message-passing between lightweight processes the whole programming model — and stays up for years.

2009

Go ships with channels and goroutines as first-class features, popularizing CSP for a generation of server programmers.

2015

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

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.