Skip to content
intermediate Phase 12 · Threads, Channels, and Shared State

Moving Data into Threads

Give spawned closures ownership of their data with the move keyword.

40m
0 problems
Topic Progress 0%

Why spawn Demands 'static: The Borrow You Can't Take Across a Thread

Recall the signature that makes all of this concrete. thread::spawn accepts F: FnOnce() -> T + Send + 'static — three bounds, and the third one is where nearly every first threaded program breaks:

fn main() {
    let name = String::from("indexer");
    let handle = thread::spawn(|| {
        println!("working on {name}");      // borrows `name`...
    });
    //                                  ...but `name` could be dropped right here
    drop(name);
    handle.join().unwrap();
}

This fails with E0373: closure may outlive the current function, but it borrows name, which is owned by the current function. Read that error carefully: the compiler is not being bureaucratic about lifetimes, it is pointing at a real use-after-free. spawn hands your closure to another thread whose execution is not bounded by your stack frame; if that thread outlives main's locals — or simply runs after they are dropped — the closure would dereference a dangling reference. Since no lifetime annotation can promise "this borrow outlives an independent OS thread," the bound is 'static: the closure must be self-contained, holding only owned data (or references proven to live as long as the program, like 'static str slices).

'static here does not mean "lives forever." It means "no borrows of non-'static locals" — the closure owns everything it uses, so it is sound no matter when the thread runs. Three standard escapes, in order of preference:

  1. thread::scope when the work is function-bounded — the borrow checker relaxes 'static because scope joins every thread (Spawning Threads).
  2. Move the data in with a move closure when the thread should own its inputs.
  3. Clone on the way in when the parent still needs its value afterwards.

The Send bound is the other half of the transfer and is discussed in Send and Sync Marker Traits; for now: it asserts that moving the captures to another thread is memory-safe, and it fails loudly (E0277) for types like Rc that are explicitly not thread-safe.

Interview framing: interviewers use E0373 as a cheap discriminator. Expect "why doesn't this compile?" over a spawn closure borrowing a local; a strong answer names 'static, explains the dangling-borrow hazard on the spawned thread, and offers scope vs move as the fix — not "just add 'static to the variable," which is impossible for an owned String.

Worth adding under pressure: the same bound explains why closures capturing &self from a struct method can't be spawned directly — the borrow ties the closure to a stack (or heap) location the compiler can't prove outlives the thread. The idiomatic escapes are a thread::scope around the call, cloning the needed fields before spawning, or restructuring so the thread receives owned arguments instead of borrowing self. Each escape is really the same move-or-borrow decision made at the thread boundary, and the error text is the compiler forcing you to make it explicitly.

It also explains a subtler case: capturing a &'a str or &'a [u8] from a caller whose lifetime 'a is generic. Even though the data might live long enough at runtime, 'a is not provably 'static, so spawn still refuses — E0373 again, this time pointing at a lifetime parameter rather than a local variable. Passing String/Vec (owned) or Arc<str> (shared, 'static after one Arc::new) removes the lifetime from the thread's inputs entirely, which is why library APIs that spawn internal threads take owned or Arc-wrapped arguments instead of references.

move Closures: Predicting What Gets Captured

The move keyword changes the closure's capture mode from by-reference to by value:

let jobs = vec![1, 2, 3];
let handle = thread::spawn(move || {
    println!("{jobs:?}");      // jobs MOVED into the closure
});                             // jobs is no longer usable here
handle.join().unwrap();

After a move closure is called (once, FnOnce), the vector lives inside the closure on the spawned thread's stack. But the rule is narrower than "everything is taken": a closure captures only the variables it actually uses, and move only changes how those variables are captured. Unused locals are untouched:

let keep = String::from("parent keeps this");
let give = String::from("thread takes this");
let h = thread::spawn(move || println!("{give}"));   // only `give` is captured
drop(keep);                                            // fine — never captured

