SE-0298: Async/Await: Sequences

Hello Swift community,

The review of SE-0298 "Async/Await: Sequences" " begins now and runs through January 26, 2021.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager. When emailing the review manager directly, please keep the proposal link at the top of the message.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at

https://github.com/apple/swift-evolution/blob/master/process.md

Thank you,

Doug Gregor
Review Manager

27 Likes

I am +0.8, strongly supportive of the idea of async sequences and the general design. My only concern is with the cancel member of AsyncIteratorProtocol:

public protocol AsyncIteratorProtocol {
  associatedtype Element
  mutating func next() async throws -> Element?
  __consuming mutating func cancel()
}

I would prefer to drop it for three reasons:

  1. We technically don't need it to enable any designs - iterators will already have their deinit members called when existing the loop. However, it will push certain things to use classes instead of structs for iterators.
  2. Future language evolution (introduction of deinit for structs) will define away its need completely in the future.
  3. This is inconsistent with the existing IteratorProtocol which doesn't have a cancel member.

That said, I don't think the long term damage is high. It will bloat code in async for loops in some cases, and will be confusing when there are future design features. However, deprecating the member in the future when the new pieces come in place will help with the confusion, and generic specialization will eliminate the overhead in the most common case.

Yes; yes.

No; I participated in the pitch thread and read the proposal carefully.

-Chris

10 Likes

What is your evaluation of the proposal?

+0.9

Is the problem being addressed significant enough to warrant a change to Swift?

Probably.

I don’t see many use cases for the async for … in …, but it can be handy in the couple I see.

I see more added-value in the extension. I would add a func allValues() async -> [Element], that basically converts the async sequence to a sequence.

EDIT: elaborate the answer above

Does this proposal fit well with the feel and direction of Swift?

Yes.

The for … in syntax it is a bit bloated, but I don’t see a lighter syntax that would still be explicit enough.
I wonder if for line try await in file is better. Maybe not.

If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

No.

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

A quick reading, and a quick thinking.

What’s the difference (in expected usage) between AsyncSequence and a future Combine.Publisher that has been enhanced to take advantage of a sync/await?

Apart from the error being untyped, this looks identical, absent the name different (well, and the fact that combine is closed source)

1 Like

Awesome to have this reach review phase, congrats @Philippe_Hausler @Tony_Parker! :tada: :clap:

What is your evaluation of the proposal?

Strong +1, it is great to see the reactive programming / streams concepts woven into the async story of swift like this and have them feel natural and part of "what users would expect" rather than forcing them to re-learn a new style of programming :+1:

Very excited about the related proposal about rethrows protocols as well, that will make this proposal even nicer.

Is the problem being addressed significant enough to warrant a change to Swift?

Yes, it is an important piece of the puzzle; whereas without this we'd be limited to speak in terms of request/reply, now we're able to express async APIs in terms of streaming or rather async sequences -- I should get used to the new word soon :wink:

Does this proposal fit well with the feel and direction of Swift?

Yes, it fitts well.

It is a missing piece to the concurrency story -- without it we'd be limited to only talk about unary asynchronous values, while with this proposal we're able to fit in asynchronous sequences/streams into Swift's normal control flow structures (for), making it a natural fit for our async/await based direction :+1:

The semantics of this API fit well with the Structured Concurrency proposal, and also Actors (say, an actor returning a "streaming response"). Since async sequences use async functions, we will be able to use all related functionality with them -- including task-local values (notoriously difficult to get right in plain reactive programming libraries!), as well as cancelation etc.

If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

I just realized that I never used a language in anger that had built-in language sugar for async sequences like this. Even though having worked on async sequence runtimes for years... funny heh, it always felt "so natural and obvious thing to do" though :slight_smile:

I could compare it with how another language attempts to provide language sugar for FRP / reactive streams style APIs though: so I have contrasted it with how Kotlin attempts to bridge reactive-streams into the async/await. The style of syntax enabled by this proposal is more natural and fits Swift very well.

Other than API surface things which can be bikeshed forever, we notably differ in explicit task cancellation checking semantics. Kotlin takes the stance of automatically checking for cancelation of the task/coroutine within which it runs on every single emitted value edge, IMHO leading to too much noise/traffic on cancelation checking.

The here proposed semantics, that checking is up to the developer is more consistent with the language direction and co-operative cancellation semantics of Swift. :+1:

For context, I was one of the early co-authors of reactive-streams which eventually ended up as part of the Java standard library and also are adopted by Kotlin's Flow types. I also implemented large pieces of Akka Streams (similar to Combine) and have accumulated years of "if only we could have some sugar in the language" from building those library-only solutions – so, I'm very happy to see this take shape and it supports everything I would wish for :+1:

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

Have been tracking its development, reviewing since early reviews as well as compared to semantics present in other runtimes which offer such sugar (Kotlin).

// edit: typos

12 Likes

