Implementation of `std thread scope` in std lib

It's always good to read code written by brilliant people so one day I could write something like this myself : )

I was reading this file from the Rust standard library:
https://github.com/rust-lang/rust/blob/main/library/std/src/thread/scoped.rs

I was trying to understand why moving a let binding inside the scope causes a compile error, but restructuring the code makes everything compile fine. It seems that some subtle lifetime relationships are what make this work safely.

In particular, I’m confused about these two statements in the comments:

The 'scope lifetime represents the lifetime of the scope itself.
The 'env lifetime represents the lifetime of whatever is borrowed by the scoped threads.

I don’t fully understand how the scope function and the Scope struct, together with these lifetimes, work together to provide safety.

For scoped threads to be safe, the data they are borrowing must stay available for as long as the threads are running.

The scope() function makes it safe, because it can wait for all the threads to stop before allowing execution to continue (continuing could return from the function and destroy its local context, and that must not happen while any thread may still need it).

scope() can't prevent its closure from returning before the threads stop. The context inside the closure can be freed and destroyed before the control goes back to scope, and at that point it's too late to wait for threads, the data from inside of the closure is already gone.

So scope() can only ensure that pre-existing data living outside of its closure remains valid for the entire duration that the scope() call can control.

Note that lifetimes don't do anything in a running program. They only describe what the code is allowed to do in theory. So the lifetimes on scope() are there to forbid borrowing data from inside of scope()'s closure, regardless whether that's actually necessary or not. The compiler will not change program's behavior to match what the lifetimes require, it will only refuse to compile programs that don't already meet the requirement by themselves.

7 Likes

I'll take the bait, but it's going to be a pretty long reply. I'll start with an implementation which is easier to understand IMO, and then come back to std's scoped threads.

Rayon lifetimes

Let's look at the general shape of the problem space. Here's what it looks like in Rayon:

// Something in our calling environment.  We want to be able to borrow this
// from our workers.
let s = String::new();

thread::scope(|scope| {
    // Our spawning environment.  We cannot be allowed to borrow from here,
    // because this closure can return while the threads are still running.
    let borrows_cannot_be_allowed_in_workers = String::new();

    scope.spawn(|scope2| {
        // A worker for another thread.  There may be many.
        println!("{s}");

        // We want to allow this too (workers injecting more workers).
        scope2.spawn(|| println!("another: {s}"));
    })

    // We return to the body of `scope` here and locals of the
    // spawning closure drop.  But the workers may still be running.
});

// `scope` only returns when all workers and the spawning closure have completed.

The function bounds look like so:

// n.b. `'env` is invariant in `Scope<'env>`
pub fn scope<'env, OP, R>(op: OP) -> R
where
    OP: for<'any> FnOnce(&'any Scope<'env>) -> R + Send,
    R: Send,
impl<'env> Scope<'env> {
    pub fn spawn<BODY>(&self, body: BODY)
    where
        BODY: for<'any> FnOnce(&'any Scope<'env>) + Send + 'env,

You, the caller of scope gets to "choose" 'env. It's some borrow duration at least as long as the call to scope. The worker closures need to meet the bound, so any borrows they have need to be at least 'env long. In practice, 'env ends up being the intersection of the duration of all borrows in your worker closures (the shortest borrow).

Because the closures take a &Scope<'env> as an argument, 'env must be valid throughout the closure body. I typically phrase this as "'env is at least just longer than the closure body." More generally, nameable lifetimes are always at least just longer than your body. A pretty fundamental borrow checker rule follows from this: you cannot borrow a local for a named lifetime. The lifetime is also invariant in that context, so you cannot coerces the &Scope<'env> to a &Scope<'shorter_than_closure_body>.

And that is why the worker closures cannot capture borrows from the spawning environment: the borrow would have to be for 'env in order to meet the bound on spawn, and you can't borrow locals for longer than your body. So the worker closures constructed in the spawning environment cannot borrow from the spawning environment locals.

If a worker tries to borrow from the spawning environment, you're basically attempting the same thing as this (just more indirectly):

fn simple<'s>(_: &'s str) {
    let local = ();
    let _: &'s () = &local;
}

