Async closure lifetime issue

In the following code, I do not understand why the async move has a lifetime issue?

use std::time::Duration;
use tokio::time::sleep;
use std::sync::Arc;

async fn closure() {
    let num = Arc::new(42u64);
    let task =
        async move || sleep(Duration::from_millis(*num)).await;

     tokio::spawn(task()).await.unwrap();
}


async fn block() {
    let num = Arc::new(42u64);
    let task =
        async move { sleep(Duration::from_millis(*num)).await };

     tokio::spawn(task).await.unwrap();
}


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

The move keyword means that a closure (async or not) will always move captured values into itself. It does not necessarily mean that the closure will move the captured values out of itself (into the Future) when called. So, the future task() is borrowing from the closure task.

You can address this by writing the code inside the closure to force a move:

    // Method 1
    let task =
        async move || sleep(Duration::from_millis(*{ num })).await;

    // Method 2
    let task = async move || {
        let num = num;
        sleep(Duration::from_millis(*num)).await
    };

Or, you can constrain the the closure to be a AsyncFnOnce which always moves out. Unfortunately, this requires creating an entire helper function (when the compiler sees a closure passed to a function, it trusts that function about what the closure signature should be):

fn closure_helper<F>(f: F) -> F
where
    F: AsyncFnOnce()
{
    f
}

...

    let task = closure_helper(async move || {
        sleep(Duration::from_millis(*num)).await
    });
2 Likes

You have uncovered a not-so-pretty aspect of AsyncFns, and issue with the hidden ()-call sugar, which tries to use whichever of Fn{,Mut,Once} is more permissive (here AsyncFn{,Mut,Once}).

  • whilst the sugar and illusion works out in the Fn{,...} cases (since no matter how the invocation happens, the output is always the same), in the AsyncFn{,...} cases, that aspect is broken, and the illusion then comes with a loss of direct expressibility :warning:

In the AsyncFn{,...} cases, the output of the ()-call depends on how it was called / which flavor of the three AsyncFn{,...} was picked.

Most notably, in your case, the let mk_task = async move || { ... } boils down to defining:

struct Closure {
    num: Arc<u64>,
}

impl Closure {
    fn call_async_fn(&self) -> impl use<'_> + Future<Output = ()> {
        return async move /* self: &'_ Closure */ {
            sleep(Duration::from_millis(*self.num)).await
        };
    }

    fn call_async_fn_once(self) -> impl use<> + Future<Output = ()> {
        return async move /* self: Closure */ {
            self.call_async_fn().await
        };
    }
}

let mk_task = Closure { num };

If you have a mk_task: Closure:

  • and try to do tokio::spawn(mk_task.call_async_fn()), you will run into the issue that the spawned task = mk_task.call...() is an impl use<'borrow_of_mk_task> + Future, that is, a future which is borrowing from mk_task (because it wants to &-borrow the num: Arc<u64>), and since mk_task is a short-lived local, such a borrow cannot be 'static, so the task: impl Future itself cannot be : 'static.

  • but if you do tokio::spawn(mk_task.call_async_fn_once()), you will not run into any issues whatsoever, because the so obtained task = mk_task.call...() is now a fully (lifetime-)standalone impl use</* no 'strings attached */> + Future (because it now owns the num: Arc<u64> inside of it), so it gets to be : 'static.

And when using the stdlib/blessed AsyncFn{,...} traits, .call_async_fn(), .call_async_fn_mut(), and .call_async_fn_once() are all "called" (using) simply (): mk_task().

  • Herein lies then the issue: Rust will pick one implicitly in your stead (and not give you a direct/succint way of picking something else!), all following the usual heuristic guideline that the non-async Fn{,...} traits had: the less we consume/use the receiver (the mk_task instance), the better. But here, given the lifetime difference, it's not always better!

Your question was about comparing the output of an async closure to an async block. It turns out they're almost the same, and in fact, in the AsyncFnOnce, they will be the same. But in the other AsyncFn{,Mut} cases, the returned impl Future may be borrowing from the captures if Rust considers it can do that optimization (here is where there is no direct/succint way to truly prevent this, since usage of move applied to the first layer of suspension (that of ()-invocation), not the the second layer of suspension (that of .awaiting)).


A to the workarounds, what @kpreid mentioned: you'll want to somehow "forget" you had something as flexible as an AsyncFn, and instead, try to end up with an AsyncFnOnce, if anything, at least during the call.

  • Here, because our capture is not Copy, a cleverly placed { ... }-braced block allows to force a move upon call, and therefore, to restrict the closure to be own-consuming, i.e., ...Once.
  • But if the capture were Copy, this trick would not work, and you'd be forced to use these uglier fn ... AsyncFnOnce funneling/helper functions :confused:
2 Likes

Thanks alot for the detailed answer!

Many thanks for the explanation and the suggestions!

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.