- Proposal: SE-0421
- Authors: Doug Gregor, Holly Borla
- Review Manager: Freddy Kellison-Linn
- Status: Implemented (Swift 6.0)
- Review: (pitch)(review) (acceptance)
This proposal generalizes AsyncSequence
in two ways:
- Proper
throws
polymorphism is accomplished with adoption of typed throws. - A new overload of the
next
requirement onAsyncIteratorProtocol
includes an isolated parameter to abstract over actor isolation.
- Introduction
- Motivation
- Proposed solution
- Detailed design
- Source compatibility
- ABI compatibility
- Implications on adoption
- Future directions
- Alternatives considered
- Acknowledgments
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.
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. Primary associated types on AsyncSequence
would enable hiding concrete implementation details behind constrained opaque or existential types, such as in transformation APIs on AsyncSequence
:
extension AsyncSequence {
// 'AsyncThrowingMapSequence' is an implementation detail hidden from callers.
public func map<Transformed>(
_ transform: @Sendable @escaping (Element) async throws -> Transformed
) -> some AsyncSequence<Transformed, any Error> { ... }
}
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:
class NotSendable { ... }
@MainActor
func iterate(over stream: AsyncStream<NotSendable>) {
for await element in stream { // warning: non-sendable type 'NotSendable?' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary
}
}
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.
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.
Finally, next()
always running on the generic executor is the source of unnecessary hops between an actor and the generic executor.
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:
@available(SwiftStdlib 5.1, *)
protocol AsyncIteratorProtocol<Element, Failure> {
associatedtype Element
mutating func next() async throws -> Element?
@available(SwiftStdlib 6.0, *)
associatedtype Failure: Error = any Error
@available(SwiftStdlib 6.0, *)
mutating func next(isolation actor: isolated (any Actor)?) async throws(Failure) -> Element?
}
@available(SwiftStdlib 5.1, *)
public protocol AsyncSequence<Element, Failure> {
associatedtype AsyncIterator: AsyncIteratorProtocol
associatedtype Element where AsyncIterator.Element == Element
@available(SwiftStdlib 6.0, *)
associatedtype Failure = AsyncIterator.Failure where AsyncIterator.Failure == Failure
func makeAsyncIterator() -> AsyncIterator
}
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.
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 AsyncIteratorProtocol.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.
The Failure
associated type is only accessible at runtime in the Swift 6.0 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.
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:
struct MyAsyncIterator: AsyncIteratorProtocol {
typealias Failure = MyError
...
}
func iterate<S: AsyncSequence>(over s: S) where S.AsyncIterator == MyAsyncIterator {
let closure = {
for try await element in s {
print(element)
}
}
}
In the above code, the type of closure
is () async throws(MyError) -> Void
.
When the thrown error type of an AsyncIteratorProtocol
is not available, iteration over an async sequence throws any Error
:
@available(SwiftStdlib 5.1, *)
func iterate(over s: some AsyncSequence) {
let closure = {
for try await element in s {
print(element)
}
}
}
In the above code, the type of closure
is () async throws(any Error) -> Void
.
When the Failure
type of the given async sequence is constrained to Never
, try
is not required in the for-in
loop:
struct MyAsyncIterator: AsyncIteratorProtocol {
typealias Failure = Never
...
}
func iterate<S: AsyncSequence>(over s: S) where S.AsyncIterator == MyAsyncIterator {
let closure = {
for await element in s {
print(element)
}
}
}
In the above code, the type of closure
is () async -> Void
.
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>
.
The next(isolation:)
requirement abstracts over actor isolation using isolated parameters. For callers to next(isolation:)
that pass an iterator value that cannot be transferred across isolation boundaries under SE-0414: Region based isolation, 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.
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.
Because existing AsyncIteratorProtocol
-conforming types only implement next()
, the standard library provides a default implementation of next(isolation:)
:
extension AsyncIteratorProtocol {
/// Default implementation of `next(isolation:)` in terms of `next()`, which is
/// required to maintain backward compatibility with existing async iterators.
@available(SwiftStdlib 6.0, *)
@available(*, deprecated, message: "Provide an implementation of 'next(isolation:)'")
public mutating func next(isolation actor: isolated (any Actor)?) async throws(Failure) -> Element? {
nonisolated(unsafe) var unsafeIterator = self
do {
let element = try await unsafeIterator.next()
self = unsafeIterator
return element
} catch {
throw error as! Failure
}
}
}
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.
To enable conformances of AsyncIteratorProtocol
to only implement next(isolation:)
, a default implementation is also provided for next()
:
extension AsyncIteratorProtocol {
@available(SwiftStdlib 6.0, *)
public mutating func next() async throws -> Element? {
// Callers to `next()` will always run `next(isolation:)` on the generic executor.
try await next(isolation: nil)
}
}
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 6.0 standard library must provide an implementation of next()
, because the default implementation is only available with the Swift 6.0 standard library.
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.
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 compatibility, it's important to aggressively suggest that conforming types implement the new method.
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.
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.
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.
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:
extension AsyncSequence {
func contains(_ value: Element) rethrows -> Bool where Element: Hashable { ... }
}
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.
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.
The associated Failure
types of AsyncSequence
and AsyncIteratorProtocol
are only available at runtime with the Swift 6.0 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 6.0 standard library.
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 6.0 standard library.
Similarly, primary associated types of AsyncSequence
and AsyncIteratorProtocol
must be gated behind Swift 6.0 availability.
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.
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.
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:
- Represent
nonisolated
with some other value thannil
, 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 representsnonisolated
for actor isolation checking of the call. - Introduce a separate entrypoint for
next(isolation:)
that is alwaysnonisolated
. This defeats the purpose of having a single implementation ofnext(isolation:)
that abstracts over actor isolation.
Note that the use of an existential type (any Actor)?
means that embedded Swift would need to support class existentials in order to use next(isolation:)
.
Thank you to Franz Busch and Konrad Malawski for starting the discussions about typed throws and primary associated type adoption for AsyncSequence
and AsyncIteratorProtocol
in the Typed throws in the Concurrency module pitch. Thank you to John McCall for specifying the rules for generalized isolated parameters in the pitch for inheriting the caller's actor isolation.