Futures Pre-RFC: `async fn() -> impl Future`

Summary

Transition #[async] functions

from

being written with a Result return type that gets rewritten inside the macro to impl Future, e.g.

#[async]
fn fetch_rust_lang(client: hyper::Client) -> io::Result<String>
to

being written with an impl Future or similar return type directly, e.g.

#[async]
fn fetch_rust_lang(client: hyper::Client) -> impl Future<Item=String, Error=io::Error>

Also support return types of impl Stream, Box<Future>, impl StableFuture etc. (potentially any CoerceUnsized smart pointer if possible?).

Motivation

  • Tie the written signature more directly to the final signature
  • Reduce friction for migrating to #[async]
  • Forward compatibility with nominal existential types
  • Forward compatibility with impl Trait in trait
  • Remove necessity for #[async_stream] and associated macros.
  • More consistency with other languages

Drawbacks

  • The generators return type is not written down
  • impl Trait requires specifying all lifetimes

See the full RFC for expanded paragraphs on each of these motivations and drawbacks.

2 Likes

You sort of gloss over how serious the drawback of the final point, which is the reason the RFC I’m drafting (to be put online later this week) does not take this approach.

If we make async functions return impl Future, we essentially eliminate lifetime elision from async functions. This is a major downside, and from my perspective it seems like it swamps any benefits from moving to impl Future.

(I know you know this @Nemo157, but for other readers) The reason I say we eliminate lifetime elision is that the way an async fn works, it returns immediately without evaluating, returning a future which will evaluate the body of the function as it runs. This means that the future needs to capture all input lifetimes to the function. Our elision rules recognize that this is extremely rare, so the return type normally does not capture any input lifetimes except the lifetime of self in a method (and even then its impl Future + '_ to achieve that).


@nikomatsakis discussed this issue with the C# team actually - who set the precedence of returning the “outer” return type. Their primary reason was that they allow some kind of polymorphism where you could return something other than a Task<T>. You sort of allude to this in referencing async_stream - figuring out how to make functions that evaluate to streams work is one of the hardest parts of this design.

However, I’m sort of uneasy about your proposal of how to handle this, because the interpretation of how yield works will depend on whether or not the return type implements Future or Stream. Tying the semantics of the inner thing to the trait implementations of the return type seems like a layering violation to me. And what happens if you return a type that implements both Future and Stream?

I think a better approach would be to have both async functions and generators, and by implication to have async generators, which are Streams. I posted this “matrix” in the other pre-RFC.

The other use cases for return type polymorphism I think are all going away. The distinction between StableFuture and Future is just because we don’t have arbitrary object safe self types yet, but we will soon and those two traits will be merged. The returning Box<Future> use case is misdesigned right now in my opinion - the main reason is to allow async fns in traits, but we need a design for impl Trait in traits (preferably with a possible upgrade to Box<Trait> in the object case).

So I don’t see this reason as really compelling. I also don’t think the learnability argument is very strong. First, because you are most likely just going to await this anyway. Second, because there’s learning friction in the other direction also - you -> impl Future but return T. Third, and most importantly, because for learning of the cost of losing elisions outweighs all of this.

5 Likes

Sorry that you feel I glossed over this, I definitely do feel the pain of this point, I really don't like that I had to write a function signature like

pub fn read_exact<'a, 'b: 'a, R: Read + 'a>(
    mut this: Pin<'a, R>,
    buf: &'b mut [u8],
) -> impl StableFuture<Item = (), Error = Error<R::Error>>
         + Captures<'a>
         + Captures<'b>

But, I still feel that this is better solved as part of impl Trait than just avoided in async fn. Even if futures is the only use case where the current impl Trait lifetime capture rules are not the best; there are always going to be some functions that are easier to write using combinators instead of async fn, these are likely to have the exact same problem of capturing arguments in their closures and having to use an impl Future return type with way too many lifetime bounds.

In my current prototype (and, I believe, forced by the definition of Future and Stream) there's only one case (well, family of cases because of the error type) where the return value can implement both

for<E> impl Future<Item=(), Error=E> + Stream<Item=!, Error=E>

I would argue these two traits are isomorphic so there's no real problem being able to return a type that implements both.

This is a super-interesting idea, if you expand on it in your planned RFC I definitely can't wait to see it.

One case you didn't mention is "nominal existential types", I have a set of traits I hope to eventually model using ATC and implement via nominal existential types, e.g. the nominal existential type + impl Trait in trait examples are actually split from one trait I want to be able to write:

pub trait CountDown {
  type Counting<'a>: Future<Item = (), Error = !> + 'a;

  fn start(&mut self, count: Duration) -> Self::Counting<'_>;
}

impl CountDown for Timer {
  abstract type Counting<'a>: Future<Item = (), Error = !> + 'a;

  #[async]
  fn start(&mut self, count: Duration) -> Self::Counting<'_> {
    ...
  }
}

I'm still not certain that I need this over just using raw impl Trait in trait support, but this allows for adding additional constraints on the returned future:

