[Post-RFC] Stackless Coroutines

We could also have a hybrid of my and @Zoxc’s designs: since under my proposal yields are not allowed in the top-level functions at all, we could say “for your convenience, we’ll implicitly wrap function body in a closure”.

1 Like

That would make the function return a generator (meaning it needs to be “primed”), whereas closures wouldn’t. Right?

fn ten() -> impl Generator<i32> {
    for i in 0..10 { yield i }
}

takes_an_iter(ten())
takes_an_iter(|| for i in 0..10 { yield i })

With functions there’s a symmetry here that’s lost.

The usual way that we do this is to make both items available, but under distinct feature-gates (this is how closure syntax and the Fn traits work, for example). That is, it is stable to use the Fn traits with the sugar (Fn(u32), but unstable to refer to them directly Fn<(u32,)>).

I don't know how we could pull the same trick here without "blessing" the #[async] attributes in some way, but I feel like we can worry about that later. For the time I think it suffices to say that we would be willing to stabilize an appropriately sugared form of async-await without necessarily stabilizing the underlying implementation.

1 Like

I am torn here. I personally found @vadimcn's formulation elegant from a conceptual POV. It seemed to allow us to separate which computations should run immediately (when function is first called) from those that should be deferred without any real effort; this is a consequence of the fact that it mirrors what will happen at runtime. That is, it doesn't add any "implicit indirection".

On the other hand, I agree that this is not the approach most other languages have taken. When people write something like this:

#[async]
fn foo() -> Result<T, E> {
    ...
    await!(...);
    ...
}

it not only looks familiar, of course, but it also intentionally hides the fact that calling foo() doesn't, itself, do anything, but just produces a future that will do something later.

In other words, there is a "time-shift" introduced by the #[async] adapter that you can see just from the signature. This function "returns" Result<T, E>, but really the return type of the desugared version is impl Future<T, E>, and hence the body must be adjusted to follow suit:

fn foo() -> impl Future<T, E> {
    || { ... await!() ... }
}

Sorry if this message seems confusing. I've written and re-written it a few times and it still doesn't feel like I'm saying what I want in quite the way I want to. =) Maybe this is because I don't quite know what I think yet.

I guess ultimately my feeling is that I think it's definitely appropriate and right for #[async] to perform this implicit "time-shift" transformation, and it should expose a consistent view "as if" this was a regular function (hence it returns Result and not impl Future, etc).

However, I'm not yet sure that the underlying mechanism ought to try to "hide" the implementation details in the same fashion. Maybe it should, because I think @Zoxc is right that it's closer to how people want to be thinking about it most of the time; but maybe it shouldn't, because there is a definite elegance to the way that the closure formulation lays bare what's happening at runtime.

2 Likes

I strongly agree. I just think we should point to a tracking issue that makes it clear that just about every detail here is highly unstable and subject to change.

1 Like

I think it’s important to note here that the futures crate is not just any old crate. It is maintained and developed by core Rust team members, it is rapidly being adopted, and it’s meant to become a critical part of the crate ecosystem. For those reasons, blessing #[async] just seems like a really forgivable sin, even if it’s not technically supposed to happen.

I don’t think it’s a good idea to tie async/await with a specific implementation of futures.

  • For some things Future (futures-rs) is more powerful than necessary (e.g. the implicit Result built into Future).

  • For other things it’s not as powerful as one would like (Coroutine has Args and Yield but Future does not).

I would rather have either of the FnMut(Args) -> CoResult<Yield, Return> or Coroutine<Args> approaches.

If we want to promote #[async] as the sugar to use we should make sure it works for all functions, methods and closures that can be generators. It should work with all forms of self. It also needs to work for both futures and streams. My proposal avoids this issue since you’d write impl Future or impl Stream directly. A simple solution is to have #[future] fn() -> Result<A, E> and #[stream(Y)] fn() -> Result<(), E> where the stream yields Y possibly terminating with error E.

