From Scope to Thread: Mastering Closure Variable Captures in Rust

Exploring the why and how of capturing variables inside a closure running in a new thread in Rust

From Scope to Thread: Mastering Closure Variable Captures in Rust

In Rust, one of the most fascinating (and sometimes head-scratching) features is how closures capture variables from their environment—especially when you involve threads. If you’ve ever tried to spawn a new thread and pass data into the closure without using move, you’ve likely been greeted by the compiler with a wall of text about lifetimes, borrowing, and ownership.

In this blog, we will explore the why and how of capturing variables inside a closure running in a new thread. We’ll start with a simple example that fails to compile, examine why that is, and then fix it step by step. Along the way, we’ll discuss key Rust concepts like ownership, borrowing, Send, Sync, and why they matter so much for concurrency.

Let’s jump in.


The First Failure: Capturing Without move

A classic first attempt at threading in Rust looks something like this:

use std::thread;

fn main() {
    let message = String::from("Hello, Rust!");

    let handle = thread::spawn(|| {
        // Trying to capture 'message' by reference implicitly
        println!("{}", message);
    });

    handle.join().unwrap();
}

without move

You might think this is perfectly reasonable: we have a message string, we spawn a new thread and print the message inside our closure. But Rust’s compiler has a different opinion:

error[E0373]: closure may outlive the current function, but it borrows `message`, which is owned by the current function
 --> src/main.rs:6:23
  |
6 |     let handle = thread::spawn(|| {
  |                       ^^^^^^ may outlive borrowed value `message`
7 |         println!("{}", message);
  |                        ------- `message` is borrowed here

Why the Error?

  • Thread Lifetime: The new thread could, in theory, outlive the function main. Once main ends, message goes out of scope.
  • Borrow Checker: Rust won’t let a reference outlive the data it points to—this would be a dangling reference.

In other words, if the main thread exits before the spawned thread prints message, we end up with a reference to memory that no longer exists.


The Fix: Capturing by Value with move

The compiler error disappears if we add the move keyword:

use std::thread;

fn main() {
    let message = String::from("Hello, Rust!");

    let handle = thread::spawn(move || {
        // Now we're taking ownership of 'message'.
        println!("{}", message);
    });
    // you cannot use message here,
    // since the ownership of the variable is moved to the new thread
    handle.join().unwrap();
}

with move

Why move Solves It

  • Ownership Transfer: With move, the closure owns the message. It’s no longer borrowing from the outer scope.
  • No Dangling: Because the closure owns message, the data remains valid until the closure (and thus the thread) is done. Even if main finishes, the message for that thread is safely stored elsewhere.

Rust’s ownership rules ensure that once move is used, you can’t accidentally use message in the outer scope, preventing invalid access.


The Next Hurdle: Using References Inside the Closure

Now, let’s say we try to do something more “clever.” Instead of moving the entire data, we only move a reference to the data:

use std::thread;

fn main() {
    let data = vec![1, 2, 3];
    let reference_to_data = &data; // Immutable reference

    let handle = thread::spawn(move || {
        // This 'move' only captures the reference, not the actual vector.
        println!("Reference to vector: {:?}", reference_to_data);
    });

    // If 'data' goes out of scope here, 'reference_to_data' is invalid.
    handle.join().unwrap();
}

Spoiler: This still causes a compile error (E0597), complaining that data does not live long enough:

error[E0597]: `data` does not live long enough
 --> src/main.rs:3:29
  |
2 |     let data = vec![1, 2, 3];
  |         ---- binding `data` declared here
3 |     let reference_to_data = &data; // This is an immutable reference
  |                             ^^^^^ borrowed value does not live long enough
...
6 |     let handle = std::thread::spawn(move || {
  |  _________________________________-
7 | |       println!("Reference to vector: {:?}", reference_to_data);
8 | |     });
  | |______- argument requires that `data` is borrowed for `'static`
...
13 | }
  | - `data` dropped here while still borrowed

Why Isn’t move Enough with References?

  • When we do move || { ... }, only the reference (&data) is moved. The main function still owns the underlying vector.
  • As soon as main function ends (or even if data goes out of scope before the thread finishes), that reference points to memory that’s no longer valid.

This leads to a potential dangling pointer in the thread if it outlives the scope in which data was allocated.

Ownership vs. Borrowing for References

  • Moving a reference does not move the original data—only ownership of the reference itself is transferred.
  • The actual data (data in this case) is still owned by the original scope, with its own lifetime constraints.

Safely Accessing Data Across Threads

To safely share data across threads without transferring full ownership, Rust provides concurrency primitives such as Arc (an Atomic Reference Counting smart pointer) and Mutex (for mutual exclusion). One common pattern:

use std::sync::{Arc, Mutex};
use std::thread;

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

    // Wrap 'data' inside an Arc so multiple threads can own it safely.
    let shared_data = Arc::new(data);

    let handle = {
        // Clone the Arc to get another reference-counted pointer.
        let data_for_thread = Arc::clone(&shared_data);

        thread::spawn(move || {
            // The closure now owns an Arc, guaranteeing the data won't be dropped
            // until all Arcs are out of scope.
            println!("Cloned data in thread: {:?}", data_for_thread);
        })
    };

    handle.join().unwrap();

    // 'shared_data' is still accessible here, because Arc ensures
    // the data wasn't dropped prematurely.
    println!("Original data in main: {:?}", shared_data);
}

