Does the lifetime inferred in caller body outlives that of the callee's body?

This question is produced by after looking at the implementations of thread::scope, I'm curious how it work. Consider the transformed example(pseudo-code):

'out:{
   let mut i = 0;
   fn scope(f:F) where F: FnOnce(&Scope){
       'scope:{
            let scope = xxx;
            let borrowing_scope = &'scope scope;
            f(borrowing_scope); // invoke the `f`
           // assume the body of `f` is expanded in the callee's body, and the lifetime in `f` is `'f`:
          /* {
               i+=1;
               s.spawn(||{   // this closure type shall satisfy 'scope, hence caputure i for 'scope
                   i+=1;
               });
                i+=1;  // #1
           }*/
        }
    }
}
/* scope(||{
    i+=1;
    s.spawn(||{
      i+=1;
   });
   i+=1;
}); */

In above code, #1 causes the borrow checker errror. I know the closure in s.spawn shall satisfy 'scope, hence it should caputure i for lifetime 'scope. The reason of why #1 causes error is the code mutably use i after s.spawn captures i for 'scope, which seems to be a reasonable interpretation. However, this interpretation is based on that the lifetime 'callee in the function body of called and the lifetime 'caller in the body of caller satisfy the relationship 'caller : 'callee. In above code, 'scope:'f, Is this a right thought?

Is this a question about functions generally or thread::scope specifically? The latter is more complicated. If it's the former, let's talk about that separately.

Presuming the latter though, I think it's going to be hard to talk about your example as it currently stands.

  • Scope has two important lifetime parameters you've not labeled
  • One of them is typically called 'scope and at this level, it's part of a higher-ranked binding
  • You have a 'scope that came.... from where? What does it represent? It can't represent any lifetime from the function signature, including "any resolution of the higher-ranked one", or you couldn't borrow the local variable called scope for that long
    • And lifetimes don't represent lexical blocks or vice versa

I'm a sucker for these questions though, so I took a crack anyway. I'm going to walk through it in my own way first. By the way in case you didn't know, there's documentation on the lifetimes involved too.

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

pub fn scope<'env, F, T>(f: F) -> T
where
    F: for<'scope> FnOnce(&'scope Scope<'scope, 'env>) -> T,
{
    // Here, `'env` is some lifetime longer than the function body.
    // The caller of `scope` "chose" `'env`.
    let scope: Scope<'_, env> = todo!();

    // `F` has to handle *any* `'scope` such that `'env: 'scope`
    // That's what the `for<'scope> ...` means, and implicitly
    // `'env: 'scope`.
    //
    // Some lifetime `'x` is chosen here which must be shorter than
    // the function body (of `scope`)
    f(&scope);
}

Then within f: F we have

for<'scope> |scope: &'scope Scope<'scope, '_>| {
    // `'scope` is chosen by the caller and is longer than this closure
    // body.
    i += 1;
    s.spawn(|| i += 1); // borrows `i` for `'scope`
    // i += 1; // #1: would be a use while `i` is borrowed
}

Agreed.

Hmmm, I'm not sure where these lifetimes came from or what they represent. 'callee is the upper limit of local borrows, and 'caller is any lifetime a caller can choose maybe? Then 'caller: 'callee and in fact it's strictly greater[1].

What's 'f? The upper limit of local borrows in the body of

f: impl FnOnce(for<'scope> &'scope Scope<'scope, 'env>

?

If so, yes, f is like a

struct Closure<'env> { /* ... */ }
impl<'env> Closure<'env> {
    // implicitly enforced: `'env: 'scope`
    fn call_once<'scope>(self, scope: &'scope Scope<'scope, 'env>) {
        // ... body of closure ...
    }
}

The 'scope is longer than f's body / Closure::call_once's body.


Why do the spawned closure have to satisfy 'scope? Because they might live that long. If you don't join their handles, they'll be joined in the body of scope, before the end of that function -- before 'scope expires.

Why does 'env: 'scope have to be enforced? So that nothing captured becomes invalid before all the threads are joined.

The issue has a lot of interesting and technical discussion.


  1. which Rust has no syntax for ↩︎

This question is about functions generally, I know thread will be more complex, so I just simplify the model to a common function invocation such that we can easily to talk about.

