Skip to content

Commit 5e27164

Browse files
hborlateveleeluca-bernardiDougGregorJumhyn
authored
Add a proposal to generalize AsyncSequence and AsyncIteratorProtocol. (#2264)
* Add a proposal to generalize AsyncSequence and AsyncIteratorProtocol. * Update proposals/NNNN-generalize-async-sequence.md Co-authored-by: Laszlo Teveli <[email protected]> * Update proposals/NNNN-generalize-async-sequence.md Co-authored-by: Luca Bernardi <[email protected]> * Remove generalization of isolated parameters; this is covered by a separate proposal. * Remove upfront mention of `@rethrows` in the introduction. * Generalize async sequence (#3) * Move discussion of the experimental `@rethrows` out to "Source Compatibility" * Rename `nextElement(_:)` to `next(isolation:)` * Async sequence generalization is SE-0421 --------- Co-authored-by: Laszlo Teveli <[email protected]> Co-authored-by: Luca Bernardi <[email protected]> Co-authored-by: Doug Gregor <[email protected]> Co-authored-by: Freddy Kellison-Linn <[email protected]>
1 parent efd2aa4 commit 5e27164

File tree

1 file changed

+273
-0
lines changed

1 file changed

+273
-0
lines changed
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
# Generalize effect polymorphism for `AsyncSequence` and `AsyncIteratorProtocol`
2+
3+
* Proposal: [SE-0421](0421-generalize-async-sequence.md)
4+
* Authors: [Doug Gregor](https://github.com/douggregor), [Holly Borla](https://github.com/hborla)
5+
* Review Manager: [Freddy Kellison-Linn](https://github.com/Jumhyn)
6+
* Status: **Review scheduled (January 26...Febuary 7, 2024)**
7+
* Implementation: https://github.com/apple/swift/pull/70635
8+
* Review: ([pitch](https://forums.swift.org/t/pitch-generalize-asyncsequence-and-asynciteratorprotocol/69283))
9+
10+
## Introduction
11+
12+
This proposal generalizes `AsyncSequence` in two ways:
13+
1. Proper `throws` polymorphism is accomplished with adoption of typed throws.
14+
2. A new overload of the `next` requirement on `AsyncIteratorProtocol` includes an isolated parameter to abstract over actor isolation.
15+
16+
## Table of Contents
17+
18+
* [Introduction](#introduction)
19+
* [Motivation](#motivation)
20+
* [Proposed solution](#proposed-solution)
21+
* [Detailed design](#detailed-design)
22+
+ [Adopting typed throws](#adopting-typed-throws)
23+
- [Error type inference from `for try await` loops](#error-type-inference-from-for-try-await-loops)
24+
+ [Adopting primary associated types](#adopting-primary-associated-types)
25+
+ [Adopting isolated parameters](#adopting-isolated-parameters)
26+
+ [Default implementations of `next()` and `next(isolation:)`](#default-implementations-of-next-and-next-isolation)
27+
+ [Associated type inference for `AsyncIteratorProtocol` conformances](#associated-type-inference-for-asynciteratorprotocol-conformances)
28+
+ [`rethrows` checking](#rethrows-checking)
29+
* [Source compatibility](#source-compatibility)
30+
* [ABI compatibility](#abi-compatibility)
31+
* [Implications on adoption](#implications-on-adoption)
32+
* [Future directions](#future-directions)
33+
+ [Add a default argument to `next(isolation:)`](#add-a-default-argument-to-next-isolation)
34+
* [Alternatives considered](#alternatives-considered)
35+
+ [Avoiding an existential parameter in `next(isolation:)`](#avoiding-an-existential-parameter-in-next-isolation)
36+
* [Acknowledgments](#acknowledgments)
37+
38+
## Motivation
39+
40+
`AsyncSequence` and `AsyncIteratorProtocol` were intended to be polymorphic over the `throws` effect and actor isolation. However, the current API design has serious limitations that impact expressivity in generic code, `Sendable` checking, and runtime performance.
41+
42+
Some `AsyncSequence`s can throw during iteration, and others never throw. To enable callers to only require `try` when the given sequence can throw, `AsyncSequence` and `AsyncIteratorProtocol` used an experimental feature to try to capture the throwing behavior of a protocol. However, this approach was insufficiently general, which has also [prevented `AsyncSequence` from adopting primary associated types](https://forums.swift.org/t/se-0346-lightweight-same-type-requirements-for-primary-associated-types/55869/70). Primary associated types on `AsyncSequence` would enable hiding concrete implementation details behind constrained opaque or existential types, such as in transformation APIs on `AsyncSequence`:
43+
44+
```swift
45+
extension AsyncSequence {
46+
// 'AsyncThrowingMapSequence' is an implementation detail hidden from callers.
47+
public func map<Transformed>(
48+
_ transform: @Sendable @escaping (Element) async throws -> Transformed
49+
) -> some AsyncSequence<Transformed, any Error> { ... }
50+
}
51+
```
52+
53+
Additionally, `AsyncSequence` types are designed to work with `Sendable` and non-`Sendable` element types, but it's currently impossible to use an `AsyncSequence` with non-`Sendable` elements in an actor-isolated context:
54+
55+
```swift
56+
class NotSendable { ... }
57+
58+
@MainActor
59+
func iterate(over stream: AsyncStream<NotSendable>) {
60+
for await element in stream { // warning: non-sendable type 'NotSendable?' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary
61+
62+
}
63+
}
64+
```
65+
66+
Because `AsyncIteratorProtocol.next()` is `nonisolated async`, it always runs on the generic executor, so calling it from an actor-isolated context crosses an isolation boundary. If the result is non-`Sendable`, the call is invalid under strict concurrency checking.
67+
68+
More fundamentally, calls to `AsyncIteratorProtocol.next()` from an actor-isolated context are nearly always invalid in practice today. Most concrete `AsyncIteratorProtocol` types are not `Sendable`; concurrent iteration using `AsyncIteratorProtocol` is a programmer error, and the iterator is intended to be used/mutated from the isolation domain that formed it. However, when an iterator is formed in an actor-isolated context and `next()` is called, the non-`Sendable` iterator is passed across isolation boundaries, resulting in a diagnostic under strict concurrency checking.
69+
70+
Finally, `next()` always running on the generic executor is the source of unnecessary hops between an actor and the generic executor.
71+
72+
## Proposed solution
73+
74+
This proposal introduces a new associated type `Failure` to `AsyncSequence` and `AsyncIteratorProtocol`, adopts both `Element` and `Failure` as primary associated types, adds a new protocol requirement to `AsyncIteratorProtocol` that generalizes the existing `next()` requirement by throwing the `Failure` type, and adds an `isolated` parameter to the new requirement to abstract over actor isolation:
75+
76+
```swift
77+
@available(SwiftStdlib 5.1, *)
78+
protocol AsyncIteratorProtocol<Element, Failure> {
79+
associatedtype Element
80+
81+
mutating func next() async throws -> Element?
82+
83+
@available(SwiftStdlib 5.11, *)
84+
associatedtype Failure: Error = any Error
85+
86+
@available(SwiftStdlib 5.11, *)
87+
mutating func next(isolation actor: isolated (any Actor)?) async throws(Failure) -> Element?
88+
}
89+
90+
@available(SwiftStdlib 5.1, *)
91+
public protocol AsyncSequence<Element, Failure> {
92+
associatedtype AsyncIterator: AsyncIteratorProtocol
93+
associatedtype Element where AsyncIterator.Element == Element
94+
95+
@available(SwiftStdlib 5.11, *)
96+
associatedtype Failure = AsyncIterator.Failure where AsyncIterator.Failure == Failure
97+
98+
func makeAsyncIterator() -> AsyncIterator
99+
}
100+
```
101+
102+
The new `next(isolation:)` has a default implementation so that conformances will continue to behave as they do today. Code generation for `for-in` loops will switch over to calling `next(isolation:)` instead of `next()` when the context has appropriate availability.
103+
104+
## Detailed design
105+
106+
### Adopting typed throws
107+
108+
Concrete `AsyncSequence` and `AsyncIteratorProtocol` types determine whether calling `next()` can `throw`. This can be described in each protocol with a `Failure` associated type that is thrown by the `AsyncIteratorProtcol.next(isolation:)` requirement. Describing the thrown error with an associated type allows conformances to fulfill the requirement with a type parameter, which means that libraries do not need to expose separate throwing and non-throwing concrete types that otherwise have the same async iteration functionality.
109+
110+
#### Error type inference from `for try await` loops
111+
112+
The `Failure` associated type is only accessible at runtime in the Swift 5.11 standard library; code running against older standard library versions does not include the `Failure` requirement in the witness tables for `AsyncSequence` and `AsyncIteratorProtocol` conformances. This impacts error type inference from `for try await` loops.
113+
114+
When the thrown error type of an `AsyncIteratorProtocol` is available, either through the associated type witness (because the context has appropriate availability) or because the iterator type is concrete, iteration over an async sequence throws its `Failure` type:
115+
116+
```swift
117+
struct MyAsyncIterator: AsyncIteratorProtocol {
118+
typealias Failure = MyError
119+
...
120+
}
121+
122+
func iterate<S: AsyncSequence>(over s: S) where S.AsyncIterator == MyAsyncIterator {
123+
let closure = {
124+
for try await element in s {
125+
print(element)
126+
}
127+
}
128+
}
129+
```
130+
131+
In the above code, the type of `closure` is `() async throws(MyError) -> Void`.
132+
133+
When the thrown error type of an `AsyncIteratorProtocol` is not available, iteration over an async sequence throws `any Error`:
134+
135+
```swift
136+
@available(SwiftStdlib 5.1, *)
137+
func iterate(over s: some AsyncSequence) {
138+
let closure = {
139+
for try await element in s {
140+
print(element)
141+
}
142+
}
143+
}
144+
```
145+
146+
In the above code, the type of `closure` is `() async throws(any Error) -> Void`.
147+
148+
When the `Failure` type of the given async sequence is constrained to `Never`, `try` is not required in the `for-in` loop:
149+
150+
```swift
151+
struct MyAsyncIterator: AsyncIteratorProtocol {
152+
typealias Failure = Never
153+
...
154+
}
155+
156+
func iterate<S: AsyncSequence>(over s: S) where S.AsyncIterator == MyAsyncIterator {
157+
let closure = {
158+
for await element in s {
159+
print(element)
160+
}
161+
}
162+
}
163+
```
164+
165+
In the above code, the type of `closure` is `() async -> Void`.
166+
167+
### Adopting primary associated types
168+
169+
The `Element` and `Failure` associated types are promoted to primary associated types. This enables using constrained existential and opaque `AsyncSequence` and `AsyncIteratorProtocol` types, e.g. `some AsyncSequence<Element, Never>` or `any AsyncSequence<Element, any Error>`.
170+
171+
### Adopting isolated parameters
172+
173+
The `next(isolation:)` requirement abstracts over actor isolation using [isolated parameters](/proposals/0313-actor-isolation-control.md). For callers to `next(isolation:)` that pass an iterator value that cannot be transferred across isolation boundaries under [SE-0414: Region based isolation](/proposals/0414-region-based-isolation.md), the call is only valid if it does not cross an isolation boundary. Explicit callers can pass in a value of `#isolation` to use the isolation of the caller, or `nil` to evaluate `next(isolation:)` on the generic executor.
174+
175+
Desugared async `for-in` loops will call `AsyncIteratorProtocol.next(isolation:)` instead of `next()` when the context has appropriate availability, and pass in an isolated argument value of `#isolation` of type `(any Actor)?`. The `#isolation` macro always expands to the isolation of the caller so that the call does not cross an isolation boundary.
176+
177+
### Default implementations of `next()` and `next(isolation:)`
178+
179+
Because existing `AsyncIteratorProtocol`-conforming types only implement `next()`, the standard library provides a default implementation of `next(isolation:)`:
180+
181+
```swift
182+
extension AsyncIteratorProtocol {
183+
/// Default implementation of `next(isolation:)` in terms of `next()`, which is
184+
/// required to maintain backward compatibility with existing async iterators.
185+
@available(SwiftStdlib 5.11, *)
186+
@available(*, deprecated, message: "Provide an implementation of 'next(isolation:)'")
187+
public mutating func next(isolation actor: isolated (any Actor)?) async throws(Failure) -> Element? {
188+
nonisolated(unsafe) var unsafeIterator = self
189+
do {
190+
let element = try await unsafeIterator.next()
191+
self = unsafeIterator
192+
return element
193+
} catch {
194+
throw error as! Failure
195+
}
196+
}
197+
}
198+
```
199+
200+
Note that the default implementation of `next(isolation:)` necessarily violates `Sendable` checking in order to pass `self` from a possibly-isolated context to a `nonisolated` one. Though this is generally unsafe, this is how calls to `next()` behave today, so existing conformances will maintain the behavior they already have. Implementing `next(isolation:)` directly will eliminate the unsafety.
201+
202+
To enable conformances of `AsyncIteratorProtocol` to only implement `next(isolation:)`, a default implementation is also provided for `next()`:
203+
204+
```swift
205+
extension AsyncIteratorProtocol {
206+
@available(SwiftStdlib 5.11, *)
207+
public mutating func next() async throws -> Element? {
208+
// Callers to `next()` will always run `next(isolation:)` on the generic executor.
209+
try await next(isolation: nil)
210+
}
211+
}
212+
```
213+
214+
Both function requirements of `AsyncIteratorProtocol` have default implementations that are written in terms of each other, meaning that it is a programmer error to implement neither of them. Types that are available prior to the Swift 5.11 standard library must provide an implementation of `next()`, because the default implementation is only available with the Swift 5.11 standard library.
215+
216+
To avoid silently allowing conformances that implement neither requirement, and to facilitate the transition of conformances from `next()` to `next(isolation:)`, we add a new availability rule where the witness checker diagnoses a protocol conformance that uses an deprecated, obsoleted, or unavailable default witness implementation. Deprecated implementations will produce a warning, while obsoleted and unavailable implementations will produce an error.
217+
218+
Because the default implementation of `next(isolation:)` is deprecated, conformances that do not provide a direct implementation will produce a warning. This is desirable because the default implementation of `next(isolation:)` violates `Sendable` checking, so while it's necessary for source compatibilty, it's important to aggressively suggest that conforming types implement the new method.
219+
220+
### Associated type inference for `AsyncIteratorProtocol` conformances
221+
222+
When an `AsyncIteratorProtocol`-conforming type provides a `next(isolation:)` function, the `Failure` type is inferred based on whether (and what) `next(isolation:)` throws using the rules described in [SE-0413](/proposals/0413-typed-throws.md).
223+
224+
If the `AsyncIteratorProtocol`-conforming type uses the default implementation of `next(isolation:)`, then the `Failure` associated type is inferred from the `next` function instead. Whatever type is thrown from the `next` function (including `Never` if it is non-throwing) is inferred as the `Failure` type.
225+
226+
## Source compatibility
227+
228+
The new requirements to `AsyncSequence` and `AsyncIteratorProtocol` are additive, with default implementations and `Failure` associated type inference heuristics that ensure that existing types that conform to these protocols will continue to work.
229+
230+
The experimental "rethrowing conformances" feature used by `AsyncSequence` and `AsyncIteratorProtocol` presents some challenges for source compatibility. Namely, one can declare a `rethrows` function that considers conformance to these rethrowing protocols as sources of errors for rethrowing. For example, the following `rethrows` function is currently valid:
231+
232+
```swift
233+
extension AsyncSequence {
234+
func contains(_ value: Element) rethrows -> Bool where Element: Hashable { ... }
235+
}
236+
```
237+
238+
With the removal of the experimental "rethrowing conformances" feature, this function becomes ill-formed because there is no closure argument that can throw. To preserve source compatibility for such functions, this proposal introduces a specific rule that allows requirements on `AsyncSequence` and `AsyncIteratorProtocol` to be involved in `rethrows` checking: a `rethrows` function is considered to be able to throw `T.Failure` for every `T: AsyncSequence` or `T: AsyncIteratorProtocol` conformance requirement. In the case of this `contains` operation, that means it can throw `Self.Failure`. The rule permitting the definition of these `rethrows` functions will only be permitted prior to Swift 6.
239+
240+
## ABI compatibility
241+
242+
This proposal is purely an extension of the ABI of the standard library and does not change any existing features. Note that the addition of a new `next(isolation:)` requirement, rather than modifying the existing `next()` requirement, is necessary to maintain ABI compatibility, because changing `next()` to abstract over actor isolation requires passing the actor as a parameter in order to hop back to that actor after any `async` calls in the implementation. The typed throws ABI is also different from the rethrows ABI, so the adoption of typed throws alone necessitates a new requirement.
243+
244+
## Implications on adoption
245+
246+
The associated `Failure` types of `AsyncSequence` and `AsyncIteratorProtocol` are only available at runtime with the Swift 5.11 standard library, because code that runs against prior standard library versions does not have a witness table entry for `Failure`. Code that needs to access the `Failure` type through the associated type, e.g. to dynamic cast to it or constrain it in a generic signature, must be availability constrained. For this reason, the default implementations of `next()` and `next(isolation:)` have the same availability as the Swift 5.11 standard library.
247+
248+
This means that concrete `AsyncIteratorProtocol` conformances cannot switch over to implementing `next(isolation:)` only (without providing an implementation of `next()`) if they are available earlier than the Swift 5.11 standard library.
249+
250+
Simiarly, primary associated types of `AsyncSequence` and `AsyncIteratorProtocol` must be gated behind Swift 5.11 availability.
251+
252+
Once the concrete `AsyncIteratorProtocol` types in the standard library, such as `Async{Throwing}Stream.Iterator`, implement `next(isolation:)` directly, code that iterates over those concrete `AsyncSequence` types in an actor-isolated context may exhibit fewer hops to the generic executor at runtime.
253+
254+
## Future directions
255+
256+
### Add a default argument to `next(isolation:)`
257+
258+
Most calls to `next(isolation:)` will pass the isolation of the enclosing context. We could consider lifting the restriction that protocol requirements cannot have default arguments, and adding a default argument value of `#isolated` as described in the [pitch for actor isolation inheritance](https://forums.swift.org/t/pitch-inheriting-the-callers-actor-isolation/68391).
259+
260+
## Alternatives considered
261+
262+
### Avoiding an existential parameter in `next(isolation:)`
263+
264+
The isolated parameter to `next(isolation:)` has existential type `(any Actor)?` because a `nil` value is used to represent `nonisolated`. There is no concrete `Actor` type that describes a `nonisolated` context, which necessitates using `(any Actor)?` instead of `some Actor` or `(some Actor)?`. Potential alternatives to this are:
265+
266+
1. Represent `nonisolated` with some other value than `nil`, or a specific declaration in the standard library that has a concrete optional actor type to enable `(some Actor)?`. Any solution in this category requires the compiler to have special knowledge of the value that represents `nonisolated` for actor isolation checking of the call.
267+
2. Introduce a separate entrypoint for `next(isolation:)` that is always `nonisolated`. This defeats the purpose of having a single implementation of `next(isolation:)` that abstracts over actor isolation.
268+
269+
Note that the use of an existential type `(any Actor)?` means that [embedded Swift](/visions/embedded-swift.md) would need to support class existentials in order to use `next(isolation:)`.
270+
271+
## Acknowledgments
272+
273+
Thank you to Franz Busch and Konrad Malawski for starting the discussions about typed throws and primary associated type adotpion for `AsyncSequence` and `AsyncIteratorProtocol` in the [Typed throws in the Concurrency module](https://forums.swift.org/t/pitch-typed-throws-in-the-concurrency-module/68210/1) pitch. Thank you to John McCall for specifying the rules for generalized isolated parameters in the [pitch for inheriting the caller's actor isolation](https://forums.swift.org/t/pitch-inheriting-the-callers-actor-isolation/68391).

0 commit comments

Comments
 (0)