0Pricing
Learn Rust Coding · Lesson

Scoped Threads

Borrow across threads.

Scoped Threads is a free Learn Rust Coding lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Learn Rust Coding learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

The Problem with Borrowing in Threads

A normal thread::spawn closure must be 'static: it cannot borrow local variables, because the thread might outlive the function that owns them. That is why you so often see move and Arc.

Scoped threads solve this by guaranteeing every thread finishes before the scope ends, so borrowing local data becomes safe.

Why spawn Needs 'static

With thread::spawn, the spawned thread can keep running after main or any function returns. If it borrowed a local variable, that variable could be destroyed while the thread still used it. Rust forbids this at compile time, forcing you to move owned data into the closure.

use std::thread;

fn main() {
    let nums = vec![1, 2, 3];
    // move transfers ownership into the thread
    let handle = thread::spawn(move || {
        println!("in thread: {:?}", nums);
    });
    handle.join().unwrap();
}

Introducing thread::scope

Stabilized in Rust 1.63, std::thread::scope creates a scope where threads can borrow local variables. The scope blocks until all threads inside it complete, so the borrows can never dangle.

You spawn with s.spawn(...) using the scope handle s instead of thread::spawn.

use std::thread;

fn main() {
    let data = vec![10, 20, 30];
    thread::scope(|s| {
        s.spawn(|| {
            println!("borrowed: {:?}", data);
        });
    });
    // data is still usable here
    println!("after scope: {:?}", data);
}

Borrowing Without move

Inside thread::scope you can read local variables by reference without move. Multiple scoped threads can share an immutable borrow of the same data at the same time, just like normal references.

use std::thread;

fn main() {
    let message = String::from("shared text");
    thread::scope(|s| {
        s.spawn(|| println!("thread 1 sees: {}", message));
        s.spawn(|| println!("thread 2 sees: {}", message));
    });
    println!("main still owns: {}", message);
}

Splitting Work Across a Slice

A common pattern is to split a slice and let each thread process a chunk. Scoped threads make this clean because each thread can borrow part of the original slice directly, no cloning required.

use std::thread;

fn main() {
    let numbers = [1, 2, 3, 4, 5, 6];
    let (left, right) = numbers.split_at(3);
    thread::scope(|s| {
        s.spawn(|| {
            let sum: i32 = left.iter().sum();
            println!("left sum:  {}", sum);
        });
        s.spawn(|| {
            let sum: i32 = right.iter().sum();
            println!("right sum: {}", sum);
        });
    });
}

Collecting Return Values

Like regular threads, s.spawn returns a ScopedJoinHandle. Call .join() to get the thread's return value. You can collect handles and join them after spawning to gather results.

use std::thread;

fn main() {
    let inputs = [2, 4, 6];
    let mut handles = vec![];
    thread::scope(|s| {
        for &x in &inputs {
            handles.push(s.spawn(move || x * x));
        }
        let results: Vec<i32> = handles.into_iter()
            .map(|h| h.join().unwrap())
            .collect();
        println!("{:?}", results);
    });
}

Mutable Borrows Need Care

Two scoped threads cannot hold mutable borrows of the same data at once; that would break Rust's aliasing rules. To mutate shared data from multiple threads you still need a Mutex, but a single thread can take a unique mutable borrow of disjoint pieces.

Below, each thread mutates a separate half of the array via split_at_mut.

use std::thread;

fn main() {
    let mut data = [1, 2, 3, 4];
    let (a, b) = data.split_at_mut(2);
    thread::scope(|s| {
        s.spawn(|| { for x in a.iter_mut() { *x *= 10; } });
        s.spawn(|| { for x in b.iter_mut() { *x += 100; } });
    });
    println!("{:?}", data);
}

Scope Joins Automatically

You do not have to call join on every scoped thread. When the scope closure returns, Rust automatically joins all not-yet-joined threads before continuing. This is why borrows are guaranteed valid for the whole thread lifetime.

use std::thread;
use std::time::Duration;

fn main() {
    let label = String::from("task");
    thread::scope(|s| {
        s.spawn(|| {
            thread::sleep(Duration::from_millis(30));
            println!("{} done", label);
        });
        println!("spawned, scope will wait");
    });
    println!("all scoped threads finished");
}

Combining Scope with Shared Mutation

When threads must mutate the same value, combine scoped threads with a Mutex. You skip Arc because the scope already lets threads borrow the local Mutex directly.

use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    thread::scope(|s| {
        for _ in 0..5 {
            s.spawn(|| {
                let mut n = counter.lock().unwrap();
                *n += 1;
            });
        }
    });
    println!("counter = {}", *counter.lock().unwrap());
}

Scoped vs Spawned: When to Use Which

Use scoped threads when the work is bounded and finishes within a function, and you want to borrow stack data without Arc or cloning.

Use thread::spawn when a thread must outlive the current function or run for the whole program lifetime. Scoped threads cannot escape their scope.

Parallel Map Example

Putting it together: a tiny parallel map that transforms each element of a vector in its own thread while borrowing the input, then collects the results in order.

use std::thread;

fn parallel_double(items: &[i32]) -> Vec<i32> {
    let mut handles = Vec::new();
    let mut out = Vec::new();
    thread::scope(|s| {
        for &x in items {
            handles.push(s.spawn(move || x * 2));
        }
        for h in handles {
            out.push(h.join().unwrap());
        }
    });
    out
}

fn main() {
    let nums = vec![1, 2, 3, 4];
    println!("{:?}", parallel_double(&nums));
}

Quick Check

Test your understanding of scoped threads.

Recap

You learned about scoped threads:

  • thread::spawn requires 'static closures; scoped threads do not.
  • thread::scope lets threads borrow local variables safely.
  • The scope auto-joins all threads before returning.
  • Mutable sharing still needs a Mutex, but no Arc inside a scope.
  • Use scoped threads for bounded, function-local parallelism.

Frequently asked questions

Is the “Scoped Threads” lesson free?

Yes — the full text of “Scoped Threads” is free to read here on the web, and the Learn Rust Coding course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Learn Rust Coding course, upgrade to CoddyKit PRO.

What will I learn in “Scoped Threads”?

Borrow across threads. You practise Learn Rust Coding with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Learn Rust Coding?

No prior experience is required. Learn Rust Coding on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Scoped Threads” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Learn Rust Coding lesson?

Yes. Every Learn Rust Coding lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. mpsc Channels
  2. Sharing State with Arc/Mutex
  3. Scoped Threads
  4. Crossbeam Channels
← Back to Learn Rust Coding