'f is the upper representable lifetime in the body of the closure that is the argument of s.spawn.

Yes, sorry for this unclear part. 'caller is the upper representable lifetime in the body of a function(named as F) and 'callee is the upper representable lifetime in the body of a function that is invoked by F.

Yes, for functions:

fn f<'caller_chosen>(_: &'caller_chosen ()) {
    // 'eof: End of function - longest you could possibly borrow a local for
    // [some equate this to the block of the function but I find equating
    // lifetimes to scopes to be a net loss in understandability
    // (but recognize opinions differ on this topic)]
}

'caller_chosen: 'eof and in fact it's strictly greater. You can never borrow a local for a 'caller_chosen lifetime.


Mmmm, if you're including closure captures here, that's a different thing. A closure is similar to a

Closure /* <'closure> */ { // see below
    capture_1: &'closure mut i32,
    // ...
}

// No impl<'closure>, see below
impl FnOnce for Closure<'closure> {
    type Output = ();
    extern "rust" call_once(self) { /* closure body */ }
}

Here, 'closure is some concrete inferred lifetime, not a lifetime parameter per se. It's still the case that any use of the closure has to be within the lifetime / the closure is not valid outside of that lifetime.

But the point is that 'closure is not limited by the closure's body. Or looking at it a different way, non-move captures aren't borrowing locals of the closure, they're borrowing from the surrounding environment. The borrows can outlive the closure body.

There's probably an easier way to demonstrate, but here's a demonstration.

In fact, your OP borrow error is a (probably better) demonstration that the captures last longer than the closure body.


In the case of spawn, it forces the closures to be as long as 'scope because it can't guarantee their threads will be joined before then. So any captures have to be at least that long too.

The history of scoped threads and leaking being recognized as safe is also interesting. In short, you can't rely on destructors running, so you need some other way to guarantee scoped threads don't outlive their captures than join guard.

Since we have the conclusion

'caller_chosen: 'eof and in fact it's strictly greater. You can never borrow a local for a 'caller_chosen lifetime.

For closure, especially for invoking a closure, it is the same except that the called function is a (associated)method. So, I think the above conclusion is also applied to the "function body" of a closure, isn't it?

fn f<'caller_chosen>(_: &'caller_chosen ()) {
    let closure = ||{
       let i = 0;
       let rf:&/*'c */ i32 = &i;
    };
}

The hypothetical lifetime 'c should satisfy 'caller_chosen: 'c, right?

Well, that 'rf can't outlive the body of the closure; it's a borrow of a local to the closure body. But that's not the same thing. There's not necessarily a relationship between the two lifetimes. You can call the returned closure in the playground long after the 'caller_chosen lifetime is gone.

(The borrow of a local isn't a capture from the environment either.)

Yes, I agree with that. Maybe, I didn't express my thought clearly. I try to simplify the original question to a simple example, which may be clear.

use std::marker::PhantomData;
struct S<'scope,F: 'scope>{
     f:F,
     marker:PhantomData<&'scope i32>
}
fn scope<'scope>(i:&'scope mut i32){
    *i+=2;
    let f = ||{
      let s = S::<'scope>{f:||{
  	    *i+=1;
      },marker:PhantomData};
   };
    //*i+=2;
}

If we desugar the above code to the following(pseudo-code)

'scope:{
   fn scope(i:&'scope mut i32){
       // 'body, the upper representable lifetime of the body of the function `scope`
      // f is desugared to, whose function's body lifetime is 'f
     let f = ||{ 
        // 'f 
        let s = S::<'scope>{f:||{
           // 'f2
    	    *i+=1;
        },marker:PhantomData};
     };
   }
}

So, we may have the relationship: 'scope : 'body :'f :'f2, these lifetime parameters denote the corresponding function body's lifetime, respectively.

@quinedot Oh, I find a simple way to express my concern. Consider this example

fn scope(f:F){
    let scope = xxx;
    f(&'scope scope);
}
let i = 0;
scope(|s|{ // this closure called `f0`
   s.borrow_for_'scope(||{  // this closure called `f1`
       i+=1;
   });
   i+=1;
});

