22.1Threads
Everything you've written so far runs one instruction after another, top to bottom, a single line of execution. Modern computers have many processor cores, and a program can run several lines of execution at the same time, each on its own core. Each such line is a thread. Threads let a program do several things at once: download files while the interface stays responsive, process chunks of a big computation in parallel, serve many users simultaneously. This chapter is Rust's headline feature, "fearless concurrency", and it earns the name by making the worst concurrency bugs compile errors. We start with threads themselves.
Most beginner courses skip threads entirely, because in most languages concurrency is a minefield of subtle, intermittent bugs. Rust is the reason a gentle course can cover it: the same ownership and borrowing rules you've spent twelve chapters learning turn out to be exactly what's needed to make concurrency safe, and the compiler enforces them here too.
Spawning a thread
The standard library's thread::spawn starts a new thread running a closure (chapter 19, now load-bearing):
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..=5 {
println!("spawned: {i}");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..=3 {
println!("main: {i}");
thread::sleep(Duration::from_millis(1));
}
}
A possible run:
main: 1
spawned: 1
main: 2
spawned: 2
main: 3
spawned: 3
thread::spawn takes a closure and runs it on a new thread, concurrently with the code that follows. So main keeps going to its own loop while the spawned thread runs its loop, and the two interleave. The sleep calls just slow things down enough to see the interleaving. Run it several times and the exact order shifts, because the operating system schedules the two threads independently, and which one prints next is up to it. That non-determinism is the first thing to internalize about threads: you don't control the ordering unless you explicitly coordinate it.
Notice something unsettling about that output, though: the spawned thread printed only up to 3, not 5. That's the next problem.
When main ends, everything ends
When main returns, the whole program exits, immediately, even if spawned threads are still working. In the run above, main finished its three iterations and returned, and the spawned thread was killed mid-loop before reaching i = 4. The spawned thread isn't guaranteed to finish, or even to start. This is rarely what you want. You need a way to say "wait for that thread to finish before moving on."
Joining: waiting for a thread
thread::spawn returns a JoinHandle, a value representing the running thread. Calling .join() on it blocks, waits, until that thread finishes:
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=5 {
println!("spawned: {i}");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..=3 {
println!("main: {i}");
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap();
println!("all threads done");
}main: 1
spawned: 1
main: 2
spawned: 2
main: 3
spawned: 3
spawned: 4
spawned: 5
all threads done
Now main runs its loop, then hits handle.join(), which pauses main until the spawned thread has run all five iterations. Only then does all threads done print. The join returns a Result (the thread might have panicked), which we unwrap here. Where you place the join matters: putting it right after spawn (before main's own loop) would make main wait for the whole spawned thread before starting its loop, serializing them and defeating the point. Join when you actually need the results, which is usually near the end.
Capturing data: the move closure
A thread usually needs data from the code that spawned it. The obvious attempt fails instructively:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("from thread: {data:?}"); // error: may outlive `data`
});
handle.join().unwrap();
}error[E0373]: closure may outlive the current function, but it borrows `data`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `data`
7 | println!("from thread: {data:?}");
| ---- `data` borrowed here
|
help: to force the closure to take ownership of `data` (and any other referenced variables), use the `move` keyword
|
6 | let handle = thread::spawn(move || {
| ++++
Read what the compiler is worried about. The closure borrows data. But a spawned thread can outlive the function that created it, the thread is independent, and main might return while the thread is still running. If the thread borrowed data and main ended, dropping data, the thread would be left holding a dangling reference, exactly the use-after-free the borrow checker exists to prevent, now in a concurrent setting. The compiler can't prove the thread finishes before data is dropped, so it refuses.
And, as it does so well, it tells you the fix: move. Adding move (lesson 19.1) makes the closure take ownership of data, so the data moves into the thread and lives as long as the thread does:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("from thread: {data:?}");
});
handle.join().unwrap();
// `data` is owned by the thread now; main can't use it here.
}from thread: [1, 2, 3]
With move, data belongs to the thread. There's no borrow that could dangle, so the compiler is satisfied. This is why spawned closures are almost always move closures: the thread needs to own what it uses, because nobody can promise the spawner will outlive it. The same ownership rule from chapter 8, applied across a thread boundary, and the same dangling-reference protection from chapter 9.
Key insight
Threads don't introduce a new safety model; they reuse the ownership system you already know. "A spawned thread might outlive its spawner" is just "a value might outlive a reference to it" with a thread boundary in the middle, and move is the same answer as always: give the thread ownership so nothing dangles. This is the heart of fearless concurrency. The compiler checks thread safety with the exact rules that checked single-threaded memory safety.
Quiz time
Question #1
What does thread::spawn do, and what happens to a spawned thread if main returns before it finishes?
Show solution
thread::spawn starts a new thread running the given closure, concurrently with the code that follows. If main returns before the spawned thread finishes, the whole program exits immediately and the spawned thread is killed mid-work, it isn't guaranteed to complete (or even start). To wait for it, keep the JoinHandle and call .join().
Question #2
What does handle.join() do, and why does its placement matter?
Show solution
.join() blocks the current thread until the spawned thread finishes (and returns a Result, since the thread could have panicked). Placement matters because join is a wait: calling it immediately after spawn makes the current thread wait for the whole spawned thread before continuing, serializing them and defeating concurrency. You generally join later, when you actually need the thread's work to be done.
Question #3
Why does a closure passed to thread::spawn usually need move?
Show solution
Because a spawned thread can outlive the function that created it, so if its closure merely borrowed a local variable, that variable could be dropped while the thread is still using it, a dangling reference. The compiler rejects that (E0373). move makes the closure take ownership of the captured data, so the data lives in the thread for as long as the thread runs, and nothing can dangle. It's the chapter-8/9 ownership rules enforced across the thread boundary.
Threads can run independently, but real concurrent programs need them to communicate. The next lesson (22.2) covers channels: the message-passing approach where threads send ownership of values to each other, and the compiler ensures a value is never used after it's sent.