How do I tell the compiler the variables won't outlive an enclosure?

Hi!

I'm writing a quick test and I'm met with the following. The code has been modified to get the problem across (the actual test is slightly more complex):

#[test]
fn internal_test() {
    let mock_data = vec![5, 1, 2];

    let num_threads = 25;
    let num_iterations = mock_data.len();

    let mut handles = vec![];

    for _ in 0..num_threads {
        let handle: thread::JoinHandle<()> = thread::spawn( || {
            for j in 0..num_iterations {
                print!( "{}", mock_data[j] );
            }
        });
        handles.push(handle);
    }

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

Now the compiler naturally complains:

closure may outlive the current function, but it borrows `num_iterations`, which is owned by the current function may outlive borrowed value `num_iterations`

closure may outlive the current function, but it borrows `mock_data`, which is owned by the current function may outlive borrowed value `mock_data`

The num_iterations one can be fixed by adding move to the enclosure. However not the mock_data one:

use of moved value: `mock_data`
value moved into closure here, in previous iteration of loop

Note: There's other more complex structures I left out of this example that have the same problem as mock_data.

Normally Arc would have to be used. But the thing is; in this case I don't care. I know that the variables in the enclosure won't outlive the main function. The enclosure stops in handle.join().unwrap();.

Is there a way to tell Rust of that? I suspect lifetime annotations could fix this but I can't figure out the right syntax. I don't mind using unsafe in this scenario either.

Cheers and thanks in advance!

In general, the compiler will never be willing to rely on the fact that you do call some function like join(). Type, drop, and borrow checking care whether all the things that might happen are acceptable, but they don't reason from what will happen.[1]

You're right that lifetimes can help here. Doing so is a little subtle, though; in order to have the right lifetimes and guaranteed cleanup, you need a function that takes a callback (higher-order function). Luckily, the standard library already does this for threads; your problem is solved by scoped threads.

#[test]
fn internal_test() {
    let mock_data = vec![5, 1, 2];

    let num_threads = 25;
    let num_iterations = mock_data.len();

    thread::scope(|s| {
        for _ in 0..num_threads {
            s.spawn(|| {
                for j in 0..num_iterations {
                    print!( "{}", mock_data[j] );
                }
            });
        }
    });
}

Code inside the scope can borrow anything from outside the scope. In exchange, the std::thread::scope() function enforces that all the threads must terminate before the scope ends — this is enforced by implicitly join()ing all of them after the |s| {...} closure ends. (So, you don't need the Vec<JoinHandle> at all.)

In general, any time there is some kind of mandatory cleanup step, or data that must not be borrowed past some end point that isn't a mutation, you'll see a callback being used (e.g. thread locals require a with() call to ensure that the reference can't outlive the thread).

If you're interested in more general patterns of using threads on borrowed data, check out rayon, which does the same thing with a thread pool and provides efficient “work-stealing” parallelism.


  1. And, in fact, it won't necessarily happen: suppose you run out of memory for new threads, and spawn panics. Then none of the join()s will execute, and the spawned threads will be accessing freed memory. ↩︎

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