First, f0 captures i for some lifetime 'c, which outlives 'scope or is at least equal to 'scope. Then f1 captures i for lifetime 'scope, then we use i in the function body of f whose body's lifetime is 'f1. It appears to me that the only reason why i+1 appears in f causes error is 'scope includes 'f1, otherwise, how do they overlap such that i+=1 in 'f1 causes error?

I'm on mobile now and will be unresponsive for some hours soonish, so I can't give this the response it deserves for awhile. But I'll do my best in the short term.

I think this example (and the last one) is an example of labeling blocks with lifetimes falling short. There's no local variables besides the function argument, which you don't take a reference to, so no borrows are restricted by function bodies.

There's a reborrow of *i that is as long as the original lifetime, making the original unusable. (I'm assuming edition 2021 capture semantics here.)

The lifetime validity of the closures themselves are the lifetime in i as a result. It's not limited to the body of scope. The bodies of the closures don't limit anything either.

Your lifetime relationship chain doesn't hold.

(If this is supposed to be about scoped threads again, the lifetimes are invariant by the way.)

If we use the rule, 2094-nll - The Rust RFC Book, as you answered in my other question

For this example:

fn scope(f:F){
    let scope = xxx;
    f(&'scope scope);
}
let i = 0;
scope(|s|{ // this closure called `f0`, capture `i` with `capture_in_f0`
  // Desugar: let capture_in_f0 = &' mut i;
   s.closure_borrow_i_for_'scope(||{  // this closure called `f1`, using `capture_in_f1 ` captures `i` by reborrowing `capture_in_f0`
//  Desugar: let capture_in_f1 = &mut *capture_in_f0; // #1
       i+=1;
   });
   i+=1;  // #2, desguar to *capture_in_f0+=1
});

we assume f0 captures i with a variable(it is actually a field but for simple, we assume it is a variable), called capture_in_f0. capture_in_f1 reborrows capture_in_f0, then i+=1 in f0 can be desugared to *capture_in_f0+=1

let capture_in_f0 = &' mut i;
let capture_in_f1 = &mut *capture_in_f0; // #1
*capture_in_f0+=1; // #2