Capture granularity goes down to fields (since Rust 2021's disjoint capture): a move closure that reads user.name captures that field, not the whole struct, so user.id remains usable in the parent. And for Copy types, "move" is a copy — an i32 capture costs nothing on either side.

When the parent still needs its value, clone at the capture boundary and be explicit about it:

let data = load_config();
let h = thread::spawn({
    let data = data.clone();          // explicit: this thread gets its own copy
    move || apply(data)
});
apply(data);                          // parent's copy still alive

That block-then-move idiom — clone into a temporary, move the temporary — keeps the intent readable and avoids accidentally cloning everything the closure mentions. It is also the honest cost statement: every clone is a memory copy (and for Arc, only a reference-count bump — the subject of the next section).

The failure mode to recognize in review: code that looks shared but is actually moved. If a second spawned thread also wants data, the first move closure has already consumed it — E0382 (use of moved value) appears at the second spawn, and the fix is never "wrap in RefCell": across threads the correct answer is shared ownership, Arc<T>.

Interview framing: "What does move capture?" has a layered answer: only used variables, by value, field-by-field in edition 2021, with Copy types copied rather than cloned. Follow-up exercises usually ask you to explain why move || println!("{x}") still allows reading a Copy x afterwards — same capture rule, different type semantics.

One more practical edge: closures can capture other closures. If the outer closure is move, it takes ownership of the inner one — so pass Fn/FnMut inner closures by reference or clone Fn ones (they're cheap) rather than moving a stateful FnMut you still need. And since a move closure that captures a Box<dyn Fn> or a channel Sender becomes FnOnce if it consumes the capture (calling a Box<dyn FnOnce> or sending the last Sender), the spawn bound still fits — spawn wants exactly FnOnce. Recognizing that consuming captures downgrade the trait (Fn → FnOnce) keeps you from fighting the closure-trait errors that show up when refactoring worker code.

The mirror case — wanting to avoid move because the closure needs to mutate a captured counter — is where move plus interior mutability meets: move || { *counter.lock().unwrap() += 1; } moves an Arc<Mutex<i32>> (cheap, atomic bump) rather than the counter itself, so the thread mutates shared state without violating ownership. That pattern appears in full in Arc and Mutex for Shared State; the capture-level point is that move moves whatever the closure names — an Arc is just a particularly good thing to name.

Moving vs. Sharing: When Only Arc Will Do

A move gives one thread exclusive ownership — clean, zero-overhead, and exactly right for independent work over private data. It reaches its limit the moment two threads must touch the same values, because ownership transferred away cannot be transferred back for the second consumer. The general pattern then becomes shared ownership plus a thread-safety proof:

use std::sync::Arc;
use std::thread;

let shared = Arc::new(vec![1, 2, 3]);            // atomic refcount, T: Send
let mut handles = vec![];
for id in 0..3 {
    let shared = Arc::clone(&shared);             // bump count, no data copy
    handles.push(thread::spawn(move || {
        println!("thread {id} sees {} items", shared.len());
    }));
}
for h in handles { h.join().unwrap(); }

Arc::clone copies the pointer and increments an atomic counter, so handing the same data to N threads costs N pointer-sized bumps — the distinction from String::clone (deep copy) is the entire point. Mutation through shared ownership is not allowed here for the same reason Rc forbids it: two owners, no unique borrow. That combination — Arc for ownership, Mutex for mutation — is Arc and Mutex for Shared State, and the compiler enforces its prerequisites through Send.

The errors are the curriculum. Rc<T> is neither Send nor Sync (a deliberate negative impl), so sharing one into a thread yields E0277: Rc<dyn Any> cannot be sent between threads safely — its non-atomic refcount would race. Cell/RefCell fail similar bounds (Cell is !Sync; RefCell's runtime borrow flag would allow two threads to take &mut at once). Raw pointers are !Send/!Sync by default. In every case the message is the same: this type's safety depends on single-threaded discipline, and a closure that crosses a thread boundary voids that discipline. Arc is the substitute because its counter is atomic; Mutex will supply the missing exclusivity for mutation.

A final discipline: joins still matter. Threads holding clones of an Arc finish only when joined — dropped handles merely detach, and the count reaching zero on a dead process is not "cleanup" (Spawning Threads). Ownership transferred to threads is ownership you must collect.

Interview framing: the progression move → clone → Arc is the standard "how do threads get data?" ladder. State when each rung is correct: move for private data, clone for snapshots the parent keeps, Arc for genuinely shared values — and add "Arc<Mutex<T>>" before being asked, showing you know sharing ownership is only half the story.

A useful closing detail for interviews: Arc itself doesn't make mutation safe, only ownership shared. Arc<Mutex<T>>'s lock is what serializes writers, and its guard's Drop releases the lock — so a thread that panics while holding it triggers poisoning, a flag letting later lockers detect possibly-broken state. Mentioning that chain — move for ownership, Arc for sharing, Mutex for mutation, poisoning for failure handling — demonstrates the full mental model in one breath. It also clarifies why channels (Message Passing with Channels) feel simpler: they fuse "share" and "mutate" into one ownership transfer per message.

For a concrete sizing intuition: Arc::clone on a Vec<u8> of a megabyte costs an atomic increment and two pointer copies; a deep Vec::clone costs a megabyte of memcpy plus an allocation. When a worker pool processes the same dictionary, corpus, or schema on every thread, the Arc version keeps one copy in memory and the clone version multiplies it by the thread count — the difference shows up immediately in a profiler as allocation pressure, not just CPU. Choosing between them is a memory-model question, and the compile-time machinery (Send, 'static) is what keeps the choice sound.

Quiz

1. A program fails with E0373 ("closure may outlive the current function, but it borrows `name`") on a thread::spawn closure. What is the underlying hazard and the correct fixes?

Question 1 options

2. let x = String::from("a"); let h = thread::spawn(move || println!("{x}")); After the closure runs, what is x's state and why?

Question 2 options

3. Two threads need to read the same Vec<i32>, and the parent still uses it afterwards. What is the correct approach?

Question 3 options

4. Which statements about what a move closure captures are true?

Question 4 options

Flashcards

Question

What bounds does thread::spawn place on its closure, and what does each one mean?

Answer

`F: FnOnce() -> T + Send + 'static`. FnOnce: called at most once on the other thread. Send: the captures can be transferred there. 'static: no borrows of non-'static locals — the closure owns everything it uses, so it can't dangle if the thread outlives the frame. T (the return) must be Send too.

Question

What compiler error do you get from a spawn closure borrowing a local, and what does it mean?

Answer

E0373 — "closure may outlive the current function, but it borrows `x`". The spawned thread isn't bounded by the current stack frame, so the borrow could outlive the local. Fixes: thread::scope (bounded lifetime), move (thread owns a copy), or clone the value at the boundary.

Question

How does a move closure decide what to capture, and how does that interact with Copy types?

Answer

Only variables the body actually uses are captured (edition 2021: individual fields, not whole structs). move flips those captures from by-reference to by-value. Copy types (i32, bool) are copied, so the parent keeps using them; non-Copy types are moved out and any later parent use is E0382.

Question

When is Arc required instead of moving or cloning data into threads?

Answer

When two or more threads must access the same values and the parent may too. A move gives one thread exclusive ownership; clones give independent copies. Arc provides shared ownership via an atomic refcount — Arc::clone is a pointer copy plus an atomic bump, not a deep copy — and it's Send/Sync where Rc is not.

Question

What do E0277 errors mentioning Send or Sync on thread::spawn closures usually indicate?

Answer

A captured type isn't thread-safe: Rc (non-atomic refcount), Cell/RefCell (unsynchronized mutation flags), raw pointers (compiler can't prove pointee discipline). The compiler is refusing the ownership transfer across threads. Replace Rc with Arc, and pair Arc with Mutex for mutation — or keep the data on one thread and use channels.

Revision Notes

Key Takeaways

  • 1. spawn's 'static bound exists because a spawned thread isn't bounded by your stack frame — a borrowed capture could outlive its local (E0373), so the closure must own everything it uses
  • 2. move changes capture to by-value for only the variables the closure uses; Copy types are copied (still usable in the parent), everything else is moved (later parent use is E0382)
  • 3. The three data-plumbing options are scope (borrow, bounded), move/clone (private copies), and Arc (shared ownership via atomic refcount — clone costs a pointer bump, not a copy)
  • 4. E0277 "cannot be sent between threads" on Rc, Cell, or raw pointers is the compiler enforcing Send — swap in Arc, and pair it with Mutex for mutation
  • 5. Transferring ownership to threads is not a fire-and-forget: handles must be joined to collect results and to know when the thread's copies are dropped

Interview Tips

  • • "Why does spawn need 'static?" — answer with the hazard, not the rule: the other thread may run after your locals die, so borrowed captures could dangle; then name scope vs move vs clone as the fixes
  • • "What does move capture?" — only what's used, by value, field-wise in 2021; add that Copy types are copied so the parent keeps using them — that detail separates a memorized answer from a working model
  • • "How do you share data between threads?" — the ladder: move for private, clone for snapshots, Arc for shared, Arc<Mutex<T>> for shared mutation; mention Arc::clone is an atomic pointer bump
  • • When shown E0277 on Rc in a closure, don't just say "use Arc" — explain that Rc's refcount is non-atomic so concurrent clones would race, which is exactly why it's explicitly !Send

Cheat Sheet

Moving Data into Threads

let h = thread::spawn(move || use_it(data));  // captures `data` BY VALUE
// data moved into closure → E0382 if used here afterwards
Situation Error / Fix
Closure borrows a local E0373 — use scope, move, or clone
Second use of moved value E0382 — share with Arc instead of moving
Rc captured into spawn E0277 — Rc is !Send; use Arc
Parent keeps needing the value `let d = d.clone(); move

Capture rules (edition 2021): only used variables · fields, not whole structs · move = by-value · Copy types are copied (parent keeps a copy) · FnOnce consumes non-Copy captures when called.

Move vs. share:

// move: private data, one thread, zero overhead
let h = thread::spawn(move || summarize(my_report));
// share: same values, many threads, atomic count only
let a = Arc::new(data);
let h2 = thread::spawn({ let a = Arc::clone(&a); move || a.len() });

Rule: threads get data by move (ownership), clone (snapshot), or Arc (shared ownership) — and Arc<Mutex<T>> when shared and mutable (Arc and Mutex).

Continue Learning

Before You Start

Related Reading