We can do a number of modifications to @vadimcn’s proposal to bring it closer to mine, highlighting the fundamental difference.

  • We can make it use the Generator trait.
  • We can make it use another syntax for generator literals, distinct from closures.
  • We can make it have an implicit argument, referenced by gen arg.

Now we can look at the examples I provided earlier again.

@vadimcn’s proposal modified:

fn foo() -> impl Generator<Return=Value, Yield=()> {
    gen {
        let bar = |arg| {    
            gen {
                await!(task(arg + 1));
                await!(task(arg + 2));
            }
        };

        await!(bar(1));
        await!(bar(2));
    }
}
fn range(a, b) -> impl Generator {
    gen {    
        for x in a..b {
            println("{}", gen arg);
            yield x;
        }
    }
}

These changes makes it more syntactically and semantically clear that we are dealing with generators and not closures.

For comparison purposes, here’s the same examples again in my proposal with a gen keyword to mark bodies as generators (the gen keyword does not affect the signature of functions or closures):

gen fn range(a, b) -> impl Generator {
    for x in a..b {
        println("{}", gen arg);
        yield x;
    }
}
gen fn foo() -> impl Generator<Return=Value, Yield=()> {
    let bar = gen |arg| {    
        await!(task(arg + 1));
        await!(task(arg + 2));
    };

    await!(bar(1));
    await!(bar(2));
}

We are not restricted to doing just one of these proposal, in fact, we can do both. Adding gen to a function or closure signature can then be interpreted as wrapping the body in gen { ... }. We could then allow examples like this:

gen fn foo() -> impl Generator<Return=Value, Yield=()> {
    let bar = |arg| {    
        gen {
            await!(task(arg + 1));
            await!(task(arg + 2));
        }
    };

    await!(bar(1));
    await!(bar(2));
}

I wonder if we could desugar this in HIR.

Just to make sure I understand, the changes you discuss here are not because the other proposal requires those changes in order to express futures or streams, right? It's just to help highlight the difference?

Basically, the way I understand things right now (and I may be misunderstanding), it seems like @vadimcn's proposal is a bit "lower level" than yours -- that is, it encompasses a kind of fundamental building block, whereas your proposal is trying to be more ergonomic and "package up" common patters. Is this correct, or is there also an "expressiveness gap" that you see?

Here is a related question I've been meaning to raise for some time. In JavaScript, there is a notion of an async function and there is a separate syntax (function*, I think) for creating iterators. These seem like very similar concepts: both are kind of "yieldable" functions, but the distinction seems to be who they are yielding to -- i.e., an iterator yields to its caller, whereas a future suspends the whole task.

Anyway, I believe that the intention is that these can be combined, resulting in an "asynchronous iterator". This seems like a useful thing to have, but I'm not entirely clear on how it maps to these proposals and our abstractions. (Is this a stream?) Can someone spell out how this would work under the various proposals at hand (or point me to such an explanation)?

In my view, async/await is basically an instantiation of the more general mechanism that specifically targets the futures crate.

2 Likes

Neither proposal has the concept of futures or streams nor do they require it. The first paragraph is unrelated to the rest of the post. I should probably have separated that better.

This sounds about right. Having an implicit argument seems to be more expressive if the argument is of type for<'a> &'a T though. This type cannot live across suspend points, yet @vadimcn's proposal will bind this as an argument, making such an argument type illegal if the generator contains any suspend points..

Generators can be considered a generalization of iterators, futures and streams. We could just be using a Generator trait for all of them (that would also help with trait coherence). It is convenient to have separate traits for clarity and domain specific methods though.

Also note that futures do not suspend the task, but just the current function, like iterators. It is up to the caller to decide if it wants to bubble the suspension up. await! will always proxy through suspension, but for example the select combinator may decide make progress on another future. You can kind of consider select to spawn subtasks though.

Yes. (This is however the common case.)

So I sort of think this is the answer: that is, we only have one mechanism (suspend current function) but we model the rest by handling the various kinds of cases differently, using suitable macros (e.g., await!).

