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. But you can capture copies of the &'scope Scope<'scope, 'env> 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).
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, 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.
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.