How to use scoped threads

Rust lacks scoped threads; use std::thread::spawn with join to manage thread lifetimes manually.

When stack data meets concurrency

You have a Vec<u8> sitting on the stack in your function. You want to spawn a thread to hash it. You write thread::spawn, and the compiler rejects you. The thread might outlive the function. The data dies. The thread dangles. You try wrapping the vector in Arc, but then you realize you need to mutate a result variable back in the main thread, so you need Arc<Mutex<Vec<u8>>>. The code is drowning in boilerplate just to share a local variable.

Scoped threads solve this. They let you spawn threads that borrow data from the current stack frame. The compiler guarantees those threads finish before the function returns. No Arc. No Mutex. No Send bounds. Just clean, safe concurrency that respects the stack.

How scoped threads work

Scoped threads use std::thread::scope. You pass a closure to scope, and inside that closure you get a Scope object. You call scope.spawn to launch threads. The scope call blocks until every spawned thread finishes. Because the scope blocks, the compiler knows that any data borrowed by the threads stays alive as long as the threads need it.

The lifetime of the thread is tied to the scope block. If a thread tries to return a reference, the compiler checks that the reference lives at least as long as the scope. If the scope ends, all threads are joined, so no dangling references can escape.

use std::thread;

/// Processes data using a scoped thread to borrow stack variables.
fn process_with_scope() {
    let data = vec![1, 2, 3, 4];
    let mut result = 0;

    // The scope ensures all threads finish before `data` and `result` drop.
    thread::scope(|s| {
        // Spawn a thread that borrows `data` and `result`.
        s.spawn(|| {
            let sum: i32 = data.iter().sum();
            result = sum;
        });

        // Main thread can do other work while the spawned thread runs.
        println!("Main thread is waiting for the scope to close.");
    });

    // All threads are joined here. `result` is safe to read.
    println!("Final result: {}", result);
}

The scope closure takes a &Scope argument. You use s.spawn to launch threads. Each spawn returns a ScopedJoinHandle. You can call .join() on the handle to wait for a specific thread, or just let the scope block wait for everything. The scope blocks implicitly at the end of the block.

Convention aside: the community prefers s.spawn over thread::scope for spawning inside the scope. It keeps the API surface small and makes it obvious the thread is scoped. Also, thread::scope has been stable since Rust 1.63. If you're on an older compiler, you'll need to upgrade.

Trust the scope block. It's the boundary that makes the borrow checker happy.

The Send trap

Standard thread::spawn requires the closure to be Send + 'static. The Send trait means the type can be safely transferred across thread boundaries. The 'static lifetime means the closure cannot borrow any data that might die before the thread finishes.

Many useful types are not Send. Rc<T> is not Send. RefCell<T> is not Send. Raw pointers are not Send. If you have a RefCell on the stack, you cannot pass it to spawn. You get E0277 (trait bound not satisfied) because RefCell doesn't implement Send. You have to rewrite your code to use Mutex, which adds locking overhead and complexity.

Scoped threads remove the Send requirement. The closure passed to scope.spawn does not need to be Send. It only needs to not escape the scope. Since the thread cannot outlive the scope, and the scope cannot outlive the stack frame, the compiler knows the data is safe. You can pass RefCell, Rc, or raw pointers into a scoped thread as long as you don't try to move them out.

use std::cell::RefCell;
use std::thread;

/// Demonstrates passing non-Send types into a scoped thread.
fn use_refcell_in_thread() {
    let shared = RefCell::new(vec![1, 2, 3]);

    thread::scope(|s| {
        // This compiles. `RefCell` is not `Send`, but the thread is scoped.
        s.spawn(|| {
            let mut guard = shared.borrow_mut();
            guard.push(4);
        });
    });

    // `shared` is safe to use again.
    println!("{:?}", shared.borrow());
}

If you try to pass a RefCell to thread::spawn, the compiler rejects it with E0277. Scoped threads let you keep the simpler type. You only pay for thread safety when you actually need to cross thread boundaries long-term.

Reach for scoped threads when your data is local and non-Send. Don't force Arc<Mutex> just to satisfy spawn.

Returning references from threads

Scoped threads have another superpower. You can return references from a scoped thread. Standard spawn cannot do this. References are not Send, so spawn rejects them. Scoped threads allow the return value to be a reference, as long as the reference lives long enough.

The ScopedJoinHandle<T> does not require T: Send. It requires the thread to finish before you call .join(). Since the scope guarantees the thread finishes before the scope ends, the reference is valid.

use std::thread;

