SE-0314: AsyncStream and AsyncThrowingStream

Glad we could sort out this potentially critical problem in time. I think where Failure == Error is just a patch for another missing feature, even the restriction on withThrowingTaskGroup it‘s not what we actually need here. Unless the types become @frozen until we get typed throws, I hope that in the future we can add a non restricted init and also backport it via something like @_alwaysImitsIntoClient.

The actually missing feature here is a “default generic type parameter“. For example the type should really be AsyncThrowingStream<Element, Failure: Error = Error>. That wouldn‘t require the limitation on the ìnit and also won‘t require any additional overloads in the future. Unfortunately Swift does not have this feature yet.

1 Like

I‘m really tired and I might talk nonsense, but since we got a generalized version of the stream type, is there any need for an explicit AsyncStream type? Can‘t it be a type alias?

typealias AsyncStream<Element> = AsyncThrowingStream<Element, Never>

Can‘t the compiler not already guarantee that such type won‘t ever throw an error because of Never?

This would also mean that the restricted init from the previous message should move into extension AsyncThrowingStream where Failure == Error while probably also introducing another extension AsyncThrowingStream where Failure == Never.


A bit off-topic, but this also leads me to the question why we not simply have:

typealias TaskGroup<ChildTaskResult> = ThrowingTaskGroup<ChildTaskResult, Never>

Sadly no, that would require a double conditional conformance of the iterator.

1 Like

Off-topic again, but smells like another missing generic feature. :crossed_fingers: we can move the whole generics topic forwards for Swift 6.

3 Likes

Correct, the missing feature is generic effects (e.g. making a generic decision on if a function throws or type via the conformance throws)

2 Likes

After using AsyncStream and AsyncSequence a bit, I was wondering if it makes sense to introduce an AnyAsyncSequence type similar to the AnyPublisher of Combine. A common pattern in our existing code base is something like this:

protocol UserService: AnyObject {
    var userStream: AnyPublisher<User?, Never> // replace with AnyAsyncSequence<User?, Never> if possible
}

@Philippe_Hausler Would adding such a type into the standard library make sense?

Perhaps, but I think the any form would really need generalized effects to work properly. Since no where-clause today could emit the non throwing eraser. My hopes are that the opaque types work will lead us to a more general and efficient solution for erasing things with effects. AsyncStream however probably isn’t that type fully (granted it could partially serve as that).

1 Like

(Sorry, I keep hitting the wrong key today; meant to change tab)

Overall +1 to the idea. I think it's a really valuable addition, and it's good to finally bring some kind of reactive programming to the standard library. I have some questions and suggestions, though:

  1. I agree with @ktoso about the name yield. yield is already a language keyword (for _modify), and would be great for non-async generators. Might I suggest send as an alternative?

  2. Speaking of sending, it's a little strange that the Element type doesn't need to be Sendable. The stream's task is able to send values to its consumer, and they are possibly running in parallel, so wouldn't it be easy to introduce a data race by sending a non-Sendable class instance across?

As with any sequence, iterating over an AsyncStream multiple times, or creating multiple iterators and iterating over them separately, may produce an unexpected series of values. Neither AsyncStream nor its iterator are @Sendable types, and concurrent iteration is considered a programmer error.

  1. Sequence is a protocol, so it's more understable that it omits specifying what happens if you use it outside of spec (also, it's the worst part about that protocol, and the source of countless bugs). I'd hope for a little more from AsyncStream, being a concrete type. What does happen if you iterate an AsyncStream multiple times? A runtime error? An empty sequence?

  2. I wonder if it would be worth adding convenience methods on the continuation to send/yield all values from another AsyncStream/Sequence. You could just for await them and forward all the values, but I think having a built-in convenience function for including the elements from another stream/sequence is at least as valuable as one for yielding Void, which is included.

    Also, it may be possible to avoid repeated buffering if an AsyncStream is being consumed by another AsyncStream.

There has been discussion in the past about a model for typed throws where nonthrowing functions are equivalent to throws Never. Wouldn’t this approach also make it possible without having fully generalized effects?

Here is the post that you've just mentioned (I have it bookmarked):

That is correct, it will need to be Sendable but this suffers the same enforcement problem that TaskGroup hits - many adopters are yet to be Sendable. @Douglas_Gregor has indicated that we can address this issue when Sendable is actually enforced. I think from a details standpoint we really should require all elements of all AsyncSequences to be Sendable. The conformance here would just fall-out when that requirement is implemented.

It is an immediately terminal async sequence in that case.

That can't be directly done; since there is no way to enforce if it is a throwing case or not. Doing so would be a reasonable extension when we gain generalized effects.

1 Like

I did some exploration and took the parts about some of this feedback very seriously on how they can be implemented and updated the proposal with some additional explanations, better examples, and incorporated the feedback in this PR of changes to the proposal [SE-0314] `AsyncStream` and `AsyncThrowingStream` Updates by phausler · Pull Request #1389 · apple/swift-evolution · GitHub

5 Likes