Threads and message passing
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:
- The Spawn Cut:
thread::spawnreturns aJoinHandle<T>; calljoinor the spawned work can vanish whenmainends. - The Move Bridge: a spawned closure must own what it uses;
moveforces the transfer that E0373 demanded. - The Channel Path:
mpsc::channelgivestx/rx;sendmoves the value;recv(orforoverrx) 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
- Spawn a thread, omit
join, then addjoinand name what changes whenmainexits. - Reproduce E0373 with a borrowed vector; fix it with
moveand say who owns the vector afterward. - Open an
mpscchannel,sendaString, and show E0382 if you use the value aftersend. - Iterate
rxfor multiple values; clonetxand 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.