Hedronite Lesson · Polyglot-Dev / Rust · Mon 2026-09-28

Threads and message passing

Spawn owns a job. A channel moves the value. Join waits until both are done.

Lesson Class: Duha (Rust language track · Beat A · topic T1)
Focus: spawn / JoinHandle::join / move closures / mpsc send·recv · Channel Path
Code Blocks: clean blocks, explanation in prose
Done-criteria: Can spawn, join, and send/recv on a channel
Grounding: TRPL stable ch16-00..02 · Topics #19 (Mutex/Arc/Send/Sync) held
The Spawn Cut
Keep the JoinHandle; call join or the spawned work can vanish when main ends.
The Move Bridge
A spawned closure must own what it uses; move forces the transfer E0373 demanded.
The Channel Path
send moves the value; recv takes ownership on the other side; tx.clone for producers.
Spawn owns a job. A channel moves the value. Join waits until both are done.

<!-- hal:authoritative:yaml -->

Spawn owns a job. A channel moves the value. Join waits until both are done.

§I - Frame

Duha session 18. Topics #18, TRPL Chapter 16: threads and message passing. Chapter 15 gave you Box, Rc, and RefCell on one thread. Chapter 16 opens the second half of the ownership story: the same rules that stop you from aliasing mutably also stop whole classes of concurrency bugs at compile time. The book names that stance fearless concurrency.

Three moves land by the end:

  1. The Spawn Cut: thread::spawn returns a JoinHandle<T>; call join or the spawned work can vanish when main ends.
  2. The Move Bridge: a spawned closure must own what it uses; move forces the transfer that E0373 demanded.
  3. The Channel Path: mpsc::channel gives tx/rx; send moves the value; recv (or for over rx) takes ownership on the other side.

Done-criteria: Can spawn, join, and send/recv on a channel.

Mutex, Arc, and the Send/Sync traits stay for Topics #19. Do not smuggle them in here.

§II - Spawn and join

Rust's standard library uses a 1:1 model: one language thread maps to one OS thread. Create one with thread::spawn and a closure:

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

Without join, main can finish while the spawned loop still has work left, and the runtime tears those threads down. Listing 16-1 in the book often stops the spawned loop early for that reason: the main thread printed to 4 and exited while the child still had numbers left. There is also no guarantee the child ran at all before exit.

JoinHandle::join blocks the current thread until the spawned one finishes. Where you place join changes the schedule: before the main loop, the threads do not overlap; after it, they interleave until the handle returns. Small placement choices change whether you see concurrency or a serial handoff.

Thus the Spawn Cut: keep the handle, and decide when the wait happens.

§III - The Move Bridge

A spawned closure that only borrows from main fails with E0373: the closure may outlive the current function, but it borrows a value owned there. The book's vector example shows why inference is not enough. Rust cannot prove the borrow outlives the new thread, so it refuses the borrow.

use std::thread;

fn main() {
    let v = vec![1, 2, 3];
    let handle = thread::spawn(move || {
        println!("Here's a vector: {v:?}");
    });
    handle.join().unwrap();
}

move forces the closure to take ownership of v. After that, main cannot drop(v) and leave the other thread holding a dangling reference: the value already left. Chapter 13 taught move for closures; Chapter 16 puts it on the thread boundary where the lifetime question becomes real.

§IV - Channels: share by communicating

Message passing sends data between threads instead of sharing a location both can touch. The Go slogan the book quotes still names the discipline: do not communicate by sharing memory; share memory by communicating. Rust's channel is std::sync::mpsc — multiple producer, single consumer.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}

send takes ownership of the value. Try to print val after send and you get E0382: borrow of moved value. The receiver now owns it. That is the Channel Path in one compile error.

recv blocks until a message arrives (or the transmitter closes). When every transmitter is dropped, recv returns an error that says no more values will come. try_recv returns immediately with Ok or Err when you have other work to do between polls: check, do other work, check again. When you have a stream of values, treat rx as an iterator: the loop ends when every transmitter is dropped and the channel closes. A channel is closed if either half is dropped; that rule is what ends the for received in rx loop cleanly.

Ownership is the safety rail. Once send moves the value, the sending thread cannot touch it. Once recv returns it, the receiving thread owns it. The type system is doing concurrency work here, more than memory work.

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();
    let tx1 = tx.clone();

    thread::spawn(move || {
        for val in [String::from("hi"), String::from("from")] {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    thread::spawn(move || {
        for val in [String::from("more"), String::from("messages")] {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }
}

tx.clone() is how mpsc earns the multiple in multiple producer. Order across producers is not guaranteed; that nondeterminism is the point the book wants you to feel with thread::sleep.

§V - Proof and close

  1. Spawn a thread, omit join, then add join and name what changes when main exits.
  2. Reproduce E0373 with a borrowed vector; fix it with move and say who owns the vector afterward.
  3. Open an mpsc channel, send a String, and show E0382 if you use the value after send.
  4. Iterate rx for multiple values; clone tx and name which end stays single.

Done-criteria: Can spawn, join, and send/recv on a channel.

Next: shared state, Send/Sync (Topics #19, still TRPL Ch.16). Keep the Channel Path; #19 adds Mutex and Arc as the shared-memory row.

Related