Why This Works

  • Arc: Ensures multiple owners can hold the data concurrently, and it won’t be dropped until the last owner (Arc) goes out of scope.
  • No Dangling References: Each thread has a valid handle to the data.
  • Thread-Safety: If you need to mutate the data, you’d pair Arc with Mutex (or another synchronization primitive).

5. The Role of Send (and Sync)

When you do thread::spawn, the closure and all its captures must be Send. This trait means:

“It is safe to move the type to another thread.”

Send Trait

  • Most Primitive Types Are Send: i32, bool, char, etc., are all Send.
  • Complex Types: A struct is Send if all of its fields are Send. If you have a type that internally holds something that isn’t Send, then the entire struct fails to be Send.
  • Transfer of Ownership: Even if you transfer ownership, a type must still implement Send to safely move across threads. Some types can’t be moved across threads (for example, raw pointers or certain FFI types) because the compiler can’t guarantee thread safety.

Sync Trait

Closely related is the Sync trait:

“It is safe for the type to be referenced from multiple threads.”
  • If &T is Send, then T is Sync.
  • Typically, types that can be shared among threads immutably implement Sync.
  • Arc<T> is both Send and Sync if T is Send + Sync. Mutex<T> is also Send + Sync if T is Send, etc.

Send + Sync Together

  • A type that is both Send and Sync can be transferred across threads and shared across threads safely (with the right concurrency primitives if mutation is involved).
  • In multi-threaded Rust code, you’ll often see these two traits appear together—especially in libraries or data structures that need to support concurrent operations.

Putting It All Together

  1. Capturing Data Without move: Leads to errors if the thread can outlive the scope, because references become invalid.
  2. Using move: Transfers ownership into the closure, ensuring the data lives as long as the thread needs it. This is often the simplest fix.
  3. References + move: Merely moving a reference doesn’t move the underlying data, so you can still end up with lifetime issues unless the data is 'static or managed by concurrency primitives like Arc.
  4. Send and Sync: Even if you properly move data, it must implement Send to be transferred to another thread. If multiple threads need to share data safely, the data (or references to it) must also implement Sync or use synchronization wrappers.

Conclusion

Rust’s closure capture rules might initially feel strict, but they are pivotal in ensuring memory safety and data race freedom. When you combine these rules with the Send and Sync traits, you get a concurrency model that might be more verbose than in other languages but yields a high guarantee of correctness.

  • Need to own data in another thread? Use move.
  • Need to share data across threads? Reach for Arc (and Mutex/RwLock if you need mutable access).
  • Confused about references? Remember that references don’t extend the data’s lifetime—they just borrow. For cross-thread usage, the data must remain valid (owned or 'static).

Happy coding, and may your threads never dangle a reference again!