fn foo<C>(countdown: C)
where
    C: CountDown,
    <C as CountDown>::Counting: Send + Unpin,

I guess the oft-mentioned typeof operator might provide an alternative that works with just impl Trait in trait

fn foo<C>(countdown: C)
where
    C: CountDown,
    typeof(<C as CountDown>::start)::Output: Send + Unpin,

Oh, one other point I forgot to mention, I’m still somewhat hopeful that it will be possible to have “hot” async functions that run until the first yield point. After my small amount of investigation into generator arguments I think making generators hot may actually be one way to get their first argument in nicely. This would have the benefit of allowing async fn to take arguments by reference that they don’t keep bound

#[async]
pub fn listen(addr: &MultiAddr, event_loop: &reactor::Handle)
-> impl Stream<Item=(Transport, MultiAddr), Error=io::Error>
{
    let addr = multiaddr_to_socketaddr(addr)?;
    let listener = TcpListener::bind(&addr, event_loop)?;
    #[async]
    for (transport, addr) in listener.incoming() {
        let transport = Transport(transport);
        let addr = MultiAddr::from(addr.ip()) + Segment::Tcp(addr.port());
        stream_yield!((transport, addr));
    }
    Ok(())
}

Why not just use async blocks/closures for “hot” async functions?

That is, you just write a normal function that does whatever it wants and then returns an async block/closure.

Implicit “hotness” seems a poor choice since it makes the function’s behavior drastically change if you add an await at the beginning (let’s say you change it to read some data from an async database instead of having it being static), which seems like a bad thing.

That's what I do currently, there are two downsides:

  1. Cannot push errors from the pre-async sections into the same error channel, the above function has to be -> io::Result<impl Stream<...>> to handle the possibility of the first two function calls failing. (Sometimes this is what you want to differentiate between problems during initialisation and when running, but when you don't expect to ever hit those error cases it's simpler to just merge the errors together).
  2. An extra level of nesting.

This is how C#'s async functions work, I never recall having any issues with them being implicitly hot. At least with Rust's async functions if you move a reference from one side of the initial yield point you'll get a lifetime error if that will break the current signature.

As for passing errors, the problem is that the Rust compiler doesn't let you return two different types with an impl Trait return type, and the fix is to add support for that by implicitly generating an enum (also, you can obviously work around that by doing the enum construction yourself)

But also differentiating between initialization and running errors probably makes sense if you have a distinct initialization phase.

I always found it a dubious design since a function is supposed to be "async", yet it actually does an unknown amount of work on CPU beforehand, and when you implement one you always have this nagging thought that you should somehow address this.

And again I think adding an await!() in an async function body should not have non-local implications like making anything that follows no longer part of the "special pre-async code portion". If there's some initial code that is not async it should be clearly marked, which the "return async block" solution does without adding syntax.

You're right, there is a possible way to write that function

pub fn listen(addr: &MultiAddr, event_loop: &reactor::Handle)
-> impl Stream<Item=(Transport, MultiAddr), Error=io::Error>
{
    stream::once(do catch {
        let addr = multiaddr_to_socketaddr(addr)?;
        let listener = TcpListener::bind(&addr, event_loop)?;
        Ok(listener)
    })
    .map(|listener| async_stream_block! {
        #[async]
        for (transport, addr) in listener.incoming() {
            let transport = Transport(transport);
            let addr = MultiAddr::from(addr.ip()) + Segment::Tcp(addr.port());
            stream_yield!((transport, addr));
        }
        Ok(())
    })
    .flatten()
}

But I find that super terrible to read and would prefer to just switch to taking the arguments by value and forcing users to clone them instead of doing that.

Well, since async is an implementation detail you have no way of knowing whether it was used to define the function you're calling, so when you're calling someone else's function it always does an unknown amount of work on CPU beforehand. If you're using async yourself you should be avoiding CPU bound work anyway.

Also, does it really matter that the work happened before you called resume or after? Either way in 99% of cases it's all occurring on the same Executor anyway, the same amount of work will happen on the same set of threads. If you want to spawn a Future off onto a different Executor and you care about how much pre-yield work it might do you should be spawning it via executor.spawn(future::lazy(|_| start(args)).boxed()) anyway.

Maybe I've just become too inured from working with C# and JavaScripts async/await implementations, but I feel it's pretty obvious that await is a yield point that most definitely has non-local implications on lifetimes based on what variables are live across the yield point. At least with Rust the compiler will track that for you, in C# and JavaScript it's up to you to know whether a function returning a Task/Promise will have finished with its arguments after it returns or only once it's complete.

That's true, I guess I misstated by point a bit: what I meant is that if a function returns Task/Future, it is generally expected to do any non-trivial work in the Task/Future, but "hot-by-default" async functions break that expectation.

It does matter if the executor is a thread pool and you are spawning a bunch of futures in parallel (certainly a common thing in C#, although in Rust maybe you would use Rayon for that in some or most cases).

This is only true for the first "await" in "hot" async functions. Which is my argument against "hot" async functions.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.