This is a very interesting point. It also comes up with iterators (the current trait forces you to be able to "give away" the data you are iterating over, rather than having an internal buffer that you can update). Ideally it'd be nice to support both "modes" (caller is lending you the data; caller is giving you the data), but I'm not sure if that's really possible without two traits.

It's hard to make things generic over "where the binder goes", which is kind of what we need. It may be possible with (some versions of) ATC.

Another note, await! is an operator on generators (it proxies yields through), so it works and is useful for iterators, futures and streams. I do want syntax for it. let a = ~foo()?; looks nice? The only problem with such a feature is that it only works with a Generator trait. If you try ~obj where the type of obj implements Generator, Iterator, Future, Stream, all traits which you could await on, which should you pick to use? This seems to be a tiny trait coherence nightmare.

Can you please expand on this? (an example would be helpful)

Also on this. I don't remember coherence problems being discussed here.

My preference, and I've stated this in the RFC, is to have the language implement only the barest minimum of coroutine functionality and do the pretty syntax as macros.

Yes, other languages had created built-in syntax for generators and async, but Rust's macro system is powerful enough to not need it. And we should be proud of that fact :slight_smile: At the very least, we could start with macros and then create built-in syntax if deemed necessary, kind of like what happened with try! and ?.

The #[async] attribute on functions is a crutch to help people get over having to wrap function body in || { ... }. If you can stomach a difference in behavior between top-level functions and closures, as I've proposed here, even that won't be unnecessary.
Alternatively, I'd also be fine with a gen! { ... } macro that expands to move || { ... }.

Regarding the return type of async functions: in my opinion, we should not try to hide the true return value, because it's async traits like Future or Stream that define async'ness for the compiler, not the (optional) #[async] attribute. Also, rustdoc won't see #[async], it will render the desugared signature, right? So, it should be (#[async]) fn foo() -> impl Future<Item=X, Error=Y>, rather than #[async] fn foo() -> Result<X, Y>.

The only case where #[async] might be truly necessary is #async for:

Here's my take on it. Unfortunately, Iterator<Item=Future<...>> does not quite cut it, because there's no way to determine synchronously whether there will be another item in the stream.

From my POV, generators (and coroutines in general), are just closures with a specific signature, so I don't see why we need to draw this distinction. As long as the signature matches, the consumer should not have care about how it's implemented internally. You should be free to create a "traditional" closure implementing FnMut() -> CoResult<...> and use it anywhere an Iterator/Future/etc is expected.

1 Like

Hmm… it’s interesting that at least under one of these proposals, await!(foo) and yield foo are both operations that (a) accept a value, (b) suspend the current function, and (b) return a value upon resuming; yet they work completely differently. await! desugars to calling foo.poll() repeatedly until it returns a value, then returns that. yield's “return value”, on the other hand, is actually an argument to the resume function.

Comparisons to other languages:

In Python, things are somewhat similar. yield suspends a generator and, if the caller resumes it using generator.send(foo), ‘returns’ the argument; otherwise it ‘returns’ None. await in Python is just a glorified yield from, which delegates to a sub-generator; it yields whatever the sub-generator yields, and when the sub-generator returns (aka throws StopIteration), there can be a return value, which becomes the return of yield from. Notably, if the sub-generator yields and the caller send()s a response, it goes directly to the sub-generator. So yield/send() can be used as a two-way communications channel between the original caller and whatever is at the top of the stack, though this often goes unused.

In JavaScript, both await and yield create a new closure and do the argument-to-return thing:

async function f() {
  let x = await getSomePromise();
  foo(x);
}

is roughly equivalent to

function f() {
  return getSomePromise().then((x) => foo(x));
}

while

function* f() {
  let x = yield 42;
  foo(x);
}

is roughly equivalent to

