For await loops

On “Async Streams” in Rust

Summary: I think await for reads naturally for the operation of async steam iteration. I get to this result by examining the 2x2 matrix of value production:

            | Evaluated immediately     | Evaluated asynchronously
------------------------------------------------------------------------
Return once | `fn() -> T`    (Function) | `async fn() -> T`    (Future)
Yield many  | `fn() yield T` (Iterator) | `async fn() yield T` (Stream)

ninja edit: I forgot about the generator-for-loops proposal. This post thus ignores them. That said, I think it unlikely there would be reason to have a Generator return a Future, as a AsyncGenerator would have a poll point between the final item and the generator result anyway. (I’m in favor of a postfix await anyway, which then wouldn’t conflict here.)

Here’s an attempt to find some more structure in this feature, based on the construction of the Stream trait and the hypothetical design of async fn in traits. It suggests that, like generic const fn, we might apply async to traits, and then derives some syntax ideas from there:

Async for loops by composing transformations

8 Likes

I have a preference for postfix await. I balk at await <binding> because it’s completely different from await <expr> – the thing being awaited is completely different. The postfix syntax makes a lot of sense to me:

for <binding> await in <expr> -> loop { let <binding> = await ...

If we do add await? I would expect await? <expr> to work as well.

If I could heart this twice I would.

async Trait feels right, as does try Trait even, especially now that const Trait has an established meaning (if not an accepted one).

I’d have to see them in action to be more certain how I really feel about them, though. const is “transparent” in that it doesn’t require extra syntax to move to/from it and “normal” Rust. This definitely feels right when the “effect” is “transparent”, but I could see it working for these non-“transparent” traits as well… EXCEPT:

This would also give async Drop as well, which is something people have expressed the desire for. An async Drop type would theoretically not be usable outside async context at all since it can’t be dropped synchronously. This sounds good, but it also introduces a “transparent” await point to a world that decided they want “noisy” await points (for good reason which we’ve discussed before and should not be rehashed on this thread).

Along those lines, what does try Drop mean? Drop really is a special trait.

The idea is strong. It may work for Rust. But I’m not sure right now.

I’d argue that both const and Drop are already special so that’s not a big deal.

const is special in the sense that it’s a kind of “inverse effect” the way ?Sized is an “inverse bound.” It limits what the function can do and makes it callable from more places, while try/async/generators do the reverse. The “non-const” effect is what we should be comparing them to, I think.

Drop is special in the sense that it’s mutually exclusive with Copy, and doesn’t participate in generics (everything is implicitly “drop-polymorphic” in its non-Copy type arguments). And the generic const fn RFC is looking like it will further extend this with T: Drop working on types that don’t literally impl Drop. So limiting Drop to non-try and non-async seems totally plausible.

(If we have more to discuss let’s do it off-thread and see if it goes anywhere?)

3 Likes

The discussion of graceful handling of iterators of results (as in lines()) brings to light again what is, IMO, the fundamental awkwardness of that construction. An iterator that reads from a file is not an iterator of independent Results, it’s an iterator that can fail completely, but the type signature conflates the two cases. Rather than trying to paper over the awkwardness there, I think we should look to represent that pattern more accurately, e.g. as previously discussed by @newpavlov.

7 Likes

async modifies things to make them create a future of something instead of evaluating to that thing directly - this is not what this syntax does. What this does is yield from the surrounding async item when the next item in the stream is not ready yet - exactly the same thing as what the await operator does to futures already.

Hmm, but this is in pattern position, where you expect things to be ‘reversed’. If you make await a pattern and allow let await foo = bar, consistency suggests that that should bind foo to some expression such that await foo == bar – as opposed to being equivalent to let foo = await bar. Same goes for ?.

Even if it’s not a pattern, this concern persists to some extent as long as it looks like one, i.e. if you have to put await immediately before a pattern, as in the case of for await foo in bar().

That said, I don’t have any better ideas for how they could be expressed as patterns. async could be seen as sort of like the opposite of await, but it isn’t really, and there’d be no equivalent for ?. On the other hand, it doesn’t seem all that inherently advantageous to make them patterns, and some of the proposed syntaxes for loop-specific constructs don’t have the “looks like a pattern” issue.

That's quite an interesting read. Considering T: async Trait seems like something we ought to explore as a means of getting code reuse and most bang-for-buck.

I think you get the duality of patterns & expressions right, but there's potentially a minor snag. Depending on how we view async { ... } and try { ... } (either 1. $ident $block or 2. $ident $expr) then the pattern form should try to mirror that. It is clear how you do that with 2. but less so with 1. This snag may be minor enough not to matter.

As @rpjohnst noted, try { ... } as a pattern is the dual to try { ... } as an expression.

Not only that. I think it is disadvantageous to make ? and await into patterns because:

  1. It introduces side-effects into the currently side-effect free language of patterns (possibly modulo Drop).
  2. By embedding ? and await into patterns, pattern matching (aside from guards) may now panic.
  3. While I don't have a proof, as far as I know, pattern matching aside from guards and evaluating the scrutinee is not turing complete but embedding ? and await would make it turing complete by embedding .poll() and .into_result() into patterns.
  4. Pattern aliases could be proposed now and would not have to take context into account, but if we do introduce ? and await as patterns, then we suddenly need async & try flavors of pattern aliases. I also don't know what effect this would have wrt. promotability (cc @eddyb and Never allow `const fn` calls in promoteds · Issue #19 · rust-lang/const-eval · GitHub).
2 Likes

I think one possibility wasn’t mentioned at all here. I use it in corona and that’s postfix (method call) on the stream. That turns the stream into an iterator. For corona it actually makes a lot of sense, as it is stack-full coroutine library, but I find it ergonomic and natural:

for elem in stream.iter_ok() {

}

If await is keyword, being able to await one future or await multiple times on getting the next item out of the stream should be possible.

I’m not really arguing for the possibility, just saying it seems to be overlooked.

2 Likes

Not necessarily! We just wouldn't allow them in pattern aliases (at least initially), and because pattern aliases are incredibly pure (pretty much as pure as types themselves), it would be sound to have some of their pattern "arguments" contain try/await patterns.

I mean... sure -- that sorta feels like a cop-out tho. Something about "aliases for patterns except for $these exceptions" does not seem great in terms of avoiding special cases.

They are even a total fragment, right? (making them "purer" than types which are an undecidable fragment if we forget about recursion limits...)

3 Likes

It doesn't feel like that to me, to be honest.
Just like ? is sugar for something in expressions, it could be sugar for a similar thing in pattern-matching, without ? being part of patterns, only of surface pattern syntax.

There is no pattern-matching going on in a pattern alias, so ? or await simply don't make sense there.

Similarly, if we start allowing nested guards in patterns, those wouldn't exist in pattern aliases.
They're code that runs during pattern-matching, but not "true patterns".

(We might want to include such features in pattern aliases eventually, but it'd have to be opt-in, e.g. async pattern Foo, and whatever signature shorthand we came up with for -> Result<T, E>, I forgot)

Why isn’t it possible to turn a stream into an iterator using iter::from_fn() where the provided function has an await inside of it?

from_fn takes an impl FnMut() -> Option<T>, whereas an async function (one with the await superpower) doesn’t impl that trait, as it returns a Future representing the poll-able deferred computation.

I actually meant turning it into an iterator of futures. I found the answer to my question here: Streaming Generators?

That post has a lot of good references that explain things in some depth. Well worth reading.

This was certainly an interesting and very novel idea, but thinking it through I don’t think it works out.

The fundamental difference between const and async here is that const fns are fungible as non-const fns - they have the same type signature, they just can be called in more places and have additional restrictions on their bodies; async fns in contrast have a different type signature from the an otherwise syntactically identical fn. The idea of T: async Trait is very different then from T: const Trait; a type that implements “async Iteratordoes not implement Iterator.

Ultimately I’m very dubious of these far reaching effect system types of changes because there are actually a lot of irregularities and corner cases in what people want to be able to do. I am much more optimistic about the design path we’ve been on for async/await, which focuses on introducing a few syntaxes as sugar for constructing library types without modifying the actual type system, essentially extending the success story we had with for loops to generators and async/await.

6 Likes

FYI, this is also an upcoming feature in C#8 (https://github.com/dotnet/csharplang/issues/43) and its syntax has also been a contentious issue.

In both cases the loop construct contains enough syntax sugar to warrant special async syntax, since Stream<Item = T> is not just an Iterator<Item = Future<Output = T>>. I think any syntax that looks like you’re awaiting stream items would only confuse and mislead.

I don’t think the issue should be lumped together with ? on elements, since let line = line?; is a perfectly cromulent expression that, in my opinion, doesn’t really warrant a special loop syntax, while let line = await!(line); cannot be used to do the same to a stream.

4 Likes

I didn’t get into this in my post, but an important point here is that const is really the absence of an effect, and thus equivalent to non-async, and not the other way around. The analogy thus actually works in the other direction:

Just as const fn can be used where a plain fn is expected, a plain fn can be used where an async fn is expected. Something that expects a T: async Trait could also use a plain T: Trait, with the compiler generating a trivial shim. A type that implements Iterator does implement async Iterator.

I am sure there are edge cases (I ran into a couple in my post and I have a few more in mind that I haven’t thought through yet), but I still think it’s worth considering. Some of those edge cases turn out to be simplifications rather than complications to be worked around. For example, try Iterator providing a neat way to differentiate between "Err means the iterator has ended" and "Err means a single item failed."

If anything, I suspect the main problem with this will be the lifetimes around async Iterator- a Stream is a single object produced by the compiler that can thus take advantage of pinning for self-references; the future of an async Iterator is a separate object, making it harder to package up and pass around a stream mid-next. We may end up needing to wrap an async Iterator as a Stream anyway for pragmatic reasons.

Another problem that occurred to me since posting is the syntax of const fn's promotion to runtime fn. Supplying a generic const fn with a non-const T: Trait allows it to gain the “runtime” effect. Under the analogy above, this means supplying a generic fn with a T: async Trait and promoting it to an async fn. This would be extremely useful (one could reuse combinators like unwrap_or_else with async closures) but would make await points in generic code invisible, which people seem to dislike.

8 Likes

Another issue discussed out-of-band with async Trait that I’ll bring up here:

  • async Drop (or other additive effects): probably can just be verboten.

  • async Fn(_) (or really any other one where a method takes arguments):

    The transformation is to turn the method into poll(Pin<&mut self>, &mut Context) format; async FnOnce() -> T ≈ Future<Output = T>. How can that transformation work for methods that take other arguments? Instead, async Trait would have to be "wrap the output in Future, which explicitly doesn’t work for Stream/async Iterator.

    Additionally, it’d be strange if async fn(T) { _ } does not impl async Fn(T).

This “trait effects” system requires figuring out exactly what async Trait means. And even if Stream is async Iterator, I doubt we want Iterator's provided methods to be async in that case.

Iff I can figure out how to express the async Trait “trait effect”, I plan to write a bit more about what the system looks like (as it does seem promising), but I’m currently stuck trying to figure that step out. I think it would be compatible with the recently stabilized Future type if we made Future<Output = T> an alias for async FnOnce() -> T somehow (as that would require the "async trait effect to line up with the explicit Future type and aliasing the method name as well, as well as making a subset of Fn types implementable).

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