Fearless Concurrency: Threads and Message Passing

Fearless Concurrency: Threads and Message Passing

The smart-pointers lesson ended by asking what happens once "multiple owners" means "multiple threads." Here is the answer: thread::spawn runs a closure on a real OS thread, and JoinHandle::join waits for it. Spawning a thread that touches a variable from main without move produces a real compiler error - "closure may outlive the current function" plus a note about outliving 'static - that is the closures lesson's move keyword and the lifetimes lesson's 'static prediction, both arriving at once. From there, std::sync::mpsc channels move ownership of values between threads one at a time, and Receiver<T> turns out to implement Iterator, so a spawned thread can stream results back with an ordinary for loop. Closes by previewing Arc<Mutex<T>>, the thread-safe cousin of last lesson's Rc<RefCell<T>>.

17 min read4 learning objectives

What You'll Learn

  • Spawn a real OS thread with thread::spawn, and use JoinHandle::join to wait for it to finish before main exits
  • Read the compiler error produced by a non-move closure passed to thread::spawn, and connect its 'static requirement and its suggested fix to the closures lesson's move keyword
  • Send values between threads with std::sync::mpsc channels, explaining what tx.send moves and what rx.recv blocks on
  • Receive a stream of values with for received in rx, recognizing Receiver<T> as another type that implements Iterator

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

  1. Spawn three threads in a loop — for id in 0..3 { ... }, each printing "thread {id} done" — and push each JoinHandle into a Vec (the vectors lesson) as it's created. After the loop, loop over the Vec again 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.
  2. In the channel-iterator example, clone tx let tx2 = tx.clone(); — before the thread::spawn call, and spawn a second thread, moved with tx2 instead of tx, sending one more task. mpsc stands for multiple producer: every clone of tx is a fully independent sender into the same channel. One subtlety — for received in rx only ends once every tx, including the original, has been dropped. If the original sits un-dropped in main after 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.