September 01, 2021

An Alternative Syntax for Async Functions

After thinking about the async fn in traits problem for a while, I've come to the conclusion that the syntax Rust chose for async fn is the wrong one. Specifically, the fact that the returned future type is hidden is quite limiting:

// this really returns an `impl Future<Output = usize>`, but that's hidden
async fn foo() -> usize { 1 }

I propose that we should explicitly write out the return type:

async fn foo() -> impl Future<Output = usize> { 1 }

The async modifier would be simple sugar for wrapping the body of the function in an async block:

async fn foo() -> impl Future<Output = usize> { 1 }

// becomes
fn foo() -> impl Future<Output = usize> { async { 1 } }

This approach has a number of benefits that I will go over in this post.

Bounding Async Functions

It's quite common to want to bound the Future returned by an async function. Existential types currently leak auto-traits, so an async function might be accidentally Send at first, and made !Send later by changes to the body of the function. We want to be able to require the returned future to be Send or Sync, something that is not possible with the current syntax:

#[must_be_send]
async fn foo() -> usize { 1 }

This is especially important when it comes to trait methods, where you might want to require a specific implementation of the trait to be Send in order to be spawned onto a runtime:

trait Foo {
    async fn foo(&self) -> usize;
}

fn run_foo<F: Foo>(foo: F) {
    // ERROR: future cannot be sent between threads safely
    tokio::spawn(async move {
        foo.foo().await;
    });
}

How do we express this bound with the current syntax? The async_trait macro chose to add Send requirements by default, allowing you to optout by passing an argument to the macro:

#[async_trait::async_trait(?Send)] // <---
trait Foo {
    async fn foo(&self) -> usize;
}

One common language proposal is to specify the bounds after the async keyword:

trait Foo {
    async(Send) fn foo(&self) -> usize;
}

Another is to add a typeof keyword to the language:

trait Foo where
    typeof(F::foo)::Output: Send
{
    async fn foo(&self) -> usize
}

Both of these look a bit weird to me. The only reason we need to come up with such an extension is because the future type is hidden. If this were not the case, we could simply add the bound using the normal impl Trait syntax:

trait Foo {
    fn foo(&self) -> impl Future<Output = usize> + Send + '_;
}

Note that the method isn't marked as async, because it doesn't need to be. The async modifier can be used when implementing the trait:

impl Foo for Bar {
    async fn foo(&self) -> impl Future<Output = usize> + Send + '_ {
        baz().await;
        1
    }
}

Naming the Future

What if we wanted to conditionally bound the future?

trait Foo {
    async fn foo(&self) -> usize;
}

fn run_foo<F: Foo>(foo: F) {
    // ERROR: future cannot be sent between threads safely
    tokio::spawn(async move {
        foo.foo().await;
    });
}

fn run_foo_local<F: Foo>(foo: F) {
    // we don't need Send here
    foo.foo().await;
}

The typeof solution works by bounding the future of the type F:


trait Foo {
    async fn foo(&self) -> usize;
}

fn run_foo<F: Foo>(foo: F)
where
    typeof(F::foo)::Output: Send
{
    tokio::spawn(async move {
        foo.foo().await;
    });
}

fn run_foo_local<F: Foo>(foo: F) {
    // we don't need Send here
    foo.foo().await;
}

Another proposed solution is to add a new async Send syntax:

fn run_foo<F: Foo>(foo: F)
where
    // "all futures returned by `Foo` must be Send"
    F: async Send
{
    tokio::spawn(async move {
        foo.foo().await;
    });
}

Again, I think we are fighting against the syntax here. If async functions were treated as regular functions that return futures, we could simply replace the -> impl Future with a named associated type. Note that we need a generic associated type in this case because of the reference to self:

trait Foo {
    type Output<'a>: Future<Output = usize>;
    fn foo(&self) -> Self::Output<'_>;
}