*capture_in_f0+=1 is a shallow write to lvalue *capture_in_f0. and *capture_in_f0 is the shallow prefix of loan ('x, mut, *capture_in_f0) occurs at #1. Hence, we analyze such two borrowings. #1 is a relevant borrowing of #2, however, the loan at #1 should be in the scope of #2 such that such two operations conflict with each other according to Borrow checker phase 1: computing loans in scope. How do you interpret that #1 is in the scope of #2 in this example?

The captures (reborrows) last for 'scope, which is caller chosen, and that includes the function body, hence the error.

(Lifetime parameters of a function are valid everywhere in the function. A function can assume a reference argument is usable everywhere in the function for example.)

That's all about the borrow of *i (and the limitations it puts on the validity of the capturing closures themselves) though, not function bodies or closure bodies per se. The only way a function body comes into it is the part about 'scope being active everywhere in f.

Think of the end of a function as a use of all non-reference local variables and not a lifetime, maybe.

Which functions' body are included by 'scope? I know the body of the function scope is, so, how about the bodies of the closure f0 and f1?

This one and probably all subsequent will have to wait awhile, sorry :slightly_smiling_face:

1 Like

I’m trying to understand what has been said in this discussion, it’s not entirely easy to read, so excuse me in case I’m repeating anything that has already been said…

But I need to comment on this: I would like to disagree with this phrasing

The 'env: 'scope bound is not enforced on but provided to the user of the thread::scope API. The 'env lifetime is introduced first, then the 'scope lifetime is introduced and 'env: 'scope restricts the latter.


Here’s my take at explaining this in more detail, which, I guess, has – now that I’ve written it – turned into an almost complete (short) account of my interpretation of how and why thread::scope API works and what language mechanisms are involved it its soundness.


Below, the terms “caller” and “callee” generally refer to the caller and callee of the std::thread::scope(…) function in particular; but I also imagine the “caller” as the “API user of the scoped thread API” in general.

The lifetime 'scope is not a bound/restriction on a caller-chosen lifetimes in the call to thread::scope. Instead, the 'scope lifetime is callee-chosen, as it comes from a for<'scope> FnOnce(…) -> … higher-ranked trait bound. Restrictions on a callee-chosen lifetime does not enforce anything on the caller, but instead provides them with additional capabilities, in particular, being provided with the 'env: 'scope bound, it’s possible to convert &'env T references into &'scope T references, or maybe simply – without coercing – it just allows you to know/prove the bounds like &'env T: 'scope.

Where exactly does the 'env: 'scope bound appear in the signature of thread::scope(…)? There is an (implicit) 'env: 'scope bound comes, as an implied bounnd, from using certain types: the Scope<'scope, 'env> type itself has a 'env: 'scope bound, and there’s also the reference type &'scope Scope<'scope, 'env> that implies a Scope<'scope, 'env>: 'scope bound, too, and thus ultimately 'env: 'scope. This implied bound does weaken the higher-ranked trait bound:

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

It thus relieves the caller a little bit, as the caller of std::thread::scope(…) will no longer need to ensure the closure fulfills a certain FnOnce bound for all lifetime 'scope, but only for those lifetimes 'scope that fulfill 'env: 'scope.


What does enforce that “nothing captured becomes invalid before all the threads are joined”, though? This restriction come from two factors: The 'scope lifetime is callee-chosen, so the user of the thread::scope-API can not influence what this lifetime is. Intuitively, the 'scope lifetime does thus accurately describe the true scope/duration of the thread::scope call, up to the point where the threads are all joined and terminated. The API detail that enforces something then is the signature of Scope::scope(…) which comes with a F: 'scope bound.

Of course the API does not encode in any way the compiler can understand the precise details of “this 'scope lifetime will be chosen exactly to be the one that lasts until all the threads are joined, right before thread::scope returns”. This kind of thing is too complicated for type signatures.

Instead, by using a for<'scope> … HRTB, an arbitrary callee-chosen lifetime is introduced, which a priori would mean the caller needs to be handle any choice of lifetime, including the one that happens to accurately represent the lifetime that “that lasts until all the threads are joined”.

Demanding the caller to handle any choice of lifetime is too restrictive. After all, the choice could have been 'scope == 'static; with a fully unrestricted callee-chosen lifetime 'scope, the F: 'scope bound becomes (almost) as restrictive as F: 'static. This can be demonstrated easily:

use core::marker::PhantomData;

// a simpler `Scope` without using any `'env` and thus without any `'env: 'scope` bounds
pub struct Scope<'scope>(
    // marker for invariant lifetime, mirroring what `std` does
    PhantomData<&'scope mut &'scope ()>,
);
impl<'scope> Scope<'scope> {
    pub fn spawn<F, T>(&'scope self, f: F) -> ()
    // we don't need to return a join handle for this demo
    where
        F: FnOnce() -> T + Send + 'scope,
        T: Send + 'scope,
    {
        unimplemented!() // we only care about the signature
    }
}

pub fn scope<'env, F, T>(f: F) -> T
where
    F: for<'scope> FnOnce(&'scope Scope<'scope>) -> T,
{
    unimplemented!() // we only care about the signature
}

fn demonstration1() {
    let x = 1;
    // compiles
    std::thread::scope(|s| {
        s.spawn(|| {
            println!("{x}");
        });
    });
}

fn demonstration2() {
    let x = 1;
    // fails
    scope(|s| {
        s.spawn(|| {
            println!("{x}");
        });
    });
    // error message contains hint:
    // argument requires that `x` is borrowed for `'static`
}

The error message will even call out 'static, despite there being no explicit F: 'static requirement, instead there’s a F: 'scope requirement for an unrestricted callee-chosen lifetime 'scope. The only thing we know about the 'scope lifetime is that the &'scope Scope<'scope> reference lives long enough, but for any other reference, such as one to x, the F: 'scope bound is essentially as restrictive as F: 'static.

In order for the thread::scope lifetime to be useful, the API teaches us more about the 'scope lifetime than “it’s any arbitrarily chosen lifetime”. Instead it gives the 'env: 'scope implied bound, which provides the information “the 'scope lifetime is any lifetime, but it will be at most as large as 'env”. While previously we were only able to deduce F: 'scope for the &'scope Scope<'scope> reference, and other 'static references, not every &'env T reference will qualify, too.

And 'env is caller-chosen, so we can choose it as small as we need, in particular small enough for a &'env i32 reference in demonstration1 to qualify. But what is to stop us from choosing 'env to be too small? Is there any restriction left at all!? We are the caller for thread::scope; why couldn’t we just chose “'env is small enough so that borrowing x for 'env works” in demonstration3 below?

fn demonstration3() {
    // fails
    std::thread::scope(|s| {
        let x = 1;
        s.spawn(|| {
            println!("{x}");
        });
    });
}

The answer is: Caller-chosen lifetimes have one important restriction, too.

If you define a function like

fn foo<'a>(r: &'a u8) {}

