Scoped threads in Rust

Rust is known for its strong emphasis on memory safety and concurrency. One of the powerful features it offers for concurrent programming is scoped threads. Scoped threads, managed by std::thread::scope, provide a safe and convenient way to spawn threads that are guaranteed to complete before the scope exits. This ensures that threads do not outlive the data they are working with, preventing common concurrency issues such as data races and dangling references.

What are Scoped Threads?

Scoped threads are threads that are confined to a specific lexical scope. The primary advantage of scoped threads is that they provide a way to safely access local variables without complex synchronization mechanisms. When the scope exits, all spawned threads are automatically joined, ensuring that they complete execution and preventing any threads from running after the scope has ended.

use std::thread;
fn main() {
    let data = vec![1, 2, 3];
    println!("Main thread started ");
    thread::scope(|scope| {
        scope.spawn(|| {
            for num in &data {
                println!("Scoped thread: {}", num);
            }
        });
    }); 
    // All scoped threads are joined here before exiting the scope
    println!("Main thread continued ");
}
/*
Main thread started 
Scoped thread: 1
Scoped thread: 2
Scoped thread: 3
Main thread continued 
*/        

In the above example, 1 threads are created within the same scope. The spawned threads can safely access the vector because it is guaranteed to complete before the scope exit data.

Example 2: Modifying Shared Data

In the below example, the vector is wrapped in an to allow safe concurrent modification. Each thread locks the mutex before modifying the vector. Because the threads are scoped, we are assured that all modifications are completed before the scope exits.dataArc<Mutex<_>>

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

fn main() {
    let data = Arc::new(Mutex::new(vec![1, 2, 3]));
    println!("Main thread started : {:?}", data.lock().unwrap());
    let data_clone = Arc::clone(&data);
    thread::scope(|scope| {
        let data = Arc::clone(&data_clone);
        scope.spawn(move || {
            let mut data = data.lock().unwrap();
            data.push(4);
            println!("Thread 1: {:?}", data);
        });

        let data = Arc::clone(&data_clone);
        scope.spawn(move || {
            let mut data = data.lock().unwrap();
            data.push(5);
            println!("Thread 2: {:?}", data);
        });
    }); 
    // All scoped threads are joined here before exiting the scope

    println!("Main thread ended : {:?}", data.lock().unwrap());
}
/*
Main thread started : [1, 2, 3]
Thread 1: [1, 2, 3, 4]
Thread 2: [1, 2, 3, 4, 5]
Main thread ended : [1, 2, 3, 4, 5]
*/        


Example 3.0: Parallel Computation

Below is simpleexample demonstrates how scoped threads can be used to perform parallel computation.

Please note in below example the vector v is barrowed and not moved or cloned.

fn main() {
    let v = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    let midpoint = v.len() / 2;

    std::thread::scope(|scope| {
        scope.spawn(|| {
            let first = &v[..midpoint];
            println!("Here's the first half of v: {first:?}");
        });
        scope.spawn(|| {
            let second = &v[midpoint..];
            println!("Here's the second half of v: {second:?}");
        });
    });
    println!("Here's v: {v:?}");
}
/*
Here's the first half of v: [1, 2, 3, 4, 5]
Here's the second half of v: [6, 7, 8, 9, 10]
Here's v: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
*/        


Example 3: Parallel Computation

Below is more advanced example where we calculate the square of each number in the vector in parallel. Each thread computes the square of a single number and stores the result in the numbers results vector. Because the threads are scoped, we are guaranteed that all computations are completed before the scope exits.


Note : In below example vector numbers is barrowed with each thread and not moved or cloned.

Since results vector is shared with each thread mutably, we need to protect it with Arc/Mutex. Since variables cant be shared mutable more than once

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

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    let results = Arc::new(Mutex::new(vec![0; numbers.len()]));

    thread::scope(|scope| {
        for (i, &num) in numbers.iter().enumerate() {
            let results = Arc::clone(&results);
            scope.spawn(move || {
                let mut results = results.lock().unwrap();
                results[i] = num * num;
                println!("Thread {}: {}", i, results[i]);
            });
        }
    }); // All scoped threads are joined here before exiting the scope

    println!("Results: {:?}", results.lock().unwrap());
}
/*
Thread 0: 1
Thread 1: 4
Thread 2: 9
Thread 3: 16
Thread 4: 25
Thread 6: 49
Thread 5: 36
Thread 7: 64
Thread 8: 81
Thread 9: 100
Results: [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
*/        

