DEV Community

Nirmalya Sengupta
Nirmalya Sengupta

Posted on

Random Rust Notes - 4

[Cover image credit: https://pixabay.com/users/engin_akyurt-3656355]

This blog is a collection of notes of key learnings / understandings / Aha moments, as I am kayaking through demanding yet exciting waters, of Rust ( #rustlang ). If you happen to find defects in the code or gaps in my understanding, please correct me. I will remain thankful.

The topic of this particular notes: scoped_threads in Rust.

A brief on spawned threads

Using the standard library, whenever a thread is launched - spawn-ed - a unit of execution - - with an associated piece of logic - is created. Underneath, the library, OS facilities and hardware work together to run that unit of execution , separately from the unit of execution that launched it. This is at the core of threading - a bit simplistic, I agree - concept. One is the parent thread and it launches a child thread, as is commonly understood.

Naturally, the child thread will finish its job at some point in time. The parent thread may need to know if and when that happens (let us keep away from the aspect of cancellation of the child thread for the time being). A mechanism must exist for it to be able to do that. This mechanism takes the form of a handle (programmer-speak). Using this handle, the parent thread waits for the child thread to join the former and then, the flow of execution continues.

So, we need a handle

pub fn start_as_free(&self) -> JoinHandle<()> {
        let message_collector_handle = thread::spawn(move || { () });
        message_collector_handle
    }
Enter fullscreen mode Exit fullscreen mode

Here's a skeleton code: a thread is spawn-ed and its handle is returned to the caller (the parent thread). For a better understanding of what that JoinHandle is, here's the standard library's documentation. JoinHandle is a structure of type std::thread::JoinHandle. One of its implementation functions is named join, pub fn join(self) -> Result<T>. This is the mechanism available to the parent thread, to wait for the child thread to finish. From the standard library's documentation:

Now, a scoped thread

Instead of spawning a free-running thread as shown above, a parent thread may launch a scoped thread. A scoped thread, is built in a way which ensures that the function in which it is launched will finish only when the threads launched inside it are also finished. This is a key aspect because a scoped thread cannot live beyond the demarcation laid out by its parent thread. Therefore, the parent thread may share its own data structures with the child thread, secure in the understanding that all the changes (if any) done to these data structures by the child thread, are guaranteed to be visible to the parent.

From the standard library's documentation:

pub struct ScopedJoinHandle<'scope, T>(_);
Enter fullscreen mode Exit fullscreen mode

So, ScopedJoinHandle is a structure, much like its elder sibling JoinHandle (elder, because it preexists). And, sure enough, it has a join() function as well. But with a twist!

Difference between spawning steps

A standard, your-friendly-companion-thread is spawned thus:

 let message_collector_handle = thread::spawn(move || { () });
Enter fullscreen mode Exit fullscreen mode

A scoped, strictly-watched-and-controlled thread, is spawned thus:

let message_collector_handle = thread::scope (|current_scope| 
                               {
                                  current_scope.spawn(|| { Ok(()) });
                               }
Enter fullscreen mode Exit fullscreen mode

Something special is happening in std::hread::scope() . From standard library's documentation:

pub fn scope<'env, F, T>(f: F) -> T
where
    F: for<'scope> FnOnce(&'scope Scope<'scope, 'env>) -> T,
Enter fullscreen mode Exit fullscreen mode

So, the scope function takes in a closure as a parameter, which in turn takes in a Scope (with uppercase 'S') value as a paremeter.

What is that Scope? It is a struct! From the standard library's documentation:

pub struct Scope<'scope, 'env: 'scope> { /* private fields */ }
Enter fullscreen mode Exit fullscreen mode

And, as expected, this struct implements a spawn function:

pub fn spawn<F, T>(&'scope self, f: F) -> ScopedJoinHandle<'scope, T>where
    F: FnOnce() -> T + Send + 'scope,
    T: Send + 'scope,
Enter fullscreen mode Exit fullscreen mode

Let us not get lost in that crowd of symbols. The key understanding:

  • thread::scope takes in a closure. This closure, in turn, takes in a Scope value. Inside the closure, a thread is spawned: Scope::spawn(). This spawn() call returns a ScopedJoinHandle.

  • The return value of type ScopedJoinHandle is valid as long as the parameters are valid. Put another way, the return value (remember, this is a struct ) cannot live even when the Scope (the struct, starts with an uppercase 'S'!) doesn't.

An example makes it clearer, hopefully!

Let's watch these two functions, being compiled:

pub fn start_as_scoped(&self) -> ScopedJoinHandle<Result<(),String>>{
        let message_collector_handle = thread::scope (|current_scope| {
                    current_scope.spawn(|| { Ok(()) })              
                });

        message_collector_handle    
    }

// Here's our old friend, std::thread::JoinHandle
pub fn start_as_free(&self) -> JoinHandle<()> {
        let message_collector_handle = thread::spawn(move || { () });

  message_collector_handle
}
Enter fullscreen mode Exit fullscreen mode

The compiler is watchful, yet helpful:

error: lifetime may not live long enough
  --> src/main.rs:30:21
   |
29 |         let message_collector_handle = 
                thread::scope (|current_scope| {                                            
                                -------------- return type of closure is ScopedJoinHandle<'2, Result<(), String>>
   |                            |
   |                            has type `&'1 Scope<'1, '_>`
30 |                     current_scope.spawn(|| { Ok(()) })
   |                     ^^^^  returning this value requires that `'1` must outlive `'2`

Enter fullscreen mode Exit fullscreen mode

Hmm! We cannot return the ScopedJoinHandle in a manner as we could, for its sibling, JoinHandle .

But, why?

Without getting into the details of lifetime, we can analyse it intuitively.

The ScopedJoinHandle cannot live beyond the lifetime of the current_scope. Put in a different way, we cannot let the ScopedJoinHandle
return from the function, because that will mean that some other thread will be able to join the thread launched under the watchful eyes of
current_scope. But that is disallowed. The standard library's documentation makes that very clear:


All threads spawned within the scope that haven’t been manually joined will be automatically joined before this function returns.


This means, that either we explicitly join the thread before leaving the scope or thread will be implicitly join-ed by Rust runtime, when the current_scope is dropped.

This compiles:

 pub fn start_as_scoped(&self) -> i32 {
        let message_collector_handle = thread::scope (|current_scope| {
           current_scope.spawn(|| { 42 }).join()    // <---- join() before leaviing the current_scope>    
         });


        match message_collector_handle {
            Ok(x) => x,
            Err(_) => -1 // We could have returned a String, by modifying the return type of the function to Result<T,E>
        }

    }
Enter fullscreen mode Exit fullscreen mode

Key takeaways

  • A thread which is spawned inside a scope, must end before scope ends.
  • Such a thread's handle cannot outlive the overarching scope.
  • Such a thread must be join-ed explicitly or those will be join-ed implicitly, at runtime. And, inside the scope!!

NB: Standard library's documentation on Scoped threads, is elaborate and worth a good reading!

Top comments (0)