This is an important building block for Swift's concurrency story. The problem is significant enough to warrant such an addition, and in general it fits well with the feel and direction of Swift. I do not have hands-on experience with other languages with similar features, but I have followed this and the related pitches closely and I studied the final proposal in some depth.

I do have a few comments which I really do hope will be considered before this proposal is accepted:

cancel()

If next() returns nil then the iteration ends naturally and the compiler does not insert a call to cancel() . If next() throws an error, then iteration also ends and the compiler does not insert a call to cancel() . In both of these cases, it was the AsyncSequence itself which decided to end iteration and there is no need to tell it to cancel.

If the AsyncIterator is a class type, it should assume that deinit is equivalent to calling cancel . This will prevent leaking of resources in cases where the iterator is used manually and cancel is not called.

I am not sure how to square these two points conceptually. On the one hand, cancel() is not called one we're done with iteration, and users must understand that what they put inside cancel() won't be executed in that circumstance. But on the other hand, if the iterator is a class, then users can rely on cancel() being equivalent to deinit?

If @Chris_Lattner3 is correct that we don't technically need this API to enable any designs, and future language evolution (introduction of deinit for structs) will define away its need completely, then I think it makes sense to strike cancel() from this proposal. I do fear that it will be quite confusing to users about how much "finality" there is in cancel() versus deinit.

AsyncSequence.first()