/// Finds the maximum value in a slice using a scoped thread.
/// Returns a reference to the maximum element.
fn find_max<'a>(data: &'a [i32]) -> &'a i32 {
    // Handle to wait for the thread and get the result.
    let handle = thread::scope(|s| {
        s.spawn(|| {
            // Return a reference to the max element.
            data.iter().max().unwrap()
        })
    });

    // Join returns the reference. This is safe because the thread finished.
    handle.join().unwrap()
}

This pattern is powerful for parallel searches. You can spawn multiple threads, each searching a chunk of data, and return references to the best results. The main thread collects the references and picks the winner. No cloning. No Arc. Just references.

If you try to return a reference from thread::spawn, you get E0277 because &T is not Send. Scoped threads bypass this by tying the lifetime to the scope.

Use scoped threads when you need to return references from parallel work. It eliminates allocation and indirection.

Realistic pattern: parallel search

A common use case is searching a large dataset. You split the data into chunks, spawn a thread per chunk, and collect the results. With scoped threads, you can return references to the found items without cloning.

use std::thread;

/// Searches for a target value in a slice using parallel chunks.
/// Returns a reference to the first occurrence, or None.
fn parallel_search<'a>(data: &'a [i32], target: i32) -> Option<&'a i32> {
    let chunk_size = data.len() / 4;
    let mut handles = Vec::new();

    thread::scope(|s| {
        // Spawn four threads, each searching a chunk.
        for i in 0..4 {
            let start = i * chunk_size;
            let end = if i == 3 { data.len() } else { start + chunk_size };
            
            handles.push(s.spawn(|| {
                // Search the chunk and return a reference if found.
                data[start..end].iter().find(|&&x| x == target)
            }));
        }
    });

    // Collect results. The first `Some` wins.
    for handle in handles {
        if let Some(result) = handle.join().unwrap() {
            return Some(result);
        }
    }
    None
}

This code spawns four threads. Each thread searches a slice of the data. If the target is found, the thread returns a reference to the element. The main thread joins each handle and checks the result. The first match wins.

The handles vector stores ScopedJoinHandle<Option<&i32>>. These handles are not Send, so you cannot pass them to another thread. You must join them in the scope or after the scope ends. Since the scope blocks, the handles are valid after the scope closes.

Convention aside: when spawning multiple threads in a loop, collect the handles in a Vec. This lets you join them in order or iterate over results. Don't try to join inside the loop unless you need ordered results.

Parallel search with scoped threads is clean and efficient. You get references without allocation.

Pitfalls and deadlocks

Scoped threads are safe, but they have pitfalls. The biggest risk is deadlock. If a spawned thread waits for something that only happens after the scope ends, you deadlock. The scope blocks until all threads finish. If a thread waits for the scope to end, nothing moves.

use std::thread;

/// Demonstrates a deadlock scenario.
fn deadlock_example() {
    let data = vec![1, 2, 3];
    
    thread::scope(|s| {
        s.spawn(|| {
            // This thread tries to spawn another thread inside the scope.
            // That's allowed, but if the inner thread waits for the outer scope,
            // you get a deadlock.
            s.spawn(|| {
                println!("Inner thread running.");
            });
        });
        
        // If the spawned thread waits for a signal that the main thread
        // sends after the scope ends, this blocks forever.
    });
}

Avoid waiting for external events inside a scoped thread if those events depend on the scope ending. Keep scoped threads focused on computation. If you need complex synchronization, consider channels or spawn with JoinHandle.

Another pitfall is stack overflow. Scoped threads use the same stack size as regular threads. If your closure captures a huge stack frame, you might overflow. This is rare, but possible. Keep closures small.

Compiler errors can be confusing. If you try to return a reference from a scoped thread that doesn't live long enough, you get E0597 (does not live long enough). The compiler checks that the reference outlives the scope. If the data is local to the thread, you cannot return it. You must return data that lives in the outer scope.

Treat the scope block as a transaction. All threads must commit before the block closes. If one thread hangs, the whole scope hangs.

Decision: scope vs spawn

Use thread::scope when you need to spawn threads that borrow local stack data. Use thread::scope when you want to return references from threads without Arc. Use thread::scope when your data is not Send and you don't want to rewrite types. Use thread::spawn when the thread must outlive the current function. Use thread::spawn when you need Send guarantees for long-lived data. Use rayon when you are doing parallel iteration over collections and don't need manual thread management.

Scoped threads are the right tool for local parallelism. They keep the borrow checker happy and the code simple. If you find yourself wrapping everything in Arc<Mutex<...>> just to share a stack variable, check for a scope.

Where to go next