fn run_foo<F: Foo>(foo: F)
where
    // note that this syntax isn't resolved yet
    F::Output<'_>: Send
{
    tokio::spawn(async move {
        foo.foo().await;
    });
}

Of course this would also work with the current syntax, but you have to go out of your way to do so. With this proposal, it's very consistent with regular async functions.

When implementing Foo, you would specify Output as impl Future, just as you would specify the return type of a regular async function as impl Future:

impl Foo for Bar {
    type Output<'a> = impl Future<Output = usize>;
    async fn foo(&self) -> Self::Output<'_> {
        baz().await;
        1
    }
}

What if you wanted to return a named future type with a regular async fn? With the current syntax, you have to depart from a regular async fn:

// note that this currently requires unstable features
type Output = impl Future<Output = usize>;

fn foo() -> Output {
    async {
        // ...
        1
    }
}

There have been proposals to add sugar for this by overloading the async keyword yet again:

async(Output) fn foo() -> usize {
    // ...
    1
}

Weird right? With this proposal, it would be as simple as changing the return type from -> impl Future to a named type alias:

type Output = impl Future<Output = usize>;

async fn foo() -> Output {
    // ...
    1
}

This makes things consistent all around:

// regular async trait
trait Foo {
    fn foo() -> impl Future<Output = usize>;
}

// async trait with named future
trait Foo {
    type Output: Future<Output = usize>;
    fn foo() -> Self::Output;
}

// regular async function
async fn foo() -> impl Future<Output = usize> {
    1
}

// async function with named future
type Output = impl Future<Output = usize>;
async fn foo() -> Output {
    1
}

Tedious Bounds

It would get quite tedious to have to write out associated types for all methods of a trait if we want to add conditional bounds:

trait Database {
    type FooOutput<'a>: Future<Output = Foo>;
    fn foo(&self) -> Self::FooOutput<'input>;

    type BarOutput<'a>: Future<Output = Bar>;
    fn bar(&self) -> Self::BarOutput<'input>;
}

fn use_send_database<D>(database: D) 
where
    D: Database + Send,
    D::FooOutput<'_>: Send,
    D::BarOutput<'_>: Send,
{
    // ...
}

Instead, we want a syntax to express that all futures must be Send. why async fn in traits are hard proposed a hypothetical async Send syntax:

fn use_send_database<D>(database: D) 
where
    D: Database + async Send, // <---
{
    // ...
}

However, I argue that such a solution should be applicable to all existential types, not just futures. What we probably want to say is that all associated types of this trait must be Send. Something like...

trait Database {
    fn foo(&self) -> impl Future<Output = Foo> + 'input;
    fn bar(&self) -> impl Future<Output = Bar> + 'input;
    fn iter(&self) -> impl Iterator<Output = usize> + 'input;
}

fn use_send_database<D>(database: D) 
where
    D: Database<_: Send> + Send, // <---
    // foo::Output: Send + bar::Output: Send + iter::Output: Send
{
    // ...
}

Or maybe Send should by default apply to all associated types (a breaking change), and you can use ?Send to opt-out?

fn use_send_database<D>(database: D) 
where
    D: Database + Send,
    // implies foo::Output: Send + bar::Output: Send + iter::Output: Send
{
    // ...
}


fn use_send_database_output_not_send<D>(database: D) 
where
    D: Database + Send,
    // would you ever want to do this?
    D::FooOutput: ?Send
{
    // ...
}

Either way, this should be solved at the impl Trait level, not at the async function level. While the above changes would also work with the current async fn syntax, explicitly defining the return type makes everything clearer and more consistent.

Similarly, the solution to grody GAT bounds should be ergonomic for any GAT, not especially for async functions:

trait IntoStreamingIterator {
    type StreamingIter<'a, T>: StreamingIterator<Item = &'a T>;
    fn into_streaming_iter<T>(&self) -> Self::StreamingIter<'_, T>;
}

