Skip to content

Latest commit

 

History

History
271 lines (190 loc) · 18.8 KB

0421-generalize-async-sequence.md

File metadata and controls

271 lines (190 loc) · 18.8 KB

Generalize effect polymorphism for AsyncSequence and AsyncIteratorProtocol

Introduction

This proposal generalizes AsyncSequence in two ways:

  1. Proper throws polymorphism is accomplished with adoption of typed throws.
  2. A new overload of the next requirement on AsyncIteratorProtocol includes an isolated parameter to abstract over actor isolation.

Table of Contents

Motivation

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 AsyncSequences 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.

Proposed solution

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.

Detailed design

Adopting typed throws

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.

Error type inference from for try await loops

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.

Adopting primary associated types

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>.

Adopting isolated parameters

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.

Default implementations of next() and next(isolation:)

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.

Associated type inference for AsyncIteratorProtocol conformances

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.

Source compatibility

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.

ABI compatibility

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.

Implications on adoption

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.

Future directions

Add a default argument to next(isolation:)

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.

Alternatives considered

Avoiding an existential parameter in next(isolation:)

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:

  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.
  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.

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:).

Acknowledgments

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.