Using Rust scoped threads to improve efficiency and safety

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.

What are scopes 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.

What are scoped threads in Rust?

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.

Exploring an example program with scoped threads

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.

An alternative with unscoped threads

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.

Directly comparing scoped and unscoped threads

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.

Using external variables

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 variables from threads

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.


More great articles from LogRocket:


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.

Sharing data between threads

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.

Organizing threads

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();
}

Real-world applications of scoped threads

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.

Conclusion

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.

LogRocket: Full visibility into web frontends for Rust apps

Debugging Rust applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking 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 app. 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 — .


Source link