[Concurrency] AsyncSequence

Hello all,

Thank you for your thoughtful feedback. I will attempt to consolidate some answers/updates/comments into one reply. I will also update the proposal at the link above to include this content shortly.

Append/Prepend

This was intended to be more like Array's + function. For now, to simplify the proposal, I've removed them.

Async Cancel

We think cancel should be synchronous, especially since the compiler will call it for you (see below for more on this). This is discussed in Alternatives Considered as well.

We will also update the proposal to suggest that deinit should be equivalent to cancel if it exists on the iterator.

Awaiting Many Things

A future enhancement (either in the standard library or a higher level library) could be to introduce a kind of buffering AsyncSequence. As an initializer argument it could be told how many things it should attempt to eagerly fetch and act as a kind of signal smoother.

We've investigated the plans for the overhead of the await keyword itself (not including any user code) and we believe it is low enough to not be an issue on its own.

Naming

We considered AsyncGenerator but would prefer to leave the Generator name for future language enhancements. Stream is a type in Foundation, so we did not reuse it here to avoid confusion.

await in

We considered a shorter syntax of await...in. However, since the behavior here is fundamentally a loop, we feel it is important to use the existing for keyword as a strong signal of intent to readers of the code. Although there are a lot of keywords, each one has purpose and meaning to readers of the code.

Add APIs to iterator instead of sequence

We went off and explored this idea in depth. It is certainly a compelling argument. However, we ultimately came back around to the decision that consistency with the existing Sequence APIs is the tradeoff decision we would like to propose.

We discussed applying the fundamental API (map, reduce, etc.) to the AsyncIterator protocol instead of AsyncSequence. There has been a long-standing (albeit deliberate) ambiguity in the Sequence API -- is it supposed to be single-pass or multi-pass? This new kind of iterator & sequence could provide an opportunity to define this more concretely.

While it is tempting to use this new API to right past wrongs, we maintain that the high level goal of consistency with existing Swift concepts is more important.

For example, for...in cannot be used on an Iterator -- only a Sequence. If we chose to make AsyncIterator use for...in as described here, that leaves us with the choice of either introducing an inconsistency between AsyncIterator and Iterator or giving up on the familiar for...in syntax. Even if we decided to add for...in to Iterator, it would still be inconsistent because we would be required to leave for...in syntax on the existing Sequence.

Another point in favor of consistency is that implementing an AsyncSequence should feel familiar to anyone who knows how to implement a Sequence.

We are hoping for widespread adoption of the protocol in API which would normally have instead used a Notification, informational delegate pattern, or multi-callback closure argument. In many of these cases we feel like the API should return the 'factory type' (an AsyncSequence) so that it can be iterated again. It will still be up to the caller to be aware of any underlying cost of performing that operation, as with iteration of any Sequence today.

Move-only iterator and removing Cancel

We discussed waiting to introduce this feature until move-only types are available in the future. This is a tradeoff in which we look to the Core Team for advice, but the authors believe the benefit of having this functionality now has the edge. It will likely be the case that move-only types will bring changes to other Sequence and Iterator types when it arrives in any case.

Prototyping of the patch does not seem to indicate undue complexity in the compiler implementation. In fact, it appears that the existing ideas around defer actually match this concept cleanly. I've updated the proposal to show how this could work.

We have included a __consuming attribute on the cancel function, which should allow move-only iterators to exist in the future.

10 Likes

The proposed design treats await as a pattern:

for await let element in myAsyncSequence {
  doSomething(element)
}
Transformed into:
var it = myAsyncSequence.makeAsyncIterator()
while let element = await it.next() {
  doSomething(element)
}

I wonder if async let could gain the same treatment. @ktoso, would this fit with the proposed structured concurrency design? We would get a basic non-customizable parallel for..in loop having the same behavior we would get by writing an async let for each element of the sequence:

for async let element in myAsyncSequence {
  doSomething(await element)
}
Transformed into:
var it = myAsyncSequence.makeAsyncIterator()
await Task.withGroup(resultType: type(of: it).Element.self) { group in
  await group.add { await it.next() }
  await group.add { await it.next() }
  ...

  while let element = await group.next() {
    doSomething(element)
  }
}
4 Likes

Hi Tony,

Can you elaborate more of why this "has the edge"? There doesn't appear to be anything specific about async-ness to the idea of having a "closing off the iterator": shouldn't we add the same thing to iterator types if this is important, for consistency and to solve equivalent use cases for normal iterators?

Relatedly, I don't think "cancel" is the obviously right term here. Cancelation is a different thing that applies to tasks. This concept something more akin to close() or finalize() operation in GC'd systems. It is a separate-from-deinit pattern for closing things off.

-Chris

2 Likes

Hi Chris,

We've done some surveys of the APIs available in Apple's SDKs and it seems to be the case that most synchronous APIs (e.g., returning an Array) does not require some kind of cancellation whereas most asynchronous APIs do have some mechanism for cancellation. Examples of cancellable APIs from Foundation include timers, notifications, and KVO. I think the idea of supporting cancellation is part of what makes an async API different from a synchronous one.

Your suggestion of the terminology of cancellation vs closing here is interesting. Perhaps there is some explanation missing from the document. It's intended that cancellation is only invoked if the for loop is exited early. The compiler would not add a call to cancel when the iterator returns nil. What that means is that cancellation is a signal from the iteration to the iterator instead of a signal from the iterator to the iteration. The reason I write that it 'has the edge' is that I am convinced it is valuable to have this be an explicit thing that either the compiler or the code author can write, whereas relying on memory management has the potential to be a lot harder to make fully deterministic.

Thanks for your thoughts and involvement on this!

4 Likes

Hi all,

Heads-up that we've scheduled the full review of this pitch as SE-0298 January 12...26, 2021.

Doug

7 Likes

Great, thanks for the feedback Tony!

2 Likes

... and the review has started over at SE-0298: Async/Await: Sequences, thank you everyone.

Doug

1 Like
for await quake in quakes {
    if quake.location == nil {
        break
    }
    if quake.magnitude > 3 {
        displaySignificantEarthquake(quake)
    }
}

So how do we code like this?

To write that you need a swift 5.5 toolchain and to target the right deployment target level for concurrency (iOS 15 et al) and in that case quakes must be an AsyncSequence of some kind and that must in done inside of an async context (a function or closure which is async)