fn use_streaming_iter<I>(iter: I)
where
    I: IntoStreamingIterator,
    // right now `'_` and `_` can't be used here, and you have to use HRTBs:
    // `for<'a, T: Clone + Copy> I::StreamingIter<'a, T>: Clone + Copy`
    I::StreamingIter<'_, _>: Clone + Copy,
{
    // ...
}

The Lifetime

async fns capture the lifetimes of all their parameters, which is difficult to write out by hand. This is one of the main arguments against specifying the return type:

async fn foo(x: &usize, y: &usize) -> usize {  
    *x + *y
}

// becomes
async fn foo<'o, 'a: 'o, 'b: 'o>(x: &'a usize, y: &'b usize) -> impl Future<Output = usize> + 'o {
     *x + *y
}

That would be a pain to write out. The solution is a special lifetime called 'input, or 'all, that refers to a combination of the lifetimes of all input parameters:

async fn foo(x: &usize, y: &usize) -> impl Future<Output = usize> + 'input {  
    *x + *y
}

Originally proposed Aaron Turon, this solves the problem quite nicely. In fact, with this proposal, lifetime capturing is more flexible. If you say, didn't want to capture the lifetime of y because you didn't use it at all, you could express that through the returned future type:

async fn foo<'a, 'b>(x: &'a usize, y: &'b usize) -> impl Future<Output = usize> + 'a {  
    *x + 1
}

More commonly, you might want to use y, but not have it captured by the Future. This syntax makes patterns like this easier, as you simply move the async modifier from the function to the function body:

fn foo<'a, 'b>(x: &'a usize, y: &'b usize) -> impl Future<Output = usize> + 'a {  
    // use `y` synchronously    
    println!("{}", *y);

    // use and capture `x` asynchronously
    async move {
        *x + 1
    }
}

Again, all of this is possible today with the current syntax, but this proposal makes everything more consistent:

// capture all (default)
async fn foo(x: &usize, y: &usize) -> impl Future<Output = usize> + 'input {
    *x + *y
}

// capture some parameters
async fn foo<'a, 'b>(x: &'a usize, y: &'b usize) -> impl Future<Output = usize> + 'a {
    *x + 1
}

// use some parameters synchronously
fn foo<'a, 'b>(x: &'a usize, y: &'b usize) -> impl Future<Output = usize> + 'a {
    println!("{}", *y);
    async {
        *x + 1
    }
}

An alternative approach is to modify how the elided lifetime ('_) works in async contexts, which is arguably nicer:

async fn foo(x: &usize, y: &usize) -> impl Future<Output = usize> + '_ {
    *x + *y
}

Note that 'input has use cases even outside of async functions, because by default existential types do not capture any lifetimes:

fn foo(&mut self, x: &str) -> impl Iterator<Item = String> + 'input {
    std::iter::from_fn(|| {
        let y = format!("{}-{}", self.counter, x);
        self.counter += 1;
        y
    })
}

Complexity

Another argument against this approach is that it makes the type signature of an async function more complex:

async fn foo(x: &usize, y: &usize) -> usize {  
    *x + *y
}

async fn foo(x: &usize, y: &usize) -> impl Future<Output = usize> + 'input {  
    *x + *y
}

This is true to some extent. The second approach is longer, and potentially more intimidating. However, I think it is very readable as well:

An asynchronous function foo that returns a future that outputs a usize and captures all input parameters.

I also think that it's important to understand the distinction between regular functions and async functions, the current syntax doesn't convey what's happening very well. It works much like a macro, transforming the signature of a function, which goes against the explicitness Rust advocates for. It's pretty important to know that async functions capture all their arguments, something that is not clear from the signature. and can lead to confusing error messages.

I will concede that the current approach is easier to start with for beginners. Making a synchronous function asynchronous is as simple as adding the async keyword. However, it's not that difficult with this proposal either, and I think the explicit syntax is valuable and more expressive.

