Why scoped thread's(closure's) lifetime is longer than the scope itself?

Here is the code snippet:

use std::thread;
fn main()
{
    thread::scope(|s|
    {
        let numbers: Vec<i32> = vec![1,2,3];
        s.spawn( ||
        {
           println!("{:?}", numbers); 
        });
    }) 
}

The above code snippet won't compile. Here is the short error message:
error[E0373]: closure may outlive the current function, but it borrows `numbers`, which is owned by the current function

My assumption 01: By saying current function in the error message I think the compiler denotes the thread::scope -- please correct me if I am wrong.

My assumption 02: By saying closure in the error message I think the compiler denotes the thread which is spawned by scope s. So the closure lifetime is supposed to smaller or similar as the scope s.

As I did not use the move keyword the closure in the spawned thread borrows the numbers.

My reasoning is scope s is responsible for spawning the thread, also it is responsible for completing the spawned thread, then how can the thread/closure life time is longer than the scope 's`?

Or is it that thread::scope s returns first -- dropping the vector numbers--then it enforces the thread to finish its work but reference to the numbers(inside the thread's closure) no longer exists because of dropping the numbers, hence compiler refuses to compile?

Thanks.

This surprised me. I had the same understanding as you about scoped threads: that since all spawned threads get joined at the end of the scope() scope, you can reference data with the scope() scope by ref.

But this page says, at the bottom, that there are two scopes: the scope within the scope() call, and the scope outside it, and the data has to be in the outside scope.

I guess maybe because it's not defined which order things get dropped in, inside a scope() call? So you can't assume that the spawned thread gets joined before numbers gets dropped.

1 Like

You need to read the error message, not just skim it. To quote the compiler:

error[E0373]: closure may outlive the current function, but it borrows `numbers`, which is owned by the current function
  --> src/main.rs:7:18
   |
4  |     thread::scope(|s|
   |                    - has type `&'1 Scope<'1, '_>`
...
7  |         s.spawn( ||
   |                  ^^ may outlive borrowed value `numbers`
8  |         {
9  |            println!("{:?}", numbers); 
   |                             ------- `numbers` is borrowed here
   |
note: function requires argument type to outlive `'1`
  --> src/main.rs:7:9
   |
7  | /         s.spawn( ||
8  | |         {
9  | |            println!("{:?}", numbers); 
10 | |         });
   | |__________^
help: to force the closure to take ownership of `numbers` (and any other referenced variables), use the `move` keyword
   |
7  |         s.spawn( move ||
   |                  ++++

Note that it points directly to the closure on line 7, not the thread::scope call, or the closure you pass to that.

No, you have this backwards. It says "closure" and points to the inner closure. In this context, the "current function" is the outer closure defined on line 4.


What is happening is that you call thread::scope and pass a closure. thread::scope constructs a Scope and passes a reference to it to your closure. Scope lets you start threads, and relies on the thread::scope call waiting for all threads started by that Scope to terminate before returning. This also allows any thread started by a Scope to borrow values which outlive the call to thread::scope.

numbers is created inside your scope closure, meaning it will be destroyed when the closure returns, but before the threads are joined. Thus, your inner closure (the one that forms the body of a thread) cannot borrow from it.

Edit: it might be worth stating that there is nothing special or magic happening here. "Scope" is not a language construct. It's just a way to introduce a lifetime (let's call it 's) as part of the thread::scope call that lets Scope::spawn say "I accept any callable provided it borrows things that outlive 's". I bring this up because I get the impression from this and your other question that you are over-thinking things, or believe Rust has a "scope" construct that doesn't actually exist.

4 Likes

The thread::scope call will be responsible for completing the spawned threads, after the closure you passed to it has returned. At that point however numbers has already been dropped, so you can't allow a thread accessing it.

On a more low level, the compiler doesn't know about threads. Everything that thread::scope does it defined in a library (the stdlib, but could also be in a 3rd party crate) through lifetime bounds. The requirements boil down to the inner closures (the ones passed to s.spawn) capturing only variables from outside the call to thread::scope or the variable s itself. This is an artificial limitation, but a necessary one to ensure that thread::scope is sound.

2 Likes

correct, assuming you mean the closure passed to thread::scope. Note that there's a difference between the whole thread::scope call, and the |s| { ... } closure you pass to it. That closure is invoked some time during the whole thread:scope call.

correct, assuming you mean the closure passed to s.spawn. A thread is not a closure; this closure of course is the main action of what the thread will be executing, but still, a thread is a thread, not a closure :wink:

There are 3 things about the scope. The whole thread::scope call, the "|s| { ... }"-closure passed to it, and that s: &'scope Scope<'scope, 'env> object. The s: &Scope<…> object itself doesn't really "do" anything, and has 2 different lifetime arguments to it, as you can see, and while the precise meanings of 'scope and 'env are an interesting (and complex) topic on its own, they don't really matter so much.[1] The reasoning you mention speaks of responsibility to complete the spawned thread, and that's something that the thread::scope function does. Calling s the “scope” is a natural simplification, but really, this whole construct is the “scope”.

The whole thread::scope call certainly does last longer than all individually spawned threads. This isn't something the compiler can know anyways, it only knows whatever type signatures are telling it. The “current function”, i.e. the closure passed to thread::scope however, does absolutely not live as long.

This is indeed roughly what's happening; just it's not the thread::scope call or s, but the _closure passed to thread::scope(..) we are talking about. That returns first, before the surrounding thread::scope call, which isn't done yet then, goes and starts waiting for all the threads to finish; and that's natural, it's just an ordinary closure, how could it not return immediately, first thing, as soon as it's finished kicking off the thread with s.spawn, it's last statement?


As mentioned, the compiler message is all about type signatures, because that's the way the compiler understands any of this.

The compiler does indeed determine that the || { ... println! ... }-closure passed to s.spawn must outlive the |s| { ... s.spawn(..) ...-closure. Why? Type signatures! We have

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

where there's a livetime 'scope introduced; the full compiler message you get calls this lifetime “'1”.

FULL COMPILER MESSAGE
error[E0373]: closure may outlive the current function, but it borrows `numbers`, which is owned by the current function
  --> src/main.rs:7:18
   |
4  |     thread::scope(|s|
   |                    - has type `&'1 Scope<'1, '_>`
...
7  |         s.spawn( ||
   |                  ^^ may outlive borrowed value `numbers`
8  |         {
9  |            println!("{:?}", numbers); 
   |                             ------- `numbers` is borrowed here
   |
note: function requires argument type to outlive `'1`
  --> src/main.rs:7:9
   |
7  | /         s.spawn( ||
8  | |         {
9  | |            println!("{:?}", numbers); 
10 | |         });
   | |__________^
help: to force the closure to take ownership of `numbers` (and any other referenced variables), use the `move` keyword
   |
7  |         s.spawn( move ||
   |                  ++++

For more information about this error, try `rustc --explain E0373`.

This signature establishes that 'scope lifetime appears as a lifetime parameter in an argument in the signature of F. The higher-order nature of this lifetime isn't really relevant here by the way; really, the fundamental principle of lifetimes that "(external) lifetime parameters in function signatures outlive the function call"[2] is all that matters. The lifetime 'scope will outlive the function call to f: F, which is the |s| { ... s.spawn(..) ...-closure.

The other puzzle-piece then is the Scope::spawn signature:

impl<'scope, 'env> Scope<'scope, 'env>
pub fn spawn<F, T>(&'scope self, f: F) -> ScopedJoinHandle<'scope, T>
where
    F: FnOnce() -> T + Send + 'scope,
    T: Send + 'scope,

Here, the important thing of note is F: 'scope. This says the spawned || { ... println! ... }-closure must outlive 'scope. The compiler calls this out as “function requires argument type to outlive `'1` ”.

This completes the compiler's reasoning why (from the compiler's POV) the || { ... println! ... }-closure must outlive the |s| { ... s.spawn(..) ...-closure. The former must outlive 'scope and 'scope must outlive the latter.


  1. If you want a quick overview: Physical lifetimes around the life of a scope consists of the time the |s| { … }-closure is running, and the time the whole thread::scope function call is running; the lifetime parameter 'scope then is (at least in principle) sandwiched between these two, being shorter than the whole thread::scope call, and longer than the closure call; the s: &'scope Scope<'scope, 'env> object doesn't really do anything that “lives”, but serves the purpose of connecting lifetimes so that the spawned thread closures are known to the compiler to be “sufficiently long-lived”, too; it uses the 'scope lifetime instead of the 'static lifetime that non-scoped thread::spawn would do.
    The 'env lifetime is longer than the whole thread::scope call. It serves to limit the 'scope lifetime, which is higher-ranked, so without limitation, the compiler would complain as restrictively as to even allow 'scope = 'static.
    So really, the true position of the 'scope lifetime in the compiler's interpretation is not sandwiched between the running times of the thread::scope call and its |s| { … }-closure; but between the 'env lifetime and the |s| { … }-closure. Admittedly, this “but technically” does little but confuse, so feel free not to think too deeply about it; also it's morally wrong; it only describes the compiler's knowledge about this higher-ranked lifetime; its real position is chosen by thread::scope, whose implementation details pass a reference that is very clearly not outliving the thread::scope call itself, but there's no syntax (nor a need) to convey this to the compiler in any of the function signatures. ↩︎

  2. then again, if the function signature introduces the lifetime with a for<'_>, then it isn't an external parameter; e.g. 'scope does not outlive the whole thread::scope call.

    But in the signature of the |s| { ... s.spawn(..) ...-closure, it isn't higher-rank anymore; which is perhaps slightly tricky to imagine, but if you thought of the actual signature on the call function of the FnOnce implementation, if hand-written, then 'scope would just be an ordinary lifetime on the impl block:

    impl<'scope> FnOnce(…) -> … for CLOSURE_TYPE {
        fn call_once(self, arg: (&'scope Scope<'scope, …>,) -> … { … }
    }
    
    ↩︎
2 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.