Benefits of Scoped Threads

  1. Safety: Scoped threads ensure that all threads complete before the scope exits, preventing dangling references and data races.
  2. Simplicity: By guaranteeing the lifetime of threads, scoped threads simplify the management of shared data.
  3. Automatic Joining: Scoped threads are automatically joined when the scope exits, eliminating the need for manual join calls.

Key points of scoped threads ;

  1. Scoped threads are created using std::thread::scope
  2. Scoped threads can safely access local variables within the scope.
  3. All scoped threads are automatically joined when the scope exits.
  4. Scoped threads simplify concurrent programming by reducing the need for explicit synchronization and manual thread management.

Differences between thread::spawn and thread::scope

Normal Threads (thread::spawn)

Normal threads in Rust are created using the function. This function launches a new OS thread to execute a given closure.

Here’s a basic example:thread::spawn


use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("Hello from the spawned thread: {}", i);
        }
    });

    for i in 1..5 {
        println!("Hello from the main thread: {}", i);
    }

    // Wait for the spawned thread to finish
    // handle.join().unwrap();  // --> Commeted here 
}        

When last line "handle.join().unwrap();" is commenetd below is output in my case., But it could be anything random where main thread will print but cant gurantee the print from spawned thread.

/*
Hello from the spawned thread: 1
Hello from the main thread: 1
Hello from the main thread: 2
Hello from the main thread: 3
Hello from the main thread: 4
Hello from the spawned thread: 2
Hello from the spawned thread: 3
Hello from the spawned thread: 
*/        

Below is the output when you have "handle.join().unwrap();" is un-commenetd

Hello from the main thread: 1
Hello from the main thread: 2
Hello from the main thread: 3
Hello from the main thread: 4
Hello from the spawned thread: 1
Hello from the spawned thread: 2
Hello from the spawned thread: 3
Hello from the spawned thread: 4
Hello from the spawned thread: 5
Hello from the spawned thread: 6
Hello from the spawned thread: 7
Hello from the spawned thread: 8
Hello from the spawned thread: 9        

Then you can understand what is difference between with and without "handle.join()" in case of normal threads.

Characteristics of Normal Threads

  1. Independent Lifetime: The spawned thread can outlive the parent (main) thread. This flexibility allows the thread to run independently, which is useful for long-running or background tasks.
  2. Explicit Join: You must explicitly call to wait for the thread to finish. Failing to join can result in the spawned thread being abruptly terminated when the main thread exits.join
  3. Memory and Resource Usage: Each normal thread consumes stack space and OS resources. While Rust is efficient, spawning many threads can still lead to high resource consumption.


Characteristics of Scoped Threads

  1. Scope-bound Lifetime: Scoped threads cannot outlive the scope in which they were created. This ensures that any local variables used within the threads are still valid, preventing dangling references.
  2. Automatic Join: Threads are automatically joined at the end of the scope, eliminating the need for explicit calls.join
  3. Safe barrow from parent scope :Because the threads are guaranteed to terminate, you can safely borrow data from the parent scope. This is a lifetime issue: a normal thread could keep running for a long time, past the time the scope that launched it ends---so borrowing data from that scope would be a bug (and a common cause of crashes and data corruption in other languages). Rust won't let you do that. But since you have the guarantee of lifetime, you can borrow data from the parent scope without having to worry about it.
  4. Reduced Complexity: By ensuring threads complete within the scope, scoped threads simplify the code and reduce the risk of synchronization issues.

Choosing between normal threads and scoped threads in Rust depends on your specific use case and requirements:

  • Use normal threads (thread::spawn) if you need independent, long-running threads or background tasks that can outlive the parent thread.
  • Use scoped threads (thread::scope) if you need safer, more straightforward thread management within a defined scope, especially when working with local variables.

要查看或添加评论,请登录

社区洞察

其他会员也浏览了