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 specifically 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 functions 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 ausize
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 explicit approach is actually used by C#, a language that is considered higher-level than Rust:
public interface Foo {
async Task<int> Foo();
// error: the `async` modifier 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 possibility of generated Unpin
futures has also been brought up. All futures returned by async
blocks are currently !Unpin
, a restriction that is unnecessary 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
}
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 ofasync 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 thanTask
. We do not have a compelling use case for this:
- In the 0.2 branch of futures, there is a distinction between
Future
andStableFuture
. However, this distinction is artificial and only because object-safe custom self-types are not available on stable yet.- 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 supportingimpl Trait
in object-safe traits (probably by boxing the return type in the object case), a feature we want separately from async fn.- 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. As I argued before, I think hiding the future is actually harmful to learnability in the long run. The implicit behavior of the future type often bites beginners later on, and they end up having to learn the details of how async fns actually work anyways.
Notice that all the upsides covered in this post were not considered at all by the RFC.