And why do the worker closures passed to spawn need to take their own &Scope<'env> argument? It's because they have to work with all possible outer lifetimes which are less than 'env, so the worker closures can't capture the &Scope<'env> from the spawning environment (whether by value or by reference). But we want workers to be able to inject more workers. They get passed their own &Scope<'env> to enable this.

There's a bunch of other things you have to do to make sure the other threads are done by the time scope returns and that you're not violating provenance and yadda yadda. But as far as borrowing goes, I think the above walk-through is approachable for someone with a decent grasp of borrow checking, even if they're a relative beginner.

Std lifetimes

std scoped threads have a nicer call site API:

// Something in our calling environment.  We want to be able to borrow this
// from our workers.
let s = String::new();

thread::scope(|scope| {
    // Our spawning environment.  We cannot be allowed to borrow from here,
    // because this closure can return while the threads are still running.
    let borrows_cannot_be_allowed_in_workers = String::new();

    // CHANGE: No argument to our worker closures!
    scope.spawn(|| {
        // A worker for another thread.  There may be many.
        println!("{s}");

        // We want to allow this too (workers injecting more workers).
        scope.spawn(|| println!("another: {s}"));
    })

    // We return to the body of `scope` here and locals of the
    // spawning closure drop.  But the workers may still be running.
});

// `scope` only returns when all workers and the spawning closure have completed.

Being able to support this pattern is why std Scope has two lifetime parameters. Explaining how gets more into the gritty details of borrow checking and closure capturing. We somehow need to allow capturing the &Scope<'_, '_> in our worker threads while still disallowing capturing borrows of the spawning environment locals.

I'll walk through it, but be warned it's not as beginner friendly.

The approach is to introduce a second named lifetime which approximates the running time of the worker threads, 'scope. 'env still represents the captures of the calling environment, but now we'll also allow worker threads to have captures as short as the running time of the worker threads. Namely, we're going to allow capturing some &'scope Scope<'scope, 'env> by value.

Here are the std function bounds:

// n.b. `'scope` is invariant in `Scope<'scope, '_>`.
pub fn scope<'env, F, T>(f: F) -> T
where
    F: for<'scope> FnOnce(&'scope Scope<'scope, 'env>) -> T,
// (I'm not covering the addition of join handles in this reply.)
pub fn spawn<F, T>(&'scope self, f: F) -> ScopedJoinHandle<'scope, T>
where
    F: FnOnce() -> T + Send + 'scope,
    T: Send + 'scope,

It is again important that 'scope is invariant. If it was covariant, you could coerce it to a &'i Scope<'i, 'env> within the closures, where 'i is less than the closure body. That would allow spawned workers to capture borrows from the spawning environment but still meet the bound on spawn.

Because the lifetime is invariant, it acts as a lower bound on the validity of the spawned closures -- and that's a lower bound you, the caller of scope, doesn't get to choose. All you know is that it's longer than the closure body and at most as long as 'env. Because 'scope is still longer than the spawning environment body, you still can't borrow locals for that long.[1] But you can capture copies of the &'scope Scope<'scope, 'env>[2] and still meet the 'scope bound on spawn.

You do get to choose 'env, and that's also important. Due to an implicit 'env: 'scope requirement, the bound on scope acts like so:

F: for<'scope where 'env: 'scope> FnOnce(&'scope Scope<'scope, 'env>) -> T,

If 'env wasn't present here, it would be possible for 'scope to be 'static as far as your closures are concerned -- which would mean in order to call spawn, your closures would have to meet a 'static bound. But that disallows capturing borrows from the calling environment, which is the motivation for scoped threads in the first place! So 'env is still playing a vital role: it's an upper bound on the validity of the closures that you get to choose.

That's it for how the lifetimes accomplish our goals of

  • allowing borrows of the calling environment, while
  • disallowing borrows of locals to the spawning environment, but still
  • allowing captures of the &'scope Scope<'scope, 'env> for ergonomic spawning within the worker closures

But why a reference

Finally, why have &'scope Scope<'scope, 'env> instead of some copiable Scope<'scope, 'env>? Seems overly complicated. Well, it's because of how closure captures work.

In general, closures prefer to capture by shared reference, and then exclusive reference, and then only if they have to by value. Variables that can be copied (as in Copy) which are used by value in the closure are captured by shared reference instead of by value due to this preference (since you can make copies through the shared reference).[3]

If we had a scope: Scope<'scope, 'env>, the calls to spawn only need to capture a reference to scope for the following cases:

  • spawn takes &self
  • spawn takes self and Scope<'_, '_>: Copy

So closures would prefer to capture &scope. But for our use case, scope is a local in the spawning environment, and we aren't allowed to capture borrows of those! We'd get a borrow checker error unless we made the closures move. But we probably don't want to move everything into the closure, either,[4] which is what move does. And if Scope<'_, '_> was not Copy, we'd lose ergonomics in a different way: we'd have to create local clones of scope outside the worker closures, like you sometimes need to do for Arc<_>s you want to move into a closure.[5]

When we have scope: &'scope Scope<'scope, 'env>, however, the closure capture analysis sees that we only need to capture *scope by shared reference. Effectively this translates into creating a &*scope to store in our closure -- technically a reborrow, but effectively we capture the scope reference by value.

...on edition 2021+ that is. Before that edition, capture analysis wasn't precise enough to capture &*scope. Instead it captures &scope, which in the context we're talking about is a borrow of a local of the spawning environment, and you get an error. So if scoped threads were stabilized before edition 2021, the API would look different. (Probably it'd look more like Rayon.)

Anyway, that's why it's a &'scope Scope<'scope, 'env> instead of something simpler.

More reading

You can start from here and the following few comments, and then follow the bread crumbs to prior discussions (there's a lot), if you're really curious.


  1. No creating borrows of locals for a named lifetime... ↩︎

  2. which is a borrow of something outside the closure body -- think of it as a borrow of something in the body of thread::scope ↩︎

  3. Example. ↩︎

  4. we're probably trying to borrow something from the calling environment ↩︎

  5. There's a "cheap clones" experiment underway to ease this pain point. If it existed in stable form before scoped threads were stabilized, the API may have looked different. ↩︎

5 Likes

very excited to read this through!!

I just started reading this, and I already have a couple of questions :sweat_smile::

The threads spawned within the scope are guaranteed to complete their execution before main returns but, there's no guarantee that their execution is completed while the closure is still around i.e. they maybe still be executing even after the closure returns, right?

also, what are the specific reasons for introducing the lifetime 'env in the function signature instead of declaring it directly inside the for<>, like this?

pub fn scope<OP, R>(op: OP) -> R
where
    OP: for<'any, 'env> FnOnce(&'any Scope<'env>) -> R + Send,
    R: Send,

How do they differ?

so that all threads are at the same initial state?

Oo, I see. I used to avoid lifetimes a lot but after seeing how powerful they are, it seems like they are a must to understand if one wants to write sound code.

I understand that'env is invariant in Scope<'env>but what exactly does the 'env lifetime resolve into? Anything that's 'static is something that exists even when main returns, similarly how does 'env resolve, what scope does it capture?

After the thread passed to spawn ends, the thread ends. There's probably some squirrely details around thread local storage I don't know off the top of my head. But scope doesn't return until all the spawned threads have ended.

With the lifetime on the function, a suitable lifetime is inferred at the call site ("the caller chooses the lifetime"). This allows the spawned closures to contain borrows that are less than 'static. If the lifetime was in the for<..>, the closure passes to scope would have to be valid for all lifetimes including 'static. In that case the spawned closures could not contain borrows less than 'static, defeating the point of scoped threads.

Effectively the lower bound of borrow captures in the spawned closures.[1]

If you're not borrowing anything in those closures, 'env could be 'static. If you're borrowing a local variable, 'env must end before the variable goes out of scope or is accessed in some other way incompatible with the borrow (like being moved).

In case it wasn't clear, F: 'static doesn't mean values of type F are never destructed (or never destructed before the program ends). It means any lifetimes in the type of F meet a 'static bound. If the type has no lifetimes, that's trivially true.

Lifetimes don't correspond to scopes or vice-versa (since NLL landed ~8 years ago). The primary connection between the two is that lexical scopes are drop scopes, and going out of scope conflicts with being borrowed. Also you can call main like a normal function so it can end many times :slightly_smiling_face:. There's no magic connection between main and 'static.


  1. nit: it doesn't have to resolve to a specific lifetime per se, the compiler need only prove there exists a lifetime that avoid borrow conflicts and satisfies any annotations and bounds, like a constraint satisfaction problem ↩︎

2 Likes

as 'env is a nameable lifetime on the scope function:

pub fn scope<'env, OP, R>(op: OP) -> R
where
    OP: for<'any> FnOnce(&'any Scope<'env>) -> R + Send,
    R: Send,

we can say that 'env lives just longer than our call to scope function, correct?

do we need this explicit + 'env bound? The closure accepts Scope<'env> and therefore we cannot borrow anything that doesn't live as long as 'env...

At least just longer, yes.

You certainly can. The closure here can't make borrows of its own locals that are as long as 'env. But that doesn't mean it can't capture borrows less than 'env. Namely, it could borrow locals of the spawning environment, which we need to prevent.

1 Like

the closure signature has two lifetimes'any and 'env and for 'env we've said that it lives at least just longer than that closure body...can we say anything about 'any?

yeah, this makes a lot of sense. thanks!

Hello, in the rayon example, what's exactly the purpose of accepting&Scope<'env>? I was reading the explanation and kind of understood it but am still a bit confused

I approached this question by trying to explain the motivation, which ended up being long-winded.


In order for spawning a thread to be sound, the closure it runs has to be joined before any borrows within it become invalid. If your closure has no borrows,[1] this is trivial: it can be joined whenever, or not at all. Which leads to the API of std::thread::spawn: you pass in the worker closure and spawn returns immediately. Since it immediately returns, you can call spawn as many times as you want to get as many threads as you want. You can choose to join them yourself whenever, or have the threads detach; they can run for longer than the spawning thread, even.

When the closures do have borrows, that would be wildly unsound. Instead, whatever we call needs to make sure that these borrowing threads get joined before it returns to the calling environment. We also might want to launch an arbitrary amount of threads, and we might want to use different closures for different threads and so on. This could be supported with an impl Iterator<Box<dyn Fn + '_>> argument or some such, but it's more Rustic to avoid such inefficiencies if possible.

So instead we have this two-step process: scope will be what we call that makes sure the threads get joined before it returns, and it will run a single closure that is provided a way to spawn multiple concurrent threads which to join yourself or not (like the 'static version), etc.

The way it provides that capability is by passing the &Scope<'env> to your closures. Scope::spawn accepts a generic closure and immediately returns something you can join or not, etc, similar to thread::spawn. It also stores whatever is needed in the scope body to make sure the spawned threads are joined.

Rayon supplies &Scope<'env> to the spawned threads too, so they can also freely spawn their own scoped threads (the way the single 'env bound works prevents them from capturing the &Scope<'env> passed to the scope closure). In std they made this unnecessary, and I tried to explain how that works above.


  1. or all borrows are for 'static ↩︎

1 Like