How to Use Closures with Thread

:spawn in Rust

Use the `move` keyword with `thread::spawn` closures to transfer ownership of captured variables into the new thread.

The thread that outlives the data

You write a function to process a list of items. The work is heavy, so you decide to offload it to a background thread. You wrap the logic in a closure and pass it to thread::spawn. The compiler immediately rejects the code. It complains that the closure may outlive the current function, but it borrows list. You stare at the error. The list is right there in main. Why can't the thread see it?

The problem isn't that the data doesn't exist. The problem is that the thread might run after main finishes. If the thread holds a reference to list, and main returns, list gets dropped. The background thread is left holding a dangling reference. Rust prevents this at compile time. The borrow checker assumes the worst case: the thread runs forever. It refuses to let you borrow stack data for a thread that could outlive the stack.

The solution is the move keyword. It tells the closure to take ownership of the captured variables instead of borrowing them. The thread gets the data, not a reference to it. The data lives as long as the thread runs.

Ownership across thread boundaries

Threads in Rust are independent execution units. They can run on different CPU cores. They can finish before or after the thread that created them. The operating system schedules them. Rust has no control over when a thread executes.

When you pass a closure to thread::spawn, the runtime creates a new OS thread and runs the closure in that thread. By default, closures capture variables by reference. They implement the Fn trait. The closure borrows the data from the surrounding scope. This works fine for local function calls. The closure runs immediately, and the borrow ends when the closure returns.

Threads break this pattern. The closure might not run until milliseconds later. It might run after the surrounding scope has ended. The borrow checker sees that the closure could outlive the borrowed data. It rejects the code.

The move keyword changes the closure's capture semantics. It forces the closure to capture variables by value. The closure takes ownership of the captured variables. The variables are moved into the closure's environment. The closure now owns the data. The thread runs with the owned data. When the thread finishes, the data is dropped.

Think of move like handing someone the deed to a house instead of a key. If you hand over the deed, the house belongs to them. You can't sell it while they own it. The house stays safe as long as they hold the deed, even if you walk away. If you hand over a key, you still own the house. If you sell the house while they have the key, the key becomes useless. Rust requires the deed for threads.

Minimal example

Here is the standard pattern. You create a value, spawn a thread with a move closure, and join the thread to wait for it to finish.

use std::thread;

fn main() {
    // Create a String on the heap.
    let message = String::from("Hello from the thread");

    // The move keyword forces the closure to take ownership of message.
    // Ownership transfers to the new thread.
    let handle = thread::spawn(move || {
        // The closure owns message. It can use it safely.
        println!("{message}");
    });

    // Wait for the thread to complete.
    // join blocks the main thread until the spawned thread finishes.
    handle.join().unwrap();
}

The move keyword appears right before the closure arguments. It applies to the entire closure. Every variable captured by the closure is moved. If the closure captures multiple variables, all of them are moved.

Add move. The error vanishes. The thread owns the data.

What happens under the hood

When you write move || { ... }, the compiler generates a struct to hold the captured variables. This struct is the closure's environment. The move keyword ensures the struct holds owned values, not references.

For the example above, the compiler generates something like this:

struct ClosureEnv {
    message: String,
}

impl FnOnce<()> for ClosureEnv {
    extern "rust-call" fn call_once(self, _args: ()) {
        println!("{}", self.message);
    }
}

The ClosureEnv struct owns the String. When you call thread::spawn, the closure is moved into the thread's stack. The String data lives on the heap, but the pointer and length are inside the closure environment. The thread runs the call_once method. It prints the message. When the thread ends, the closure environment is dropped. The String is dropped. The heap memory is freed.

This process solves the lifetime problem. The thread owns the data. The data lives as long as the thread. No dangling references. The borrow checker is satisfied.

There is a trade-off. The original scope loses access to the moved variables. In main, you can't use message after the spawn. If you try, the compiler rejects the code with E0382 (use of moved value). The variable was moved into the closure. It no longer exists in main.

If you need to use the data in both main and the thread, you can't just move it. You need to share it. That requires Arc<T>, which wraps the data in atomic reference counting. Each thread gets a clone of the Arc. The data stays alive until all threads drop their clones.

Realistic example: worker threads with shared config

In real applications, you often spawn multiple threads that need access to configuration or shared state. You can't move the config into every thread. You need to share it. The pattern is to wrap the config in Arc<T> and clone the Arc for each thread.

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

struct Config {
    url: String,
    timeout: u64,
}

fn start_worker(config: Arc<Config>) {
    // Clone the Arc for this worker.
    // Arc::clone increments the reference count atomically.
    // The clone is cheap. It copies a pointer and an atomic counter.
    let worker_config = Arc::clone(&config);

    // Move the Arc clone into the thread.
    // The worker owns its Arc handle.
    // The Config data stays alive as long as the worker holds the Arc.
    thread::spawn(move || {
        println!(
            "Worker connecting to {} with timeout {}",
            worker_config.url, worker_config.timeout
        );

        // Simulate network work.
        std::thread::sleep(std::time::Duration::from_secs(1));
    });
}

