Desire: async is IntoFuture, not Future

.await accepts IntoFuture; could async produce IntoFutures which aren't Futures yet? Is this something which even could happen over an edition? Almost certainly not for the 2024 edition, but maybe for a future edition? From a strictly technical standpoint, it could, since async is a language keyword feature, but I'm more interested in the social/ecosystem costs.

I understand the semantics-preserving migration lint would be fairly nightmarish (either lose async fn sugar entirely or hide it behind a proc macro (which could probably just override the edition of the async token)). I know existing ecosystem stuff mostly uses impl Future and not IntoFuture (cf. use of [Into]Iterator). But if we do want to push people towards using IntoFuture more, the longer we wait the harder it gets. The absolute latest such a migration could happen would be when std exposes an async fn.

The benefit of such a migration is that || async { … } is smaller than async { … } — it only has to store any captures, not also have space for the unstarted state machine. There are also alleged benefits from IntoFuture benefiting from RVO (move elision) more often than passing around impl Future does. (A minor pedagogical benefit: if async fn returns IntoFuture, it can't've been eagerly started. Potential technical benefit: hanging -> dyn Future dynamic storage-agnostic spawn functionality off of IntoFuture.)

At a minimum, it'd be nice to have || async { … } be IntoFuture.

6 Likes

Can you please elaborate a bit on a problem you're trying to solve? Is it just async fn result being smaller than actual future? Or something else? If yes, and if it's critical to you, you should be able to apply such optimization yourself.

I don't see how you could implement any reasonable transition. The reference already guarantees that async blocks evaluate to a future:

An async block is a variant of a block expression which evaluates to a future. The final expression of the block, if present, determines the result value of the future. ... the type returned for an async block implements the std::future::Future trait.

Plenty of code is written on that assumption. An obvious example, async_trait is widely used, and uses the Future guarantee in its expansion.

That alone looks like it makes a backwards-compatible migration impossible. But even if we could do it, the stated benefits are very slim.

The benefit of such a migration is that || async { … } is smaller than async { … } — it only has to store any captures, not also have space for the unstarted state machine.

As stated, you can already use a closure to achieve the same effect. I'd say in many cases it doesn't matter anyway, since the future will be immediately boxed (e.g. anything which uses async_trait does that). Having a separate into_future step is actually detrimental to performance in this case, since you'd need to allocate memory for an async block, only to ~immediately (but likely not immediately enough for allocation elision to be possible) create a new allocation for the actual state machine.

There are also alleged benefits from IntoFuture benefiting from RVO (move elision) more often than passing around impl Future does.

Why would that be? RVO doesn't care about the specifics of the returned data, nor about its implemented trait. If returning IntoFuture were somewhat consistently faster, I'd say it would warrant an investigation.

A minor pedagogical benefit: if async fn returns IntoFuture, it can't've been eagerly started.

That looks like a downside rather than upside, Future does nothing unless explicitly polled, it's important to understand. Emphasizing a separate async {}.into_future() step as if it changes the evaluation model looks more likely to lead to confusion about that fact. Like, "futures do nothing unless polled, and if you write an async block, they do even less because they are not futures"? Also people expecting async fns to be eagerly evaluated a-la Node haven't read the documentation and would be just as unaware about any IntoFuture business.

At a minimum, it'd be nice to have || async { … } be IntoFuture.

Such ad-hoc implementations of traits for closures is unprecedented. We can't even make non-capturing closures implement Default, making a specific syntactic construct implement IntoFuture looks wild.

1 Like

Not RVO of the async fn itself, but RVO on into_future. If you have fn spawn(task: impl IntoFuture), then when it does Box::new(task.into_future()), that's reasonably straightforward to RVO into building the future directly on the heap. If you have fn spawn(task: impl Future), that's more difficult to NRVO. Perhaps not the most significantly, but certainly nonnegligiblely.

3 Likes

Looks like a very niche and speculative microoptimization. Were it useful, wouldn't tokio et al provide an API fn spawn_lazy(task: impl FnOnce() -> BoxedFuture)? If the issue is boxing in the API, lazy spawning could be added once async closures become stable.

1 Like