-
Notifications
You must be signed in to change notification settings - Fork 2.5k
[Proposal] Enhancing-Async{Throwing}Stream #3287
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
NotTheNHK
wants to merge
10
commits into
swiftlang:main
Choose a base branch
from
NotTheNHK:main
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+333
−0
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
314ffc5
Add Enhancing-Async{Throwing}Stream proposal
NotTheNHK 6d0ac83
Remove implementation details from proposal
NotTheNHK c827dbd
Remove implementation details from proposal
NotTheNHK da5c798
Update spelling of the global concurrent executor to use the correct …
NotTheNHK 76c1794
Update proposal based on received feedback
NotTheNHK 135b9d5
Fix typo in code sample
NotTheNHK 79704a6
Update headers to use sentence-case
NotTheNHK f38ee56
Add GitHub issues to the bug bullet point
NotTheNHK d2132df
Refine proposal
NotTheNHK ec5a8c3
Editorial refinements
NotTheNHK File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,333 @@ | ||
| # Enhancing `Async{Throwing}Stream` | ||
|
|
||
| * Proposal: [SE-NNNN](NNNN-filename.md) | ||
| * Authors: [NotTheNHK](https://github.com/NotTheNHK) | ||
| * Review Manager: TBD | ||
| * Status: **Awaiting implementation** or **Awaiting review** | ||
| * Bug: [#75853](https://github.com/swiftlang/swift/issues/75853), [#77974](https://github.com/swiftlang/swift/issues/77974) | ||
| * Implementation: TBD | ||
| * Upcoming Feature Flag: `AsyncStreamCancelOnContinuationDeinit` | ||
| * Review: ([pitch](https://forums.swift.org/t/pitch-enhancing-async-throwing-stream/86339)) | ||
|
|
||
| ## Summary of changes | ||
|
|
||
| This proposal introduces the following changes: | ||
|
|
||
| 1. Typed throws support for `AsyncThrowingStream`. | ||
| 2. Update the unfolding initializer by adopting `nonisolated(nonsending)` and replacing `onCancel`’s `@Sendable` requirement with `sending`. | ||
| 3. Terminate the stream when its continuation is discarded. | ||
| 4. `Hashable` conformance for `Async{Throwing}Stream` and nested types. | ||
|
|
||
| ## Motivation | ||
|
|
||
| ### Typed throws | ||
|
|
||
| Currently, thrown errors are type-erased to `any Error` when `AsyncThrowingStream` throws (either by calling the `finish(throwing:)` method or when the `unfolding` closure throws), requiring additional boilerplate to preserve the thrown error's type and integrate it into typed contexts. | ||
|
|
||
| ```swift | ||
| let locationStream = AsyncThrowingStream<Location, LocationError> { ... } // Error: Initializer 'init(_:bufferingPolicy:_:)' requires the types 'LocationError' and 'any Error' be equivalent. | ||
|
|
||
| func processLocations() async throws(LocationError) { | ||
| for try await location in locationStream { // Error: Thrown expression type 'any Error' cannot be converted to error type 'LocationError'. | ||
| ... | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| There are two suboptimal workarounds. | ||
|
|
||
| 1. Type cast: | ||
|
|
||
| ```swift | ||
| let locationStream = AsyncThrowingStream<Location, any Error> { ... } | ||
|
|
||
| func processLocations() async throws(LocationError) { | ||
| do { | ||
| for try await location in locationStream { | ||
| ... | ||
| } | ||
| } catch { | ||
| throw error as! LocationError | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| 2. Result type: | ||
|
|
||
| ```swift | ||
| let locationStream = AsyncStream<Result<Location, LocationError>> { ... } | ||
|
|
||
| func processLocations() async throws(LocationError) { | ||
| for await result in locationStream { | ||
| switch result { | ||
| case .success(let location): | ||
| ... | ||
| case .failure(let locationError): | ||
| throw locationError | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Unfolding initializer | ||
|
|
||
| [SE-0314](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0314-async-stream.md#detailed-design) proposed the following unfolding initializers: | ||
|
|
||
| ```swift | ||
| // AsyncStream | ||
| public init( | ||
| unfolding produce: @escaping () async -> Element?, | ||
| onCancel: (@Sendable () -> Void)? = nil | ||
| ) | ||
|
|
||
| // AsyncThrowingStream | ||
| public init( | ||
| unfolding produce: @escaping () async throws -> Element?, | ||
| onCancel: (@Sendable () -> Void)? = nil | ||
| ) | ||
| ``` | ||
|
|
||
| However, the `AsyncThrowingStream` variant never implemented the `onCancel` parameter, creating a discrepancy between the two APIs. | ||
|
|
||
| Furthermore, [SE-0338](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0338-clarify-execution-non-actor-async.md#proposed-solution) clarified the execution semantics of `nonisolated` asynchronous functions by specifying that such functions formally run on the global concurrent executor, potentially introducing unnecessary actor hops. | ||
|
|
||
| Additionally, the `@Sendable` requirement on `onCancel` is overly restrictive, as `onCancel` is invoked at most once and never concurrently with itself. | ||
|
|
||
| ```swift | ||
| let stream = AsyncStream { | ||
| ... | ||
| } onCancel: { | ||
| ... | ||
| } | ||
|
|
||
| let throwingStream = AsyncThrowingStream { | ||
| ... | ||
| } // No `onCancel` parameter. | ||
|
|
||
| func process(on locationActor: isolated LocationActor) async { // Starts running on `locationActor`. | ||
| let locationStream = AsyncStream<Location> { ... } | ||
|
|
||
| for await location in locationStream { // Implicit call to `produce`, hop off `locationActor`. | ||
| locationActor.update(to: location) // Hop back on `locationActor`. | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| The `process(on:)` function is actor-isolated to its `locationActor` parameter, | ||
| Its formal isolation is that of the passed-in actor instance. However, the for await-in loop implicitly calls the `nonisolated` asynchronous `produce` function-type parameter to receive the next element. | ||
|
|
||
| As a result, `process(on:)` continuously hops off and back onto `locationActor` for each iteration. | ||
|
|
||
| ### Continuation and stream termination | ||
|
|
||
| When the continuation of an active stream is discarded, task cancellation becomes the only way to terminate the stream. | ||
|
|
||
| ```swift | ||
| let stream = AsyncStream<Int> { continuation in | ||
| continuation.onTermination = { reason in | ||
| print(reason) | ||
| } | ||
|
|
||
| for number in 0..<10 { | ||
| continuation.yield(number) | ||
| } | ||
| } // Continuation discarded here. | ||
|
|
||
| for await element in stream { // Prints: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9. Afterwards, suspends indefinitely. | ||
| print(element) | ||
| } | ||
| ``` | ||
|
|
||
| Unless the consumer's task is cancelled, the for await-in loop remains indefinitely suspended. | ||
|
|
||
| ### `Hashable` conformance | ||
|
|
||
| Extending `Hashable` conformance to `Async{Throwing}Stream` and its nested types would allow them to be used as stored properties or associated values in `Hashable`-conforming types, as `Dictionary` keys, and as elements of `Set`s. | ||
|
|
||
| The inherited `Equatable` conformance from `Hashable` enables equality comparisons, which can be useful for testing. | ||
|
|
||
| ## Proposed solution | ||
|
|
||
| ### Typed throws | ||
|
|
||
| `AsyncThrowingStream` already defines a type parameter `Failure: Error`, which until now has been constrained to `any Error`. | ||
|
|
||
| Because the existing `Failure == any Error` constraint cannot be lifted without breaking backward compatibility, this proposal extends `AsyncThrowingStream` with new unconstrained initializers and a `makeStream` method, eliminating existing boilerplate and enabling seamless use in typed contexts. | ||
|
|
||
| ```swift | ||
| let locationStream = AsyncThrowingStream<Location, LocationError> { ... } | ||
|
|
||
| func processLocations() async throws(LocationError) { | ||
| for try await location in locationStream { | ||
| ... | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Unfolding initializer | ||
|
|
||
| This proposal adds the missing `onCancel` parameter to the unfolding initializer of `AsyncThrowingStream`, aligning it with `AsyncStream` and with the original variant proposed in SE-0314. | ||
|
|
||
| Additionally, this proposal adopts `nonisolated(nonsending)`. As described in [SE-0461](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0461-async-function-isolation.md), this allows the `produce` closure to run on the caller’s actor, and avoids unnecessary actor hops. | ||
|
|
||
| The `@Sendable` requirement on the `onCancel` parameter is replaced with the `sending` keyword; this allows a wider range of functions and closures to be passed to the parameter wihtout the risk of a data race. | ||
|
|
||
| ```swift | ||
| let locationStream = Async{Throwing}Stream { | ||
| ... | ||
| } onCancel: { | ||
| ... | ||
| } | ||
|
|
||
| func process(on locationActor: isolated LocationActor) async { // Starts running on `locationActor`. | ||
| let locationStream = AsyncStream<Location> { ... } | ||
|
|
||
| for await location in locationStream { // Implicit call to `produce`, runs on `locationActor`. | ||
| locationActor.update(to: location) // Already running on `locationActor`, no hop needed. | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Stream termination when its continuation is discarded | ||
|
|
||
| The continuation-based `Async{Throwing}Stream` variant is modified to track outstanding references to the stream’s continuation, including the continuation itself and any copies of it. When the last reference to the continuation is discarded, the stream is canceled. | ||
|
|
||
| This change is staged in via an upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`). | ||
|
|
||
| ```swift | ||
| // With `AsyncStreamCancelOnContinuationDeinit`. | ||
|
|
||
| let stream = AsyncStream<Int> { continuation in | ||
| continuation.onTermination = { reason in | ||
| print(reason) | ||
| } | ||
|
|
||
| for number in 0..<10 { | ||
| continuation.yield(number) | ||
| } | ||
| } // Continuation discarded here. | ||
|
|
||
| for await element in stream { // Prints: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9. Afterwards, `onTermination` is invoked with `.cancelled`. | ||
| print(element) | ||
| } | ||
| ``` | ||
|
|
||
| Because the continuation is discarded after the for-in loop completes, `stream` is canceled. | ||
|
|
||
| ## Detailed design | ||
|
|
||
| Updated: | ||
|
|
||
| ```swift | ||
| extension AsyncStream { | ||
| init( | ||
| unfolding produce: nonisolated(nonsending) @escaping @Sendable () async -> Element?, | ||
| onCancel: sending (() -> Void)? = nil | ||
| ) | ||
| } | ||
|
|
||
| extension AsyncThrowingStream { | ||
| public init( | ||
| unfolding produce: nonisolated(nonsending) @escaping @Sendable () async throws(Failure) -> Element?, | ||
| onCancel: sending (() -> Void)? = nil | ||
| ) where Failure == any Error | ||
| } | ||
| ``` | ||
|
|
||
| New: | ||
|
|
||
| ```swift | ||
| extension AsyncThrowingStream { | ||
| public init( | ||
| unfolding produce: nonisolated(nonsending) @escaping @Sendable () async throws(Failure) -> Element?, | ||
| onCancel: sending (() -> Void)? = nil | ||
| ) | ||
|
|
||
| public init( | ||
| of elementType: Element.Type = Element.self, | ||
| throwing failureType: Failure.Type = Failure.self, | ||
| bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded, | ||
| _ build: (Continuation) -> Void | ||
| ) | ||
|
|
||
| public static func makeStream( | ||
| of elementType: Element.Type = Element.self, | ||
| throwing failureType: Failure.Type = Failure.self, | ||
| bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded | ||
| ) -> (stream: AsyncThrowingStream<Element, Failure>, continuation: AsyncThrowingStream<Element, Failure>.Continuation) | ||
| } | ||
| ``` | ||
|
|
||
| `Hashable` conformance: | ||
|
|
||
| For `Async{Throwing}Stream` specifically, `Hashable` conformance is identity-based. Although it is a struct, it wraps a `context` class that is unique to each instance but shared across its copies. | ||
|
|
||
| ```swift | ||
| // AsyncStream | ||
| extension AsyncStream: Hashable { | ||
| public static func == (lhs: Self, rhs: Self) -> Bool { | ||
| // ... | ||
| } | ||
|
|
||
| public func hash(into hasher: inout Hasher) { | ||
| // ... | ||
| } | ||
| } | ||
|
|
||
| extension AsyncStream.Continuation.BufferingPolicy: Hashable {} | ||
|
|
||
| extension AsyncStream.Continuation.YieldResult: Equatable, Hashable where Element: Equatable, Element: Hashable {} | ||
|
|
||
| // AsyncThrowingStream | ||
| extension AsyncThrowingStream: Hashable { | ||
| public static func == (lhs: Self, rhs: Self) -> Bool { | ||
| // ... | ||
| } | ||
|
|
||
| public func hash(into hasher: inout Hasher) { | ||
| // ... | ||
| } | ||
| } | ||
|
|
||
| extension AsyncThrowingStream.Continuation.BufferingPolicy: Hashable {} | ||
|
|
||
| extension AsyncThrowingStream.Continuation.YieldResult: Equatable, Hashable where Element: Equatable, Element: Hashable {} | ||
|
NotTheNHK marked this conversation as resolved.
|
||
|
|
||
| extension AsyncThrowingStream.Continuation.Termination: Equatable, Hashable where Failure: Hashable, Failure: Equatable {} | ||
| ``` | ||
|
|
||
| ## Source compatibility | ||
|
|
||
| This proposal changes the behavior around stream termination when the stream’s continuation is discarded. To avoid silently changing behavior, this change is gated behind an upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`). Apart from this change, the proposed changes are additive. In particular, replacing `onCancel`’s `@Sendable` requirement with `sending` makes it less restrictive for the caller while still preventing data races. | ||
|
|
||
| ## ABI compatibility | ||
|
|
||
| The changes are additive. | ||
|
|
||
| ## Implications on adoption | ||
|
|
||
| The rationale for gating this change behind an upcoming feature flag (`AsyncStreamCancelOnContinuationDeinit`) is that implicitly terminating the stream when its continuation is discarded would break code that relies on the current behavior, for example to create an indefinite suspension point. | ||
|
|
||
| ## Future directions | ||
|
|
||
| ### `~Copyable` support | ||
|
|
||
| In principle, it should be possible to support `~Copyable` types. But, several blockers currently prevent their adoption. | ||
| The key issue is the lack of support for iterating over a `~Copyable` sequence. | ||
| It is not as simple as declaring `{Async}Sequence`’s `Element` associated type as `~Copyable`. Changes to the compiler would be required. | ||
|
|
||
| However, progress is being made in other areas. Swift Collections now includes multiple types that support `~Copyable` elements, such as `UniqueDeque` and, `UniqueArray`. There is also ongoing discussion about moving `UniqueArray` into the standard library. In addition, [SE-0528](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0528-noncopyable-continuation.md) introduced a `~Copyable` continuation type. | ||
|
|
||
| ## Alternatives considered | ||
|
|
||
| An alternative approach to staging-in change Nr. 3 (“Terminate the stream when its continuation is discarded”) via an upcoming feature flag would be to introduce a new continuation-based initializer and `makeStream` method that explicitly signals this behavior to the user. | ||
|
|
||
| There are three problems with this approach: | ||
|
|
||
| 1. It would require introducing five additional initializer overloads and two `makeStream` methods. | ||
| 2. To disambiguate them, this would require adding some form of clear differentiation. | ||
| 3. It would not help with staging-in the new behavior, as users of the API would need to switch to the new, more verbose, API | ||
| and the old, less verbose, API would eventually need to be deprecated. | ||
|
|
||
| ## Acknowledgments | ||
| I would like to thank @jamieQ for initial guidance and continued feedback, as well as @FranzBusch, @ktoso, and @phausler for their feedback. | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.