There's been a lot of recent talk of making asynchronous code feel more like synchronous code. I think to some extent that isn't a good goal to have. Implicit .await as one example is something I am very much against. Async and sync represent fundamentally different execution patterns, and I think we should embrace the differences between the two, and teach them as they are. Compiler diagnostics can go a long way here to make the experience better for beginners:

error: unexpected return type for async function
  --> src/main.rs:8:20
   |
 8 | async fn foo() -> usize {
   |                ^^^^^^^^ expected `-> impl Future<Output = usize>`
   |
   = note: async functions must return `impl Future`
error: expected lifetime parameter
  --> src/main.rs:8:20
   |
 8  | async fn foo(x: &str) -> impl Future<Output = ()> {
   |                           ^^^^^^^^^^^^^^^^^^^^^^^^ expected future to capture input parameters
   |
   = note: futures generated by async functions capture all input parameters
   = help: change the return type to `impl Future<Output = ()> + 'input`

This same approach is actually used by C#, a more general purpose language, and it works very well in my opinion:

public interface Foo {
    async Task<int> Foo();
    // error: the `async` modifer can only be used in functions
    // that have a body
}

public class Foo {
    async int Foo() { return 1; }
    // error: the return type of an `async` method must be `void`, 
    // `Task`, `Task<T>`, or a task-like type, `IAsyncEnumerable<T>` 
    // or `IAsyncEnumerator<T>`
}

Boxed Futures

Returning impl Future<Output = T> instead of T opens the possibility of different return types. For example, it is sometimes better to return a boxed future instead of allocating one on the stack.

Able to easily cause some async functions, blocks, or closures to allocate their stack space lazilly when called (by 'boxing' it). Combined with profiler or other tooling support, this can help to tune the size of futures - Async Vision

One proposal is to add a #[boxed] macro for this:

#[boxed]
async fn bar() {
    // ...
}

Again, we're fighting with the syntax :) Instead, we can simply allow BoxFuture as a return type:

async fn bar(&self) -> BoxFuture<'input, ()> {
    // ...
}

Streams

Another future extension is a syntax for returning streams. The RFC used the yield T syntax to express this:

async fn foo() yield usize {
    for i in 0..10 {
        sleep(Duration::from_secs(1)).await;
        yield i;
    }
}

I need not say that this is a limitation introduced by hiding the return type, and with this proposal, we can simply allow impl Stream as a return type of async functions:

async fn foo() -> impl Stream<Output = usize> {
    for i in 0..10 {
        sleep(Duration::from_secs(1)).await;
        yield i;
    }
}

C# takes the same approach here:

static async IAsyncEnumerable<int> Foo()
{  
    for (int i = 0; i < 10; i++)  
    {  
        await Task.Delay(1000);  
        yield return i;  
    }  
} 

Unpin Futures

The possiblity of generated Unpin futures has also been brought up. All futures returned by async blocks are currently !Unpin, a restriction that is uneccesary in some cases:

We could also, with an annotation, typecheck an async function to confirm that it does not contain any references across yield points, allowing it to implement Unpin. The annotation to enable this is left unspecified for the time being. - ``

Yet another proposed annotation. Why not just allow users to mark the returned future as Unpin, and type check based on that?

async fn bar() -> impl Future<Output = usize> + Unpin {
    1
}

// ERROR: generated future is !Unpin
// = help: Consider removing the Unpin bound 
async fn bar(foo: &Foo) -> impl Future<Output = usize> + Unpin {
    let x = 1;
    let y = foo.foo().await;
    x + y
}

Returning Other Existentials

One question that was brought up with this proposal is how to return a different impl Trait. For example, you may have an async function that returns an opaque iterator:

async fn foo() -> impl Iterator<Item = usize> {
    std::iter::from_fn(|| 1)
}

With this approach it is the same as returning any other type. You specify impl Iterator as the output type of the future:

async fn foo() -> impl Future<Output = impl Iterator<Item = usize>> {
    std::iter::from_fn(|| 1)
}

Admittedly, this looks a bit weird, but it's what happens under the hood right now anyways, so why hide it?

Past Discussion

I'm not the first person to propose this syntax. It was discussed before the stabilization of async/await in one of the many design discussions, and rejected for three primary reasons. From the RFC:

The return type of an asynchronous function is a sort of complicated question. There are two different perspectives on the return type of an async fn: the "interior" return type - the type that you return with the return keyword, and the "exterior" return type - the type that the function returns when you call it.

Most statically typed languages with async fns display the "outer" return type in the function signature. This RFC proposes instead to display the "inner" return type in the function signature. This has both advantages and disadvantages.

The lifetime elision problem

As alluded to previously, the returned future captures all input lifetimes. By default, impl Trait does not capture any lifetimes. To accurately reflect the outer return type, it would become necessary to eliminate lifetime elision:

async fn foo<'ret, 'a: 'ret, 'b: 'ret>(x: &'a i32, y: &'b i32) -> impl Future<Output = i32> + 'ret {
     *x + *y
}

This would be very unergonomic and make async both much less pleasant to use and much less easy to learn. This issue weighs heavily in the decision to prefer returning the interior type.

We could have it return impl Future but have lifetime capture work differently for the return type of async fn than other functions; this seems worse than showing the interior type.

Polymorphic return (a non-factor for us)

According to the C# developers, one of the major factors in returning Task<T> (their "outer type") was that they wanted to have async functions which could return types other than Task. We do not have a compelling use case for this:

  1. In the 0.2 branch of futures, there is a distinction between Future and StableFuture. However, this distinction is artificial and only because object-safe custom self-types are not available on stable yet.
  2. The current #[async] macro has a (boxed) variant. We would prefer to have async functions always be unboxed and only box them explicitly at the call site. The motivation for the attribute variant was to support async methods in object-safe traits. This is a special case of supporting impl Trait in object-safe traits (probably by boxing the return type in the object case), a feature we want separately from async fn.
  3. It has been proposed that we support async fn which return streams. However, this mean that the semantics of the internal function would differ significantly between those which return futures and streams. As discussed in the unresolved questions section, a solution based on generators and async generators seems more promising.

For these reasons, we don't think there's a strong argument from polymorphism to return the outer type.

Learnability / documentation trade off

There are arguments from learnability in favor of both the outer and inner return type. One of the most compelling arguments in favor of the outer return type is documentation: when you read automatically generated API docs, you will definitely see what you get as the caller. In contrast, it can be easier to understand how to write an async function using the inner return type, because of the correspondence between the return type and the type of the expressions you return.

Rustdoc can handle async functions using the inner return type in a couple of ways to make them easier to understand. At minimum we should make sure to include the async annotation in the documentation, so that users who understand async notation know that the function will return a future. We can also perform other transformations, possibly optionally, to display the outer signature of the function. Exactly how to handle API documentation for async functions is left as an unresolved question.

The first argument, the lifetime elision problem, was already discussed above. It's solvable by adding a special lifetime 'input.

The second argument is that we do not need polymorphic return types. However, there are many other potential return types that would be hard to add with the current syntax, such as boxed futures, unpin futures, streams, and named futures.

The third argument I touched on when talking about the indimating complexity of the return type. I think that both versions look good in documentation:

And there are some tricks that rustdoc could use, such as graying out the future type and highlighting Output to make it more readable.

Notice that the biggest upside, being able to write async fns in traits with a consistent syntax, is not mentioned at all in the RFC.

Is It Too Late?

Let's say we all agreed that this proposal is better than what we currently have. Is it even possible to make such a big change at this point?

It would be possible to make the change across an edition. The change actually isn't as big as it seems. From a breaking change standpoint, it simply changes -> T to -> impl Future<Output = T> + 'input. This would be relatively easy for cargo fix to change, and in my opinion, well worth the cost. If this truly is the better syntax, I believe we should try to change it.

Credits

A lot of the ideas here were inspired by this internals thread: