Scoped Threads in Rust

Sep 01, 2025

Reading Two Beautiful Rust Programs I came across a construct I hadn’t seen before for working with threads in Rust: std::thread::scope.

When you create a thread in Rust, you must pass a closure of the code you want that thread to execute. Sometimes, that closure works on data from the parent scope (the code that is spawning the thread):

fn main() {
    let mut counter = 0;

    let f = || {
        counter += 1;
        println!("counter = {}", counter)
    };

    let _ = std::thread::spawn(f).join();
}

That code does not compile. This is essentially because std::thread::spawn takes a closure with the bound 'static, which means counter is technically able to outlive main. You cannot have a reference to something that might not be there (dangling pointer). So, you must force the closure to take ownership of counter. We fix that by adding move:

fn main() {
    let mut counter = 0;

    let f = move || {
        counter += 1;
        println!("counter = {}", counter)
    };

    let _ = std::thread::spawn(f).join();
}

What happens if we access counter at the end of the program?

fn main() {
    let mut counter = 0;

    let f = move || {
        counter += 1;
        println!("counter = {}", counter)
    };

    let _ = std::thread::spawn(f).join();

    println!("counter = {}", counter)
}
counter = 1
counter = 0

Because counter implements Copy, a copy of it is made and passed to the closure. What if we don’t want that behavior? Suppose we want to mutably borrow counter. We can use std::thread::scope to achieve this:

fn main() {
    let mut counter = 0;

    let f = || {
        counter += 1;
        println!("counter = {}", counter)
    };

    std::thread::scope(|s| {
        s.spawn(f);
    });

    println!("counter = {}", counter)
}

And that works:

counter = 1
counter = 1

Note that we don’t use move anymore. std::thread::scope defines a lifetime 'scope that is contained within the parent scope and lasts for the duration of the call to scope. It is guaranteed that all threads spawned within the scope will be joined before scope returns. Because threads cannot outlive 'scope, any data borrowed by their closures is guaranteed to remain valid for the lifetime of the threads. When you access counter again, there’s no mutable reference to it anymore.

Let’s use Box as an example of some heap-allocated data that does not implement Copy:

fn main() {
    let mut counter = Box::new(0);

    let f = move || {
        *counter += 1;
        println!("counter = {}", counter)
    };

    let _ = std::thread::spawn(f).join();
}

That works just fine. The problem is that if we want to access counter at the end of the program, we get an error:

println!("counter = {}", counter)
error[E0382]: borrow of moved value: `counter`
  --> src/main.rs:30:30
   |
20 |     let counter = Box::new(0);
   |         ------- move occurs because `counter` has type `Box<i32>`, which does not implement the `Copy` trait
21 |
22 |     let f = move || {
   |             ------- value moved into closure here
23 |         let mut c = counter;
   |                     ------- variable moved due to use in closure
...
30 |     println!("counter = {}", *counter)
   |                              ^^^^^^^^ value borrowed here after move

Our counter was moved and we don’t have access to it after the thread is finished. We can use std::thread::scope and mutably borrow the counter to fix this:

fn main() {
    let mut counter = Box::new(0);

    let f = || {
        let c: &mut i32 = &mut counter;
        *c += 1;
        println!("counter = {}", c)
    };

    std::thread::scope(|s| {
        s.spawn(f);
    });

    println!("counter = {}", counter)
}

There it is, a simple explanation of how to use std::thread::scope to execute a thread on borrowed data from the parent scope.

#rust #threads