concurrency with futures in rust: returning borrowed values

3 minute read 29 April 2024

In concurrency with futures in rust: borrowing parameters, I've described my issues when migrating some code form intra-task to multi-tasks concurrency while using borrowed parameters.

My actual issue is actually deeper as my code is not only using borrowed values, but also returning borrowed values.

Concurrency & borrowing with futures::future::join_all

When using futures::future::join_all, I can run futures concurrently, and they can return borrowed values. Here is a simplified example where all hello_in_vec futures borrow &who and return vectors containing &who:

use futures::future::join_all;

#[tokio::main]
async fn main() {
    call_say_hello_in_vec().await;
}

async fn call_say_hello_in_vec() {
    println!("Starting jobs...");

    let world = "world";
    let results = say_hello_in_vec(world).await;
    let results = results.into_iter().flatten().collect::<Vec<&str>>();

    let length = results.len();
    let all_results = results.join(", ");
    println!("All jobs finished with {length} results: {all_results}");
}

async fn say_hello_in_vec(who: &str) -> Vec<Vec<&str>> {
    let handles = (1..=5).map(|i| hello_in_vec(i, who));

    let handle = join_all(handles);
    let results = handle.await;
    results
}

async fn hello_in_vec(i: i32, who: &str) -> Vec<&str> {
    println!("Job {i} started");
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
    let result = vec!["hello", who];
    println!("Job {i} finished");
    result
}

Please note that hello_in_vec is returning borrowed values &str.

Running this program prints out:

Starting jobs...
Job 1 started
Job 2 started
Job 3 started
Job 4 started
Job 5 started
Job 1 finished
Job 2 finished
Job 3 finished
Job 4 finished
Job 5 finished
All jobs finished with 10 results: hello, world, hello, world, hello, world, hello, world, hello, world

Now let's try to use multi-tasks concurrency to achieve parallelism.

Concurrency & borrowing with tokio::spawn

As seen in concurrency with futures in rust: borrowing parameters, tokio::spawn does not allow us to borrow parameters. So it won't be good for us.

Concurrency & borrowing with async_scoped

Previously, we could use async_scoped:

async fn say_hello_in_vec(who: &str) -> Vec<Vec<&str>> {
    let handles = (1..=5).map(|i| hello_in_vec(i, who));

    let (_, results) = unsafe {
        async_scoped::TokioScope::scope_and_collect(|s| {
            for handle in handles {
                s.spawn(handle);
            }
        })
        .await
    };
    let results = results.into_iter().map(|res| res.unwrap()).collect();

    results
}

But with borrowed returned values, it does not compile anymore:

error: lifetime may not live long enough
  --> src/main.rs:27:35
   |
26 | async fn say_hello_in_vec(who: &str) -> Vec<Vec<&str>> {
   |                                - let's call the lifetime of this reference `'1`
27 |     let handles = (1..=5).map(|i| hello_in_vec(i, who));
   |                                   ^^^^^^^^^^^^^^^^^^^^ returning this value requires that `'1` must outlive `'static`

I've opened an issue for that.

Concurrency & borrowing with futures_concurrency

With futures_concurrency, the code compiles! But we have the same issue as last time where the program does not make any progress after the first job.

Conclusion

All in all, concurrency and borrowing seems a difficult path in async rust.

This situation might evolve in the future. For now, I think that a reasonable pattern is to avoid borrowed values in async functions.

In my application, this means doing all IO related stuff concurrently (with tokio tasks), waiting for all of them to finish, and then doing the computation on the results in synchronous rust. This is not ideal. If we are doing 5 HTTP requests, we need to wait for all of them to be finished before we can start processing the results.