Why does Rust compiler show the error that closure may outlive the current function, but it borrows sum

Doesn't the presence of join suggest that the closures will not outlive the current function?

fn add1(n1 : i32, n2 : i32) -> i32{
    let mut sum =n1; 
    let (count, increment) = if n2 > 0 { (n2, 1)} else { (-n2, -1)};
    let mut handles = vec![];


    for _ in 0..count {
        
        handles.push(
            thread::spawn( move || {
               sum += increment; 
            })
        );
    }

    for handle in handles {
        handle.join().unwrap();
    }
    
    sum 
}

The code you posted compiles without problems. Since you declared that the closure should move all the referenced variables into the closure and i32 is Copy, each thread has its own copy of sum. So add1 will always return 0. So I guess you meant a version where there is no move keyword in front of the closure, with an error

error[E0373]: closure may outlive the current function, but it borrows `increment`, which is owned by the current function
  --> src/main.rs:9:36
   |
9  |         handles.push(thread::spawn(|| {
   |                                    ^^ may outlive borrowed value `increment`
10 |             sum += increment;
   |                    --------- `increment` is borrowed here
   |
note: function requires argument type to outlive `'static`
  --> src/main.rs:9:22
   |
9  |           handles.push(thread::spawn(|| {
   |  ______________________^
10 | |             sum += increment;
11 | |         }));
   | |__________^
help: to force the closure to take ownership of `increment` (and any other referenced variables), use the `move` keyword
   |
9  |         handles.push(thread::spawn(move || {
   |                                    ++++

Which is probably also the reason why you added move in the first place. So lets look at the error. You said:

Yes, logically it might. Althoug it doesn't as @alice explains in their post. However, the rust compiler has no way to reason about the program in this way. All it has, is the definition of thread::spawn(). Let's have a look

pub fn spawn<F, T>(f: F) -> JoinHandle<T>where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,

The documentation of this function explains:

As you can see in the signature of spawn there are two constraints on both the closure given to spawn and its return value, let’s explain them:

  • The 'static constraint means that the closure and its return value must have a lifetime of the whole program execution. The reason for this is that threads can outlive the lifetime they have been created in.Indeed if the thread, and by extension its return value, can outlive their caller, we need to make sure that they will be valid afterwards, and since we can’t know when it will return we need to have them valid as long as possible, that is until the end of the program, hence the 'static lifetime.

So the compiler cannot reason about what exactly your program does. All it sees, is that the closure needs to live till the end of the program. This is called local reasoning and it makes it easier for you and the compiler to think about programms. Since you only need to look at the function itself to know what contract the closure needs to fulfill, you don't have to know the whole program at once in order to know if the reference outlives the referent. If we didn't have this, one part of the program could influence the correctness of vastly different parts of the program.

There are different solutions to overcome this, but I'll let others explain what these might be.

Btw, a closure having a 'static bound, i.e.

F: FnOnce() + 'static,

means that it has to either take all its captured values by moving them into the closure, or by reference whit a 'static lifetime. I.e. the closure needs to own its captured values or be able too keep the reference forever.

1 Like

The borrow-checker does not rely on such complicated logic. And it's less obvious than you say. How do you know that, for example, one of the calls to handles.push doesn't panic? If a panic happens before the call to join, then the threads would outlive the current function. Or maybe there's a bug somewhere in Vec that causes it to loose one of the JoinHandles, causing you to not join one of the threads? The borrow checker must rule out all of those things to accept your code. After all, the borrow checker only accepts code that it is absolutely certain is correct.

To work around this, there is a dedicated method for handling this kind of thing called std::thread::scope.

fn add1(n1: i32, n2: i32) -> i32 {
    let mut sum = n1;
    let (count, increment) = if n2 > 0 { (n2, 1) } else { (-n2, -1) };

    std::thread::scope(|scope| {
        for _ in 0..count {
            scope.spawn(|| {
                sum += increment;
            });
        }
        
        // Returning from `scope` automatically
        // joins all spawned threads.
    });

    sum
}

Now, there is still another error, but that has to do with a different problem:

   Compiling playground v0.0.1 (/playground)
error[E0499]: cannot borrow `sum` as mutable more than once at a time
 --> src/lib.rs:7:25
  |
5 |       std::thread::scope(|scope| {
  |                           ----- has type `&'1 Scope<'1, '_>`
6 |           for _ in 0..count {
7 |               scope.spawn(|| {
  |               -           ^^ `sum` was mutably borrowed here in the previous iteration of the loop
  |  _____________|
  | |
8 | |                 sum += increment;
  | |                 --- borrows occur due to use of `sum` in closure
9 | |             });
  | |______________- argument requires that `sum` is borrowed for `'1`

namely that only one thread can modify a variable at the time, but your code would try to modify it in parallel, which is not allowed.

5 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.