The caller of foo can chose almost any lifetime for 'a. But the lifetime 'a has to last at least as long as the call to foo! This is a universal rule in Rust that is always enforced: Lifetimes that are lifetime parameters to a function last as long as the duration of the function! And it makes sense, after all, in a function like foo, the argument r is usable for the entire duration of the function, so it’d better be alive the whole time.

This is the missing piece: The 'env lifetime is a caller-chosen lifetime parameter of the function thread::scope, and thus it must be at least as long as the entire time until thread::scope returns. This is what makes thread::scope’s API sound: No matter what lifetime the caller chooses for 'env, it’s always true that the lifetime 'env lasts longer than the call the thread::scope, while the duration “until all the threads are joined” is always shorter than the call to thread::scope. Hence the 'env: 'scope bound is correct. 'env outlives the time “until all the threads are joined” which is the intended meaning for 'scope.

2 Likes

Having mulled on it awhile, let me try a new analogy before I circle back to responding to posts.


Consider this code, which I think you'll have an intuition for already:

// Invariant in `'a`
struct Borrower<'a, T> {
    s: Cell<&'a mut String>,
    t: T,
}

fn ex1() {
    let mut s = String::new();
    let local = ();
    let b = Borrower::new(&mut s, &local);
    println!("{s}");
}

fn ex2<'caller>(s: &'caller mut String) {
    let local = ();
    let b = Borrower::new(s, &local);
    println!("{s}");
}

fn ex3<'caller>(s: &'caller mut String) {
    let local = ();
    let b = Borrower::<'caller, _>::new(s, &local);
    // This line fails
    // println!("{s}");
}

In ex1, we borrow the local s for some short 'a in order to create b.

In ex2, we reborrow the argument s for some short 'a in order to create b.

In ex3, we reborrow the argument s for 'caller in order to create b. We can't use s any more because it's exclusively reborrowed for all of 'caller.

Now, I expect you understood all that, don't really have any questions about it, and never thought to ask if the if the "lifetime" of this part of code:

    let b = Borrower::<'caller, _>::new(s, &local);
    //                                  ^^^^^^^^^

was contained in any other part of code.


Now consider these variations:

fn ex4() {
    let mut s = String::new();
    let local = ();
    let b = Borrower { s: Cell::new(&mut s), t: &local };
    println!("{s}");
}

fn ex5<'caller>(s: &'caller mut String) {
    let local = ();
    let b = Borrower { s: Cell::new(s), t: &local };
    println!("{s}");
}

fn ex6<'caller>(s: &'caller mut String) {
    let local = ();
    let b = Borrower::<'caller, _> { s: Cell::new(s), t: &local };
    // This line fails
    // println!("{s}");
}

They behave the same way as the previous examples,[1] even though we have now introduced some curly-brace blocks. The presence of the blocks didn't change what lifetimes ended up in the type of b or prevent ex6 from being constructed from a lifetime that came from outside the inner block or from outside of the call to ex6. You've probably seen literal struct syntax so often that if it weren't part of this conversation, you wouldn't look at this code:

    let b = Borrower { s: Cell::new(s), t: &local };
    //                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

and ask if the "lifetime" of the highlighted code was contained in any other code.

Something similar was actually going on in the first set of examples already. Here's some code I left out:

impl<'a, T> Borrower<'a, T> {
    fn new(s: &'a mut String, t: T) -> Self {
        let s = Cell::new(s);
        Borrower { s, t }
        //        ^^^^^^
    }
}

We can move b around, and if it's not borrowing a local, we can move it out of the function it was defined in even.