fn main() {
    // Create the config and wrap it in Arc.
    let config = Arc::new(Config {
        url: String::from("https://api.example.com"),
        timeout: 30,
    });

    // Spawn three workers.
    // Each worker gets its own Arc clone.
    let mut handles = vec![];
    for _ in 0..3 {
        // Pass the Arc to the function.
        // The function clones it internally and moves the clone.
        start_worker(Arc::clone(&config));
    }

    // Wait for all workers to finish.
    for handle in handles {
        handle.join().unwrap();
    }

    // config is dropped here.
    // The Arc reference count hits zero.
    // The Config is freed.
}

The Arc::clone(&config) call is the convention. It makes it clear that you are cloning the smart pointer, not the underlying data. Some developers write config.clone(), which works but looks like a deep clone. The explicit Arc::clone form signals that you are bumping the reference count.

Don't share references across threads. Move ownership or wrap in Arc.

Pitfalls and compiler errors

Forgetting move is the most common mistake. The compiler rejects the code with E0373 (closure may outlive current function). The error message points to the captured variable and says it might not live long enough. The fix is to add move or restructure the code to avoid capturing the variable.

use std::thread;

fn main() {
    let data = vec![1, 2, 3];

    // Missing move. The closure tries to borrow data.
    // The compiler rejects this with E0373.
    // It says the closure may outlive the current function.
    let _handle = thread::spawn(|| {
        println!("{:?}", data);
    });
}

Adding move doesn't fix all thread safety issues. The captured data must implement the Send trait. Send is a marker trait that means the type can be safely transferred across thread boundaries. Most types are Send. String, Vec, integers, and structs of Send types are all Send.

Some types are not Send. Rc<T> is not Send. Rc uses non-atomic reference counting. If you move an Rc to another thread, and the main thread also holds an Rc, the counter updates aren't synchronized. You get a data race. The compiler catches this. If you try to move an Rc into a thread, you get E0277 (the trait Send is not implemented for Rc).

use std::rc::Rc;
use std::thread;

fn main() {
    let data = Rc::new(vec![1, 2, 3]);

    // Rc is not Send. The compiler rejects this with E0277.
    // It says the closure cannot be sent between threads safely.
    let _handle = thread::spawn(move || {
        println!("{:?}", data);
    });
}

The fix is to use Arc<T> instead of Rc<T>. Arc uses atomic operations for reference counting. It is Send. Replace Rc with Arc and the code compiles.

Another pitfall is moving too much. move captures every variable used in the closure. If you have a large struct and only need one field, moving the entire struct wastes memory and prevents the owner from using the rest of the data. Clone the field you need, or restructure the code to pass only the necessary data.

use std::thread;

struct LargeData {
    id: u32,
    payload: Vec<u8>, // 1MB of data
}

fn main() {
    let data = LargeData {
        id: 42,
        payload: vec![0; 1_000_000],
    };

    // We only need the id. Moving data copies the 1MB payload.
    // This is inefficient.
    // let _handle = thread::spawn(move || {
    //     println!("ID: {}", data.id);
    // });

    // Clone the id instead. The payload stays in main.
    let id = data.id;
    let _handle = thread::spawn(move || {
        println!("ID: {id}");
    });

    // main can still use data.payload.
    println!("Payload size: {}", data.payload.len());
}

Check the Send bound. If it's not Send, no amount of move will help.

Decision: when to use move and alternatives

Use move with thread::spawn when the closure needs to own the data it captures. This is the standard pattern for spawning threads. The thread takes full ownership, and the data lives as long as the thread runs. The compiler requires move for any captured variable that isn't 'static.

Use Arc<T> inside a move closure when multiple threads need to share the same data. Arc provides atomic reference counting. Each thread gets a clone of the Arc, bumping the counter. The data stays alive until all threads drop their clones. Clone the Arc before moving it into each thread.

Use Arc<Mutex<T>> when threads need to mutate shared data. Mutex provides interior mutability with synchronization. The move closure captures the Arc, and you lock the mutex inside the thread to modify the value safely. The lock ensures only one thread mutates the data at a time.

Reach for cloning specific fields when the closure only needs a subset of a large struct. Moving the entire struct wastes memory and prevents the owner from using the rest of the data. Clone the string or integer you need, and leave the heavy struct behind.

Avoid move when the closure runs immediately and doesn't escape. If you pass a closure to a function that calls it synchronously, move is unnecessary overhead. The borrow checker handles local borrows fine. move is only required for thread::spawn and other async contexts where the closure might outlive the current scope.

Trust the move keyword. It's the bridge between the main thread and the worker.

Where to go next