Scoped threads are a new feature in Rust that makes multithreading and concurrency much easier and safer.
In the past, the crossbeam
crate provided a way to create scoped threads. But now, its scoped thread function has been soft-deprecated in favor of Rust’s built-in scoped thread function.
This article will teach you about scoped threads, how they function, how to build them, and how they vary from unscoped threads. Jump ahead:
Before diving into scoped threads, it’s a good idea to understand scopes and how they function in Rust.
A scope is a chunk of code that is contained within a code block. The Rust compiler restricts access to variables and constants inside a scope to lines and other scopes within that scope.
Depending on the programming language, scopes are typically associated with function definitions, condition definitions, loop definitions, and selection definitions. Consider the following example:
fn main () { // function scope let condition = true; // variable println!("Condition: {}", condition); // 'condition' can be accessed if condition { // conditional scope let word = "Hello"; // variable println!("Condition: {}", condition); // 'condition' can be accessed println!("word: {}", word); // 'word' can be accessed } println!("word: {}", word); // this will throw an error. // 'word' can't be accessed in this scope }
There are two variables in this example — word
and condition
— in two separate scopes, which are the main function and the conditional scope.
As you should know, threads provide a way to achieve concurrency in your Rust projects. Scoped threads are normal threads that exist and operate in a supervised context. The scope is a regulated environment that lets you manage numerous threads in your code with ease.
To construct the scope, use the std::thread::scope
function and pass it a closure:
std::thread::scope(|scope| { });
The scope
parameter in the closure is a Scope
object that lets you create and manage threads in the scope. Using the scope
parameter, create a thread by using its spawn
function and providing a closure to run within the new thread:
>scope.spawn(|| { // your code });
Consider the diagram below, which represents a program:
The program begins at “Start” and finishes at “End” in the figure. In the center, the program constructs a scope, inside which the program spawns three threads. Before continuing with the rest of the program, the scope ensures that all threads are closed.
In this section, I’ll show you an example of a program that uses scoped threads so you can see them in action. There are no external packages required in this example.
To see the example, you must first create a project and then paste the following into your main.rs
file:
use std::{ thread, time }; fn main() { // create a scope thread::scope(|scope| { // spawn first thread scope.spawn(|| { thread::sleep( time::Duration::from_secs(5) ); // wait for 5 seconds before printing "Hello, from thread 1" println!("Hello, from thread 1"); }); // spawn second thread scope.spawn(|| { thread::sleep( time::Duration::from_secs(2) ); // wait for 2 seconds before printing "Hello, from thread 2" println!("Hello, from thread 2"); }); // spawn third thread scope.spawn(|| { thread::sleep( time::Duration::from_secs(10) ); // wait for 10 seconds before printing "Hello, from thread 3" println!("Hello, from thread 3"); }); }); // all threads within the scope has to be closed // for the program to continue println!("All threads completed!"); }
When you execute this program, the following output should appear:
Compiling scoped-threads-example v0.1.0 (/path/to/scoped-threads-example) Finished dev [unoptimized + debuginfo] target(s) in 5.49s Running `target/debug/scoped-threads-example` Hello, from thread 2 Hello, from thread 1 Hello, from thread 3 All threads completed!
Once the thread::scope
function creates the scope, the program executes the closure that you provided. In the closure, the three scope.spawn
methods will spawn three threads.
The second thread prints its message before the first and third threads in the terminal output because of the timings of all the threads. The first thread completes in five seconds, the second in two seconds, and the third in ten seconds.
The program outputs “All threads completed!” at the end because the scope does not shut until all threads have finished running.
In this part, I’ll demonstrate another program that performs the same operations as the previous example, but without the use of scoped threads. This will allow you to easily observe how they vary from one another.
Copy the following into the main.rs
file in a new project directory:
use std::{ thread, time }; fn main() { // spawn first thread let thread1 = thread::spawn(|| { thread::sleep(time::Duration::from_secs(5)); println!("Hello, from thread 1"); }); // spawn second thread let thread2 = thread::spawn(|| { thread::sleep(time::Duration::from_secs(2)); println!("Hello, from thread 2"); }); // spawn third thread let thread3 = thread::spawn(|| { thread::sleep(time::Duration::from_secs(10)); println!("Hello, from thread 3"); }); thread1.join().unwrap(); // wait for first thread thread2.join().unwrap(); // wait for second thread thread3.join().unwrap(); // wait for third thread println!("All threads completed!"); }
If you run the program, you will obtain the same results as in the previous section:
Compiling scoped-threads-example v0.1.0 (/path/to/scoped-threads-example) Finished dev [unoptimized + debuginfo] target(s) in 5.49s Running `target/debug/scoped-threads-example` Hello, from thread 2 Hello, from thread 1 Hello, from thread 3 All threads completed!
As you can see, keeping track of all the threads in your application without the scope is considerably more difficult. As a result, your software will be more prone to mistakes. Without the scope, you’ll have to manually control each thread you start to avoid unexpected behavior.
A more direct comparison may provide a better understanding of how scoped and unscoped threads compare. In the following sections, we’ll go through some of the important distinctions between the two types of threads.
Scoped threads allow you to borrow variables from another scope without moving them. Unscoped threads need you to move the variable to the thread you want to utilize it in, which stops it from being used in its previous scope.
You can borrow an immutable reference to as many threads as you like in scoped threads, but you can only borrow a mutable reference once.
Consider the following example of an unscoped thread:
fn main() { let word = String::from("Hello"); std::thread::spawn(|| { println!("{}, world!", word); }).join().unwrap(); }
When you run the code, the following error is displayed:
Compiling scoped-threads v0.1.0 (/path/to/scoped-threads) error[E0373]: closure may outlive the current function, but it borrows `word`, which is owned by the current function --> src/main.rs:4:22 | 4 | std::thread::spawn(|| { | ^^ may outlive borrowed value `word` 5 | println!("{}, world!", word); | ---- `word` is borrowed here | note: function requires argument type to outlive `'static` --> src/main.rs:4:3 | 4 | / std::thread::spawn(|| { 5 | | println!("{}, world!", word); 6 | | }).join().unwrap(); | |____^ help: to force the closure to take ownership of `word` (and any other referenced variables), use the `move` keyword | 4 | std::thread::spawn(move || { | ++++ For more information about this error, try `rustc --explain E0373`. error: could not compile `scoped-threads-experiments` due to previous error
The error message informs you that you must move the variable to the scope before using it. The program will run successfully if the variable is moved as shown below:
fn main() { let word = String::from("Hello"); std::thread::spawn(move || { println!("{}, world!", word); }).join().unwrap(); }
However, if you subsequently add a line that utilizes the moved variable, as seen below, you will receive an error:
fn main() { let word = String::from("Hello"); std::thread::spawn(move || { println!("{}, world!", word); }).join().unwrap(); println!("{}, from outside the thread!", word); }
The error indicates that you cannot utilize a variable after it has been moved:
Compiling scoped-threads v0.1.0 (/path/to/scoped-threads) error[E0382]: borrow of moved value: `word` --> src/main.rs:8:44 | 2 | let word = String::from("Hello"); | ---- move occurs because `word` has type `String`, which does not implement the `Copy` trait 3 | 4 | std::thread::spawn(move || { | ------- value moved into closure here 5 | println!("{}, world!", word); | ---- variable moved due to use in closure ... 8 | println!("{}, from outside the thread!", word); | ^^^^ value borrowed here after move | = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info) For more information about this error, try `rustc --explain E0382`. error: could not compile `scoped-threads-experiments` due to previous error
In comparison, take a look at the scoped thread example below:
fn main() { let word = String::from("Hello"); std::thread::scope(|scope| { scope.spawn(|| { println!("{}, from inside the thread!", word); }); }); println!("{}, from outside the thread!", word); }
When you execute it, you’ll notice that the program functions as expected:
Compiling scoped-threads v0.1.0 (/path/to/scoped-threads) Finished dev [unoptimized + debuginfo] target(s) in 0.67s Running `target/debug/scoped-threads-experiments` Hello, from inside the thread! Hello, from outside the thread!
It is not necessary to move the variable to the thread, and it will function nicely even if you add any lines that use the variable after the scope.
Returning values from scoped threads and unscoped threads follows a similar process. Take a look at the following example:
fn main() { let word = "Hello, world"; let thread = std::thread::spawn(move || { return format!("{}", word) }); let result = thread.join().unwrap(); println!("{}", result); }
The example above uses unscoped threads. If you execute the example, it returns the thread’s return value. As you can see in the output below:
Finished dev [unoptimized + debuginfo] target(s) in 0.01s Running `target/debug/scoped-threads` Hello, world
The thread plainly returns “Hello, world” in this example, which can be accessed by executing the.join()
function.
It’s hard to imagine utilizing the .join()
method to retrieve the results of scoped threads. Because the threads are not directly tied to the main thread, determining where to utilize the .join()
method is difficult if you don’t already know where to put it.
Follow this example to get the results of scoped threads:
use std::thread; fn main() { let words = "Hello, world"; let (t1, t2) = thread::scope(|scope| { // spawn first thread let t1 = scope.spawn(|| { format!("{} 1", words) }); // spawn second thread let t2 = scope.spawn(|| { format!("{} 2", words) }); // get results of both threads and return return (t1.join().unwrap(), t2.join().unwrap()); }); println!("t1: {}\nt2: {}", t1, t2); }
You can evaluate the results of all threads that were working in your scope if you return their results.
Threads work independently from one another. Therefore, being able to share data between threads allows you to use them for complex applications.
In this section, we’ll look at how data sharing works in scoped and unscoped threads. Sharing data between scoped and unscoped threads follows a similar process.
Take a look at the unscoped thread example below:
use std::sync::{ mpsc, mpsc::{Sender, Receiver} }; use std::{thread, time}; fn main() { let (tx, rx): (Sender<i32>, Receiver<i32>) = mpsc::channel(); let t1 = tx.clone(); let t2 = tx.clone(); let t3 = tx.clone(); // spawn first thread let th1 = thread::spawn(move || { // simulate heavy computation thread::sleep( time::Duration::from_secs(5) ); t1.send(50).unwrap(); println!("Thread 1 completed: 50"); }); // spawn second thread let th2 = thread::spawn(move || { // simulate heavy computation thread::sleep( time::Duration::from_secs(2) ); t2.send(123).unwrap(); println!("Thread 2 completed: 123"); }); // spawn third thread let th3 = thread::spawn(move || { // simulate heavy computation thread::sleep( time::Duration::from_secs(10) ); t3.send(66).unwrap(); println!("Thread 3 completed: 66"); }); // spawn fourth thread let th4 = thread::spawn(move || { let result = rx.recv().unwrap() + rx.recv().unwrap() + rx.recv().unwrap(); println!("Total: {}", result); }); th1.join().unwrap(); th2.join().unwrap(); th3.join().unwrap(); th4.join().unwrap(); println!("All threads completed!"); }
In the above, mpsc::channels
provides a Sender
and a Receiver
object. On the ninth line, we use mpsc::channels
to initialize the Sender
and Receiver
objects as tx
and rx
.
You can clone a Sender
object as many times as you want. But you can only have one Receiver
object.
Calling rx.recv()
pauses the current thread until it receives a message from tx
.
Now, take a look at the same example above, but using scoped threads this time:
use std::sync::{ mpsc, mpsc::{Sender, Receiver} }; use std::{thread, time}; fn main() { // create a scope thread::scope(|scope| { let (tx, rx): (Sender<i32>, Receiver<i32>) = mpsc::channel(); let t1 = tx.clone(); let t2 = tx.clone(); let t3 = tx.clone(); // spawn first thread scope.spawn(move || { // simulate heavy computation thread::sleep( time::Duration::from_secs(5) ); t1.send(50).unwrap(); println!("Thread 1 completed: 50"); }); // spawn second thread scope.spawn(move || { // simulate heavy computation thread::sleep( time::Duration::from_secs(2) ); t2.send(123).unwrap(); println!("Thread 2 completed: 123"); }); // spawn third thread scope.spawn(move || { // simulate heavy computation thread::sleep( time::Duration::from_secs(10) ); t3.send(66).unwrap(); println!("Thread 3 completed: 66"); }); scope.spawn(move || { let result = rx.recv().unwrap() + rx.recv().unwrap() + rx.recv().unwrap(); println!("Total: {}", result); }); }); println!("All threads completed!"); }
As you can see in the example above, scoped threads and unscoped threads share data with each other in the same way.
Comparing the appearance of scoped and unscoped threads will quickly show you which is more organized. Because they are not handled in a supervised environment, unscoped threads are not as well organized as scoped threads.
It’s simple to picture unscoped threads becoming difficult to maintain in an application with up to a hundred threads. Scoped threads are grouped together and maintained in a way that allows you to easily handle up to that many threads.
Consider the following example with 20 scoped threads:
fn main () { std::thread::scope(|scope| { scope.spawn(|| { println!("Hello, from this thread 1"); }); scope.spawn(|| { println!("Hello, from this thread 2"); }); scope.spawn(|| { println!("Hello, from this thread 3"); }); scope.spawn(|| { println!("Hello, from this thread 4"); }); scope.spawn(|| { println!("Hello, from this thread 5"); }); scope.spawn(|| { println!("Hello, from this thread 6"); }); scope.spawn(|| { println!("Hello, from this thread 7"); }); scope.spawn(|| { println!("Hello, from this thread 8"); }); scope.spawn(|| { println!("Hello, from this thread 9"); }); scope.spawn(|| { println!("Hello, from this thread 10"); }); scope.spawn(|| { println!("Hello, from this thread 11"); }); scope.spawn(|| { println!("Hello, from this thread 12"); }); scope.spawn(|| { println!("Hello, from this thread 13"); }); scope.spawn(|| { println!("Hello, from this thread 14"); }); scope.spawn(|| { println!("Hello, from this thread 15"); }); scope.spawn(|| { println!("Hello, from this thread 16"); }); scope.spawn(|| { println!("Hello, from this thread 17"); }); scope.spawn(|| { println!("Hello, from this thread 18"); }); scope.spawn(|| { println!("Hello, from this thread 19"); }); scope.spawn(|| { println!("Hello, from this thread 20"); }); }); }
When compared to this example, which does not use scopes, it is obvious to notice the difference in manageability:
use std::thread; fn main () { let thread1 = thread::spawn(|| { println!("Hello, from this thread 1"); }); let thread2 = thread::spawn(|| { println!("Hello, from this thread 2"); }); let thread3 = thread::spawn(|| { println!("Hello, from this thread 3"); }); let thread4 = thread::spawn(|| { println!("Hello, from this thread 4"); }); let thread5 = thread::spawn(|| { println!("Hello, from this thread 5"); }); let thread6 = thread::spawn(|| { println!("Hello, from this thread 6"); }); let thread7 = thread::spawn(|| { println!("Hello, from this thread 7"); }); let thread8 = thread::spawn(|| { println!("Hello, from this thread 8"); }); let thread9 = thread::spawn(|| { println!("Hello, from this thread 9"); }); let thread10 = thread::spawn(|| { println!("Hello, from this thread 10"); }); let thread11 = thread::spawn(|| { println!("Hello, from this thread 11"); }); let thread12 = thread::spawn(|| { println!("Hello, from this thread 12"); }); let thread13 = thread::spawn(|| { println!("Hello, from this thread 13"); }); let thread14 = thread::spawn(|| { println!("Hello, from this thread 14"); }); let thread15 = thread::spawn(|| { println!("Hello, from this thread 15"); }); let thread16 = thread::spawn(|| { println!("Hello, from this thread 16"); }); let thread17 = thread::spawn(|| { println!("Hello, from this thread 17"); }); let thread18 = thread::spawn(|| { println!("Hello, from this thread 18"); }); let thread19 = thread::spawn(|| { println!("Hello, from this thread 19"); }); let thread20 = thread::spawn(|| { println!("Hello, from this thread 20"); }); thread1.join().unwrap(); thread2.join().unwrap(); thread3.join().unwrap(); thread4.join().unwrap(); thread5.join().unwrap(); thread6.join().unwrap(); thread7.join().unwrap(); thread8.join().unwrap(); thread9.join().unwrap(); thread10.join().unwrap(); thread11.join().unwrap(); thread12.join().unwrap(); thread13.join().unwrap(); thread14.join().unwrap(); thread15.join().unwrap(); thread16.join().unwrap(); thread17.join().unwrap(); thread18.join().unwrap(); thread19.join().unwrap(); thread20.join().unwrap(); }
Scoped threads provide a mechanism to ensure thread and memory safety. There are several types of applications that can benefit from this mechanism.
For example, web servers receive benefits from handling multiple requests in separate threads. Using scoped threads, the memory allocation and lifetime of each thread’s data can be controlled.
Similarly, to provide a seamless gaming experience, game engines need to process and render graphics, physics, and AI calculations in parallel. Scoped threads can help manage the lifetime of data and synchronization between threads, ensuring thread safety and preventing crashes.
In data analysis and machine learning, large datasets are processed in parallel to derive insights and make predictions. Scoped threads can be used to process data in parallel, reducing the time taken for computations, and ensuring thread safety and data consistency.
Multimedia applications, such as video and audio players, can also benefit from scoped threads. Scoped threads can be used to manage the lifetime of data and synchronize the processing of multiple streams, preventing race conditions and memory leaks.
Finally, IoT devices need to handle multiple sensors and data streams concurrently. Scoped threads can be used to handle each data stream in a separate thread.
This article taught you about Rust scoped threads, how they work, how to create them, and how they differ from unscoped threads. Scoped threads are a fantastic way to make multitasking in Rust more efficient.
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowExplore use cases for using npm vs. npx such as long-term dependency management or temporary tasks and running packages on the fly.
Validating and auditing AI-generated code reduces code errors and ensures that code is compliant.
Build a real-time image background remover in Vue using Transformers.js and WebGPU for client-side processing with privacy and efficiency.
Optimize search parameter handling in React and Next.js with nuqs for SEO-friendly, shareable URLs and a better user experience.