The last lesson ended with a question: what if "multiple owners" means "multiple threads," running at the same time, for real? Here's "for real." Everything in this lesson can run two — or twenty — pieces of a program genuinely simultaneously, on genuinely different CPU cores. Rust's pitch, the one this whole section is named after, is fearless concurrency: the same borrow checker that's been catching mistakes since the ownership lesson catches a whole category of concurrency bugs too — at compile time, before the program ever runs.
thread::spawn and JoinHandle
thread::spawn takes a closure — the closures lesson's || { ... }, the no-argument form — and runs it on a brand-new OS thread, starting immediately. It returns a JoinHandle: a value that represents "that thread, still running, somewhere." Both loops run — main's two lines, and the spawned thread's four. On this machine, one run printed both of main's lines first, then all four of the spawned thread's — but run it yourself a few times, especially with a bigger range than 1..5, and that ordering can change. Rust makes no promise about how two threads' println! calls interleave. That's not a bug to fix — it's the honest behavior of two independent threads, and the entire reason this lesson exists.
handle.join().unwrap() is the one line doing real work beyond starting the thread. .join() blocks main until the spawned thread finishes — without it, main could exit (ending the whole program, spawned thread included) before the spawned thread got to run at all. It returns a Result — Ok if the thread finished normally, Err if it panicked — and .unwrap() is the option-and-result lesson's familiar "if something went wrong, panic here too," same as ever.
Borrowing across threads doesn't work
Spawn a thread that touches a variable from main, without move:
Every line of this should sound familiar. "closure may outlive the current function, but it borrows tasks" is the closures lesson's exact justification for move, word for word: "the closure needs to actually own the value, often because it will outlive the scope it was written in." A spawned thread is the most literal possible version of that — main has no way to know whether the spawned thread finishes before, after, or long after the || { ... } line returns, so the borrow checker refuses to let it borrow anything at all. "function requires argument type to outlive 'static" — the lifetimes lesson's 'static, predicted twice now, meaning here "this closure can't contain any borrowed reference that might expire before the thread does." And the help: line says exactly what to do — the keyword the closures lesson taught for exactly this reason.
One word fixes it
move forces the closure to take ownership of tasks — exactly the closures lesson's greet, exactly the ownership lesson's moves, just moving into a thread instead of into a binding or a function call. After this point, main doesn't have tasks anymore — the spawned thread does, for as long as it needs it, which answers the borrow checker's worry completely: the value can't expire while main is still borrowing it, because main isn't borrowing it. It owns nothing here anymore. Output: ["Write lesson 34"].
Sending values: mpsc channels
Moving a value into a thread, once, at the start, is one pattern. Often what's wanted is a stream — a thread that produces values over time, and another that consumes them as they arrive. That's std::sync::mpsc — multiple producer, single consumer:
mpsc::channel() returns a pair — tx ("transmitter") and rx ("receiver") — connected to the same underlying queue. tx moves into the spawned thread, move again, for the same reason as last block: the closure is used inside thread::spawn, so it has to own what it touches. tx.send(task) moves task through the channel — it stops existing in the spawned thread after this line, the same as if it had been passed by value to a function. rx.recv(), back in main, blocks — pauses main — until a value arrives, then returns it wrapped in a Result (Err if every tx was dropped with nothing sent) — .unwrap() once more, for "that won't happen here." Output: got: Write lesson 34.
rx as an iterator
One value is the easy case. For a stream of them:
for received in rx — the iterators lesson's for loop, over something that was never a Vec or a range. Receiver<T> implements Iterator: each .next() blocks until a value arrives and yields it — Some(value) — or, once every tx has been dropped (here, when the spawned thread's closure ends and its tx goes out of scope), returns None, and the loop ends naturally, the same as any other for loop running out of items. Two sends, two got: lines, in the order they were sent — a channel is first-in-first-out, so even though threads themselves make no ordering promises, this queue does.
Quick exercise
- Spawn three threads in a loop —
for id in 0..3 { ... }, each printing"thread {id} done"— and push eachJoinHandleinto aVec(the vectors lesson) as it's created. After the loop, loop over theVecagain and call.join().unwrap()on each handle. Run it a few times: the three threads finish in some order, but every.join()still returns —.join()guarantees completion, never order. - In the channel-iterator example, clone
tx—let tx2 = tx.clone();— before thethread::spawncall, and spawn a second thread, moved withtx2instead oftx, sending one more task.mpscstands for multiple producer: every clone oftxis a fully independent sender into the same channel. One subtlety —for received in rxonly ends once everytx, including the original, has been dropped. If the original sits un-dropped inmainafter both threads finish, the loop waits forever.drop(tx);after spawning both threads fixes it.
Channels move ownership: at any instant, exactly one thread holds a given value, passed along a queue. That covers a huge fraction of real concurrent programs — a producer thread and a consumer thread, handing data off, never touching it at the same time.
But sometimes that's not the shape of the problem. Sometimes several threads all need to read and write the same piece of data, at the same time — a shared counter, a shared cache, a shared Vec<Task>. The smart-pointers lesson's closing line predicted exactly this: Rc<RefCell<T>>'s thread-safe cousin, Arc<Mutex<T>>. The next lesson is that — plus Send and Sync, the two traits that let the compiler decide, at compile time, whether a type is even safe to share across threads at all. Fearless, indeed.