function f() {
  let iter = {
    next: () => {
        // overwrite the next() method
        iter.next = (x) => {
          foo(x);
          return {value: undefined, done: true};
        };
        return {value: 42, done: false};
    }
  };
  return iter;
}

JavaScript’s approach would definitely be suboptimal for Rust because it requires boxing. Python’s approach could work without boxing, but for the send channel to really be useful (IMO), it would need to be able to accept different types at different suspend points. Assuming this should be strongly typed, it’s obviously incompatible with an &mut self resume method. It could theoretically work if generators accept self by value and return a new generator, but even ignoring the performance implications, that seems like it would make for very gnarly type signatures.

Even for generators that don’t delegate, a yield return value which can only be one type throughout the function seems pretty niche to me, unlikely to be useful except in very simple cases.

Since the syntax is tricky, why not just get rid of it? Drop support for passing arguments to the resume function. If someone really wants them, they can always emulate them by returning a mutable slot to the caller for them to fill in: in futures-rs lingo, this is a futures::sync::oneshot::Sender. If needed, a wrapper function can recover the originally desired type signature, and with unsafe code this can probably be made equally efficient to a native implementation (at least once immovable types are implemented). But most code probably doesn’t need this at all.

(By the way, JavaScript’s closure-based approach can also be emulated on top of generators, and the same can almost be said for an API very similar to JS promises: monads… However, doing this properly would require the ability to clone generators.)

Both inputs and outputs are streams of values. Values of type X go in, values of type Y come out. Passing objects of different types at different times would mean that the caller has to know what state the coroutine state machine is currently in. Surely, that's not what we want.

We could omit parameters from the MVP, since neither iterators (but not double-ended ones!) nor futures need this. However, ultimately, we should have that feature, IMO.

Hmm… I suppose it makes sense considered that way. But how are the values actually used?

For generators used directly as streams of values, like iterators, I guess there's some elegance in having an input as well. The generator would be sort of like an iterator adapter, transforming input values to output values with the option to keep state. But unlike real iterator adapters, it would be limited to yielding at most one value per input value (exactly one, really, but you could fake 'at most one' with an Option). By contrast, if a generator takes no resume inputs but accepts an Iterator as an initial argument, it can work as an iterator adapter with no limitations.

For futures implemented on top of generators, the possible values of type Y are just 'come back later' and 'done, here's your return value'. Viewing things at a higher level of abstraction, there's only one real value going out at the end, which can be paired with the ability to pass values in at the beginning via regular function arguments. So there's no high-level need for resume arguments; that leaves the possibility that the implementation requires them. However, futures-rs at least does not.

Might some alternative futures-like system need them? One possibility is that the values coming out are IO requests, and the values going in are responses to those requests: a sort of inversion of control. This is where I was going with the idea of having different argument types depending on the state; otherwise, this can't really be strongly typed, since the type of response depends on the type of request. I suppose it would be okay to have loosely typed code as an implementation detail, where at a high level the user would call strongly typed 'async functions' that yield loosely typed requests down the stack and process their responses. But even then, is there any advantage to processing I/O in this inverted way, rather than just doing what futures-rs does? I suppose it's more of an immutable/functional style, and it might work a bit more nicely in combination with cloning the generator, but… dunno, it's not that hard to emulate.

In sum, I just don't see the use cases. Maybe you have use cases in mind I'm not thinking of… And even if not, I'm sympathetic to the idea of making the low-level primitive as elegant/complete as possible, for the sake of aesthetics and for future innovation. But not that sympathetic if it requires a lot of syntax :slight_smile:

Stuff like this and, yes, inversion of control. For example, you might create an incremental lexer that takes slices of characters as input and spits out completed tokens once in a while.

Could you fake it with a shared buffer, into which you put your input before resuming the generator? Sure, but I thought it'd be nice to this properly.

That's still possible: you'd simply close over the input iterator.

This situation is not unique to coroutines, is it? If you want to feed polymorphic inputs into a regular function, you'd have to either wrap them with enum or impl some trait, right?

1 Like