fn ex7<'caller>(s: &'caller mut String) -> Borrower<'caller, ()> {
    let b = Borrower { s: Cell::new(s), t: () };
    // This line fails
    // println!("{s}");
    b
}

The cell we created within the block is now "outside of" es7's block altogether.


OK, how about closures? You can think of a borrow-capturing closure as something similar to

struct Closure<'capture1, 'capture2, ...> { /* captured fields */ }

The lifetime parameters only take on one particular "value" based on the analysis at the definition site of the closure, and so arguably shouldn't be parameters. But there is no syntax for that, so this approximation is easier to talk about.

When we look at this:

fn ex8<'caller>(s: &'caller mut String) -> impl FnOnce() -> Borrower<'caller, ()> {
    || {
        let local = 0;
        let rf = &local;
        println!("{rf}");
        Borrower::new(s, ())
    }
}

It's sort of like we did this:

struct __Closure_42<'a> { capture1: &'a mut String, }
impl<'a> __Closure_42<'a> {
    fn new(capture1: &'a mut String) -> Self {
        Self { capture1 }
    }
}
impl<'a> FnOnce<()> for __Closure_42<'a> {
    type Output = Borrower<'a, ()>;
    extern "rust" fn call_once(self, args: ()) {
        let local = 0;
        let rf = &local;
        println!("{rf}");
        Borrower::new(self.capture1, ())
    }
}

fn ex8<'caller>(s: &'caller mut String) -> impl FnOnce() -> Borrower<'caller, ()> {
    __Closure_42::new(s) // aka __Closure_42::<'caller>::new(s)
}

What's the relationship of the function body of the call_once to the function body of ex8? We could try to reason about that -- 'caller is valid for the entirety of both function bodies for example. But 'caller is a generic parameter that can be something different for each call to ex8, and where the call_once body is called from is entirely outside of ex8.

I don't really find much value in trying to reason about those -- I find them rather unrelated. I find more value in reasoning about what the lifetime of __Closure_42 is and what the use sites of the closure imply (e.g. it's lifetimes have to still be valid when you use it, if it has exclusive captures those remain exclusive so long as the closure is valid, etc).

The lexical blocks in this example don't mean much at all.


Here's a playground with the above examples. I thought I might take them further but this seems like a good stopping point. I'll circle back to actually read and reply to the newer posts later.


  1. being a function parameters may influence some code via coercion or such, but that's not relevant here ↩︎

This one is pretty interesting. Here's what I think is going on: the inner closure is capturing a reborrow with the lifetime of 'scope, and that's not a problem on its own. But the outer closure is being interpreted as a FnMut closure, and this is incompatible with creating reborrows of length 'scope -- multiple calls would produce multiple reborrows which all overlap.

If you make the inner closure move, they both become a FnOnce and it compiles.

If you nudge the compiler to make the outer closure a FnOnce closure, it also compiles. The inner closure is still a borrow capturing FnMut.

The original error wasn't a scoping problem, it was a compiler-inferred-the-wrong-flavor-of-closure problem.


What are the lifetimes of the closure types? They're 'scope. 'scope has to be valid when they're called ('scope: 'closure_lifetime) and your creation of S<'scope> requires the reborrow be at least as long ('closure_lifetime: 'scope).

What are the lifetimes of the closure bodies? Closure bodies, function bodies, lexical scopes... these things don't have lifetimes. They (the presence of scopes) are not influencing the lifetimes in this example. The annotations and uses of i are. The inner closure has no local variables. The outer closure has s, which is still valid and not borrowed when it drops at the end of the outer closure.[1]

Why do *i += 3 and *i += 4 error here? Because they try to use i after *i has been reborrowed for 'scope. The exclusive reborrow means that i is unusable until 'scope expires -- so unusable ever again.

You can mock up the closures as Inner<'a> and Outer<'a> with new(i: &'a mut i32) constructors and FnOnce and FnMut implementations like I did above. If Outer tries to be FnMut, you get another version of a "captured lifetime trying to escape".


  1. And maybe has a trivial destructor anyway. ↩︎

@quinedot Please look at this comment: Does the lifetime inferred in caller body outlives that of the callee's body? - #13 by xmh0511

I think we discuss this question in terms of RFC rules, which is more helpful. The pseudo-code in the comment is the simplified version of the subject of what I want to ask.