I do very much agree that consistency in the API design of Sequence and AsyncSequence is a laudable goal, and it is achieved quite well for the most part. However, if accepted as proposed, first() would be a function here whereas it is a property on Collection. This may lead users to be confused as to why there is such a difference, and overall it creates an inconsistency in our API naming. Indeed, the proposal authors here write that the reason for the difference is that "properties cannot throw"--but, indeed, another reason is that they also cannot be async (as per the core team's decision in SE-0296).

As it happens, we don't need to have throwing and async getters in order to resolve this inconsistency. Notably, Sequence doesn't have the property first, only first(where:), and by the same token it's fine if it also doesn't exist on AsyncSequence. One can always add this property later (to both Sequence and AsyncSequence) if it's deemed useful enough, once it becomes possible to do so without contorting ourselves in terms of API design.

10 Likes

I am uncertain about parts of the proposal, but I do appreciate the initiative to introducing kind of a official "streams of values over time" support to Swift.

Streams of values over time as a pattern has seen inclining adoption, especially after the introduction of Combine. There had been calls on Swift Evolution to a standardized abstraction akin to JVM Reactive Streams for greater interoperability. I do believe that AsyncSequence as proposed can fulfill this gap, and fits well with where Swift is going.

However,

The proposal did not argue why anything besides library support for for await ... in ... must be incorporated into the Swift Standard Library.

The WIP Concurrency module can be justified for its close integration with the compiler for langauge feature support, especially around the areas of e.g. actor isolation. The same can be said for the AsyncIterator protocol, which supports the new async for await ... in ... sugar.

However, for AsyncSequence as proposed, the protocol, its extension functions ("operators" as some may say), and its potential pre-baked builders/implementations all sound like pure "user code" of async-await. It feels that they can have an independent life outside the Standard Library, much like Swift Numerics and Swift System. For example, Kotlin ships its AsyncSequence equivalents (Channel and Flow) in a separate library from the Kotlin stdlib, has its own release schedule, and can be independently updated.

So here are a few core questions I wish the proposal can address:

  • Why should we take the approach to fuse this large API surface with huge growth potential into the language ABI?

  • Let's say if one of the arguments is that "libraries from the ecosystem can fill in the rest", what criteria do the proposal suggest to draw the line on, in terms of what goes into the Standard Library and what's not?

  • What is the exceptional differentiation of this feature from packages like Swift Atomics, Swift Numerics and Swift Systems, that makes incorporation into the Standard Library the best choice?

  • Should incubration outside the Standard Library be first considered?

4 Likes

It’s AsyncSequence that corresponds to for await ... in ..., not AsyncIterator.

AsyncSequence is lower level. It facilitates basic async for loop. Combine is a platform API to facilitate a certain architecture (reactive).

  • What is your evaluation of the proposal?
    Great addition to Swift

  • Is the problem being addressed significant enough to warrant a change to Swift?
    Yes

  • Does this proposal fit well with the feel and direction of Swift?
    Yes, fits perfectly with other async/await proposals and direction

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
    Have not seen this feature before

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?
    Quick reading

We have built some toolchains for Linux and macOS so folks can experiment with this feature.

Doug

13 Likes

Random drive-by comment: If you want to try this out, do add

import _Concurrency

to your programs. The macOS toolchain can be downloaded here: https://ci.swift.org/job/swift-PR-toolchain-osx/820//artifact/branch-main/swift-PR-35224-820-osx.tar.gz

6 Likes

Being closed-source is the main downside. Relying on a feature in the standard library is much more preferrable than sticking to a closed-source framework that doesn't have a public roadmap and supports only a very limited subset of platforms.

5 Likes

+0.8, I agree with the rest of the reviewers that having an explicit requirement for cancel muddies the water here. It makes much more sense for async sequences that require cancellation to be declared as classes and to define their cancellation logic in deinit.

Yes, absolutely. Without support for async iteration async/await feels incomplete to me.

I used Python and JavaScript, which have async generators. I'd prefer Swift to gain support for generators first and get support for this in the same way, through async generators. OTOH async generators aren't mutually exclusive with the presence of AsyncSequence, so this could be resolved at a later point if the Swift community is interested in that direction.

I've read the pitch and the proposal. If that matters, I'm also a co-maintainer of OpenCombine, which reimplements Combine publishers and operators for non-Apple platforms (or older versions of Apple platforms that didn't get support for Combine). I look forward to using AsyncSequence on all platforms rather than fighting Combine/OpenCombine incompatibilities :sweat_smile:

7 Likes

Strong +1!

I think it is. I find that the concerns raised above (cancel & move semantics, by @Chris_Lattner3 & @xwu) were adequately addressed in the proposal itself. I had other concerns myself, such as all the rethrows and non-opaque return types for AS-to-AS functions, but I understand we’re still constrained by language limitations there. first as a function rather than a property is unfortunate (as mentioned by @xwu), but I prefer it over omitting it entirely or using a different name (although I’d be fine to have to resort to .first(where: { _ in true })). Overall I’m convinced by the case made in the proposal to move forward and improve on some of there points later.

Absolutely!

To some degree: async/await in other languages (e.g. JavaScript & Python), event-based streaming APIs, FRP libraries, etc. I’d expect this for await … in … to be nicely intuitive and easy to use for beginners and advanced users alike.

Read the proposal and this forum thread.

What's the different in usage? When should I use:

for await y in someAsyncSequence.map({ |x| transform(x) }) {
    doSomething(y)
}

over:

SomePublisher
    .map { |x| transform(x) }
    .sink { |y| doSomething(y) }

Combine is for events being pushed. You are not in control of when the events are fired. AsyncSequence is for reading a stream asynchronously. Typically used where there is IO involved and you don’t want to block a thread while waiting for the next chunk of data.

Important improvement is readability, you no longer need to translate imperative code to a declarative pipeline of Combine operators and to manage subscriptions. This is especially handy for long pipelines that contain operators like flatMap and frequently require eraseToAnyPublisher().

for await x in someAsyncSequence {
  let y = transform(x)
  guard let z = doSomething(y) else { continue }

  let a: A
  if branch(z) {
    a = await doSomethingAsync(z)
  } else {
    a = doSomethingElse(z)
  }

  guard check(a) else { throw CheckFailed() }

  print(a)
}

Compare this with the Combine version:

var subscriptions = [AnyCancellable]()
someAsyncSequence
  .map(transform)
  .compactMap(doSomething)
  .flatMap { z -> AnyPublisher<A, Error> in
    if branch(z) {
      return doSomethingAsync(z).eraseToAnyPublisher()
    } else {
      return Just(doSomethingElse(z)).eraseToAnyPublisher()
    }
  }.tryMap { a in
    guard check(a) else { throw CheckFailed() }
    return a
  }.sink { a in
    print(a)
  }.store(in: &subscriptions)

In the latter case not only you're forced to use eraseToAnyPublisher() and to provide an explicit type signature, the code is much harder to read because of the braces clutter. This is especially hard when teaching beginners, I look forward to explaning await just once instead of explaining all of the necessary advanced topics required by Combine, like type erasure, subscriptions etc.

Most importantly, memory management with Combine is much more tricky. Since flatMap, handleEvents, and sink are inherently "imperative" operators, it's common to capture a reference to some state outside of their respective closures (say auth tokens for networking calls etc). It's so much easier to create an unwanted memory cycle that way. With async/await these issues are nipped in the bud by avoiding unnecessary closure scopes.

11 Likes

I'm not sure this is a fair comparison. When given an arbitrary Publisher you also may not be in control of when the events are fired. OTOH nothing prevents you from creating a custom AsyncSequence that allows you to "publish" values on it manually, even from another thread if needed.

Likewise, Combine publishers are just as useful when IO is involved and you don't want block a thread, as you can subscribe publishers on an appropriate non-blocking scheduler.

4 Likes
  • What is your evaluation of the proposal?
    +1 iterating is a basic activity.

Not including it would be a significant miss.

  • Is the problem being addressed significant enough to warrant a change to Swift?

Almost all work involves some level of iteration. Yes.

  • Does this proposal fit well with the feel and direction of Swift?

The language is familiar. Although it should be clearly documented if each item is run parallel and all are waited for vs. each iteration waiting to complete before the next item.

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

NodeJS is not blocking by default and requires some getting used to. Being clear about “on / off” is important for reduced “cognitive load” of whoever is programming. If I think about it from a test If perspective, I want to know how I would be able to write tests against this. Going forward it should be clear about how to write tests against feature like this.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

A quick reading.