spawn Hands You a Handle, Not a Guarantee
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
thread::sleep(Duration::from_millis(50));
println!("worker: computing");
41 + 1
});
println!("main: doing something else");
let answer = handle.join().unwrap(); // blocks HERE until the worker finishes
println!("answer = {answer}"); // answer = 42
}
thread::spawn starts your closure on a new OS thread and returns a JoinHandle<T> immediately — before, usually, the closure has run a single line. The handle is the only connection you have to that thread: it owns the right to wait for it and to collect its return value T. Notice what the handle does not promise: ordering. main: doing something else and worker: computing race; across runs you will see both orders, because the closure's 50 ms sleep guarantees only that worker: happens after the spawn — nothing about relative to the parent's next statement.
That gives you the first rule of thread code: join is what creates the ordering. Nothing before a join().unwrap() on a given handle may assume the spawned work happened; everything after it may.
The second fact is sharper than it looks: Rust threads map 1:1 onto OS threads. There is no scheduler of green threads underneath — the OS creates a thread, gives it a stack (2 MiB by default), and runs your closure on some core. Two consequences follow. Thread creation costs real microseconds and reserved memory, so spawning per request or per item is a design error — spawn per unit of parallel work, and look at async or a pool for thousands of concurrent tasks. And since every thread owns its stack, unbounded spawning is a memory problem before it is a CPU problem.
Configuration goes through Builder when the defaults are wrong:
let handle = thread::Builder::new()
.name("indexer".into()) // shows up in panic messages
.stack_size(8 * 1024 * 1024) // deep recursion / large frames
.spawn(|| { /* work */ })
.expect("failed to spawn thread");
A named thread turns thread '<unnamed>' panicked at ... into thread 'indexer' panicked at ... — cheap diagnostics. (The default stack can also be changed process-wide with RUST_MIN_STACK.) Note what Builder::spawn adds beyond thread::spawn: it returns io::Result<JoinHandle<T>>, because an OS thread is an operating-system resource that can be refused under an fd or memory limit — production code matches on that Err instead of expecting the machine to always say yes.
Finally, what happens when a spawned thread panics: the panic stays on that thread. It prints its message with that thread's name and stack, the thread dies, and main keeps running as if nothing happened — you find out only when you join and inspect the Result. Data races are impossible across the panic (each thread has its own stack), but locks and shared state must still cope with a worker vanishing mid-critical-section — a scenario called poisoning, which Arc and Mutex for Shared State covers.
Interview framing: the classic beginner question is "what does spawn return and when does the work happen?" — answer: a JoinHandle<T> right away, work on another OS thread concurrently, and no ordering until you join. This topic is the exit from single-threaded ownership (Reference Cycles and Weak); the next one is why captured data is the hard part.
join, Drop, and the Thread Nobody Waits For
JoinHandle::join(self) consumes the handle and blocks the calling thread until the target finishes, yielding Result<T, Box<dyn Any + Send + 'static>> — Ok(T) with the closure's return value, or Err carrying the panic payload (usually a &str or String message):
let handle = thread::spawn(|| -> i32 {
if true { panic!("bad input to worker"); }
7
});
match handle.join() {
Ok(v) => println!("worker returned {v}"),
Err(payload) => {
let msg = payload.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "non-string panic".into());
println!("worker panicked: {msg}");
}
}
Three properties worth memorizing. join blocks the caller until the target thread ends — that is synchronization, and it is the only synchronization in this API (there is no timeout on stable's join; if you need one, is_finished() polling since Rust 1.61 does the job). Panics are captured, not propagated: the panic unwinds that thread's stack, runs its destructors, and lands as an Err in your hands — the process does not die, and no panic hook fires twice. And results flow only through join (or through shared structures — channels and Mutex come later): there is no hidden channel, no global result slot.
Now the behavior the key points call out: the main thread does not wait for anyone. Dropping a JoinHandle detaches the thread — it keeps running, unobserved, and its eventual return value is discarded:
fn main() {
let h = thread::spawn(|| {
thread::sleep(std::time::Duration::from_secs(3));
println!("finished late");
});
drop(h); // detached — nobody will ever wait for this thread
println!("main done");
} // process exits NOW: the late print never happens
When main returns, the process terminates — running threads are killed mid-execution, their destructors do not run, and pending output is lost. So a detached thread is not "background work that completes"; it is "work that completes only if the process happens to still be alive." Detaching is correct for fire-and-forget tasks that must not block shutdown (an in-flight log flush with a hard deadline, say) and wrong for anything whose result matters.
Collecting many workers is just a Vec of handles:
let handles: Vec<_> = (0..4)
.map(|i| thread::spawn(move || (0..1000).filter(|n| n % 7 == i).count()))
.collect();
let totals: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
Joining in a loop serializes your waiting only if the work was already serialized — each join returns as soon as that thread finishes, so total wait time is the slowest worker, not the sum. One subtlety: joining in creation order still waits for thread 0 even if thread 3 finished first; the wait for thread 0 doesn't observe thread 3's completion. If completion order matters, drain handles as they become ready (or, better, let a channel report completions — Message Passing with Channels).
There is also JoinHandle::is_finished(&self) (stable since Rust 1.61) for non-blocking inspection: it reports whether the thread has completed without consuming the handle, which is exactly what you need for a watchdog loop or a graceful-shutdown deadline — poll is_finished until the deadline, then join to collect (or give up and let the drop detach). What stable Rust deliberately does not offer is a blocking join_timeout, so "wait at most N seconds" must always be built from polling plus a final join.
Interview framing: "join vs dropping the handle" is the crisp version of this topic: join = block, collect, observe panics; drop = detach, and main returning kills everything. Follow with the captured-panic detail (Box<dyn Any + Send> + downcast) — it shows you have actually handled a failing worker.
thread::scope: Borrow Local Data, No Arc Required
Every example so far sidesteps a problem you will hit immediately: threads spawned with spawn must be 'static — they cannot borrow anything from your stack — so the reflex is to wrap data in Arc. For a bounded, structured parallel section, that reflex is unnecessary:
let mut pixels = vec![0_u8; 8]; // a local, plain and owned
thread::scope(|s| {
let (left, right) = pixels.split_at_mut(4); // two disjoint &mut halves
s.spawn(|| { for p in left { *p += 1; } }); // borrows `left` ...
s.spawn(|| { for p in right { *p += 10; } }); // ...and `right` — both fine
}); // <- every scoped thread joined HERE
println!("{pixels:?}"); // usable again: [1,1,1,1,10,10,10,10]
thread::scope(f) runs your closure f with a Scope that can spawn threads borrowing locals, and it guarantees that all spawned threads are joined before scope returns — on every path, including early returns and unwinding. That guarantee is what makes the borrowing sound: the compiler knows the threads' lifetimes are contained in the call, so pixels (or any local) provably outlives them. No Arc, no move, no data copied — two &mut into one Vec, each thread on a disjoint half, exactly as the borrow checker would demand of sequential code.
The scoped API returns handles too, so results and panics are observed the same way:
let out: Vec<usize> = thread::scope(|s| {
let data = vec![1, 2, 3];
let h1 = s.spawn(|| data.iter().sum::<i32>() as usize); // borrows data
let h2 = s.spawn(|| data.len());
vec![h1.join().unwrap(), h2.join().unwrap()]
});
Semantics that matter when things go wrong: if a scoped thread panics and you join it, you get the usual Err and decide what to do; if it panics and you never take the handle, scope auto-joins it and propagates that panic after all other threads have finished — nothing is left running, ever. Compare with raw spawn, where a panic is a silent Err on a handle you may have dropped.
Notice also what the compiler enforces inside the closure. Each s.spawn(|| ...) closure borrows the locals it names, and because those borrows must live until scope returns, the same rules as sequential code apply: two closures taking &mut to the same variable are rejected unless the halves are provably disjoint — hence split_at_mut, partition_dedup, or index ranges. A scoped spawn that tries to hand both threads the whole Vec fails with the familiar E0499 (cannot borrow \pixels` as mutable more than once at a time`), the same error you'd get writing the loops by hand. Structured concurrency doesn't weaken the borrow checker; it lets the borrow checker keep working across a thread boundary.
When should you reach for which? scope for structured sections — a parallel chunk of work over data you already own, function-local, bounded lifetime. Plain spawn for threads that must outlive the function (a long-running worker), which forces you to confront ownership head-on: everything it needs must move in or be shared — which is precisely Moving Data into Threads.
Interview framing: "how do you share data with a spawned thread?" has a three-step answer interviewers want in order: move it in (move closure, next topic), share it (Arc), or avoid ownership transfer entirely (thread::scope for function-scoped work, borrowing disjoint &mut just like sequential code). Being able to show the split_at_mut scope example demonstrates both threading and borrow-checker fluency at once.
Quiz
1. `thread::spawn(|| println!("work"))` returns immediately. When does the closure run, and what ordering is guaranteed?
2. A spawned thread panics. What does the process see, and what does handle.join() return?
3. What does dropping a JoinHandle without joining do, and what happens when main returns while threads run?
4. What does thread::scope guarantee, and why does that make borrowing locals sound?
Flashcards
Question
What does thread::spawn return, and what are the closure's bounds?
Click to reveal answer
Answer
A `JoinHandle<T>` immediately, while the closure `F: FnOnce() -> T + Send + 'static` runs on a fresh OS thread (1:1, 2 MiB stack by default). `'static` forbids borrowing your locals, `Send` is required to move the capture across threads, and T (the return value) must also be Send to come back.
Question
What are the semantics of JoinHandle::join?
Click to reveal answer
Answer
Consumes the handle and blocks until the target thread finishes; returns `Ok(T)` with the closure's value or `Err(Box<dyn Any + Send>)` carrying a captured panic (downcast to read it). It is the only synchronization point — ordering relative to spawned work exists only after joining. Dropping the handle instead detaches the thread.
Question
What happens to running threads when main returns?
Click to reveal answer
Answer
The process exits: running threads are terminated mid-execution, their destructors do not run, and their results are lost. Detaching (dropping handles) is therefore fine only for work whose completion is optional; anything whose result matters must be joined first.
Question
What does thread::scope give you over plain spawn?
Click to reveal answer
Answer
Threads that may borrow locals (no 'static, no Arc) because scope joins every one of them before returning — even on early return or panic — so borrows provably die inside the call. Results come from scoped handles' join(); an un-joined panicked scoped thread makes scope itself panic after all joins complete.
Question
When do you use scope versus spawn?
Click to reveal answer
Answer
scope: bounded, function-local parallelism over data you already own (split_at_mut halves, a work array) — borrowing, no copying, guaranteed join at the end. spawn: threads that must outlive the function — which forces the ownership decision (move the data in, or share it with Arc), the next topic.
Revision Notes
Key Takeaways
- 1. thread::spawn creates an OS thread and returns JoinHandle<T> immediately — execution and interleaving are the OS's choice, and join is the only point that creates ordering
- 2. join blocks and yields Result<T, panic-payload>; worker panics are captured on their own thread (destructors run there), never propagated automatically
- 3. Dropping a handle detaches rather than cancels, and main returning kills every remaining thread without running destructors — so joined work is the only guaranteed work
- 4. thread::scope spawns threads that borrow locals and joins them all before returning, on every path; that containment guarantee is what makes the borrows sound without Arc
- 5. Threads are 1:1 with the OS: spawn per unit of parallel work, not per item — stack reservation (2 MiB) and creation cost make unbounded spawning a memory bug
Interview Tips
- • "What does spawn return?" — JoinHandle<T> right away; state the bounds (FnOnce + Send + 'static), note no ordering until join, and add the default stack size to show you know the cost model
- • "What happens if a worker panics?" — contained on that thread, join gives Err(Box<dyn Any + Send>), downcast to read it; process survives, and contrast with the poison problem a panicking thread leaves behind for shared state
- • "How do threads share data?" — three answers in order: move it in, Arc it, or borrow it with thread::scope; show split_at_mut for disjoint &mut to prove threading and the borrow checker coexist
- • "What does dropping a handle do?" — detach, not cancel; and main exiting kills threads without destructors — a good place to mention RAII resources (locks, files) that would never get released
Cheat Sheet
Spawning Threads
let h: JoinHandle<i32> = thread::spawn(|| 41 + 1); // returns NOW, runs on a new OS thread
let v = h.join().unwrap(); // blocks; Ok(T) or Err(panic payload)
| Fact | Detail |
|---|---|
| Mapping | 1:1 OS thread; default stack 2 MiB (Builder::stack_size, RUST_MIN_STACK) |
| Ordering | none until you join that handle — prints interleave per run |
| Drop the handle | detach: thread runs unobserved, result discarded, no cancellation |
main returns |
process exits — running threads killed mid-flight, no destructors run |
| Worker panics | contained on that thread (its stack unwinds); join → Err(Box<dyn Any + Send>), downcast to read |
| Config | thread::Builder::new().name("indexer".into()).stack_size(n).spawn(f) |
Results come only from join (or later topics: channels, Mutex):
let handles: Vec<_> = jobs.into_iter().map(|j| thread::spawn(move || work(j))).collect();
let out: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); // slowest worker, not the sum
Scoped threads — borrow locals, no Arc:
thread::scope(|s| {
let (l, r) = data.split_at_mut(half);
s.spawn(|| use_left(l)); // disjoint &mut, sound because scope joins ALL
s.spawn(|| use_right(r)); // before returning — on every path, panics included
}); // <- guaranteed join point
An un-joined panicked scoped thread → scope panics after joining the rest; joined one → you get Err and decide.
Rule: scope = function-bounded parallel sections · spawn = threads outliving the function (then data must move or be shared).