From c036b802ebc876429b179667be95d28a4ab47b23 Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Thu, 3 Nov 2022 11:07:40 +0000 Subject: [PATCH 01/18] Add Shared algorithm --- Evolution/NNNN-share.md | 127 ++++ .../AsyncAlgorithms/AsyncSharedSequence.swift | 578 ++++++++++++++++++ .../Support/StartableSequence.swift | 126 ++++ Tests/AsyncAlgorithmsTests/TestShare.swift | 482 +++++++++++++++ 4 files changed, 1313 insertions(+) create mode 100644 Evolution/NNNN-share.md create mode 100644 Sources/AsyncAlgorithms/AsyncSharedSequence.swift create mode 100644 Tests/AsyncAlgorithmsTests/Support/StartableSequence.swift create mode 100644 Tests/AsyncAlgorithmsTests/TestShare.swift diff --git a/Evolution/NNNN-share.md b/Evolution/NNNN-share.md new file mode 100644 index 00000000..51181225 --- /dev/null +++ b/Evolution/NNNN-share.md @@ -0,0 +1,127 @@ +# Share + +* Proposal: [NNNN](NNNN-deferred.md) +* Authors: [Tristan Celder](https://github.com/tcldr) +* Review Manager: TBD +* Status: **Awaiting implementation** + + + * Implementation: [[Source](https://github.com/tcldr/swift-async-algorithms/blob/pr/share/Sources/AsyncAlgorithms/AsyncSharedSequence.swift) | + [Tests](https://github.com/tcldr/swift-async-algorithms/blob/pr/share/Tests/AsyncAlgorithmsTests/TestShare.swift)] + +## Introduction + +`AsyncSharedSequence` unlocks additional use cases for structured concurrency and asynchronous sequences by allowing almost any asynchronous sequence to be adapted for consumption by multiple concurrent consumers. + +## Motivation + +The need often arises to distribute the values of an asynchronous sequence to multiple consumers. Intuitively, it seems that a sequence _should_ be iterable by more than a single consumer, but many types of asynchronous sequence are restricted to supporting only one consumer at a time. + +## Proposed solution + +`AsyncSharedSequence` lifts this restriction, providing a way to multicast a single upstream asynchronous sequence to any number of consumers. + +It also provides two conveniences to adapt the sequence for the most common multicast use-cases: + 1. A history feature that allows late-coming consumers to receive the most recently emitted elements prior to their arrival. + 2. A configurable iterator disposal policy that determines whether the shared upstream iterator is disposed of when the consumer count count falls to zero. + +## Detailed design + +### AsyncSharedSequence + +#### Declaration + +```swift +public struct AsyncSharedSequence where Base: Sendable, Base.Element: Sendable, Base.AsyncIterator: Sendable +``` + +#### Overview + +An asynchronous sequence that can be iterated by multiple concurrent consumers. + +Use a shared asynchronous sequence when you have multiple downstream asynchronous sequences with which you wish to share the output of a single asynchronous sequence. This can be useful if you have expensive upstream operations, or if your asynchronous sequence represents the output of a physical device. + +Elements are emitted from a multicast asynchronous sequence at a rate that does not exceed the consumption of its slowest consumer. If this kind of back-pressure isn't desirable for your use-case, `AsyncSharedSequence` can be composed with buffers – either upstream, downstream, or both – to acheive the desired behavior. + +If you have an asynchronous sequence that consumes expensive system resources, it is possible to configure `AsyncSharedSequence` to discard its upstream iterator when the connected downstream consumer count falls to zero. This allows any cancellation tasks configured on the upstream asynchronous sequence to be initiated and for expensive resources to be terminated. `AsyncSharedSequence` will re-create a fresh iterator if there is further demand. + +For use-cases where it is important for consumers to have a record of elements emitted prior to their connection, a `AsyncSharedSequence` can also be configured to prefix its output with the most recently emitted elements. If `AsyncSharedSequence` is configured to drop its iterator when the connected consumer count falls to zero, its history will be discarded at the same time. + +#### Creating a sequence + +``` +init( + _ base: Base, + history historyCount: Int = 0, + disposingBaseIterator iteratorDisposalPolicy: IteratorDisposalPolicy = .whenTerminatedOrVacant +) +``` + +Contructs a shared asynchronous sequence. + + - `history`: the number of elements previously emitted by the sequence to prefix to the iterator of a new consumer + - `iteratorDisposalPolicy`: the iterator disposal policy applied to the upstream iterator + +### AsyncSharedSequence.IteratorDisposalPolicy + +#### Declaration + +```swift +public enum IteratorDisposalPolicy: Sendable { + case whenTerminated + case whenTerminatedOrVacant +} +``` + +#### Overview +The iterator disposal policy applied by a shared asynchronous sequence to its upstream iterator + + - `whenTerminated`: retains the upstream iterator for use by future consumers until the base asynchronous sequence is terminated + - `whenTerminatedOrVacant`: discards the upstream iterator when the number of consumers falls to zero or the base asynchronous sequence is terminated + +### share(history:disposingBaseIterator) + +#### Declaration + +```swift +extension AsyncSequence { + + public func share( + history historyCount: Int = 0, + disposingBaseIterator iteratorDisposalPolicy: AsyncSharedSequence.IteratorDisposalPolicy = .whenTerminatedOrVacant + ) -> AsyncSharedSequence +} +``` + +#### Overview + +Creates an asynchronous sequence that can be shared by multiple consumers. + + - `history`: the number of elements previously emitted by the sequence to prefix to the iterator of a new consumer + - `iteratorDisposalPolicy`: the iterator disposal policy applied by a shared asynchronous sequence to its upstream iterator + +## Naming + + The `share(history:disposingBaseIterator)` function takes its inspiration from the [`share()`](https://developer.apple.com/documentation/combine/fail/share()) Combine publisher, and the RxSwift [`share(replay:)`](https://github.com/ReactiveX/RxSwift/blob/3d3ed05bed71f19999db2207c714dab0028d37be/Documentation/GettingStarted.md#sharing-subscription-and-share-operator) operator, both of which fall under the multicasting family of operators in their respective libraries. + + ## Comparison with other libraries + + - **ReactiveX** ReactiveX has the [Publish](https://reactivex.io/documentation/operators/publish.html) observable which when can be composed with the [Connect](https://reactivex.io/documentation/operators/connect.html), [RefCount](https://reactivex.io/documentation/operators/refcount.html) and [Replay](https://reactivex.io/documentation/operators/replay.html) operators to support various multi-casting use-cases. The `discardsBaseIterator` behavior is applied via `RefCount` (or the .`share().refCount()` chain of operators in RxSwift), while the history behavior is achieved through `Replay` (or the .`share(replay:)` convenience in RxSwift) + + - **Combine** Combine has the [ multicast(_:)](https://developer.apple.com/documentation/combine/publishers/multicast) operator, which along with the functionality of [ConnectablePublisher](https://developer.apple.com/documentation/combine/connectablepublisher) and associated conveniences supports many of the same use cases as the ReactiveX equivalent, but in some instances requires third-party ooperators to achieve the same level of functionality. + +Due to the way a Swift `AsyncSequence`, and therefore `AsyncSharedSequence`, naturally applies back-pressure, the characteristics of an `AsyncSharedSequence` are different enough that a one-to-one API mapping of other reactive programmming libraries isn't applicable. + +However, with the available configuration options – and through composition with other asynchronous sequences – `AsyncSharedSequence` can trivially be adapted to support many of the same use-cases, including that of [Connect](https://reactivex.io/documentation/operators/connect.html), [RefCount](https://reactivex.io/documentation/operators/refcount.html), and [Replay](https://reactivex.io/documentation/operators/replay.html). + + ## Effect on API resilience + +TBD + +## Alternatives considered + +Creating a one-to-one multicast analog that matches that of existing reactive programming libraries. However, it would mean fighting the back-pressure characteristics of `AsyncSequence`. Instead, this implementation embraces back-pressure to yield a more flexible result. + +## Acknowledgments + +Thanks to [Philippe Hausler](https://github.com/phausler) and [Franz Busch](https://github.com/FranzBusch), as well as all other contributors on the Swift forums, for their thoughts and feedback. diff --git a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift new file mode 100644 index 00000000..d45b0ad9 --- /dev/null +++ b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift @@ -0,0 +1,578 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Async Algorithms open source project +// +// Copyright (c) 2022 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import DequeModule + +extension AsyncSequence { + + /// Creates an asynchronous sequence that can be shared by multiple consumers. + /// + /// - parameter history: the number of previously emitted elements to prefix to the iterator of a new + /// consumer + /// - parameter iteratorDisposalPolicy:the iterator disposal policy applied by a shared + /// asynchronous sequence to its upstream iterator + public func share( + history historyCount: Int = 0, + disposingBaseIterator iteratorDisposalPolicy: AsyncSharedSequence.IteratorDisposalPolicy = .whenTerminatedOrVacant + ) -> AsyncSharedSequence { + AsyncSharedSequence( + self, history: historyCount, disposingBaseIterator: iteratorDisposalPolicy) + } +} + +// MARK: - Sequence + +/// An asynchronous sequence that can be iterated by multiple concurrent consumers. +/// +/// Use a shared asynchronous sequence when you have multiple downstream asynchronous sequences +/// with which you wish to share the output of a single asynchronous sequence. This can be useful if +/// you have expensive upstream operations, or if your asynchronous sequence represents the output +/// of a physical device. +/// +/// Elements are emitted from a shared asynchronous sequence at a rate that does not exceed the +/// consumption of its slowest consumer. If this kind of back-pressure isn't desirable for your +/// use-case, ``AsyncSharedSequence`` can be composed with buffers – either upstream, downstream, +/// or both – to acheive the desired behavior. +/// +/// If you have an asynchronous sequence that consumes expensive system resources, it is possible to +/// configure ``AsyncSharedSequence`` to discard its upstream iterator when the connected +/// downstream consumer count falls to zero. This allows any cancellation tasks configured on the +/// upstream asynchronous sequence to be initiated and for expensive resources to be terminated. +/// ``AsyncSharedSequence`` will re-create a fresh iterator if there is further demand. +/// +/// For use-cases where it is important for consumers to have a record of elements emitted prior to +/// their connection, a ``AsyncSharedSequence`` can also be configured to prefix its output with +/// the most recently emitted elements. If ``AsyncSharedSequence`` is configured to drop its +/// iterator when the connected consumer count falls to zero, its history will be discarded at the +/// same time. +public struct AsyncSharedSequence +where Base: Sendable, Base.Element: Sendable, Base.AsyncIterator: Sendable { + + /// The iterator disposal policy applied by a shared asynchronous sequence to its upstream iterator + /// + /// - note: the iterator is always disposed when the base asynchronous sequence terminates + public enum IteratorDisposalPolicy: Sendable { + /// retains the upstream iterator for use by future consumers until the base asynchronous + /// sequence is terminated + case whenTerminated + /// discards the upstream iterator when the number of consumers falls to zero or the base + /// asynchronous sequence is terminated + case whenTerminatedOrVacant + } + + private let base: Base + private let state: State + private let deallocToken: DeallocToken + + /// Contructs a shared asynchronous sequence + /// + /// - parameter base: the asynchronous sequence to be shared + /// - parameter history: the number of previously emitted elements to prefix to the iterator of a + /// new consumer + /// - parameter iteratorDisposalPolicy: the iterator disposal policy applied to the upstream + /// iterator + public init( + _ base: Base, + history historyCount: Int = 0, + disposingBaseIterator iteratorDisposalPolicy: IteratorDisposalPolicy = .whenTerminatedOrVacant + ) { + let state = State(base, replayCount: historyCount, discardsIterator: iteratorDisposalPolicy) + self.base = base + self.state = state + self.deallocToken = .init { state.abort() } + } +} + +// MARK: - Iterator + +extension AsyncSharedSequence: AsyncSequence, Sendable { + + public typealias Element = Base.Element + + public struct Iterator: AsyncIteratorProtocol, Sendable { + + private let id: UInt + private let deallocToken: DeallocToken? + private var prefix: Deque + private var state: State? + + fileprivate init(_ state: State) { + switch state.establish() { + case .active(let id, let prefix, let continuation): + self.id = id + self.prefix = prefix + self.deallocToken = .init { state.cancel(id) } + self.state = state + continuation?.resume() + case .terminal(let id, let prefix): + self.id = id + self.prefix = prefix + self.deallocToken = nil + self.state = nil + } + } + + public mutating func next() async rethrows -> Element? { + do { + return try await withTaskCancellationHandler { + if prefix.isEmpty == false, let element = prefix.popFirst() { + return element + } + guard let state else { return nil } + let command = state.run(id) + switch command { + case .fetch(var iterator): + let upstreamResult = await Result.async { try await iterator.next() } + let output = state.fetch(id, resumedWithResult: upstreamResult, iterator: iterator) + return try processOutput(output) + case .wait: + let output = await withUnsafeContinuation { continuation in + let immediateOutput = state.wait(id, suspendedWithContinuation: continuation) + if let immediateOutput { continuation.resume(returning: immediateOutput) } + } + return try processOutput(output) + case .yield(let output): + return try processOutput(output) + case .hold: + await withUnsafeContinuation { continuation in + let shouldImmediatelyResume = state.hold(id, suspendedWithContinuation: continuation) + if shouldImmediatelyResume { continuation.resume() } + } + return try await next() + } + } onCancel: { [state, id] in + state?.cancel(id) + } + } + catch { + self.state = nil + throw error + } + } + + private mutating func processOutput(_ output: RunOutput) rethrows -> Element? { + output.continuation?.resume() + if output.shouldCancel { + self.state = nil + } + do { + guard let element = try output.value._rethrowGet() else { + self.state = nil + return nil + } + return element + } + catch { + self.state = nil + throw error + } + } + } + + public func makeAsyncIterator() -> Iterator { + Iterator(state) + } +} + +// MARK: - State + +fileprivate extension AsyncSharedSequence { + + final class DeallocToken: Sendable { + let action: @Sendable () -> Void + init(_ dealloc: @escaping @Sendable () -> Void) { + self.action = dealloc + } + deinit { action() } + } + + struct SharedUpstreamIterator { + + private enum State { + case pending + case active(Base.AsyncIterator) + case terminal + } + + var isTerminal: Bool { + guard case .terminal = state else { return false } + return true + } + + private let createIterator: () -> Base.AsyncIterator + private var state = State.pending + + init(_ createIterator: @escaping @Sendable () -> Base.AsyncIterator) { + self.createIterator = createIterator + } + + mutating func next() async rethrows -> Element? { + switch state { + case .pending: + self.state = .active(createIterator()) + return try await next() + case .active(var iterator): + let result = await Result.async { try await iterator.next() } + switch result { + case .success(_?): + self.state = .active(iterator) + default: + self.state = .terminal + } + return try result._rethrowGet() + case .terminal: + return nil + } + } + + mutating func reset() { + guard case .active(_) = state else { return } + self.state = .pending + } + } + + struct Runner { + let id: UInt + var group: RunGroup + var isCancelled = false + var isRunning = false + } + + enum Connection { + case active(id: UInt, prefix: Deque, continuation: RunContinuation?) + case terminal(id: UInt, prefix: Deque) + } + + enum Command { + case fetch(SharedUpstreamIterator) + case wait + case yield(RunOutput) + case hold + } + + enum RunGroup { + case a + case b + var nextGroup: RunGroup { self == .a ? .b : .a } + mutating func flip() { self = nextGroup } + } + + struct RunOutput { + let value: Result + var shouldCancel = false + var continuation: RunContinuation? + } + + struct RunContinuation { + var held: [UnsafeContinuation]? + var waiting: [(UnsafeContinuation, RunOutput)]? + func resume() { + if let held { + for continuation in held { continuation.resume() } + } + if let waiting { + for (continuation, output) in waiting { continuation.resume(returning: output) } + } + } + } + + enum Phase { + case pending + case fetching + case done(Result) + } + + struct State: Sendable { + + private struct Storage: Sendable { + + let replayCount: Int + let iteratorDiscardPolicy: IteratorDisposalPolicy + var iterator: SharedUpstreamIterator? + var nextRunnerID = UInt.min + var runners = [UInt: Runner]() + var phase = Phase.pending + var currentGroup = RunGroup.a + var history = Deque() + var heldRunnerContinuations = [UnsafeContinuation]() + var waitingRunnerContinuations = [UInt: UnsafeContinuation]() + var terminal = false + var hasActiveOrPendingRunnersInCurrentGroup: Bool { + runners.values.contains { $0.group == currentGroup } + } + + init(_ base: Base, replayCount: Int, discardsIterator: IteratorDisposalPolicy) { + precondition(replayCount >= 0, "history must be greater than or equal to zero") + self.replayCount = replayCount + self.iteratorDiscardPolicy = discardsIterator + self.iterator = .init { base.makeAsyncIterator() } + } + + mutating func establish() -> Connection { + defer { nextRunnerID += 1} + if terminal { + return .terminal(id: nextRunnerID, prefix: history) + } + else { + let group: RunGroup + if case .done(_) = phase { + group = currentGroup.nextGroup + } else { + group = currentGroup + } + let runner = Runner(id: nextRunnerID, group: group) + runners[nextRunnerID] = runner + let continuation = RunContinuation(held: finalizeRunGroupIfNeeded()) + return .active(id: nextRunnerID, prefix: history, continuation: continuation) + } + } + + mutating func run(_ runnerID: UInt) -> Command { + guard terminal == false, var runner = runners[runnerID] else { + if case .done(let result) = phase { + return .yield(RunOutput(value: result, shouldCancel: true)) + } + return .yield(RunOutput(value: .success(nil), shouldCancel: true)) + } + runner.isRunning = true + runners.updateValue(runner, forKey: runnerID) + guard runner.group == currentGroup else { return .hold } + switch phase { + case .pending: + phase = .fetching + guard let iterator = iterator else { + preconditionFailure("iterator must not be over-borrowed") + } + self.iterator = nil + return .fetch(iterator) + case .fetching: + return .wait + case .done(let result): + finish(runnerID) + return .yield( + RunOutput( + value: result, + shouldCancel: runner.isCancelled, + continuation: .init(held: finalizeRunGroupIfNeeded()) + ) + ) + } + } + + mutating func fetch( + _ runnerID: UInt, + resumedWithResult result: Result, + iterator: SharedUpstreamIterator + ) -> RunOutput { + guard let runner = runners[runnerID] else { + preconditionFailure("fetching runner resumed out of band") + } + guard case .fetching = phase, case currentGroup = runner.group else { + preconditionFailure("fetching runner resumed out of band") + } + guard self.iterator == nil else { + preconditionFailure("iterator is already in place") + } + self.iterator = iterator + phase = .done(result) + terminal = (try? result.get()) == nil + finish(runnerID) + updateHistory(withResult: result) + let continuationPairs = waitingRunnerContinuations.map { waitingRunnerID, continuation in + guard let waitingRunner = runners[waitingRunnerID] else { + preconditionFailure("fetching runner resumed out of band") + } + finish(waitingRunnerID) + return (continuation, RunOutput(value: result, shouldCancel: waitingRunner.isCancelled)) + } + waitingRunnerContinuations.removeAll() + return RunOutput( + value: result, + shouldCancel: runner.isCancelled, + continuation: .init(held: finalizeRunGroupIfNeeded(), waiting: continuationPairs) + ) + } + + mutating func wait( + _ runnerID: UInt, + suspendedWithContinuation continuation: UnsafeContinuation + ) -> RunOutput? { + guard let runner = runners[runnerID] else { + preconditionFailure("waiting runner resumed out of band") + } + switch phase { + case .fetching: + waitingRunnerContinuations[runnerID] = continuation + return nil + case .done(let result): + finish(runnerID) + return RunOutput( + value: result, + shouldCancel: runner.isCancelled, + continuation: .init(held: finalizeRunGroupIfNeeded()) + ) + default: + preconditionFailure("waiting runner resumed out of band") + } + } + + mutating func hold( + _ runnerID: UInt, + suspendedWithContinuation continuation: UnsafeContinuation + ) -> Bool { + guard let runner = runners[runnerID] else { + preconditionFailure("held runner resumed out of band") + } + if currentGroup == runner.group { + return true + } + else { + heldRunnerContinuations.append(continuation) + return false + } + } + + private mutating func finish(_ runnerID: UInt) { + guard var runner = runners[runnerID] else { return } + if runner.isCancelled { + runners.removeValue(forKey: runnerID) + } + else { + runner.isRunning = false + runner.group.flip() + runners[runnerID] = runner + } + } + + mutating func cancel(_ runnerID: UInt) -> [UnsafeContinuation]? { + if var runner = runners[runnerID] { + if runner.isRunning { + runner.isCancelled = true + runners[runnerID] = runner + } + else { + runners.removeValue(forKey: runnerID) + return finalizeRunGroupIfNeeded() + } + } + return nil + } + + mutating func abort() -> [UnsafeContinuation]? { + terminal = true + runners = runners.compactMapValues { $0.isRunning ? $0 : nil } + return finalizeRunGroupIfNeeded() + } + + private mutating func finalizeRunGroupIfNeeded() -> [UnsafeContinuation]? { + if hasActiveOrPendingRunnersInCurrentGroup { return nil } + if terminal { + self.iterator = nil + self.phase = .done(.success(nil)) + self.history.removeAll() + } + else { + self.currentGroup.flip() + self.phase = .pending + if runners.isEmpty && iteratorDiscardPolicy == .whenTerminatedOrVacant { + self.iterator?.reset() + self.history.removeAll() + } + } + let continuations = self.heldRunnerContinuations + self.heldRunnerContinuations.removeAll() + return continuations + } + + private mutating func updateHistory(withResult result: Result) { + guard replayCount > 0, case .success(let element?) = result else { return } + if history.count >= replayCount { + history.removeFirst() + } + history.append(element) + } + } + + private let storage: ManagedCriticalState + + init(_ base: Base, replayCount: Int, discardsIterator: IteratorDisposalPolicy) { + self.storage = .init( + Storage(base, replayCount: replayCount, discardsIterator: discardsIterator) + ) + } + + func establish() -> Connection { + storage.withCriticalRegion { $0.establish() } + } + + func run(_ runnerID: UInt) -> Command { + storage.withCriticalRegion { $0.run(runnerID) } + } + + func fetch( + _ runnerID: UInt, + resumedWithResult result: Result, + iterator: SharedUpstreamIterator + ) -> RunOutput { + storage.withCriticalRegion { state in + state.fetch(runnerID, resumedWithResult: result, iterator: iterator) + } + } + + func wait( + _ runnerID: UInt, + suspendedWithContinuation continuation: UnsafeContinuation + ) -> RunOutput? { + storage.withCriticalRegion { state in + state.wait(runnerID, suspendedWithContinuation: continuation) + } + } + + func hold( + _ runnerID: UInt, + suspendedWithContinuation continuation: UnsafeContinuation + ) -> Bool { + storage.withCriticalRegion { state in + state.hold(runnerID, suspendedWithContinuation: continuation) + } + } + + func cancel(_ runnerID: UInt) { + let continuations = storage.withCriticalRegion { $0.cancel(runnerID) } + if let continuations { + for continuation in continuations { + continuation.resume() + } + } + } + + func abort() { + let continuations = storage.withCriticalRegion { state in state.abort() } + if let continuations { + for continuation in continuations { + continuation.resume() + } + } + } + } +} + +fileprivate extension Result where Failure == Error { + + static func async(_ operation: @escaping () async throws -> Success) async -> Self { + do { + return .success(try await operation()) + } + catch let error { + return .failure(error) + } + } +} diff --git a/Tests/AsyncAlgorithmsTests/Support/StartableSequence.swift b/Tests/AsyncAlgorithmsTests/Support/StartableSequence.swift new file mode 100644 index 00000000..7413d7a0 --- /dev/null +++ b/Tests/AsyncAlgorithmsTests/Support/StartableSequence.swift @@ -0,0 +1,126 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Async Algorithms open source project +// +// Copyright (c) 2022 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +extension AsyncSequence { + + /// Creates a ``StartableSequence`` that suspends the output of its Iterator until `enter()` is called `count` times. + public func delayed(_ count: Int) -> StartableSequence { + StartableSequence(self, count: count) + } +} + +/// An `AsyncSequence` that delays publishing elements until an entry threshold has been reached. +/// Once the entry threshold has been met the sequence proceeds as normal. +public struct StartableSequence { + + private let base: Base + private let semaphore: BasicSemaphore + + /// Decrements the entry counter and, upon reaching zero, resumes the iterator + public func enter() { + semaphore.signal() + } + + /// Creates new ``StartableSequence`` with an initial entry count + public init(_ base: Base, count: Int) { + self.base = base + self.semaphore = .init(count: 1 - count) + } +} + +extension StartableSequence: AsyncSequence { + + public typealias Element = Base.Element + + public struct Iterator: AsyncIteratorProtocol { + + private var iterator: Base.AsyncIterator + private var terminal = false + private let semaphore: BasicSemaphore + private let id = Int.random(in: 0...100_000) + + init(iterator: Base.AsyncIterator, semaphore: BasicSemaphore) { + self.iterator = iterator + self.semaphore = semaphore + } + + public mutating func next() async rethrows -> Element? { + await semaphore.wait() + semaphore.signal() + if terminal { return nil } + do { + guard let value = try await iterator.next() else { + self.terminal = true + return nil + } + return value + } + catch { + self.terminal = true + throw error + } + } + } + + public func makeAsyncIterator() -> Iterator { + Iterator(iterator: base.makeAsyncIterator(), semaphore: semaphore) + } +} + +extension StartableSequence: Sendable where Base: Sendable { } +extension StartableSequence.Iterator: Sendable where Base.AsyncIterator: Sendable { } + +struct BasicSemaphore { + + struct State { + var count: Int + var continuations: [UnsafeContinuation] + } + + private let state: ManagedCriticalState + + /// Creates new counting semaphore with an initial value. + init(count: Int) { + self.state = ManagedCriticalState(.init(count: count, continuations: [])) + } + + /// Waits for, or decrements, a semaphore. + func wait() async { + await withUnsafeContinuation { continuation in + let shouldImmediatelyResume = state.withCriticalRegion { state in + state.count -= 1 + if state.count < 0 { + state.continuations.append(continuation) + return false + } + else { + return true + } + } + if shouldImmediatelyResume { continuation.resume() } + } + } + + /// Signals (increments) a semaphore. + func signal() { + let continuations = state.withCriticalRegion { state -> [UnsafeContinuation] in + state.count += 1 + if state.count >= 0 { + defer { state.continuations = [] } + return state.continuations + } + else { + return [] + } + } + for continuation in continuations { continuation.resume() } + } +} diff --git a/Tests/AsyncAlgorithmsTests/TestShare.swift b/Tests/AsyncAlgorithmsTests/TestShare.swift new file mode 100644 index 00000000..230eeb96 --- /dev/null +++ b/Tests/AsyncAlgorithmsTests/TestShare.swift @@ -0,0 +1,482 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Async Algorithms open source project +// +// Copyright (c) 2022 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +@preconcurrency import XCTest +import AsyncAlgorithms + +final class TestShare: XCTestCase { + + func test_share_basic() async { + let expected = [1, 2, 3, 4] + let base = expected.async.delayed(2) + let sequence = base.share() + let results = await withTaskGroup(of: [Int].self) { group in + group.addTask { + var iterator = sequence.makeAsyncIterator() + base.enter() + return await iterator.collect() + } + group.addTask { + var iterator = sequence.makeAsyncIterator() + base.enter() + return await iterator.collect() + } + return await Array(group) + } + XCTAssertEqual(expected, results[0]) + XCTAssertEqual(expected, results[1]) + } + + func test_share_iterator_iterates_past_end() async { + let base = [1, 2, 3, 4].async.delayed(2) + let sequence = base.share() + let results = await withTaskGroup(of: Int?.self) { group in + group.addTask { + var iterator = sequence.makeAsyncIterator() + base.enter() + let _ = await iterator.collect() + return await iterator.next() + } + group.addTask { + var iterator = sequence.makeAsyncIterator() + base.enter() + let _ = await iterator.collect() + return await iterator.next() + } + return await Array(group) + } + XCTAssertNil(results[0]) + XCTAssertNil(results[1]) + } + + func test_share_throws() async { + let base = [1, 2, 3, 4].async.map { try throwOn(3, $0) }.delayed(2) + let expected = [1, 2] + let sequence = base.share() + let results = await withTaskGroup(of: (elements: [Int], error: Error?).self) { group in + group.addTask { + var iterator = sequence.makeAsyncIterator() + base.enter() + return await iterator.collectWithError() + } + group.addTask { + var iterator = sequence.makeAsyncIterator() + base.enter() + return await iterator.collectWithError() + } + return await Array(group) + } + XCTAssertEqual(expected, results[0].elements) + XCTAssertEqual(expected, results[1].elements) + XCTAssertNotNil(results[0].error as? Failure) + XCTAssertNotNil(results[1].error as? Failure) + } + + func test_share_from_channel() async { + let expected = [0,1,2,3,4,5,6,7,8,9] + let base = AsyncChannel() + let delayedSequence = base.delayed(2) + let sequence = delayedSequence.share() + let results = await withTaskGroup(of: [Int].self) { group in + group.addTask { + var sent = [Int]() + for i in expected { + sent.append(i) + await base.send(i) + } + base.finish() + return sent + } + group.addTask { + var iterator = sequence.makeAsyncIterator() + delayedSequence.enter() + return await iterator.collect() + } + group.addTask { + var iterator = sequence.makeAsyncIterator() + delayedSequence.enter() + return await iterator.collect() + } + return await Array(group) + } + XCTAssertEqual(expected, results[0]) + XCTAssertEqual(expected, results[1]) + XCTAssertEqual(expected, results[2]) + } + + func test_share_concurrent_consumer_wide() async throws { + let noOfConsumers = 100 + let noOfEmissions = 100 + let expected = (0.. 0) + } + + func test_share_multiple_consumer_cancellation() async { + let base = Indefinite(value: 1).async + let sequence = base.share() + let gate = Gate() + let task = Task { + var elements = [Int]() + for await element in sequence { + elements.append(element) + gate.open() + } + return elements + } + Task { for await _ in sequence { } } + Task { for await _ in sequence { } } + Task { for await _ in sequence { } } + await gate.enter() + task.cancel() + let result = await task.value + XCTAssert(result.count > 0) + } + + func test_share_iterator_retained_when_vacant_if_policy() async { + let base = [0,1,2,3].async + let sequence = base.share(disposingBaseIterator: .whenTerminated) + let expected0 = [0] + let expected1 = [1] + let expected2 = [2] + let result0 = await sequence.prefix(1).reduce(into:[Int]()) { $0.append($1) } + let result1 = await sequence.prefix(1).reduce(into:[Int]()) { $0.append($1) } + let result2 = await sequence.prefix(1).reduce(into:[Int]()) { $0.append($1) } + XCTAssertEqual(expected0, result0) + XCTAssertEqual(expected1, result1) + XCTAssertEqual(expected2, result2) + } + + func test_share_iterator_discarded_when_vacant_if_policy() async { + let base = [0,1,2,3].async + let sequence = base.share(disposingBaseIterator: .whenTerminatedOrVacant) + let expected0 = [0] + let expected1 = [0] + let expected2 = [0] + let result0 = await sequence.prefix(1).reduce(into:[Int]()) { $0.append($1) } + let result1 = await sequence.prefix(1).reduce(into:[Int]()) { $0.append($1) } + let result2 = await sequence.prefix(1).reduce(into:[Int]()) { $0.append($1) } + XCTAssertEqual(expected0, result0) + XCTAssertEqual(expected1, result1) + XCTAssertEqual(expected2, result2) + } + + func test_share_iterator_discarded_when_terminal_regardless_of_policy() async { + typealias Event = ReportingAsyncSequence.Event + let base = [0,1,2,3].async + let sequence = base.share(disposingBaseIterator: .whenTerminated) + let expected0 = [0,1,2,3] + let expected1 = [Int]() + let expected2 = [Int]() + let result0 = await sequence.reduce(into:[Int]()) { $0.append($1) } + let result1 = await sequence.reduce(into:[Int]()) { $0.append($1) } + let result2 = await sequence.reduce(into:[Int]()) { $0.append($1) } + XCTAssertEqual(expected0, result0) + XCTAssertEqual(expected1, result1) + XCTAssertEqual(expected2, result2) + } + + func test_share_iterator_discarded_when_throws_regardless_of_policy() async { + let base = [0,1,2,3].async.map { try throwOn(1, $0) } + let sequence = base.share(disposingBaseIterator: .whenTerminatedOrVacant) + let expected0 = [0] + let expected1 = [Int]() + let expected2 = [Int]() + var iterator0 = sequence.makeAsyncIterator() + let result0 = await iterator0.collectWithError(count: 2) + var iterator1 = sequence.makeAsyncIterator() + let result1 = await iterator1.collectWithError(count: 2) + var iterator2 = sequence.makeAsyncIterator() + let result2 = await iterator2.collectWithError(count: 2) + XCTAssertEqual(expected0, result0.elements) + XCTAssertEqual(expected1, result1.elements) + XCTAssertEqual(expected2, result2.elements) + XCTAssertNotNil(result0.error as? Failure) + XCTAssertNil(result1.error) + XCTAssertNil(result2.error) + } + + func test_share_history_count_0() async throws { + let a0 = Array(["a","b","c","d"]).async + let a1 = Array(["e","f","g","h"]).async.delayed(1) + let a2 = Array(["i","j","k","l"]).async.delayed(1) + let a3 = Array(["m","n","o","p"]).async.delayed(1) + let base = merge(a0, a1, merge(a2, a3)) + let sequence = base.share(history: 0) + let expected = [["e", "f"], ["i", "j"], ["m", "n"]] + let gate = Gate() + Task { + for await el in sequence { + switch el { + case "d": gate.open() + case "h": gate.open() + case "l": gate.open() + case "p": gate.open() + default: break + } + } + return [] + } + await gate.enter() + let results0 = await Task { + var iterator = sequence.makeAsyncIterator() + a1.enter() + let results = await iterator.collect(count: 2) // e, f + return results + }.value + await gate.enter() + let results1 = await Task { + var iterator = sequence.makeAsyncIterator() + a2.enter() + let results = await iterator.collect(count: 2) // i, j + return results + }.value + await gate.enter() + let results2 = await Task { + var iterator = sequence.makeAsyncIterator() + a3.enter() + let results = await iterator.collect(count: 2) // m, n + return results + }.value + XCTAssertEqual(expected[0], results0) + XCTAssertEqual(expected[1], results1) + XCTAssertEqual(expected[2], results2) + } + + func test_share_history_count_1() async throws { + let a0 = Array(["a","b","c","d"]).async + let a1 = Array(["e","f","g","h"]).async.delayed(1) + let a2 = Array(["i","j","k","l"]).async.delayed(1) + let a3 = Array(["m","n","o","p"]).async.delayed(1) + let base = merge(a0, a1, merge(a2, a3)) + let sequence = base.share(history: 1) + let expected = [["d", "e"], ["h", "i"], ["l", "m"]] + let gate = Gate() + Task { + for await el in sequence { + switch el { + case "d": gate.open() + case "h": gate.open() + case "l": gate.open() + case "p": gate.open() + default: break + } + } + return [] + } + await gate.enter() + let results0 = await Task { + var iterator = sequence.makeAsyncIterator() + a1.enter() + let results = await iterator.collect(count: 2) // e, f + return results + }.value + await gate.enter() + let results1 = await Task { + var iterator = sequence.makeAsyncIterator() + a2.enter() + let results = await iterator.collect(count: 2) // i, j + return results + }.value + await gate.enter() + let results2 = await Task { + var iterator = sequence.makeAsyncIterator() + a3.enter() + let results = await iterator.collect(count: 2) // m, n + return results + }.value + XCTAssertEqual(expected[0], results0) + XCTAssertEqual(expected[1], results1) + XCTAssertEqual(expected[2], results2) + } + + func test_share_history_count_2() async throws { + let a0 = Array(["a","b","c","d"]).async + let a1 = Array(["e","f","g","h"]).async.delayed(1) + let a2 = Array(["i","j","k","l"]).async.delayed(1) + let a3 = Array(["m","n","o","p"]).async.delayed(1) + let base = merge(a0, a1, merge(a2, a3)) + let sequence = base.share(history: 2) + let expected = [["c", "d"], ["g", "h"], ["k", "l"]] + let gate = Gate() + Task { + for await el in sequence { + switch el { + case "d": gate.open() + case "h": gate.open() + case "l": gate.open() + case "p": gate.open() + default: break + } + } + return [] + } + await gate.enter() + let results0 = await Task { + var iterator = sequence.makeAsyncIterator() + a1.enter() + let results = await iterator.collect(count: 2) // e, f + return results + }.value + await gate.enter() + let results1 = await Task { + var iterator = sequence.makeAsyncIterator() + a2.enter() + let results = await iterator.collect(count: 2) // i, j + return results + }.value + await gate.enter() + let results2 = await Task { + var iterator = sequence.makeAsyncIterator() + a3.enter() + let results = await iterator.collect(count: 2) // m, n + return results + }.value + XCTAssertEqual(expected[0], results0) + XCTAssertEqual(expected[1], results1) + XCTAssertEqual(expected[2], results2) + } + + func test_share_iterator_disposal_policy_when_terminated_or_vacant_discards_history_on_vacant() async { + let expected = ["a","b","c","d"] + let base = Array(["a","b","c","d"]).async + let sequence = base.share(history: 2, disposingBaseIterator: .whenTerminatedOrVacant) + var result0 = [String]() + var result1 = [String]() + var result2 = [String]() + for await el in sequence { result0.append(el); if el == "d" { break } } + for await el in sequence { result1.append(el); if el == "d" { break } } + for await el in sequence { result2.append(el); if el == "d" { break } } + XCTAssertEqual(expected, result0) + XCTAssertEqual(expected, result1) + XCTAssertEqual(expected, result2) + } + + func test_share_iterator_disposal_policy_when_terminated_persists_history_on_vacant() async { + let expected0 = ["a","b","c","d"] + let expected1 = ["c","d"] + let expected2 = ["c","d"] + let base = Array(["a","b","c","d"]).async + let sequence = base.share(history: 2, disposingBaseIterator: .whenTerminated) + var result0 = [String]() + var result1 = [String]() + var result2 = [String]() + for await el in sequence { result0.append(el); if el == "d" { break } } + for await el in sequence { result1.append(el); if el == "d" { break } } + for await el in sequence { result2.append(el); if el == "d" { break } } + XCTAssertEqual(expected0, result0) + XCTAssertEqual(expected1, result1) + XCTAssertEqual(expected2, result2) + } + + func test_share_shutdown_on_dealloc() async { + typealias Sequence = AsyncSharedSequence>> + let completion = expectation(description: "iteration completes") + let base = Indefinite(value: 1).async + var sequence: Sequence! = base.share() + let iterator = sequence.makeAsyncIterator() + Task { + var i = iterator + let _ = await i.collect() + completion.fulfill() + } + sequence = nil + wait(for: [completion], timeout: 1.0) + } +} + +fileprivate extension AsyncIteratorProtocol { + + mutating func collect(count: Int = .max) async rethrows -> [Element] { + var result = [Element]() + var i = count + while let element = try await next() { + result.append(element) + i -= 1 + if i <= 0 { return result } + } + return result + } + + mutating func collectWithError(count: Int = .max) async -> (elements: [Element], error: Error?) { + var result = [Element]() + var i = count + do { + while let element = try await next() { + result.append(element) + i -= 1 + if i <= 0 { return (result, nil) } + } + return (result, nil) + } + catch { + return (result, error) + } + } +} From 8abc3351353331f44bfbceb17a74724bcca67cc0 Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Thu, 3 Nov 2022 13:49:35 +0000 Subject: [PATCH 02/18] remove sendable base iterator constraint --- Sources/AsyncAlgorithms/AsyncSharedSequence.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift index d45b0ad9..ae92a97c 100644 --- a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift +++ b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift @@ -11,7 +11,7 @@ import DequeModule -extension AsyncSequence { +extension AsyncSequence where Self: Sendable, Element: Sendable { /// Creates an asynchronous sequence that can be shared by multiple consumers. /// @@ -54,7 +54,7 @@ extension AsyncSequence { /// iterator when the connected consumer count falls to zero, its history will be discarded at the /// same time. public struct AsyncSharedSequence -where Base: Sendable, Base.Element: Sendable, Base.AsyncIterator: Sendable { + where Base: Sendable, Base.Element: Sendable { /// The iterator disposal policy applied by a shared asynchronous sequence to its upstream iterator /// @@ -97,7 +97,7 @@ extension AsyncSharedSequence: AsyncSequence, Sendable { public typealias Element = Base.Element - public struct Iterator: AsyncIteratorProtocol, Sendable { + public struct Iterator: AsyncIteratorProtocol, Sendable where Base.Element: Sendable { private let id: UInt private let deallocToken: DeallocToken? @@ -194,9 +194,9 @@ fileprivate extension AsyncSharedSequence { deinit { action() } } - struct SharedUpstreamIterator { + struct SharedUpstreamIterator: Sendable { - private enum State { + private enum State: @unchecked Sendable { case pending case active(Base.AsyncIterator) case terminal @@ -207,7 +207,7 @@ fileprivate extension AsyncSharedSequence { return true } - private let createIterator: () -> Base.AsyncIterator + private let createIterator: @Sendable () -> Base.AsyncIterator private var state = State.pending init(_ createIterator: @escaping @Sendable () -> Base.AsyncIterator) { From ee160eeb737aa5cbff0b9fe91027b94f49a7f2a3 Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Thu, 3 Nov 2022 15:25:17 +0000 Subject: [PATCH 03/18] protected iterator with actor --- .../AsyncAlgorithms/AsyncSharedSequence.swift | 63 ++++++++----------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift index ae92a97c..f754ac2f 100644 --- a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift +++ b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift @@ -129,8 +129,8 @@ extension AsyncSharedSequence: AsyncSequence, Sendable { guard let state else { return nil } let command = state.run(id) switch command { - case .fetch(var iterator): - let upstreamResult = await Result.async { try await iterator.next() } + case .fetch(let iterator): + let upstreamResult = await iterator.next() let output = state.fetch(id, resumedWithResult: upstreamResult, iterator: iterator) return try processOutput(output) case .wait: @@ -194,9 +194,9 @@ fileprivate extension AsyncSharedSequence { deinit { action() } } - struct SharedUpstreamIterator: Sendable { + actor SharedUpstreamIterator { - private enum State: @unchecked Sendable { + private enum State { case pending case active(Base.AsyncIterator) case terminal @@ -207,36 +207,37 @@ fileprivate extension AsyncSharedSequence { return true } - private let createIterator: @Sendable () -> Base.AsyncIterator + private let base: Base private var state = State.pending - init(_ createIterator: @escaping @Sendable () -> Base.AsyncIterator) { - self.createIterator = createIterator + init(_ base: Base) { + self.base = base } - mutating func next() async rethrows -> Element? { + func next() async -> Result { switch state { case .pending: - self.state = .active(createIterator()) - return try await next() + self.state = .active(base.makeAsyncIterator()) + return await next() case .active(var iterator): - let result = await Result.async { try await iterator.next() } - switch result { - case .success(_?): - self.state = .active(iterator) - default: + do { + if let element = try await iterator.next() { + self.state = .active(iterator) + return .success(element) + } + else { + self.state = .terminal + return .success(nil) + } + } + catch { self.state = .terminal + return .failure(error) } - return try result._rethrowGet() case .terminal: - return nil + return .success(nil) } } - - mutating func reset() { - guard case .active(_) = state else { return } - self.state = .pending - } } struct Runner { @@ -294,6 +295,7 @@ fileprivate extension AsyncSharedSequence { private struct Storage: Sendable { + let base: Base let replayCount: Int let iteratorDiscardPolicy: IteratorDisposalPolicy var iterator: SharedUpstreamIterator? @@ -311,9 +313,10 @@ fileprivate extension AsyncSharedSequence { init(_ base: Base, replayCount: Int, discardsIterator: IteratorDisposalPolicy) { precondition(replayCount >= 0, "history must be greater than or equal to zero") + self.base = base self.replayCount = replayCount self.iteratorDiscardPolicy = discardsIterator - self.iterator = .init { base.makeAsyncIterator() } + self.iterator = .init(base) } mutating func establish() -> Connection { @@ -483,7 +486,7 @@ fileprivate extension AsyncSharedSequence { self.currentGroup.flip() self.phase = .pending if runners.isEmpty && iteratorDiscardPolicy == .whenTerminatedOrVacant { - self.iterator?.reset() + self.iterator = SharedUpstreamIterator(base) self.history.removeAll() } } @@ -564,15 +567,3 @@ fileprivate extension AsyncSharedSequence { } } } - -fileprivate extension Result where Failure == Error { - - static func async(_ operation: @escaping () async throws -> Success) async -> Self { - do { - return .success(try await operation()) - } - catch let error { - return .failure(error) - } - } -} From 690749d9197f4a38d172bcb74f938b382a2db668 Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Thu, 3 Nov 2022 15:44:39 +0000 Subject: [PATCH 04/18] removed unused history on terminal connection --- Sources/AsyncAlgorithms/AsyncSharedSequence.swift | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift index f754ac2f..5f8c5e13 100644 --- a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift +++ b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift @@ -112,9 +112,9 @@ extension AsyncSharedSequence: AsyncSequence, Sendable { self.deallocToken = .init { state.cancel(id) } self.state = state continuation?.resume() - case .terminal(let id, let prefix): + case .terminal(let id): self.id = id - self.prefix = prefix + self.prefix = .init() self.deallocToken = nil self.state = nil } @@ -202,11 +202,6 @@ fileprivate extension AsyncSharedSequence { case terminal } - var isTerminal: Bool { - guard case .terminal = state else { return false } - return true - } - private let base: Base private var state = State.pending @@ -249,7 +244,7 @@ fileprivate extension AsyncSharedSequence { enum Connection { case active(id: UInt, prefix: Deque, continuation: RunContinuation?) - case terminal(id: UInt, prefix: Deque) + case terminal(id: UInt) } enum Command { @@ -322,7 +317,7 @@ fileprivate extension AsyncSharedSequence { mutating func establish() -> Connection { defer { nextRunnerID += 1} if terminal { - return .terminal(id: nextRunnerID, prefix: history) + return .terminal(id: nextRunnerID) } else { let group: RunGroup From 447cdd0741b827ec4da51a9aff231b5a611fa4b2 Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Fri, 4 Nov 2022 14:02:39 +0000 Subject: [PATCH 05/18] algorithm notes and cleanup --- .../AsyncAlgorithms/AsyncSharedSequence.swift | 417 ++++++++++-------- 1 file changed, 222 insertions(+), 195 deletions(-) diff --git a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift index 5f8c5e13..02e4cc5d 100644 --- a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift +++ b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift @@ -9,6 +9,57 @@ // //===----------------------------------------------------------------------===// +// ALGORITHM SUMMARY: +// +// The basic idea behind the `AsyncSharedSequence` algorithm is as follows: +// Every vended `AsyncSharedSequence` iterator (runner) takes part in a race +// (run group) to grab the next element from the base iterator. The 'winner' +// returns the element to the shared state, who then supplies the result to +// later finishers (other iterators). Once every runner has completed the +// current run group cycle, the next run group begins. This means that +// iterators run in lock-step, only moving forward when the the last iterator +// in the run group completes its current run (iteration). +// +// ITERATOR LIFECYCLE: +// +// 1. CONNECTION: On connection, each 'runner' is issued with an ID (and any +// prefixed values from the history buffer). From this point on, the +// algorithm will wait on this iterator to consume its values before moving +// on. This means that until `next()` is called on this iterator, all the +// other iterators will be held until such time that it is, or the +// iterator's task is cancelled. +// +// 2. RUN: After its prefix values have been exhausted, each time `next()` is +// called on the iterator, the iterator attempts to start a 'run' by +// calling `startRun(_:)` on the shared state. The shared state marks the +// iterator as 'running' and issues a role to determine the iterator's +// action for the current run group. The roles are as follows: +// +// - FETCH: The iterator is the 'winner' of this run group. It is issued +// with the 'borrowed' base iterator. It calls `next()` on it and, +// once it resumes, returns the value and the borrowed base iterator +// to the shared state. +// – WAIT: The iterator hasn't won this group, but was fast enough that +// the winner has yet to resume with the element from the base +// iterator. Therefore, it is told to suspend (WAIT) until such time +// that the winner resumes. +// – YIELD: The iterator is late (and is holding up the other iterators). +// The shared state issues it with the value retrieved by the winning +// iterator and lets it continue immediately. +// – HOLD: The iterator is early for the next run group. So it is put in +// the holding pen until the next run group can start. This is because +// there are other iterators that still haven't finished their run for +// the current run group. Once all other iterators have completed their +// run, this iterator will be resumed. +// +// 3. COMPLETION: The iterator calls cancel on the shared state which ensures +// the iterator does not take part in the next run group. However, if it is +// currently suspended it may not resume until the current run group +// concludes. This is especially important if it is filling the key FETCH +// role for the current run group. + +// MARK: - Member Function + import DequeModule extension AsyncSequence where Self: Sendable, Element: Sendable { @@ -53,7 +104,7 @@ extension AsyncSequence where Self: Sendable, Element: Sendable { /// the most recently emitted elements. If ``AsyncSharedSequence`` is configured to drop its /// iterator when the connected consumer count falls to zero, its history will be discarded at the /// same time. -public struct AsyncSharedSequence +public struct AsyncSharedSequence : Sendable where Base: Sendable, Base.Element: Sendable { /// The iterator disposal policy applied by a shared asynchronous sequence to its upstream iterator @@ -84,7 +135,8 @@ public struct AsyncSharedSequence history historyCount: Int = 0, disposingBaseIterator iteratorDisposalPolicy: IteratorDisposalPolicy = .whenTerminatedOrVacant ) { - let state = State(base, replayCount: historyCount, discardsIterator: iteratorDisposalPolicy) + let state = State( + base, replayCount: historyCount, iteratorDisposalPolicy: iteratorDisposalPolicy) self.base = base self.state = state self.deallocToken = .init { state.abort() } @@ -93,11 +145,11 @@ public struct AsyncSharedSequence // MARK: - Iterator -extension AsyncSharedSequence: AsyncSequence, Sendable { +extension AsyncSharedSequence: AsyncSequence { public typealias Element = Base.Element - public struct Iterator: AsyncIteratorProtocol, Sendable where Base.Element: Sendable { + public struct Iterator: AsyncIteratorProtocol { private let id: UInt private let deallocToken: DeallocToken? @@ -106,12 +158,11 @@ extension AsyncSharedSequence: AsyncSequence, Sendable { fileprivate init(_ state: State) { switch state.establish() { - case .active(let id, let prefix, let continuation): + case .active(let id, let prefix): self.id = id self.prefix = prefix self.deallocToken = .init { state.cancel(id) } self.state = state - continuation?.resume() case .terminal(let id): self.id = id self.prefix = .init() @@ -127,8 +178,8 @@ extension AsyncSharedSequence: AsyncSequence, Sendable { return element } guard let state else { return nil } - let command = state.run(id) - switch command { + let role = state.startRun(id) + switch role { case .fetch(let iterator): let upstreamResult = await iterator.next() let output = state.fetch(id, resumedWithResult: upstreamResult, iterator: iterator) @@ -159,7 +210,6 @@ extension AsyncSharedSequence: AsyncSequence, Sendable { } private mutating func processOutput(_ output: RunOutput) rethrows -> Element? { - output.continuation?.resume() if output.shouldCancel { self.state = nil } @@ -186,12 +236,21 @@ extension AsyncSharedSequence: AsyncSequence, Sendable { fileprivate extension AsyncSharedSequence { - final class DeallocToken: Sendable { - let action: @Sendable () -> Void - init(_ dealloc: @escaping @Sendable () -> Void) { - self.action = dealloc - } - deinit { action() } + enum RunnerConnection { + case active(id: UInt, prefix: Deque) + case terminal(id: UInt) + } + + enum RunRole { + case fetch(SharedUpstreamIterator) + case wait + case yield(RunOutput) + case hold + } + + struct RunOutput { + let value: Result + var shouldCancel = false } actor SharedUpstreamIterator { @@ -235,133 +294,100 @@ fileprivate extension AsyncSharedSequence { } } - struct Runner { - let id: UInt - var group: RunGroup - var isCancelled = false - var isRunning = false - } - - enum Connection { - case active(id: UInt, prefix: Deque, continuation: RunContinuation?) - case terminal(id: UInt) - } - - enum Command { - case fetch(SharedUpstreamIterator) - case wait - case yield(RunOutput) - case hold - } - - enum RunGroup { - case a - case b - var nextGroup: RunGroup { self == .a ? .b : .a } - mutating func flip() { self = nextGroup } - } - - struct RunOutput { - let value: Result - var shouldCancel = false - var continuation: RunContinuation? - } - - struct RunContinuation { - var held: [UnsafeContinuation]? - var waiting: [(UnsafeContinuation, RunOutput)]? - func resume() { - if let held { - for continuation in held { continuation.resume() } - } - if let waiting { - for (continuation, output) in waiting { continuation.resume(returning: output) } + struct State: Sendable { + + private struct RunContinuation { + var held: [UnsafeContinuation]? + var waiting: [(UnsafeContinuation, RunOutput)]? + func resume() { + if let held { + for continuation in held { continuation.resume() } + } + if let waiting { + for (continuation, output) in waiting { continuation.resume(returning: output) } + } } } - } - - enum Phase { - case pending - case fetching - case done(Result) - } - - struct State: Sendable { private struct Storage: Sendable { + enum Phase { + case pending + case fetching + case done(Result) + } + + struct Runner { + var group: Int + var active = false + var cancelled = false + } + let base: Base let replayCount: Int let iteratorDiscardPolicy: IteratorDisposalPolicy var iterator: SharedUpstreamIterator? var nextRunnerID = UInt.min - var runners = [UInt: Runner]() - var phase = Phase.pending - var currentGroup = RunGroup.a + var currentGroup = 0 + var nextGroup: Int { (currentGroup + 1) % 2 /* could be any denominator 2 or greater... */ } var history = Deque() + var runners = [UInt: Runner]() var heldRunnerContinuations = [UnsafeContinuation]() var waitingRunnerContinuations = [UInt: UnsafeContinuation]() + var phase = Phase.pending var terminal = false - var hasActiveOrPendingRunnersInCurrentGroup: Bool { - runners.values.contains { $0.group == currentGroup } - } - init(_ base: Base, replayCount: Int, discardsIterator: IteratorDisposalPolicy) { + init(_ base: Base, replayCount: Int, iteratorDisposalPolicy: IteratorDisposalPolicy) { precondition(replayCount >= 0, "history must be greater than or equal to zero") self.base = base self.replayCount = replayCount - self.iteratorDiscardPolicy = discardsIterator + self.iteratorDiscardPolicy = iteratorDisposalPolicy self.iterator = .init(base) } - mutating func establish() -> Connection { + mutating func establish() -> (RunnerConnection, RunContinuation?) { defer { nextRunnerID += 1} if terminal { - return .terminal(id: nextRunnerID) + return (.terminal(id: nextRunnerID), nil) } else { - let group: RunGroup - if case .done(_) = phase { - group = currentGroup.nextGroup - } else { - group = currentGroup - } - let runner = Runner(id: nextRunnerID, group: group) - runners[nextRunnerID] = runner + let group: Int + if case .done(_) = phase { group = nextGroup } else { group = currentGroup } + runners[nextRunnerID] = .init(group: group) + let connection = RunnerConnection.active(id: nextRunnerID, prefix: history) let continuation = RunContinuation(held: finalizeRunGroupIfNeeded()) - return .active(id: nextRunnerID, prefix: history, continuation: continuation) + return (connection, continuation) } } - mutating func run(_ runnerID: UInt) -> Command { - guard terminal == false, var runner = runners[runnerID] else { + mutating func run(_ runnerID: UInt) -> (RunRole, RunContinuation?) { + guard terminal == false, let runner = runners[runnerID], runner.cancelled == false else { if case .done(let result) = phase { - return .yield(RunOutput(value: result, shouldCancel: true)) + return (.yield(RunOutput(value: result, shouldCancel: true)), nil) } - return .yield(RunOutput(value: .success(nil), shouldCancel: true)) + return (.yield(RunOutput(value: .success(nil), shouldCancel: true)), nil) } - runner.isRunning = true - runners.updateValue(runner, forKey: runnerID) - guard runner.group == currentGroup else { return .hold } - switch phase { - case .pending: - phase = .fetching - guard let iterator = iterator else { - preconditionFailure("iterator must not be over-borrowed") + if runner.group == currentGroup { + runners.updateValue( + .init(group: runner.group, active: true, cancelled: runner.cancelled), forKey: runnerID) + switch phase { + case .pending: + guard let iterator = iterator else { + preconditionFailure("iterator must not be over-borrowed") + } + self.iterator = nil + phase = .fetching + return (.fetch(iterator), nil) + case .fetching: + return (.wait, nil) + case .done(let result): + finish(runnerID) + let command = RunRole.yield(RunOutput(value: result, shouldCancel: runner.cancelled)) + return (command, .init(held: finalizeRunGroupIfNeeded())) } - self.iterator = nil - return .fetch(iterator) - case .fetching: - return .wait - case .done(let result): - finish(runnerID) - return .yield( - RunOutput( - value: result, - shouldCancel: runner.isCancelled, - continuation: .init(held: finalizeRunGroupIfNeeded()) - ) - ) + } + else { + return (.hold, nil) } } @@ -369,124 +395,111 @@ fileprivate extension AsyncSharedSequence { _ runnerID: UInt, resumedWithResult result: Result, iterator: SharedUpstreamIterator - ) -> RunOutput { + ) -> (RunOutput, RunContinuation) { + precondition(self.iterator == nil, "iterator is already in place") guard let runner = runners[runnerID] else { preconditionFailure("fetching runner resumed out of band") } - guard case .fetching = phase, case currentGroup = runner.group else { - preconditionFailure("fetching runner resumed out of band") - } - guard self.iterator == nil else { - preconditionFailure("iterator is already in place") - } self.iterator = iterator - phase = .done(result) - terminal = (try? result.get()) == nil + self.terminal = self.terminal || ((try? result.get()) == nil) + self.phase = .done(result) finish(runnerID) updateHistory(withResult: result) - let continuationPairs = waitingRunnerContinuations.map { waitingRunnerID, continuation in - guard let waitingRunner = runners[waitingRunnerID] else { - preconditionFailure("fetching runner resumed out of band") - } - finish(waitingRunnerID) - return (continuation, RunOutput(value: result, shouldCancel: waitingRunner.isCancelled)) - } - waitingRunnerContinuations.removeAll() - return RunOutput( - value: result, - shouldCancel: runner.isCancelled, - continuation: .init(held: finalizeRunGroupIfNeeded(), waiting: continuationPairs) - ) + var continuation = gatherWaitingRunnerContinuationsForResumption(withResult: result) + continuation.held = finalizeRunGroupIfNeeded() + return (RunOutput(value: result, shouldCancel: runner.cancelled), continuation) } mutating func wait( _ runnerID: UInt, suspendedWithContinuation continuation: UnsafeContinuation - ) -> RunOutput? { - guard let runner = runners[runnerID] else { - preconditionFailure("waiting runner resumed out of band") - } + ) -> (RunOutput, RunContinuation)? { switch phase { case .fetching: waitingRunnerContinuations[runnerID] = continuation return nil case .done(let result): + guard let runner = runners[runnerID] else { + preconditionFailure("waiting runner resumed out of band") + } finish(runnerID) - return RunOutput( - value: result, - shouldCancel: runner.isCancelled, - continuation: .init(held: finalizeRunGroupIfNeeded()) - ) + let output = RunOutput(value: result, shouldCancel: runner.cancelled) + let continuation = RunContinuation(held: finalizeRunGroupIfNeeded()) + return (output, continuation) default: - preconditionFailure("waiting runner resumed out of band") + preconditionFailure("waiting runner suspended out of band") } } + private mutating func gatherWaitingRunnerContinuationsForResumption( + withResult result: Result + ) -> RunContinuation { + let continuationPairs = waitingRunnerContinuations.map { waitingRunnerID, continuation in + guard let waitingRunner = runners[waitingRunnerID] else { + preconditionFailure("waiting runner resumed out of band") + } + finish(waitingRunnerID) + return (continuation, RunOutput(value: result, shouldCancel: waitingRunner.cancelled)) + } + waitingRunnerContinuations.removeAll() + return .init(waiting: continuationPairs) + } + mutating func hold( _ runnerID: UInt, suspendedWithContinuation continuation: UnsafeContinuation ) -> Bool { - guard let runner = runners[runnerID] else { - preconditionFailure("held runner resumed out of band") - } - if currentGroup == runner.group { + guard let runner = runners[runnerID], runner.group == nextGroup else { return true } - else { - heldRunnerContinuations.append(continuation) - return false - } + heldRunnerContinuations.append(continuation) + return false } private mutating func finish(_ runnerID: UInt) { - guard var runner = runners[runnerID] else { return } - if runner.isCancelled { - runners.removeValue(forKey: runnerID) + guard var runner = runners.removeValue(forKey: runnerID) else { + preconditionFailure("run finished out of band") } - else { - runner.isRunning = false - runner.group.flip() + if runner.cancelled == false { + runner.active = false + runner.group = nextGroup runners[runnerID] = runner } } - mutating func cancel(_ runnerID: UInt) -> [UnsafeContinuation]? { - if var runner = runners[runnerID] { - if runner.isRunning { - runner.isCancelled = true - runners[runnerID] = runner - } - else { - runners.removeValue(forKey: runnerID) - return finalizeRunGroupIfNeeded() - } + mutating func cancel(_ runnerID: UInt) -> RunContinuation? { + if let runner = runners.removeValue(forKey: runnerID), runner.active { + runners[runnerID] = .init(group: runner.group, active: true, cancelled: true) + return nil + } + else { + return .init(held: finalizeRunGroupIfNeeded()) } - return nil } - mutating func abort() -> [UnsafeContinuation]? { + mutating func abort() -> RunContinuation { terminal = true - runners = runners.compactMapValues { $0.isRunning ? $0 : nil } - return finalizeRunGroupIfNeeded() + runners = runners.filter { _, runner in runner.active } + return .init(held: finalizeRunGroupIfNeeded()) } private mutating func finalizeRunGroupIfNeeded() -> [UnsafeContinuation]? { - if hasActiveOrPendingRunnersInCurrentGroup { return nil } + if (runners.values.contains { $0.group == currentGroup }) { return nil } if terminal { - self.iterator = nil self.phase = .done(.success(nil)) + self.iterator = nil self.history.removeAll() } else { - self.currentGroup.flip() + self.currentGroup = nextGroup self.phase = .pending if runners.isEmpty && iteratorDiscardPolicy == .whenTerminatedOrVacant { self.iterator = SharedUpstreamIterator(base) self.history.removeAll() } } - let continuations = self.heldRunnerContinuations - self.heldRunnerContinuations.removeAll() + let continuations = heldRunnerContinuations + heldRunnerContinuations.removeAll() return continuations } @@ -501,18 +514,22 @@ fileprivate extension AsyncSharedSequence { private let storage: ManagedCriticalState - init(_ base: Base, replayCount: Int, discardsIterator: IteratorDisposalPolicy) { + init(_ base: Base, replayCount: Int, iteratorDisposalPolicy: IteratorDisposalPolicy) { self.storage = .init( - Storage(base, replayCount: replayCount, discardsIterator: discardsIterator) + Storage(base, replayCount: replayCount, iteratorDisposalPolicy: iteratorDisposalPolicy) ) } - func establish() -> Connection { - storage.withCriticalRegion { $0.establish() } + func establish() -> RunnerConnection { + let (connection, continuation) = storage.withCriticalRegion { state in state.establish() } + continuation?.resume() + return connection } - func run(_ runnerID: UInt) -> Command { - storage.withCriticalRegion { $0.run(runnerID) } + func startRun(_ runnerID: UInt) -> RunRole { + let (command, continuation) = storage.withCriticalRegion { state in state.run(runnerID) } + continuation?.resume() + return command } func fetch( @@ -520,18 +537,22 @@ fileprivate extension AsyncSharedSequence { resumedWithResult result: Result, iterator: SharedUpstreamIterator ) -> RunOutput { - storage.withCriticalRegion { state in + let (output, continuation) = storage.withCriticalRegion { state in state.fetch(runnerID, resumedWithResult: result, iterator: iterator) } + continuation.resume() + return output } func wait( _ runnerID: UInt, suspendedWithContinuation continuation: UnsafeContinuation ) -> RunOutput? { - storage.withCriticalRegion { state in + guard let (output, continuation) = storage.withCriticalRegion({ state in state.wait(runnerID, suspendedWithContinuation: continuation) - } + }) else { return nil } + continuation.resume() + return output } func hold( @@ -544,21 +565,27 @@ fileprivate extension AsyncSharedSequence { } func cancel(_ runnerID: UInt) { - let continuations = storage.withCriticalRegion { $0.cancel(runnerID) } - if let continuations { - for continuation in continuations { - continuation.resume() - } - } + let continuation = storage.withCriticalRegion { $0.cancel(runnerID) } + continuation?.resume() } func abort() { - let continuations = storage.withCriticalRegion { state in state.abort() } - if let continuations { - for continuation in continuations { - continuation.resume() - } - } + let continuation = storage.withCriticalRegion { state in state.abort() } + continuation.resume() } } } + +// MARK: - Sendable + +extension AsyncSharedSequence.Iterator: Sendable where Base.Element: Sendable { } + +// MARK: - Utilities + +fileprivate final class DeallocToken: Sendable { + let action: @Sendable () -> Void + init(_ dealloc: @escaping @Sendable () -> Void) { + self.action = dealloc + } + deinit { action() } +} From a114b398e308621570f40df48cf478bde0068500 Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Mon, 7 Nov 2022 11:54:13 +0000 Subject: [PATCH 06/18] remove Sendable iterator conformance --- Sources/AsyncAlgorithms/AsyncSharedSequence.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift index 02e4cc5d..b6f56b5e 100644 --- a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift +++ b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift @@ -576,10 +576,6 @@ fileprivate extension AsyncSharedSequence { } } -// MARK: - Sendable - -extension AsyncSharedSequence.Iterator: Sendable where Base.Element: Sendable { } - // MARK: - Utilities fileprivate final class DeallocToken: Sendable { From 20876706eef08f04695130ca9746157633fa284f Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Mon, 7 Nov 2022 12:09:57 +0000 Subject: [PATCH 07/18] formatting --- .../AsyncAlgorithms/AsyncSharedSequence.swift | 194 +++++++++++------- 1 file changed, 123 insertions(+), 71 deletions(-) diff --git a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift index b6f56b5e..dd891dc1 100644 --- a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift +++ b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift @@ -66,10 +66,10 @@ extension AsyncSequence where Self: Sendable, Element: Sendable { /// Creates an asynchronous sequence that can be shared by multiple consumers. /// - /// - parameter history: the number of previously emitted elements to prefix to the iterator of a new - /// consumer - /// - parameter iteratorDisposalPolicy:the iterator disposal policy applied by a shared - /// asynchronous sequence to its upstream iterator + /// - parameter history: the number of previously emitted elements to prefix + /// to the iterator of a new consumer + /// - parameter iteratorDisposalPolicy:the iterator disposal policy applied by + /// a shared asynchronous sequence to its upstream iterator public func share( history historyCount: Int = 0, disposingBaseIterator iteratorDisposalPolicy: AsyncSharedSequence.IteratorDisposalPolicy = .whenTerminatedOrVacant @@ -81,41 +81,49 @@ extension AsyncSequence where Self: Sendable, Element: Sendable { // MARK: - Sequence -/// An asynchronous sequence that can be iterated by multiple concurrent consumers. +/// An asynchronous sequence that can be iterated by multiple concurrent +/// consumers. /// -/// Use a shared asynchronous sequence when you have multiple downstream asynchronous sequences -/// with which you wish to share the output of a single asynchronous sequence. This can be useful if -/// you have expensive upstream operations, or if your asynchronous sequence represents the output -/// of a physical device. +/// Use a shared asynchronous sequence when you have multiple downstream +/// asynchronous sequences with which you wish to share the output of a single +/// asynchronous sequence. This can be useful if you have expensive upstream +/// operations, or if your asynchronous sequence represents the output of a +/// physical device. /// -/// Elements are emitted from a shared asynchronous sequence at a rate that does not exceed the -/// consumption of its slowest consumer. If this kind of back-pressure isn't desirable for your -/// use-case, ``AsyncSharedSequence`` can be composed with buffers – either upstream, downstream, -/// or both – to acheive the desired behavior. +/// Elements are emitted from a shared asynchronous sequence at a rate that does +/// not exceed the consumption of its slowest consumer. If this kind of +/// back-pressure isn't desirable for your use-case, ``AsyncSharedSequence`` can +/// be composed with buffers – either upstream, downstream, or both – to acheive +/// the desired behavior. /// -/// If you have an asynchronous sequence that consumes expensive system resources, it is possible to -/// configure ``AsyncSharedSequence`` to discard its upstream iterator when the connected -/// downstream consumer count falls to zero. This allows any cancellation tasks configured on the -/// upstream asynchronous sequence to be initiated and for expensive resources to be terminated. -/// ``AsyncSharedSequence`` will re-create a fresh iterator if there is further demand. +/// If you have an asynchronous sequence that consumes expensive system +/// resources, it is possible to configure ``AsyncSharedSequence`` to discard +/// its upstream iterator when the connected downstream consumer count falls to +/// zero. This allows any cancellation tasks configured on the upstream +/// asynchronous sequence to be initiated and for expensive resources to be +/// terminated. ``AsyncSharedSequence`` will re-create a fresh iterator if there +/// is further demand. /// -/// For use-cases where it is important for consumers to have a record of elements emitted prior to -/// their connection, a ``AsyncSharedSequence`` can also be configured to prefix its output with -/// the most recently emitted elements. If ``AsyncSharedSequence`` is configured to drop its -/// iterator when the connected consumer count falls to zero, its history will be discarded at the -/// same time. +/// For use-cases where it is important for consumers to have a record of +/// elements emitted prior to their connection, a ``AsyncSharedSequence`` can +/// also be configured to prefix its output with the most recently emitted +/// elements. If ``AsyncSharedSequence`` is configured to drop its iterator when +/// the connected consumer count falls to zero, its history will be discarded at +/// the same time. public struct AsyncSharedSequence : Sendable where Base: Sendable, Base.Element: Sendable { - /// The iterator disposal policy applied by a shared asynchronous sequence to its upstream iterator + /// The iterator disposal policy applied by a shared asynchronous sequence to + /// its upstream iterator /// - /// - note: the iterator is always disposed when the base asynchronous sequence terminates + /// - note: the iterator is always disposed when the base asynchronous + /// sequence terminates public enum IteratorDisposalPolicy: Sendable { - /// retains the upstream iterator for use by future consumers until the base asynchronous - /// sequence is terminated - case whenTerminated - /// discards the upstream iterator when the number of consumers falls to zero or the base + /// retains the upstream iterator for use by future consumers until the base /// asynchronous sequence is terminated + case whenTerminated + /// discards the upstream iterator when the number of consumers falls to + /// zero or the base asynchronous sequence is terminated case whenTerminatedOrVacant } @@ -126,10 +134,10 @@ public struct AsyncSharedSequence : Sendable /// Contructs a shared asynchronous sequence /// /// - parameter base: the asynchronous sequence to be shared - /// - parameter history: the number of previously emitted elements to prefix to the iterator of a - /// new consumer - /// - parameter iteratorDisposalPolicy: the iterator disposal policy applied to the upstream - /// iterator + /// - parameter history: the number of previously emitted elements to prefix + /// to the iterator of a new consumer + /// - parameter iteratorDisposalPolicy: the iterator disposal policy applied + /// to the upstream iterator public init( _ base: Base, history historyCount: Int = 0, @@ -182,19 +190,24 @@ extension AsyncSharedSequence: AsyncSequence { switch role { case .fetch(let iterator): let upstreamResult = await iterator.next() - let output = state.fetch(id, resumedWithResult: upstreamResult, iterator: iterator) + let output = state.fetch( + id, resumedWithResult: upstreamResult, iterator: iterator) return try processOutput(output) case .wait: let output = await withUnsafeContinuation { continuation in - let immediateOutput = state.wait(id, suspendedWithContinuation: continuation) - if let immediateOutput { continuation.resume(returning: immediateOutput) } + let immediateOutput = state.wait( + id, suspendedWithContinuation: continuation) + if let immediateOutput { + continuation.resume(returning: immediateOutput) + } } return try processOutput(output) case .yield(let output): return try processOutput(output) case .hold: await withUnsafeContinuation { continuation in - let shouldImmediatelyResume = state.hold(id, suspendedWithContinuation: continuation) + let shouldImmediatelyResume = state.hold( + id, suspendedWithContinuation: continuation) if shouldImmediatelyResume { continuation.resume() } } return try await next() @@ -209,7 +222,9 @@ extension AsyncSharedSequence: AsyncSequence { } } - private mutating func processOutput(_ output: RunOutput) rethrows -> Element? { + private mutating func processOutput( + _ output: RunOutput + ) rethrows -> Element? { if output.shouldCancel { self.state = nil } @@ -304,7 +319,9 @@ fileprivate extension AsyncSharedSequence { for continuation in held { continuation.resume() } } if let waiting { - for (continuation, output) in waiting { continuation.resume(returning: output) } + for (continuation, output) in waiting { + continuation.resume(returning: output) + } } } } @@ -329,7 +346,7 @@ fileprivate extension AsyncSharedSequence { var iterator: SharedUpstreamIterator? var nextRunnerID = UInt.min var currentGroup = 0 - var nextGroup: Int { (currentGroup + 1) % 2 /* could be any denominator 2 or greater... */ } + var nextGroup: Int { (currentGroup + 1) % 2 } var history = Deque() var runners = [UInt: Runner]() var heldRunnerContinuations = [UnsafeContinuation]() @@ -337,7 +354,11 @@ fileprivate extension AsyncSharedSequence { var phase = Phase.pending var terminal = false - init(_ base: Base, replayCount: Int, iteratorDisposalPolicy: IteratorDisposalPolicy) { + init( + _ base: Base, + replayCount: Int, + iteratorDisposalPolicy: IteratorDisposalPolicy + ) { precondition(replayCount >= 0, "history must be greater than or equal to zero") self.base = base self.replayCount = replayCount @@ -354,7 +375,8 @@ fileprivate extension AsyncSharedSequence { let group: Int if case .done(_) = phase { group = nextGroup } else { group = currentGroup } runners[nextRunnerID] = .init(group: group) - let connection = RunnerConnection.active(id: nextRunnerID, prefix: history) + let connection = RunnerConnection.active( + id: nextRunnerID, prefix: history) let continuation = RunContinuation(held: finalizeRunGroupIfNeeded()) return (connection, continuation) } @@ -368,8 +390,9 @@ fileprivate extension AsyncSharedSequence { return (.yield(RunOutput(value: .success(nil), shouldCancel: true)), nil) } if runner.group == currentGroup { - runners.updateValue( - .init(group: runner.group, active: true, cancelled: runner.cancelled), forKey: runnerID) + let updatedRunner = Runner( + group: runner.group, active: true, cancelled: runner.cancelled) + runners.updateValue(updatedRunner, forKey: runnerID) switch phase { case .pending: guard let iterator = iterator else { @@ -382,8 +405,10 @@ fileprivate extension AsyncSharedSequence { return (.wait, nil) case .done(let result): finish(runnerID) - let command = RunRole.yield(RunOutput(value: result, shouldCancel: runner.cancelled)) - return (command, .init(held: finalizeRunGroupIfNeeded())) + let role = RunRole.yield( + RunOutput(value: result, shouldCancel: runner.cancelled) + ) + return (role, .init(held: finalizeRunGroupIfNeeded())) } } else { @@ -405,9 +430,11 @@ fileprivate extension AsyncSharedSequence { self.phase = .done(result) finish(runnerID) updateHistory(withResult: result) - var continuation = gatherWaitingRunnerContinuationsForResumption(withResult: result) + var continuation = gatherWaitingRunnerContinuationsForResumption( + withResult: result) continuation.held = finalizeRunGroupIfNeeded() - return (RunOutput(value: result, shouldCancel: runner.cancelled), continuation) + let ouput = RunOutput(value: result, shouldCancel: runner.cancelled) + return (ouput, continuation) } mutating func wait( @@ -434,13 +461,16 @@ fileprivate extension AsyncSharedSequence { private mutating func gatherWaitingRunnerContinuationsForResumption( withResult result: Result ) -> RunContinuation { - let continuationPairs = waitingRunnerContinuations.map { waitingRunnerID, continuation in - guard let waitingRunner = runners[waitingRunnerID] else { - preconditionFailure("waiting runner resumed out of band") + let continuationPairs = waitingRunnerContinuations + .map { waitingRunnerID, continuation in + guard let waitingRunner = runners[waitingRunnerID] else { + preconditionFailure("waiting runner resumed out of band") + } + finish(waitingRunnerID) + let output = RunOutput( + value: result, shouldCancel: waitingRunner.cancelled) + return (continuation, output) } - finish(waitingRunnerID) - return (continuation, RunOutput(value: result, shouldCancel: waitingRunner.cancelled)) - } waitingRunnerContinuations.removeAll() return .init(waiting: continuationPairs) } @@ -469,7 +499,8 @@ fileprivate extension AsyncSharedSequence { mutating func cancel(_ runnerID: UInt) -> RunContinuation? { if let runner = runners.removeValue(forKey: runnerID), runner.active { - runners[runnerID] = .init(group: runner.group, active: true, cancelled: true) + runners[runnerID] = .init( + group: runner.group, active: true, cancelled: true) return nil } else { @@ -483,7 +514,8 @@ fileprivate extension AsyncSharedSequence { return .init(held: finalizeRunGroupIfNeeded()) } - private mutating func finalizeRunGroupIfNeeded() -> [UnsafeContinuation]? { + private mutating func finalizeRunGroupIfNeeded( + ) -> [UnsafeContinuation]? { if (runners.values.contains { $0.group == currentGroup }) { return nil } if terminal { self.phase = .done(.success(nil)) @@ -503,8 +535,12 @@ fileprivate extension AsyncSharedSequence { return continuations } - private mutating func updateHistory(withResult result: Result) { - guard replayCount > 0, case .success(let element?) = result else { return } + private mutating func updateHistory( + withResult result: Result + ) { + guard replayCount > 0, case .success(let element?) = result else { + return + } if history.count >= replayCount { history.removeFirst() } @@ -514,22 +550,34 @@ fileprivate extension AsyncSharedSequence { private let storage: ManagedCriticalState - init(_ base: Base, replayCount: Int, iteratorDisposalPolicy: IteratorDisposalPolicy) { + init( + _ base: Base, + replayCount: Int, + iteratorDisposalPolicy: IteratorDisposalPolicy + ) { self.storage = .init( - Storage(base, replayCount: replayCount, iteratorDisposalPolicy: iteratorDisposalPolicy) + Storage( + base, + replayCount: replayCount, + iteratorDisposalPolicy: iteratorDisposalPolicy + ) ) } func establish() -> RunnerConnection { - let (connection, continuation) = storage.withCriticalRegion { state in state.establish() } + let (connection, continuation) = storage.withCriticalRegion { + storage in storage.establish() + } continuation?.resume() return connection } func startRun(_ runnerID: UInt) -> RunRole { - let (command, continuation) = storage.withCriticalRegion { state in state.run(runnerID) } + let (role, continuation) = storage.withCriticalRegion { + storage in storage.run(runnerID) + } continuation?.resume() - return command + return role } func fetch( @@ -537,8 +585,8 @@ fileprivate extension AsyncSharedSequence { resumedWithResult result: Result, iterator: SharedUpstreamIterator ) -> RunOutput { - let (output, continuation) = storage.withCriticalRegion { state in - state.fetch(runnerID, resumedWithResult: result, iterator: iterator) + let (output, continuation) = storage.withCriticalRegion { storage in + storage.fetch(runnerID, resumedWithResult: result, iterator: iterator) } continuation.resume() return output @@ -548,8 +596,8 @@ fileprivate extension AsyncSharedSequence { _ runnerID: UInt, suspendedWithContinuation continuation: UnsafeContinuation ) -> RunOutput? { - guard let (output, continuation) = storage.withCriticalRegion({ state in - state.wait(runnerID, suspendedWithContinuation: continuation) + guard let (output, continuation) = storage.withCriticalRegion({ storage in + storage.wait(runnerID, suspendedWithContinuation: continuation) }) else { return nil } continuation.resume() return output @@ -559,18 +607,22 @@ fileprivate extension AsyncSharedSequence { _ runnerID: UInt, suspendedWithContinuation continuation: UnsafeContinuation ) -> Bool { - storage.withCriticalRegion { state in - state.hold(runnerID, suspendedWithContinuation: continuation) + storage.withCriticalRegion { storage in + storage.hold(runnerID, suspendedWithContinuation: continuation) } } func cancel(_ runnerID: UInt) { - let continuation = storage.withCriticalRegion { $0.cancel(runnerID) } + let continuation = storage.withCriticalRegion { storage in + storage.cancel(runnerID) + } continuation?.resume() } func abort() { - let continuation = storage.withCriticalRegion { state in state.abort() } + let continuation = storage.withCriticalRegion { + storage in storage.abort() + } continuation.resume() } } From 8df85e015326e605629e138542dfe580ef4b7b94 Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Mon, 7 Nov 2022 12:19:11 +0000 Subject: [PATCH 08/18] remove redundant copy/replace shared iterator mechanism --- .../AsyncAlgorithms/AsyncSharedSequence.swift | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift index dd891dc1..039f4f86 100644 --- a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift +++ b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift @@ -190,8 +190,7 @@ extension AsyncSharedSequence: AsyncSequence { switch role { case .fetch(let iterator): let upstreamResult = await iterator.next() - let output = state.fetch( - id, resumedWithResult: upstreamResult, iterator: iterator) + let output = state.fetch(id, resumedWithResult: upstreamResult) return try processOutput(output) case .wait: let output = await withUnsafeContinuation { continuation in @@ -396,9 +395,8 @@ fileprivate extension AsyncSharedSequence { switch phase { case .pending: guard let iterator = iterator else { - preconditionFailure("iterator must not be over-borrowed") + preconditionFailure("fetching runner started out of band") } - self.iterator = nil phase = .fetching return (.fetch(iterator), nil) case .fetching: @@ -417,15 +415,11 @@ fileprivate extension AsyncSharedSequence { } mutating func fetch( - _ runnerID: UInt, - resumedWithResult result: Result, - iterator: SharedUpstreamIterator + _ runnerID: UInt, resumedWithResult result: Result ) -> (RunOutput, RunContinuation) { - precondition(self.iterator == nil, "iterator is already in place") guard let runner = runners[runnerID] else { preconditionFailure("fetching runner resumed out of band") } - self.iterator = iterator self.terminal = self.terminal || ((try? result.get()) == nil) self.phase = .done(result) finish(runnerID) @@ -581,12 +575,10 @@ fileprivate extension AsyncSharedSequence { } func fetch( - _ runnerID: UInt, - resumedWithResult result: Result, - iterator: SharedUpstreamIterator + _ runnerID: UInt, resumedWithResult result: Result ) -> RunOutput { let (output, continuation) = storage.withCriticalRegion { storage in - storage.fetch(runnerID, resumedWithResult: result, iterator: iterator) + storage.fetch(runnerID, resumedWithResult: result) } continuation.resume() return output From fb487411d4ebbbe1d6f68fe4a90208ed2b8c6b4e Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Mon, 7 Nov 2022 13:31:56 +0000 Subject: [PATCH 09/18] improve cancellation responsiveness --- .../AsyncAlgorithms/AsyncSharedSequence.swift | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift index 039f4f86..a5be3d45 100644 --- a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift +++ b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift @@ -382,16 +382,13 @@ fileprivate extension AsyncSharedSequence { } mutating func run(_ runnerID: UInt) -> (RunRole, RunContinuation?) { - guard terminal == false, let runner = runners[runnerID], runner.cancelled == false else { - if case .done(let result) = phase { - return (.yield(RunOutput(value: result, shouldCancel: true)), nil) - } - return (.yield(RunOutput(value: .success(nil), shouldCancel: true)), nil) + guard var runner = runners[runnerID], runner.cancelled == false else { + let output = RunOutput(value: .success(nil), shouldCancel: true) + return (.yield(output), nil) } if runner.group == currentGroup { - let updatedRunner = Runner( - group: runner.group, active: true, cancelled: runner.cancelled) - runners.updateValue(updatedRunner, forKey: runnerID) + runner.active = true + runners[runnerID] = runner switch phase { case .pending: guard let iterator = iterator else { @@ -403,8 +400,9 @@ fileprivate extension AsyncSharedSequence { return (.wait, nil) case .done(let result): finish(runnerID) + let shouldCancel = terminal || runner.cancelled let role = RunRole.yield( - RunOutput(value: result, shouldCancel: runner.cancelled) + RunOutput(value: result, shouldCancel: shouldCancel) ) return (role, .init(held: finalizeRunGroupIfNeeded())) } @@ -444,7 +442,8 @@ fileprivate extension AsyncSharedSequence { preconditionFailure("waiting runner resumed out of band") } finish(runnerID) - let output = RunOutput(value: result, shouldCancel: runner.cancelled) + let shouldCancel = terminal || runner.cancelled + let output = RunOutput(value: result, shouldCancel: shouldCancel) let continuation = RunContinuation(held: finalizeRunGroupIfNeeded()) return (output, continuation) default: @@ -461,8 +460,8 @@ fileprivate extension AsyncSharedSequence { preconditionFailure("waiting runner resumed out of band") } finish(waitingRunnerID) - let output = RunOutput( - value: result, shouldCancel: waitingRunner.cancelled) + let shouldCancel = terminal || waitingRunner.cancelled + let output = RunOutput(value: result, shouldCancel: shouldCancel) return (continuation, output) } waitingRunnerContinuations.removeAll() From fff7d23282105f7324fb9ce1b832168dee80d185 Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Mon, 7 Nov 2022 16:18:16 +0000 Subject: [PATCH 10/18] clarify internal type naming --- .../AsyncAlgorithms/AsyncSharedSequence.swift | 122 +++++++++--------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift index a5be3d45..378997d3 100644 --- a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift +++ b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift @@ -128,7 +128,7 @@ public struct AsyncSharedSequence : Sendable } private let base: Base - private let state: State + private let storage: Storage private let deallocToken: DeallocToken /// Contructs a shared asynchronous sequence @@ -143,11 +143,11 @@ public struct AsyncSharedSequence : Sendable history historyCount: Int = 0, disposingBaseIterator iteratorDisposalPolicy: IteratorDisposalPolicy = .whenTerminatedOrVacant ) { - let state = State( + let storage = Storage( base, replayCount: historyCount, iteratorDisposalPolicy: iteratorDisposalPolicy) self.base = base - self.state = state - self.deallocToken = .init { state.abort() } + self.storage = storage + self.deallocToken = .init { storage.abort() } } } @@ -162,20 +162,20 @@ extension AsyncSharedSequence: AsyncSequence { private let id: UInt private let deallocToken: DeallocToken? private var prefix: Deque - private var state: State? + private var storage: Storage? - fileprivate init(_ state: State) { - switch state.establish() { + fileprivate init(_ storage: Storage) { + switch storage.establish() { case .active(let id, let prefix): self.id = id self.prefix = prefix - self.deallocToken = .init { state.cancel(id) } - self.state = state + self.deallocToken = .init { storage.cancel(id) } + self.storage = storage case .terminal(let id): self.id = id self.prefix = .init() self.deallocToken = nil - self.state = nil + self.storage = nil } } @@ -185,16 +185,16 @@ extension AsyncSharedSequence: AsyncSequence { if prefix.isEmpty == false, let element = prefix.popFirst() { return element } - guard let state else { return nil } - let role = state.startRun(id) + guard let storage else { return nil } + let role = storage.startRun(id) switch role { case .fetch(let iterator): let upstreamResult = await iterator.next() - let output = state.fetch(id, resumedWithResult: upstreamResult) + let output = storage.fetch(id, resumedWithResult: upstreamResult) return try processOutput(output) case .wait: let output = await withUnsafeContinuation { continuation in - let immediateOutput = state.wait( + let immediateOutput = storage.wait( id, suspendedWithContinuation: continuation) if let immediateOutput { continuation.resume(returning: immediateOutput) @@ -205,18 +205,18 @@ extension AsyncSharedSequence: AsyncSequence { return try processOutput(output) case .hold: await withUnsafeContinuation { continuation in - let shouldImmediatelyResume = state.hold( + let shouldImmediatelyResume = storage.hold( id, suspendedWithContinuation: continuation) if shouldImmediatelyResume { continuation.resume() } } return try await next() } - } onCancel: { [state, id] in - state?.cancel(id) + } onCancel: { [storage, id] in + storage?.cancel(id) } } catch { - self.state = nil + self.storage = nil throw error } } @@ -225,24 +225,24 @@ extension AsyncSharedSequence: AsyncSequence { _ output: RunOutput ) rethrows -> Element? { if output.shouldCancel { - self.state = nil + self.storage = nil } do { guard let element = try output.value._rethrowGet() else { - self.state = nil + self.storage = nil return nil } return element } catch { - self.state = nil + self.storage = nil throw error } } } public func makeAsyncIterator() -> Iterator { - Iterator(state) + Iterator(storage) } } @@ -250,23 +250,6 @@ extension AsyncSharedSequence: AsyncSequence { fileprivate extension AsyncSharedSequence { - enum RunnerConnection { - case active(id: UInt, prefix: Deque) - case terminal(id: UInt) - } - - enum RunRole { - case fetch(SharedUpstreamIterator) - case wait - case yield(RunOutput) - case hold - } - - struct RunOutput { - let value: Result - var shouldCancel = false - } - actor SharedUpstreamIterator { private enum State { @@ -308,7 +291,24 @@ fileprivate extension AsyncSharedSequence { } } - struct State: Sendable { + enum RunnerConnection { + case active(id: UInt, prefix: Deque) + case terminal(id: UInt) + } + + enum RunRole { + case fetch(SharedUpstreamIterator) + case wait + case yield(RunOutput) + case hold + } + + struct RunOutput { + let value: Result + var shouldCancel = false + } + + struct Storage: Sendable { private struct RunContinuation { var held: [UnsafeContinuation]? @@ -325,7 +325,7 @@ fileprivate extension AsyncSharedSequence { } } - private struct Storage: Sendable { + private struct State: Sendable { enum Phase { case pending @@ -341,7 +341,7 @@ fileprivate extension AsyncSharedSequence { let base: Base let replayCount: Int - let iteratorDiscardPolicy: IteratorDisposalPolicy + let iteratorDisposalPolicy: IteratorDisposalPolicy var iterator: SharedUpstreamIterator? var nextRunnerID = UInt.min var currentGroup = 0 @@ -361,7 +361,7 @@ fileprivate extension AsyncSharedSequence { precondition(replayCount >= 0, "history must be greater than or equal to zero") self.base = base self.replayCount = replayCount - self.iteratorDiscardPolicy = iteratorDisposalPolicy + self.iteratorDisposalPolicy = iteratorDisposalPolicy self.iterator = .init(base) } @@ -518,7 +518,7 @@ fileprivate extension AsyncSharedSequence { else { self.currentGroup = nextGroup self.phase = .pending - if runners.isEmpty && iteratorDiscardPolicy == .whenTerminatedOrVacant { + if runners.isEmpty && iteratorDisposalPolicy == .whenTerminatedOrVacant { self.iterator = SharedUpstreamIterator(base) self.history.removeAll() } @@ -541,15 +541,15 @@ fileprivate extension AsyncSharedSequence { } } - private let storage: ManagedCriticalState + private let state: ManagedCriticalState init( _ base: Base, replayCount: Int, iteratorDisposalPolicy: IteratorDisposalPolicy ) { - self.storage = .init( - Storage( + self.state = .init( + State( base, replayCount: replayCount, iteratorDisposalPolicy: iteratorDisposalPolicy @@ -558,16 +558,16 @@ fileprivate extension AsyncSharedSequence { } func establish() -> RunnerConnection { - let (connection, continuation) = storage.withCriticalRegion { - storage in storage.establish() + let (connection, continuation) = state.withCriticalRegion { + state in state.establish() } continuation?.resume() return connection } func startRun(_ runnerID: UInt) -> RunRole { - let (role, continuation) = storage.withCriticalRegion { - storage in storage.run(runnerID) + let (role, continuation) = state.withCriticalRegion { + state in state.run(runnerID) } continuation?.resume() return role @@ -576,8 +576,8 @@ fileprivate extension AsyncSharedSequence { func fetch( _ runnerID: UInt, resumedWithResult result: Result ) -> RunOutput { - let (output, continuation) = storage.withCriticalRegion { storage in - storage.fetch(runnerID, resumedWithResult: result) + let (output, continuation) = state.withCriticalRegion { state in + state.fetch(runnerID, resumedWithResult: result) } continuation.resume() return output @@ -587,8 +587,8 @@ fileprivate extension AsyncSharedSequence { _ runnerID: UInt, suspendedWithContinuation continuation: UnsafeContinuation ) -> RunOutput? { - guard let (output, continuation) = storage.withCriticalRegion({ storage in - storage.wait(runnerID, suspendedWithContinuation: continuation) + guard let (output, continuation) = state.withCriticalRegion({ state in + state.wait(runnerID, suspendedWithContinuation: continuation) }) else { return nil } continuation.resume() return output @@ -598,21 +598,21 @@ fileprivate extension AsyncSharedSequence { _ runnerID: UInt, suspendedWithContinuation continuation: UnsafeContinuation ) -> Bool { - storage.withCriticalRegion { storage in - storage.hold(runnerID, suspendedWithContinuation: continuation) + state.withCriticalRegion { state in + state.hold(runnerID, suspendedWithContinuation: continuation) } } func cancel(_ runnerID: UInt) { - let continuation = storage.withCriticalRegion { storage in - storage.cancel(runnerID) + let continuation = state.withCriticalRegion { state in + state.cancel(runnerID) } continuation?.resume() } func abort() { - let continuation = storage.withCriticalRegion { - storage in storage.abort() + let continuation = state.withCriticalRegion { state in + state.abort() } continuation.resume() } From 6adf30e0d99981de5a94c76dee4f77d8d30401a8 Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Mon, 7 Nov 2022 16:26:27 +0000 Subject: [PATCH 11/18] remove Sendable base iterator constraint from docs --- Evolution/NNNN-share.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Evolution/NNNN-share.md b/Evolution/NNNN-share.md index 51181225..ec3b7375 100644 --- a/Evolution/NNNN-share.md +++ b/Evolution/NNNN-share.md @@ -32,7 +32,7 @@ It also provides two conveniences to adapt the sequence for the most common mult #### Declaration ```swift -public struct AsyncSharedSequence where Base: Sendable, Base.Element: Sendable, Base.AsyncIterator: Sendable +public struct AsyncSharedSequence where Base: Sendable, Base.Element: Sendable ``` #### Overview From c3a1a24492cfbb8199fd462c6b00f512227b890a Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Wed, 9 Nov 2022 15:15:07 +0000 Subject: [PATCH 12/18] clarity and naming --- .../AsyncAlgorithms/AsyncSharedSequence.swift | 394 +++++++++--------- 1 file changed, 191 insertions(+), 203 deletions(-) diff --git a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift index 378997d3..580bb8e8 100644 --- a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift +++ b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift @@ -11,52 +11,52 @@ // ALGORITHM SUMMARY: // -// The basic idea behind the `AsyncSharedSequence` algorithm is as follows: -// Every vended `AsyncSharedSequence` iterator (runner) takes part in a race -// (run group) to grab the next element from the base iterator. The 'winner' -// returns the element to the shared state, who then supplies the result to -// later finishers (other iterators). Once every runner has completed the -// current run group cycle, the next run group begins. This means that -// iterators run in lock-step, only moving forward when the the last iterator -// in the run group completes its current run (iteration). +// The idea behind the `AsyncSharedSequence` algorithm is as follows: Vended +// iterators of `AsyncSharedSequence` are known as 'runners'. Runners compete +// in a race to grab the next element from a base iterator for each of its +// iteration cycles. The 'winner' of an iteration cycle returns the element to +// the shared context which then supplies the result to later finishers. Once +// every runner has finished, the current cycle completes and the next +// iteration can start. This means that runners move forward in lock-step, only +// proceeding when the the last runner in the current iteration has received a +// value or has cancelled. // -// ITERATOR LIFECYCLE: +// `AsyncSharedSequence` ITERATOR LIFECYCLE: // // 1. CONNECTION: On connection, each 'runner' is issued with an ID (and any -// prefixed values from the history buffer). From this point on, the -// algorithm will wait on this iterator to consume its values before moving -// on. This means that until `next()` is called on this iterator, all the -// other iterators will be held until such time that it is, or the -// iterator's task is cancelled. +// prefixed values from the history buffer) by the shared context. From +// this point on, the algorithm will wait on this iterator to consume its +// values before moving on. This means that until `next()` is called on +// this iterator, all the other iterators will be held until such time that +// it is, or the iterator's task is cancelled. // // 2. RUN: After its prefix values have been exhausted, each time `next()` is // called on the iterator, the iterator attempts to start a 'run' by -// calling `startRun(_:)` on the shared state. The shared state marks the -// iterator as 'running' and issues a role to determine the iterator's -// action for the current run group. The roles are as follows: +// calling `startRun(_:)` on the shared context. The shared context marks +// the iterator as 'running' and issues a role to determine the iterator's +// action for the current iteration cycle. The roles are as follows: // -// - FETCH: The iterator is the 'winner' of this run group. It is issued -// with the 'borrowed' base iterator. It calls `next()` on it and, -// once it resumes, returns the value and the borrowed base iterator -// to the shared state. -// – WAIT: The iterator hasn't won this group, but was fast enough that +// - FETCH: The iterator is the 'winner' of this iteration cycle. It is +// issued with the shared base iterator, calls `next()` on it, and +// once it resumes returns the value to the shared context. +// – WAIT: The iterator hasn't won this cycle, but was fast enough that // the winner has yet to resume with the element from the base // iterator. Therefore, it is told to suspend (WAIT) until such time // that the winner resumes. // – YIELD: The iterator is late (and is holding up the other iterators). -// The shared state issues it with the value retrieved by the winning +// The shared context issues it with the value retrieved by the winning // iterator and lets it continue immediately. -// – HOLD: The iterator is early for the next run group. So it is put in -// the holding pen until the next run group can start. This is because -// there are other iterators that still haven't finished their run for -// the current run group. Once all other iterators have completed their -// run, this iterator will be resumed. +// – HOLD: The iterator is early for the next iteration cycle. So it is +// put in the holding pen until the next cycle can start. This is +// because there are other iterators that still haven't finished their +// run for the current iteration cycle. This iterator will be resumed +// when all other iterators have completed their run // -// 3. COMPLETION: The iterator calls cancel on the shared state which ensures -// the iterator does not take part in the next run group. However, if it is -// currently suspended it may not resume until the current run group -// concludes. This is especially important if it is filling the key FETCH -// role for the current run group. +// 3. COMPLETION: The iterator calls cancel on the shared context which +// ensures the iterator does not take part in the next iteration cycle. +// However, if it is currently suspended it may not resume until the +// current iteration cycle concludes. This is especially important if it is +// filling the key FETCH role for the current iteration cycle. // MARK: - Member Function @@ -128,7 +128,7 @@ public struct AsyncSharedSequence : Sendable } private let base: Base - private let storage: Storage + private let context: Context private let deallocToken: DeallocToken /// Contructs a shared asynchronous sequence @@ -143,11 +143,11 @@ public struct AsyncSharedSequence : Sendable history historyCount: Int = 0, disposingBaseIterator iteratorDisposalPolicy: IteratorDisposalPolicy = .whenTerminatedOrVacant ) { - let storage = Storage( + let context = Context( base, replayCount: historyCount, iteratorDisposalPolicy: iteratorDisposalPolicy) self.base = base - self.storage = storage - self.deallocToken = .init { storage.abort() } + self.context = context + self.deallocToken = .init { context.abort() } } } @@ -162,20 +162,20 @@ extension AsyncSharedSequence: AsyncSequence { private let id: UInt private let deallocToken: DeallocToken? private var prefix: Deque - private var storage: Storage? + private var context: Context? - fileprivate init(_ storage: Storage) { + fileprivate init(_ storage: Context) { switch storage.establish() { case .active(let id, let prefix): self.id = id self.prefix = prefix self.deallocToken = .init { storage.cancel(id) } - self.storage = storage - case .terminal(let id): - self.id = id + self.context = storage + case .terminal: + self.id = UInt.min self.prefix = .init() self.deallocToken = nil - self.storage = nil + self.context = nil } } @@ -185,134 +185,145 @@ extension AsyncSharedSequence: AsyncSequence { if prefix.isEmpty == false, let element = prefix.popFirst() { return element } - guard let storage else { return nil } - let role = storage.startRun(id) + guard let context else { return nil } + let role = context.startRun(id) switch role { case .fetch(let iterator): let upstreamResult = await iterator.next() - let output = storage.fetch(id, resumedWithResult: upstreamResult) + let output = context.fetch(id, resumedWithResult: upstreamResult) return try processOutput(output) case .wait: let output = await withUnsafeContinuation { continuation in - let immediateOutput = storage.wait( - id, suspendedWithContinuation: continuation) - if let immediateOutput { - continuation.resume(returning: immediateOutput) - } + context.wait(id, suspendedWithContinuation: continuation) } return try processOutput(output) case .yield(let output): return try processOutput(output) case .hold: await withUnsafeContinuation { continuation in - let shouldImmediatelyResume = storage.hold( - id, suspendedWithContinuation: continuation) - if shouldImmediatelyResume { continuation.resume() } + context.hold(id, suspendedWithContinuation: continuation) } return try await next() } - } onCancel: { [storage, id] in - storage?.cancel(id) + } onCancel: { [context, id] in + context?.cancel(id) } } catch { - self.storage = nil + self.context = nil throw error } } private mutating func processOutput( - _ output: RunOutput + _ output: Context.RunOutput ) rethrows -> Element? { if output.shouldCancel { - self.storage = nil + self.context = nil } do { guard let element = try output.value._rethrowGet() else { - self.storage = nil + self.context = nil return nil } return element } catch { - self.storage = nil + self.context = nil throw error } } } public func makeAsyncIterator() -> Iterator { - Iterator(storage) + Iterator(context) } } -// MARK: - State +// MARK: - Context -fileprivate extension AsyncSharedSequence { +private extension AsyncSharedSequence { - actor SharedUpstreamIterator { - - private enum State { - case pending - case active(Base.AsyncIterator) - case terminal - } + struct Context: Sendable { - private let base: Base - private var state = State.pending - - init(_ base: Base) { - self.base = base - } + typealias WaitContinuation = UnsafeContinuation + typealias HoldContinuation = UnsafeContinuation - func next() async -> Result { - switch state { - case .pending: - self.state = .active(base.makeAsyncIterator()) - return await next() - case .active(var iterator): - do { - if let element = try await iterator.next() { - self.state = .active(iterator) - return .success(element) + actor SharedUpstreamIterator { + + private enum State { + case pending + case active(Base.AsyncIterator) + case terminal + } + + private let base: Base + private var state = State.pending + + init(_ base: Base) { + self.base = base + } + + func next() async -> Result { + switch state { + case .pending: + self.state = .active(base.makeAsyncIterator()) + return await next() + case .active(var iterator): + do { + if let element = try await iterator.next() { + self.state = .active(iterator) + return .success(element) + } + else { + self.state = .terminal + return .success(nil) + } } - else { + catch { self.state = .terminal - return .success(nil) + return .failure(error) } + case .terminal: + return .success(nil) } - catch { - self.state = .terminal - return .failure(error) - } - case .terminal: - return .success(nil) } } - } - - enum RunnerConnection { - case active(id: UInt, prefix: Deque) - case terminal(id: UInt) - } - - enum RunRole { - case fetch(SharedUpstreamIterator) - case wait - case yield(RunOutput) - case hold - } - - struct RunOutput { - let value: Result - var shouldCancel = false - } - - struct Storage: Sendable { + + enum RunRole { + case fetch(SharedUpstreamIterator) + case wait + case yield(RunOutput) + case hold + } + + struct RunOutput { + let value: Result + var shouldCancel = false + } + + enum Connection { + case active(id: UInt, prefix: Deque) + case terminal + } + + private enum IterationPhase { + case pending + case fetching + case done(Result) + } + + private struct Runner { + var iterationIndex: Int + var active = false + var cancelled = false + } private struct RunContinuation { - var held: [UnsafeContinuation]? - var waiting: [(UnsafeContinuation, RunOutput)]? + + var held: [HoldContinuation]? + var waiting: [(WaitContinuation, RunOutput)]? + func resume() { if let held { for continuation in held { continuation.resume() } @@ -327,30 +338,18 @@ fileprivate extension AsyncSharedSequence { private struct State: Sendable { - enum Phase { - case pending - case fetching - case done(Result) - } - - struct Runner { - var group: Int - var active = false - var cancelled = false - } - let base: Base let replayCount: Int let iteratorDisposalPolicy: IteratorDisposalPolicy - var iterator: SharedUpstreamIterator? - var nextRunnerID = UInt.min - var currentGroup = 0 - var nextGroup: Int { (currentGroup + 1) % 2 } + var baseIterator: SharedUpstreamIterator? + var nextRunnerID = (UInt.min + 1) + var currentIterationIndex = 0 + var nextIterationIndex: Int { (currentIterationIndex + 1) % 2 } var history = Deque() var runners = [UInt: Runner]() var heldRunnerContinuations = [UnsafeContinuation]() - var waitingRunnerContinuations = [UInt: UnsafeContinuation]() - var phase = Phase.pending + var waitingRunnerContinuations = [UInt: WaitContinuation]() + var phase = IterationPhase.pending var terminal = false init( @@ -362,49 +361,48 @@ fileprivate extension AsyncSharedSequence { self.base = base self.replayCount = replayCount self.iteratorDisposalPolicy = iteratorDisposalPolicy - self.iterator = .init(base) + self.baseIterator = .init(base) } - mutating func establish() -> (RunnerConnection, RunContinuation?) { + mutating func establish() -> (Connection, RunContinuation?) { + guard terminal == false else { return (.terminal, nil) } defer { nextRunnerID += 1} - if terminal { - return (.terminal(id: nextRunnerID), nil) - } - else { - let group: Int - if case .done(_) = phase { group = nextGroup } else { group = currentGroup } - runners[nextRunnerID] = .init(group: group) - let connection = RunnerConnection.active( - id: nextRunnerID, prefix: history) - let continuation = RunContinuation(held: finalizeRunGroupIfNeeded()) - return (connection, continuation) + let iterationIndex: Int + if case .done(_) = phase { + iterationIndex = nextIterationIndex + } else { + iterationIndex = currentIterationIndex } + runners[nextRunnerID] = Runner(iterationIndex: iterationIndex) + let connection = Connection.active(id: nextRunnerID, prefix: history) + let continuation = RunContinuation(held: finalizeIterationIfNeeded()) + return (connection, continuation) } - mutating func run(_ runnerID: UInt) -> (RunRole, RunContinuation?) { + mutating func run( + _ runnerID: UInt + ) -> (RunRole, RunContinuation?) { guard var runner = runners[runnerID], runner.cancelled == false else { let output = RunOutput(value: .success(nil), shouldCancel: true) return (.yield(output), nil) } - if runner.group == currentGroup { + if runner.iterationIndex == currentIterationIndex { runner.active = true runners[runnerID] = runner switch phase { case .pending: - guard let iterator = iterator else { + guard let baseIterator = baseIterator else { preconditionFailure("fetching runner started out of band") } phase = .fetching - return (.fetch(iterator), nil) + return (.fetch(baseIterator), nil) case .fetching: return (.wait, nil) case .done(let result): finish(runnerID) let shouldCancel = terminal || runner.cancelled - let role = RunRole.yield( - RunOutput(value: result, shouldCancel: shouldCancel) - ) - return (role, .init(held: finalizeRunGroupIfNeeded())) + let role = RunRole.yield(RunOutput(value: result, shouldCancel: shouldCancel)) + return (role, .init(held: finalizeIterationIfNeeded())) } } else { @@ -422,17 +420,16 @@ fileprivate extension AsyncSharedSequence { self.phase = .done(result) finish(runnerID) updateHistory(withResult: result) - var continuation = gatherWaitingRunnerContinuationsForResumption( - withResult: result) - continuation.held = finalizeRunGroupIfNeeded() + var continuation = gatherWaitingRunnerContinuationsForResumption(withResult: result) + continuation.held = finalizeIterationIfNeeded() let ouput = RunOutput(value: result, shouldCancel: runner.cancelled) return (ouput, continuation) } mutating func wait( _ runnerID: UInt, - suspendedWithContinuation continuation: UnsafeContinuation - ) -> (RunOutput, RunContinuation)? { + suspendedWithContinuation continuation: WaitContinuation + ) -> RunContinuation? { switch phase { case .fetching: waitingRunnerContinuations[runnerID] = continuation @@ -444,8 +441,11 @@ fileprivate extension AsyncSharedSequence { finish(runnerID) let shouldCancel = terminal || runner.cancelled let output = RunOutput(value: result, shouldCancel: shouldCancel) - let continuation = RunContinuation(held: finalizeRunGroupIfNeeded()) - return (output, continuation) + let continuation = RunContinuation( + held: finalizeIterationIfNeeded(), + waiting: [(continuation, output)] + ) + return continuation default: preconditionFailure("waiting runner suspended out of band") } @@ -470,13 +470,13 @@ fileprivate extension AsyncSharedSequence { mutating func hold( _ runnerID: UInt, - suspendedWithContinuation continuation: UnsafeContinuation - ) -> Bool { - guard let runner = runners[runnerID], runner.group == nextGroup else { - return true + suspendedWithContinuation continuation: HoldContinuation + ) -> RunContinuation? { + guard let runner = runners[runnerID], runner.iterationIndex == nextIterationIndex else { + return RunContinuation(held: [continuation]) } heldRunnerContinuations.append(continuation) - return false + return nil } private mutating func finish(_ runnerID: UInt) { @@ -485,7 +485,7 @@ fileprivate extension AsyncSharedSequence { } if runner.cancelled == false { runner.active = false - runner.group = nextGroup + runner.iterationIndex = nextIterationIndex runners[runnerID] = runner } } @@ -493,33 +493,35 @@ fileprivate extension AsyncSharedSequence { mutating func cancel(_ runnerID: UInt) -> RunContinuation? { if let runner = runners.removeValue(forKey: runnerID), runner.active { runners[runnerID] = .init( - group: runner.group, active: true, cancelled: true) + iterationIndex: runner.iterationIndex, active: true, cancelled: true) return nil } else { - return .init(held: finalizeRunGroupIfNeeded()) + return RunContinuation(held: finalizeIterationIfNeeded()) } } mutating func abort() -> RunContinuation { terminal = true runners = runners.filter { _, runner in runner.active } - return .init(held: finalizeRunGroupIfNeeded()) + return RunContinuation(held: finalizeIterationIfNeeded()) } - private mutating func finalizeRunGroupIfNeeded( - ) -> [UnsafeContinuation]? { - if (runners.values.contains { $0.group == currentGroup }) { return nil } + private mutating func finalizeIterationIfNeeded() -> [HoldContinuation]? { + let isCurrentIterationActive = runners.values.contains { runner in + runner.iterationIndex == currentIterationIndex + } + if isCurrentIterationActive { return nil } if terminal { self.phase = .done(.success(nil)) - self.iterator = nil + self.baseIterator = nil self.history.removeAll() } else { - self.currentGroup = nextGroup + self.currentIterationIndex = nextIterationIndex self.phase = .pending if runners.isEmpty && iteratorDisposalPolicy == .whenTerminatedOrVacant { - self.iterator = SharedUpstreamIterator(base) + self.baseIterator = SharedUpstreamIterator(base) self.history.removeAll() } } @@ -528,9 +530,7 @@ fileprivate extension AsyncSharedSequence { return continuations } - private mutating func updateHistory( - withResult result: Result - ) { + private mutating func updateHistory(withResult result: Result) { guard replayCount > 0, case .success(let element?) = result else { return } @@ -543,11 +543,7 @@ fileprivate extension AsyncSharedSequence { private let state: ManagedCriticalState - init( - _ base: Base, - replayCount: Int, - iteratorDisposalPolicy: IteratorDisposalPolicy - ) { + init(_ base: Base, replayCount: Int, iteratorDisposalPolicy: IteratorDisposalPolicy) { self.state = .init( State( base, @@ -557,7 +553,7 @@ fileprivate extension AsyncSharedSequence { ) } - func establish() -> RunnerConnection { + func establish() -> Connection { let (connection, continuation) = state.withCriticalRegion { state in state.establish() } @@ -573,9 +569,7 @@ fileprivate extension AsyncSharedSequence { return role } - func fetch( - _ runnerID: UInt, resumedWithResult result: Result - ) -> RunOutput { + func fetch(_ runnerID: UInt, resumedWithResult result: Result) -> RunOutput { let (output, continuation) = state.withCriticalRegion { state in state.fetch(runnerID, resumedWithResult: result) } @@ -583,24 +577,18 @@ fileprivate extension AsyncSharedSequence { return output } - func wait( - _ runnerID: UInt, - suspendedWithContinuation continuation: UnsafeContinuation - ) -> RunOutput? { - guard let (output, continuation) = state.withCriticalRegion({ state in + func wait(_ runnerID: UInt, suspendedWithContinuation continuation: WaitContinuation) { + let continuation = state.withCriticalRegion { state in state.wait(runnerID, suspendedWithContinuation: continuation) - }) else { return nil } - continuation.resume() - return output + } + continuation?.resume() } - func hold( - _ runnerID: UInt, - suspendedWithContinuation continuation: UnsafeContinuation - ) -> Bool { - state.withCriticalRegion { state in + func hold(_ runnerID: UInt, suspendedWithContinuation continuation: HoldContinuation) { + let continuation = state.withCriticalRegion { state in state.hold(runnerID, suspendedWithContinuation: continuation) } + continuation?.resume() } func cancel(_ runnerID: UInt) { From 4889719d5cd50b753681af3f80815a50b97ccdb1 Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Thu, 10 Nov 2022 13:33:23 +0000 Subject: [PATCH 13/18] update proposal with feedback --- Evolution/NNNN-share.md | 190 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 186 insertions(+), 4 deletions(-) diff --git a/Evolution/NNNN-share.md b/Evolution/NNNN-share.md index ec3b7375..a827b9f3 100644 --- a/Evolution/NNNN-share.md +++ b/Evolution/NNNN-share.md @@ -17,16 +17,198 @@ The need often arises to distribute the values of an asynchronous sequence to multiple consumers. Intuitively, it seems that a sequence _should_ be iterable by more than a single consumer, but many types of asynchronous sequence are restricted to supporting only one consumer at a time. +One example of an asynchronous sequence that would naturally fit this 'one to many' shape is the output of a hardware sensor. A hypothetical hardware sensor might include the following API: + +```swift +public final class Accelerometer { + + public struct Event { /* ... */ } + + // exposed as a singleton to represent the single on-device sensor + public static let shared = Accelerometer() + + private init() {} + + public var updateHandler: ((Event) -> Void)? + + public func startAccelerometer() { /* ... */ } + public func stopAccelerometer() { /* ... */ } +} +``` + +To share the sensor data with a consumer through an asynchronous sequence you might choose an `AsyncStream`: + +```swift +final class OrientationMonitor { /* ... */ } +extension OrientationMonitor { + + static var orientation: AsyncStream { + AsyncStream { continuation in + Accelerometer.shared.updateHandler = { event in + continuation.yield(event) + } + continuation.onTermination = { @Sendable _ in + Accelerometer.shared.stopAccelerometer() + } + Accelerometer.shared.startAccelerometer() + } + } +} +``` + +With a single consumer, this pattern works as expected: + +```swift +let consumer1 = Task { + for await orientation in OrientationMonitor.orientation { + print("Consumer 1: Orientation: \(orientation)") + } +} +// Output: +// Consumer 1: Orientation: (0.0, 1.0, 0.0) +// Consumer 1: Orientation: (0.0, 0.8, 0.0) +// Consumer 1: Orientation: (0.0, 0.6, 0.0) +// Consumer 1: Orientation: (0.0, 0.4, 0.0) +// ... +``` + +However, as soon as a second consumer comes along, data for the first consumer stops. This is because the singleton `Accelerometer.shared.updateHandler` is updated within the closure for the creation of the second `AsyncStream`. This has the effect of redirecting all Accelerometer data to the second stream. + +One attempted workaround might be to vend a single `AsyncStream` to all consumers: + +```swift +extension OrientationMonitor { + + static let orientation: AsyncStream = { + AsyncStream { continuation in + Accelerometer.shared.updateHandler = { event in + continuation.yield(event) + } + continuation.onTermination = { @Sendable _ in + Accelerometer.shared.stopAccelerometer() + } + Accelerometer.shared.startAccelerometer() + } + }() +} +``` + +This comes with another issue though: if two consumers materialise, the output of the stream becomes split between them: + +```swift +let consumer1 = Task { + for await orientation in OrientationMonitor.orientation { + print("Consumer 1: Orientation: \(orientation)") + } +} +let consumer2 = Task { + for await orientation in OrientationMonitor.orientation { + print("Consumer 2: Orientation: \(orientation)") + } +} +// Output: +// Consumer 1: Orientation: (0.0, 1.0, 0.0) +// Consumer 2: Orientation: (0.0, 0.8, 0.0) +// Consumer 2: Orientation: (0.0, 0.6, 0.0) +// Consumer 1: Orientation: (0.0, 0.4, 0.0) +// ... +``` +Rather than consumers receiving all values emitted by the `AsyncStream`, they receive only a subset. In addition, if the task of a consumer is cancelled, via `consumer2.cancel()` for example, the `onTermination` trigger of the `AsyncSteam.Continuation` executes and stops Accelerometer data being generated for _both_ tasks. + ## Proposed solution -`AsyncSharedSequence` lifts this restriction, providing a way to multicast a single upstream asynchronous sequence to any number of consumers. +`AsyncSharedSequence` provides a way to multicast a single upstream asynchronous sequence to any number of consumers. + +``` +extension OrientationMonitor { + + static let orientation: AsyncSharedSequence> = { + let stream = AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + Accelerometer.shared.updateHandler = { event in + continuation.yield(event) + } + Accelerometer.shared.startAccelerometer() + } + return stream.share(disposingBaseIterator: .whenTerminated) + }() +} +``` + +Now, each consumer receives every element output by the source stream: + +```swift +let consumer1 = Task { + for await orientation in OrientationMonitor.orientation { + print("Consumer 1: Orientation: \(orientation)") + } +} +let consumer2 = Task { + for await orientation in OrientationMonitor.orientation { + print("Consumer 2: Orientation: \(orientation)") + } +} +// Output: +// Consumer 1: Orientation: (0.0, 1.0, 0.0) +// Consumer 2: Orientation: (0.0, 1.0, 0.0) +// Consumer 1: Orientation: (0.0, 0.8, 0.0) +// Consumer 2: Orientation: (0.0, 0.8, 0.0) +// Consumer 1: Orientation: (0.0, 0.6, 0.0) +// Consumer 2: Orientation: (0.0, 0.6, 0.0) +// Consumer 1: Orientation: (0.0, 0.4, 0.0) +// Consumer 2: Orientation: (0.0, 0.4, 0.0) +// ... +``` -It also provides two conveniences to adapt the sequence for the most common multicast use-cases: - 1. A history feature that allows late-coming consumers to receive the most recently emitted elements prior to their arrival. - 2. A configurable iterator disposal policy that determines whether the shared upstream iterator is disposed of when the consumer count count falls to zero. +This does leave our accelerometer running even when the last consumer has cancelled though. While this makes sense for some use-cases, it would be better if we could automate shutdown of the accelerometer when there's no longer any demand, and start it up again when demand returns. With the help of the `deferred` algorithm, we can: + +```swift +extension OrientationMonitor { + + static let orientation: AsyncSharedSequence>> = { + let stream = deferred { + AsyncStream { continuation in + Accelerometer.shared.updateHandler = { event in + continuation.yield(event) + } + continuation.onTermination = { @Sendable _ in + Accelerometer.shared.stopAccelerometer() + } + Accelerometer.shared.startAccelerometer() + } + } + // `.whenTerminatedOrVacant` is the default, so we could equally write `.share()` + // but it's included here for clarity. + return stream.share(disposingBaseIterator: .whenTerminatedOrVacant) + }() +} +``` + +With `.whenTerminatedOrVacant` set as the iterator disposal policy (the default), when the last downstream consumer cancels the upstream iterator is dropped. This triggers `AsyncStream`'s `onTermination` handler, shutting off the Accelerometer. + +Now, with `AsyncStream` composed with `AsyncDeferredSequence`, any new demand triggers the re-execution of `AsyncDeferredSequence`'s' closure, the restart of the Accelerometer, and a new sequence for `AsyncSharedSequence` to share. + +### Configuration Options + +`AsyncSharedSequence` provides two conveniences to adapt the sequence for the most common multicast use-cases: + 1. As described above, a configurable iterator disposal policy that determines whether the shared upstream iterator is disposed of when the consumer count count falls to zero. + 2. A history feature that allows late-coming consumers to receive the most recently emitted elements prior to their arrival. One use-case could be a UI that is updated by an infrequently emitting sequence. Rather than wait for the sequence to emit a new element to populate an interface, the last emitted value can be used until such time that fresh data is emitted. ## Detailed design +### Algorithm Summary: +The idea behind the `AsyncSharedSequence` algorithm is as follows: Vended iterators of `AsyncSharedSequence` are known as 'runners'. Runners compete in a race to grab the next element from a base iterator for each of its iteration cycles. The 'winner' of an iteration cycle returns the element to the shared context which then supplies the result to later finishers. Once every runner has finished, the current cycle completes and the next iteration can start. This means that runners move forward in lock-step, only proceeding when the the last runner in the current iteration has received a value or has cancelled. + +#### `AsyncSharedSequence` Iterator Lifecycle: + + 1. **Connection**: On connection, each 'runner' is issued with an ID (and any prefixed values from the history buffer) by the shared context. From this point on, the algorithm will wait on this iterator to consume its values before moving on. This means that until `next()` is called on this iterator, all the other iterators will be held until such time that it is, or the iterator's task is cancelled. + 2. **Run**: After its prefix values have been exhausted, each time `next()` is called on the iterator, the iterator attempts to start a 'run' by calling `startRun(_:)` on the shared context. The shared context marks the iterator as 'running' and issues a role to determine the iterator's action for the current iteration cycle. The roles are as follows: + - **FETCH**: The iterator is the 'winner' of this iteration cycle. It is issued with the shared base iterator, calls `next()` on it, and once it resumes returns the value to the shared context. + - **WAIT**: The iterator hasn't won this cycle, but was fast enough that the winner has yet to resume with the element from the base iterator. Therefore, it is told to suspend (WAIT) until such time that the winner resumes. + - **YIELD**: The iterator is late (and is holding up the other iterators). The shared context issues it with the value retrieved by the winning iterator and lets it continue immediately. + - **HOLD**: The iterator is early for the next iteration cycle. So it is put in the holding pen until the next cycle can start. This is because there are other iterators that still haven't finished their run for the current iteration cycle. This iterator will be resumed when all other iterators have completed their run. + + 3. **Completion**: The iterator calls cancel on the shared context which ensures the iterator does not take part in the next iteration cycle. However, if it is currently suspended it may not resume until the current iteration cycle concludes. This is especially important if it is filling the key FETCH role for the current iteration cycle. + ### AsyncSharedSequence #### Declaration From 9ca6bb9ab275b138b3ab8820d53f59620eb17879 Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Mon, 14 Nov 2022 14:35:06 +0000 Subject: [PATCH 14/18] add Task based shared iterator --- .../AsyncAlgorithms/AsyncSharedSequence.swift | 228 ++++++++++++------ 1 file changed, 158 insertions(+), 70 deletions(-) diff --git a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift index 580bb8e8..2d3ccebe 100644 --- a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift +++ b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift @@ -127,7 +127,6 @@ public struct AsyncSharedSequence : Sendable case whenTerminatedOrVacant } - private let base: Base private let context: Context private let deallocToken: DeallocToken @@ -145,7 +144,6 @@ public struct AsyncSharedSequence : Sendable ) { let context = Context( base, replayCount: historyCount, iteratorDisposalPolicy: iteratorDisposalPolicy) - self.base = base self.context = context self.deallocToken = .init { context.abort() } } @@ -193,16 +191,12 @@ extension AsyncSharedSequence: AsyncSequence { let output = context.fetch(id, resumedWithResult: upstreamResult) return try processOutput(output) case .wait: - let output = await withUnsafeContinuation { continuation in - context.wait(id, suspendedWithContinuation: continuation) - } + let output = await context.wait(id) return try processOutput(output) case .yield(let output): return try processOutput(output) case .hold: - await withUnsafeContinuation { continuation in - context.hold(id, suspendedWithContinuation: continuation) - } + await context.hold(id) return try await next() } } onCancel: { [context, id] in @@ -249,49 +243,8 @@ private extension AsyncSharedSequence { typealias WaitContinuation = UnsafeContinuation typealias HoldContinuation = UnsafeContinuation - actor SharedUpstreamIterator { - - private enum State { - case pending - case active(Base.AsyncIterator) - case terminal - } - - private let base: Base - private var state = State.pending - - init(_ base: Base) { - self.base = base - } - - func next() async -> Result { - switch state { - case .pending: - self.state = .active(base.makeAsyncIterator()) - return await next() - case .active(var iterator): - do { - if let element = try await iterator.next() { - self.state = .active(iterator) - return .success(element) - } - else { - self.state = .terminal - return .success(nil) - } - } - catch { - self.state = .terminal - return .failure(error) - } - case .terminal: - return .success(nil) - } - } - } - enum RunRole { - case fetch(SharedUpstreamIterator) + case fetch(SharedIterator) case wait case yield(RunOutput) case hold @@ -336,12 +289,50 @@ private extension AsyncSharedSequence { } } + struct SharedIterator: Sendable { + + private let task: Task + private let relay: AsyncRelay> + + init(_ base: Base) { + let relay = AsyncRelay>() + let operation = { @Sendable /* @MainActor */ in + await withTaskCancellationHandler { + var iterator = base.makeAsyncIterator() + while let send = await relay.sendHandler() { + let result: Result + do { + result = .success(try await iterator.next()) + } + catch { + result = .failure(error) + } + send(result) + if (try? result.get()) == nil { break } + } + } onCancel: { + relay.cancel() + } + } + self.relay = relay + self.task = Task(operation: operation) + } + + func next() async -> Result { + await relay.next() ?? .success(nil) + } + + func cancel() { + relay.cancel() + } + } + private struct State: Sendable { let base: Base let replayCount: Int let iteratorDisposalPolicy: IteratorDisposalPolicy - var baseIterator: SharedUpstreamIterator? + var baseIterator: SharedIterator? var nextRunnerID = (UInt.min + 1) var currentIterationIndex = 0 var nextIterationIndex: Int { (currentIterationIndex + 1) % 2 } @@ -359,9 +350,9 @@ private extension AsyncSharedSequence { ) { precondition(replayCount >= 0, "history must be greater than or equal to zero") self.base = base + self.baseIterator = SharedIterator(base) self.replayCount = replayCount self.iteratorDisposalPolicy = iteratorDisposalPolicy - self.baseIterator = .init(base) } mutating func establish() -> (Connection, RunContinuation?) { @@ -391,9 +382,7 @@ private extension AsyncSharedSequence { runners[runnerID] = runner switch phase { case .pending: - guard let baseIterator = baseIterator else { - preconditionFailure("fetching runner started out of band") - } + guard let baseIterator else { preconditionFailure("run started out of band") } phase = .fetching return (.fetch(baseIterator), nil) case .fetching: @@ -483,7 +472,7 @@ private extension AsyncSharedSequence { guard var runner = runners.removeValue(forKey: runnerID) else { preconditionFailure("run finished out of band") } - if runner.cancelled == false { + if terminal == false, runner.cancelled == false { runner.active = false runner.iterationIndex = nextIterationIndex runners[runnerID] = runner @@ -514,6 +503,7 @@ private extension AsyncSharedSequence { if isCurrentIterationActive { return nil } if terminal { self.phase = .done(.success(nil)) + self.baseIterator?.cancel() self.baseIterator = nil self.history.removeAll() } @@ -521,7 +511,8 @@ private extension AsyncSharedSequence { self.currentIterationIndex = nextIterationIndex self.phase = .pending if runners.isEmpty && iteratorDisposalPolicy == .whenTerminatedOrVacant { - self.baseIterator = SharedUpstreamIterator(base) + self.baseIterator?.cancel() + self.baseIterator = SharedIterator(base) self.history.removeAll() } } @@ -545,11 +536,7 @@ private extension AsyncSharedSequence { init(_ base: Base, replayCount: Int, iteratorDisposalPolicy: IteratorDisposalPolicy) { self.state = .init( - State( - base, - replayCount: replayCount, - iteratorDisposalPolicy: iteratorDisposalPolicy - ) + State(base, replayCount: replayCount, iteratorDisposalPolicy: iteratorDisposalPolicy) ) } @@ -577,18 +564,22 @@ private extension AsyncSharedSequence { return output } - func wait(_ runnerID: UInt, suspendedWithContinuation continuation: WaitContinuation) { - let continuation = state.withCriticalRegion { state in - state.wait(runnerID, suspendedWithContinuation: continuation) + func wait(_ runnerID: UInt) async -> RunOutput { + await withUnsafeContinuation { continuation in + let continuation = state.withCriticalRegion { state in + state.wait(runnerID, suspendedWithContinuation: continuation) + } + continuation?.resume() } - continuation?.resume() } - func hold(_ runnerID: UInt, suspendedWithContinuation continuation: HoldContinuation) { - let continuation = state.withCriticalRegion { state in - state.hold(runnerID, suspendedWithContinuation: continuation) + func hold(_ runnerID: UInt) async { + await withUnsafeContinuation { continuation in + let continuation = state.withCriticalRegion { state in + state.hold(runnerID, suspendedWithContinuation: continuation) + } + continuation?.resume() } - continuation?.resume() } func cancel(_ runnerID: UInt) { @@ -609,6 +600,7 @@ private extension AsyncSharedSequence { // MARK: - Utilities +/// A utility to perform deallocation tasks on value types fileprivate final class DeallocToken: Sendable { let action: @Sendable () -> Void init(_ dealloc: @escaping @Sendable () -> Void) { @@ -616,3 +608,99 @@ fileprivate final class DeallocToken: Sendable { } deinit { action() } } + +/// An asynchronous primitive that synchronizes sending elements between Tasks +fileprivate struct AsyncRelay: Sendable { + + private enum State { + + case idle + case pendingRequest(UnsafeContinuation<(@Sendable (Element) -> Void)?, Never>) + case pendingResponse(UnsafeContinuation) + case terminal + + mutating func sendHandler( + continuation: UnsafeContinuation<(@Sendable (Element) -> Void)?, Never> + ) -> (() -> Void)? { + switch self { + case .idle: + self = .pendingRequest(continuation) + case .pendingResponse(let receiveContinuation): + self = .idle + return { + continuation.resume { element in + receiveContinuation.resume(returning: element) + } + } + case .pendingRequest(_): + fatalError("attempt to await requestHandler() on more than one task") + case .terminal: + return { continuation.resume(returning: nil) } + } + return nil + } + + mutating func next(continuation: UnsafeContinuation) -> (() -> Void)? { + switch self { + case .idle: + self = .pendingResponse(continuation) + case .pendingResponse(_): + fatalError("attempt to await next(_:) on more than one task") + case .pendingRequest(let sendContinuation): + self = .idle + return { + sendContinuation.resume { element in + continuation.resume(returning: element) + } + } + case .terminal: + return { continuation.resume(returning: nil) } + } + return nil + } + + mutating func cancel() -> (() -> Void)? { + switch self { + case .idle: + self = .terminal + case .pendingResponse(let receiveContinuation): + self = .terminal + return { receiveContinuation.resume(returning: nil) } + case .pendingRequest(let sendContinuation): + self = .terminal + return { sendContinuation.resume(returning: nil) } + case .terminal: break + } + return nil + } + } + + private let state = ManagedCriticalState(State.idle) + + init() {} + + func sendHandler() async -> (@Sendable (Element) -> Void)? { + await withUnsafeContinuation { continuation in + let resume = state.withCriticalRegion { state in + state.sendHandler(continuation: continuation) + } + resume?() + } + } + + func next() async -> Element? { + await withUnsafeContinuation { continuation in + let resume = state.withCriticalRegion { state in + state.next(continuation: continuation) + } + resume?() + } + } + + func cancel() { + let resume = state.withCriticalRegion { state in + state.cancel() + } + resume?() + } +} From a1e9b820fea9b7215787d5e3411250929d69a81a Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Mon, 14 Nov 2022 15:12:05 +0000 Subject: [PATCH 15/18] add lazy shared iterator initialization --- .../AsyncAlgorithms/AsyncSharedSequence.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift index 2d3ccebe..e2dc92c8 100644 --- a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift +++ b/Sources/AsyncAlgorithms/AsyncSharedSequence.swift @@ -296,7 +296,8 @@ private extension AsyncSharedSequence { init(_ base: Base) { let relay = AsyncRelay>() - let operation = { @Sendable /* @MainActor */ in + self.relay = relay + self.task = Task { @Sendable /* @MainActor */ in await withTaskCancellationHandler { var iterator = base.makeAsyncIterator() while let send = await relay.sendHandler() { @@ -314,8 +315,6 @@ private extension AsyncSharedSequence { relay.cancel() } } - self.relay = relay - self.task = Task(operation: operation) } func next() async -> Result { @@ -382,9 +381,8 @@ private extension AsyncSharedSequence { runners[runnerID] = runner switch phase { case .pending: - guard let baseIterator else { preconditionFailure("run started out of band") } phase = .fetching - return (.fetch(baseIterator), nil) + return (.fetch(sharedIterator()), nil) case .fetching: return (.wait, nil) case .done(let result): @@ -512,7 +510,7 @@ private extension AsyncSharedSequence { self.phase = .pending if runners.isEmpty && iteratorDisposalPolicy == .whenTerminatedOrVacant { self.baseIterator?.cancel() - self.baseIterator = SharedIterator(base) + self.baseIterator = nil self.history.removeAll() } } @@ -530,6 +528,15 @@ private extension AsyncSharedSequence { } history.append(element) } + + private mutating func sharedIterator() -> SharedIterator { + guard let baseIterator else { + let iterator = SharedIterator(base) + self.baseIterator = iterator + return iterator + } + return baseIterator + } } private let state: ManagedCriticalState From 7d82a0025c7135efaa155cfae213ca4eb197d432 Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Wed, 16 Nov 2022 09:56:58 +0000 Subject: [PATCH 16/18] renamed to `broadcast` - renamed to broadcast - improved tests - updated docs to reflect new name --- .../{NNNN-share.md => NNNN-broadcast.md} | 56 +- ...nce.swift => AsyncBroadcastSequence.swift} | 516 +++++++++--------- .../Support/GatedSequence.swift | 14 +- .../Support/GatedStartSequence.swift | 110 ++++ .../Support/StartableSequence.swift | 126 ----- .../{TestShare.swift => TestBroadcast.swift} | 244 ++++----- 6 files changed, 485 insertions(+), 581 deletions(-) rename Evolution/{NNNN-share.md => NNNN-broadcast.md} (73%) rename Sources/AsyncAlgorithms/{AsyncSharedSequence.swift => AsyncBroadcastSequence.swift} (59%) create mode 100644 Tests/AsyncAlgorithmsTests/Support/GatedStartSequence.swift delete mode 100644 Tests/AsyncAlgorithmsTests/Support/StartableSequence.swift rename Tests/AsyncAlgorithmsTests/{TestShare.swift => TestBroadcast.swift} (64%) diff --git a/Evolution/NNNN-share.md b/Evolution/NNNN-broadcast.md similarity index 73% rename from Evolution/NNNN-share.md rename to Evolution/NNNN-broadcast.md index a827b9f3..13675435 100644 --- a/Evolution/NNNN-share.md +++ b/Evolution/NNNN-broadcast.md @@ -1,17 +1,17 @@ -# Share +# Broadcast -* Proposal: [NNNN](NNNN-deferred.md) +* Proposal: [NNNN](NNNN-broadcast.md) * Authors: [Tristan Celder](https://github.com/tcldr) * Review Manager: TBD * Status: **Awaiting implementation** - * Implementation: [[Source](https://github.com/tcldr/swift-async-algorithms/blob/pr/share/Sources/AsyncAlgorithms/AsyncSharedSequence.swift) | - [Tests](https://github.com/tcldr/swift-async-algorithms/blob/pr/share/Tests/AsyncAlgorithmsTests/TestShare.swift)] + * Implementation: [[Source](https://github.com/tcldr/swift-async-algorithms/blob/pr/share/Sources/AsyncAlgorithms/AsyncBroadcastSequence.swift) | + [Tests](https://github.com/tcldr/swift-async-algorithms/blob/pr/share/Tests/AsyncAlgorithmsTests/TestBroadcast.swift)] ## Introduction -`AsyncSharedSequence` unlocks additional use cases for structured concurrency and asynchronous sequences by allowing almost any asynchronous sequence to be adapted for consumption by multiple concurrent consumers. +`AsyncBroadcastSequence` unlocks additional use cases for structured concurrency and asynchronous sequences by allowing almost any asynchronous sequence to be adapted for consumption by multiple concurrent consumers. ## Motivation @@ -93,7 +93,7 @@ extension OrientationMonitor { } ``` -This comes with another issue though: if two consumers materialise, the output of the stream becomes split between them: +This comes with another issue though: when two consumers materialise, the output of the stream becomes split between them: ```swift let consumer1 = Task { @@ -117,12 +117,12 @@ Rather than consumers receiving all values emitted by the `AsyncStream`, they re ## Proposed solution -`AsyncSharedSequence` provides a way to multicast a single upstream asynchronous sequence to any number of consumers. +`AsyncBroadcastSequence` provides a way to multicast a single upstream asynchronous sequence to any number of consumers. ``` extension OrientationMonitor { - static let orientation: AsyncSharedSequence> = { + static let orientation: AsyncBroadcastSequence> = { let stream = AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in Accelerometer.shared.updateHandler = { event in continuation.yield(event) @@ -164,7 +164,7 @@ This does leave our accelerometer running even when the last consumer has cancel ```swift extension OrientationMonitor { - static let orientation: AsyncSharedSequence>> = { + static let orientation: AsyncBroadcastSequence>> = { let stream = deferred { AsyncStream { continuation in Accelerometer.shared.updateHandler = { event in @@ -185,20 +185,20 @@ extension OrientationMonitor { With `.whenTerminatedOrVacant` set as the iterator disposal policy (the default), when the last downstream consumer cancels the upstream iterator is dropped. This triggers `AsyncStream`'s `onTermination` handler, shutting off the Accelerometer. -Now, with `AsyncStream` composed with `AsyncDeferredSequence`, any new demand triggers the re-execution of `AsyncDeferredSequence`'s' closure, the restart of the Accelerometer, and a new sequence for `AsyncSharedSequence` to share. +Now, with `AsyncStream` composed with `AsyncDeferredSequence`, any new demand triggers the re-execution of `AsyncDeferredSequence`'s' closure, the restart of the Accelerometer, and a new sequence for `AsyncBroadcastSequence` to share. ### Configuration Options -`AsyncSharedSequence` provides two conveniences to adapt the sequence for the most common multicast use-cases: +`AsyncBroadcastSequence` provides two conveniences to adapt the sequence for the most common multicast use-cases: 1. As described above, a configurable iterator disposal policy that determines whether the shared upstream iterator is disposed of when the consumer count count falls to zero. 2. A history feature that allows late-coming consumers to receive the most recently emitted elements prior to their arrival. One use-case could be a UI that is updated by an infrequently emitting sequence. Rather than wait for the sequence to emit a new element to populate an interface, the last emitted value can be used until such time that fresh data is emitted. ## Detailed design ### Algorithm Summary: -The idea behind the `AsyncSharedSequence` algorithm is as follows: Vended iterators of `AsyncSharedSequence` are known as 'runners'. Runners compete in a race to grab the next element from a base iterator for each of its iteration cycles. The 'winner' of an iteration cycle returns the element to the shared context which then supplies the result to later finishers. Once every runner has finished, the current cycle completes and the next iteration can start. This means that runners move forward in lock-step, only proceeding when the the last runner in the current iteration has received a value or has cancelled. +The idea behind the `AsyncBroadcastSequence` algorithm is as follows: Vended iterators of `AsyncBroadcastSequence` are known as 'runners'. Runners compete in a race to grab the next element from a base iterator for each of its iteration cycles. The 'winner' of an iteration cycle returns the element to the shared context which then supplies the result to later finishers. Once every runner has finished, the current cycle completes and the next iteration can start. This means that runners move forward in lock-step, only proceeding when the the last runner in the current iteration has received a value or has cancelled. -#### `AsyncSharedSequence` Iterator Lifecycle: +#### `AsyncBroadcastSequence` Iterator Lifecycle: 1. **Connection**: On connection, each 'runner' is issued with an ID (and any prefixed values from the history buffer) by the shared context. From this point on, the algorithm will wait on this iterator to consume its values before moving on. This means that until `next()` is called on this iterator, all the other iterators will be held until such time that it is, or the iterator's task is cancelled. 2. **Run**: After its prefix values have been exhausted, each time `next()` is called on the iterator, the iterator attempts to start a 'run' by calling `startRun(_:)` on the shared context. The shared context marks the iterator as 'running' and issues a role to determine the iterator's action for the current iteration cycle. The roles are as follows: @@ -209,25 +209,25 @@ The idea behind the `AsyncSharedSequence` algorithm is as follows: Vended iterat 3. **Completion**: The iterator calls cancel on the shared context which ensures the iterator does not take part in the next iteration cycle. However, if it is currently suspended it may not resume until the current iteration cycle concludes. This is especially important if it is filling the key FETCH role for the current iteration cycle. -### AsyncSharedSequence +### AsyncBroadcastSequence #### Declaration ```swift -public struct AsyncSharedSequence where Base: Sendable, Base.Element: Sendable +public struct AsyncBroadcastSequence where Base: Sendable, Base.Element: Sendable ``` #### Overview An asynchronous sequence that can be iterated by multiple concurrent consumers. -Use a shared asynchronous sequence when you have multiple downstream asynchronous sequences with which you wish to share the output of a single asynchronous sequence. This can be useful if you have expensive upstream operations, or if your asynchronous sequence represents the output of a physical device. +Use an asynchronous broadcast sequence when you have multiple downstream asynchronous sequences with which you wish to share the output of a single asynchronous sequence. This can be useful if you have expensive upstream operations, or if your asynchronous sequence represents the output of a physical device. -Elements are emitted from a multicast asynchronous sequence at a rate that does not exceed the consumption of its slowest consumer. If this kind of back-pressure isn't desirable for your use-case, `AsyncSharedSequence` can be composed with buffers – either upstream, downstream, or both – to acheive the desired behavior. +Elements are emitted from an asynchronous broadcast sequence at a rate that does not exceed the consumption of its slowest consumer. If this kind of back-pressure isn't desirable for your use-case, `AsyncBroadcastSequence` can be composed with buffers – either upstream, downstream, or both – to acheive the desired behavior. -If you have an asynchronous sequence that consumes expensive system resources, it is possible to configure `AsyncSharedSequence` to discard its upstream iterator when the connected downstream consumer count falls to zero. This allows any cancellation tasks configured on the upstream asynchronous sequence to be initiated and for expensive resources to be terminated. `AsyncSharedSequence` will re-create a fresh iterator if there is further demand. +If you have an asynchronous sequence that consumes expensive system resources, it is possible to configure `AsyncBroadcastSequence` to discard its upstream iterator when the connected downstream consumer count falls to zero. This allows any cancellation tasks configured on the upstream asynchronous sequence to be initiated and for expensive resources to be terminated. `AsyncBroadcastSequence` will re-create a fresh iterator if there is further demand. -For use-cases where it is important for consumers to have a record of elements emitted prior to their connection, a `AsyncSharedSequence` can also be configured to prefix its output with the most recently emitted elements. If `AsyncSharedSequence` is configured to drop its iterator when the connected consumer count falls to zero, its history will be discarded at the same time. +For use-cases where it is important for consumers to have a record of elements emitted prior to their connection, a `AsyncBroadcastSequence` can also be configured to prefix its output with the most recently emitted elements. If `AsyncBroadcastSequence` is configured to drop its iterator when the connected consumer count falls to zero, its history will be discarded at the same time. #### Creating a sequence @@ -244,7 +244,7 @@ Contructs a shared asynchronous sequence. - `history`: the number of elements previously emitted by the sequence to prefix to the iterator of a new consumer - `iteratorDisposalPolicy`: the iterator disposal policy applied to the upstream iterator -### AsyncSharedSequence.IteratorDisposalPolicy +### AsyncBroadcastSequence.IteratorDisposalPolicy #### Declaration @@ -261,17 +261,17 @@ The iterator disposal policy applied by a shared asynchronous sequence to its up - `whenTerminated`: retains the upstream iterator for use by future consumers until the base asynchronous sequence is terminated - `whenTerminatedOrVacant`: discards the upstream iterator when the number of consumers falls to zero or the base asynchronous sequence is terminated -### share(history:disposingBaseIterator) +### broadcast(history:disposingBaseIterator) #### Declaration ```swift extension AsyncSequence { - public func share( + public func broadcast( history historyCount: Int = 0, - disposingBaseIterator iteratorDisposalPolicy: AsyncSharedSequence.IteratorDisposalPolicy = .whenTerminatedOrVacant - ) -> AsyncSharedSequence + disposingBaseIterator iteratorDisposalPolicy: AsyncBroadcastSequence.IteratorDisposalPolicy = .whenTerminatedOrVacant + ) -> AsyncBroadcastSequence } ``` @@ -282,19 +282,15 @@ Creates an asynchronous sequence that can be shared by multiple consumers. - `history`: the number of elements previously emitted by the sequence to prefix to the iterator of a new consumer - `iteratorDisposalPolicy`: the iterator disposal policy applied by a shared asynchronous sequence to its upstream iterator -## Naming - - The `share(history:disposingBaseIterator)` function takes its inspiration from the [`share()`](https://developer.apple.com/documentation/combine/fail/share()) Combine publisher, and the RxSwift [`share(replay:)`](https://github.com/ReactiveX/RxSwift/blob/3d3ed05bed71f19999db2207c714dab0028d37be/Documentation/GettingStarted.md#sharing-subscription-and-share-operator) operator, both of which fall under the multicasting family of operators in their respective libraries. - ## Comparison with other libraries - **ReactiveX** ReactiveX has the [Publish](https://reactivex.io/documentation/operators/publish.html) observable which when can be composed with the [Connect](https://reactivex.io/documentation/operators/connect.html), [RefCount](https://reactivex.io/documentation/operators/refcount.html) and [Replay](https://reactivex.io/documentation/operators/replay.html) operators to support various multi-casting use-cases. The `discardsBaseIterator` behavior is applied via `RefCount` (or the .`share().refCount()` chain of operators in RxSwift), while the history behavior is achieved through `Replay` (or the .`share(replay:)` convenience in RxSwift) - **Combine** Combine has the [ multicast(_:)](https://developer.apple.com/documentation/combine/publishers/multicast) operator, which along with the functionality of [ConnectablePublisher](https://developer.apple.com/documentation/combine/connectablepublisher) and associated conveniences supports many of the same use cases as the ReactiveX equivalent, but in some instances requires third-party ooperators to achieve the same level of functionality. -Due to the way a Swift `AsyncSequence`, and therefore `AsyncSharedSequence`, naturally applies back-pressure, the characteristics of an `AsyncSharedSequence` are different enough that a one-to-one API mapping of other reactive programmming libraries isn't applicable. +Due to the way a Swift `AsyncSequence`, and therefore `AsyncBroadcastSequence`, naturally applies back-pressure, the characteristics of an `AsyncBroadcastSequence` are different enough that a one-to-one API mapping of other reactive programmming libraries isn't applicable. -However, with the available configuration options – and through composition with other asynchronous sequences – `AsyncSharedSequence` can trivially be adapted to support many of the same use-cases, including that of [Connect](https://reactivex.io/documentation/operators/connect.html), [RefCount](https://reactivex.io/documentation/operators/refcount.html), and [Replay](https://reactivex.io/documentation/operators/replay.html). +However, with the available configuration options – and through composition with other asynchronous sequences – `AsyncBroadcastSequence` can trivially be adapted to support many of the same use-cases, including that of [Connect](https://reactivex.io/documentation/operators/connect.html), [RefCount](https://reactivex.io/documentation/operators/refcount.html), and [Replay](https://reactivex.io/documentation/operators/replay.html). ## Effect on API resilience diff --git a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift b/Sources/AsyncAlgorithms/AsyncBroadcastSequence.swift similarity index 59% rename from Sources/AsyncAlgorithms/AsyncSharedSequence.swift rename to Sources/AsyncAlgorithms/AsyncBroadcastSequence.swift index e2dc92c8..4d4aa1ea 100644 --- a/Sources/AsyncAlgorithms/AsyncSharedSequence.swift +++ b/Sources/AsyncAlgorithms/AsyncBroadcastSequence.swift @@ -11,8 +11,8 @@ // ALGORITHM SUMMARY: // -// The idea behind the `AsyncSharedSequence` algorithm is as follows: Vended -// iterators of `AsyncSharedSequence` are known as 'runners'. Runners compete +// The idea behind the `AsyncBroadcastSequence` algorithm is as follows: Vended +// iterators of `AsyncBroadcastSequence` are known as 'runners'. Runners compete // in a race to grab the next element from a base iterator for each of its // iteration cycles. The 'winner' of an iteration cycle returns the element to // the shared context which then supplies the result to later finishers. Once @@ -21,7 +21,7 @@ // proceeding when the the last runner in the current iteration has received a // value or has cancelled. // -// `AsyncSharedSequence` ITERATOR LIFECYCLE: +// `AsyncBroadcastSequence` ITERATOR LIFECYCLE: // // 1. CONNECTION: On connection, each 'runner' is issued with an ID (and any // prefixed values from the history buffer) by the shared context. From @@ -64,17 +64,18 @@ import DequeModule extension AsyncSequence where Self: Sendable, Element: Sendable { - /// Creates an asynchronous sequence that can be shared by multiple consumers. + /// Creates an asynchronous sequence that can be broadcast to multiple + /// consumers. /// /// - parameter history: the number of previously emitted elements to prefix /// to the iterator of a new consumer /// - parameter iteratorDisposalPolicy:the iterator disposal policy applied by - /// a shared asynchronous sequence to its upstream iterator - public func share( + /// a asynchronous broadcast sequence to its upstream iterator + public func broadcast( history historyCount: Int = 0, - disposingBaseIterator iteratorDisposalPolicy: AsyncSharedSequence.IteratorDisposalPolicy = .whenTerminatedOrVacant - ) -> AsyncSharedSequence { - AsyncSharedSequence( + disposingBaseIterator iteratorDisposalPolicy: AsyncBroadcastSequence.IteratorDisposalPolicy = .whenTerminatedOrVacant + ) -> AsyncBroadcastSequence { + AsyncBroadcastSequence( self, history: historyCount, disposingBaseIterator: iteratorDisposalPolicy) } } @@ -84,36 +85,36 @@ extension AsyncSequence where Self: Sendable, Element: Sendable { /// An asynchronous sequence that can be iterated by multiple concurrent /// consumers. /// -/// Use a shared asynchronous sequence when you have multiple downstream +/// Use an asynchronous broadcast sequence when you have multiple downstream /// asynchronous sequences with which you wish to share the output of a single /// asynchronous sequence. This can be useful if you have expensive upstream /// operations, or if your asynchronous sequence represents the output of a /// physical device. /// -/// Elements are emitted from a shared asynchronous sequence at a rate that does -/// not exceed the consumption of its slowest consumer. If this kind of -/// back-pressure isn't desirable for your use-case, ``AsyncSharedSequence`` can -/// be composed with buffers – either upstream, downstream, or both – to acheive -/// the desired behavior. +/// Elements are emitted from a asynchronous broadcast sequence at a rate that +/// does not exceed the consumption of its slowest consumer. If this kind of +/// back-pressure isn't desirable for your use-case, ``AsyncBroadcastSequence`` +/// can be composed with buffers – either upstream, downstream, or both – to +/// acheive the desired behavior. /// /// If you have an asynchronous sequence that consumes expensive system -/// resources, it is possible to configure ``AsyncSharedSequence`` to discard +/// resources, it is possible to configure ``AsyncBroadcastSequence`` to discard /// its upstream iterator when the connected downstream consumer count falls to /// zero. This allows any cancellation tasks configured on the upstream /// asynchronous sequence to be initiated and for expensive resources to be -/// terminated. ``AsyncSharedSequence`` will re-create a fresh iterator if there -/// is further demand. +/// terminated. ``AsyncBroadcastSequence`` will re-create a fresh iterator if +/// there is further demand. /// /// For use-cases where it is important for consumers to have a record of -/// elements emitted prior to their connection, a ``AsyncSharedSequence`` can +/// elements emitted prior to their connection, a ``AsyncBroadcastSequence`` can /// also be configured to prefix its output with the most recently emitted -/// elements. If ``AsyncSharedSequence`` is configured to drop its iterator when -/// the connected consumer count falls to zero, its history will be discarded at -/// the same time. -public struct AsyncSharedSequence : Sendable +/// elements. If ``AsyncBroadcastSequence`` is configured to drop its iterator +/// when the connected consumer count falls to zero, its history will be +/// discarded at the same time. +public struct AsyncBroadcastSequence : Sendable where Base: Sendable, Base.Element: Sendable { - /// The iterator disposal policy applied by a shared asynchronous sequence to + /// The iterator disposal policy applied by a asynchronous broadcast sequence to /// its upstream iterator /// /// - note: the iterator is always disposed when the base asynchronous @@ -130,9 +131,9 @@ public struct AsyncSharedSequence : Sendable private let context: Context private let deallocToken: DeallocToken - /// Contructs a shared asynchronous sequence + /// Contructs a asynchronous broadcast sequence /// - /// - parameter base: the asynchronous sequence to be shared + /// - parameter base: the asynchronous sequence to be broadcast /// - parameter history: the number of previously emitted elements to prefix /// to the iterator of a new consumer /// - parameter iteratorDisposalPolicy: the iterator disposal policy applied @@ -151,7 +152,7 @@ public struct AsyncSharedSequence : Sendable // MARK: - Iterator -extension AsyncSharedSequence: AsyncSequence { +extension AsyncBroadcastSequence: AsyncSequence { public typealias Element = Base.Element @@ -187,13 +188,20 @@ extension AsyncSharedSequence: AsyncSequence { let role = context.startRun(id) switch role { case .fetch(let iterator): - let upstreamResult = await iterator.next() - let output = context.fetch(id, resumedWithResult: upstreamResult) - return try processOutput(output) + do { + let element = try await iterator.next() + context.fetch(id, resumedWithResult: .success(element)) + return try processOutput(.success(element)) + } + catch { + context.fetch(id, resumedWithResult: .failure(error)) + return try processOutput(.failure(error)) + } case .wait: let output = await context.wait(id) return try processOutput(output) - case .yield(let output): + case .yield(let output, let resume): + resume?() return try processOutput(output) case .hold: await context.hold(id) @@ -210,21 +218,14 @@ extension AsyncSharedSequence: AsyncSequence { } private mutating func processOutput( - _ output: Context.RunOutput + _ output: Result ) rethrows -> Element? { - if output.shouldCancel { + switch output { + case .success(let value?): + return value + default: self.context = nil - } - do { - guard let element = try output.value._rethrowGet() else { - self.context = nil - return nil - } - return element - } - catch { - self.context = nil - throw error + return try output._rethrowGet() } } } @@ -236,25 +237,20 @@ extension AsyncSharedSequence: AsyncSequence { // MARK: - Context -private extension AsyncSharedSequence { +private extension AsyncBroadcastSequence { struct Context: Sendable { - typealias WaitContinuation = UnsafeContinuation + typealias WaitContinuation = UnsafeContinuation, Never> typealias HoldContinuation = UnsafeContinuation enum RunRole { - case fetch(SharedIterator) + case fetch(SharedIterator) case wait - case yield(RunOutput) + case yield(Result, (() -> Void)?) case hold } - struct RunOutput { - let value: Result - var shouldCancel = false - } - enum Connection { case active(id: UInt, prefix: Deque) case terminal @@ -272,75 +268,21 @@ private extension AsyncSharedSequence { var cancelled = false } - private struct RunContinuation { - - var held: [HoldContinuation]? - var waiting: [(WaitContinuation, RunOutput)]? - - func resume() { - if let held { - for continuation in held { continuation.resume() } - } - if let waiting { - for (continuation, output) in waiting { - continuation.resume(returning: output) - } - } - } - } - - struct SharedIterator: Sendable { - - private let task: Task - private let relay: AsyncRelay> - - init(_ base: Base) { - let relay = AsyncRelay>() - self.relay = relay - self.task = Task { @Sendable /* @MainActor */ in - await withTaskCancellationHandler { - var iterator = base.makeAsyncIterator() - while let send = await relay.sendHandler() { - let result: Result - do { - result = .success(try await iterator.next()) - } - catch { - result = .failure(error) - } - send(result) - if (try? result.get()) == nil { break } - } - } onCancel: { - relay.cancel() - } - } - } - - func next() async -> Result { - await relay.next() ?? .success(nil) - } - - func cancel() { - relay.cancel() - } - } - private struct State: Sendable { let base: Base let replayCount: Int let iteratorDisposalPolicy: IteratorDisposalPolicy - var baseIterator: SharedIterator? + var iterator: SharedIterator? var nextRunnerID = (UInt.min + 1) var currentIterationIndex = 0 var nextIterationIndex: Int { (currentIterationIndex + 1) % 2 } var history = Deque() var runners = [UInt: Runner]() + var iterationPhase = IterationPhase.pending + var terminal = false var heldRunnerContinuations = [UnsafeContinuation]() var waitingRunnerContinuations = [UInt: WaitContinuation]() - var phase = IterationPhase.pending - var terminal = false init( _ base: Base, @@ -349,90 +291,80 @@ private extension AsyncSharedSequence { ) { precondition(replayCount >= 0, "history must be greater than or equal to zero") self.base = base - self.baseIterator = SharedIterator(base) self.replayCount = replayCount self.iteratorDisposalPolicy = iteratorDisposalPolicy } - mutating func establish() -> (Connection, RunContinuation?) { + mutating func establish() -> (Connection, (() -> Void)?) { guard terminal == false else { return (.terminal, nil) } defer { nextRunnerID += 1} let iterationIndex: Int - if case .done(_) = phase { + if case .done(_) = iterationPhase { iterationIndex = nextIterationIndex } else { iterationIndex = currentIterationIndex } runners[nextRunnerID] = Runner(iterationIndex: iterationIndex) let connection = Connection.active(id: nextRunnerID, prefix: history) - let continuation = RunContinuation(held: finalizeIterationIfNeeded()) - return (connection, continuation) + return (connection, finalizeIterationIfNeeded()) } mutating func run( _ runnerID: UInt - ) -> (RunRole, RunContinuation?) { + ) -> RunRole { guard var runner = runners[runnerID], runner.cancelled == false else { - let output = RunOutput(value: .success(nil), shouldCancel: true) - return (.yield(output), nil) + return .yield(.success(nil), nil) } if runner.iterationIndex == currentIterationIndex { runner.active = true runners[runnerID] = runner - switch phase { + switch iterationPhase { case .pending: - phase = .fetching - return (.fetch(sharedIterator()), nil) + iterationPhase = .fetching + return .fetch(sharedIterator()) case .fetching: - return (.wait, nil) + return .wait case .done(let result): finish(runnerID) - let shouldCancel = terminal || runner.cancelled - let role = RunRole.yield(RunOutput(value: result, shouldCancel: shouldCancel)) - return (role, .init(held: finalizeIterationIfNeeded())) + return .yield(result, finalizeIterationIfNeeded()) } } else { - return (.hold, nil) + return .hold } } mutating func fetch( _ runnerID: UInt, resumedWithResult result: Result - ) -> (RunOutput, RunContinuation) { - guard let runner = runners[runnerID] else { - preconditionFailure("fetching runner resumed out of band") - } + ) -> (() -> Void)? { self.terminal = self.terminal || ((try? result.get()) == nil) - self.phase = .done(result) + self.iterationPhase = .done(result) finish(runnerID) updateHistory(withResult: result) - var continuation = gatherWaitingRunnerContinuationsForResumption(withResult: result) - continuation.held = finalizeIterationIfNeeded() - let ouput = RunOutput(value: result, shouldCancel: runner.cancelled) - return (ouput, continuation) + let waitContinuation = gatherWaitingRunnerContinuationsForResumption(withResult: result) + let heldContinuation = finalizeIterationIfNeeded() + return { + waitContinuation?() + heldContinuation?() + } } mutating func wait( _ runnerID: UInt, suspendedWithContinuation continuation: WaitContinuation - ) -> RunContinuation? { - switch phase { + ) -> (() -> Void)? { + switch iterationPhase { case .fetching: waitingRunnerContinuations[runnerID] = continuation return nil case .done(let result): - guard let runner = runners[runnerID] else { - preconditionFailure("waiting runner resumed out of band") - } finish(runnerID) - let shouldCancel = terminal || runner.cancelled - let output = RunOutput(value: result, shouldCancel: shouldCancel) - let continuation = RunContinuation( - held: finalizeIterationIfNeeded(), - waiting: [(continuation, output)] - ) - return continuation + let waitContinuation = { continuation.resume(returning: result) } + let heldContinuation = finalizeIterationIfNeeded() + return { + waitContinuation() + heldContinuation?() + } default: preconditionFailure("waiting runner suspended out of band") } @@ -440,27 +372,24 @@ private extension AsyncSharedSequence { private mutating func gatherWaitingRunnerContinuationsForResumption( withResult result: Result - ) -> RunContinuation { - let continuationPairs = waitingRunnerContinuations + ) -> (() -> Void)? { + let continuations = waitingRunnerContinuations .map { waitingRunnerID, continuation in - guard let waitingRunner = runners[waitingRunnerID] else { - preconditionFailure("waiting runner resumed out of band") - } finish(waitingRunnerID) - let shouldCancel = terminal || waitingRunner.cancelled - let output = RunOutput(value: result, shouldCancel: shouldCancel) - return (continuation, output) + return { continuation.resume(returning: result) } } waitingRunnerContinuations.removeAll() - return .init(waiting: continuationPairs) + return { + for continuation in continuations { continuation() } + } } mutating func hold( _ runnerID: UInt, suspendedWithContinuation continuation: HoldContinuation - ) -> RunContinuation? { + ) -> (() -> Void)? { guard let runner = runners[runnerID], runner.iterationIndex == nextIterationIndex else { - return RunContinuation(held: [continuation]) + return continuation.resume } heldRunnerContinuations.append(continuation) return nil @@ -477,46 +406,46 @@ private extension AsyncSharedSequence { } } - mutating func cancel(_ runnerID: UInt) -> RunContinuation? { + mutating func cancel(_ runnerID: UInt) -> (() -> Void)? { if let runner = runners.removeValue(forKey: runnerID), runner.active { runners[runnerID] = .init( iterationIndex: runner.iterationIndex, active: true, cancelled: true) return nil } else { - return RunContinuation(held: finalizeIterationIfNeeded()) + return finalizeIterationIfNeeded() } } - mutating func abort() -> RunContinuation { + mutating func abort() -> (() -> Void)? { terminal = true runners = runners.filter { _, runner in runner.active } - return RunContinuation(held: finalizeIterationIfNeeded()) + return finalizeIterationIfNeeded() } - private mutating func finalizeIterationIfNeeded() -> [HoldContinuation]? { + private mutating func finalizeIterationIfNeeded() -> (() -> Void)? { let isCurrentIterationActive = runners.values.contains { runner in runner.iterationIndex == currentIterationIndex } if isCurrentIterationActive { return nil } if terminal { - self.phase = .done(.success(nil)) - self.baseIterator?.cancel() - self.baseIterator = nil + self.iterationPhase = .done(.success(nil)) + self.iterator = nil self.history.removeAll() } else { self.currentIterationIndex = nextIterationIndex - self.phase = .pending + self.iterationPhase = .pending if runners.isEmpty && iteratorDisposalPolicy == .whenTerminatedOrVacant { - self.baseIterator?.cancel() - self.baseIterator = nil + self.iterator = nil self.history.removeAll() } } let continuations = heldRunnerContinuations heldRunnerContinuations.removeAll() - return continuations + return { + for continuation in continuations { continuation.resume() } + } } private mutating func updateHistory(withResult result: Result) { @@ -529,13 +458,13 @@ private extension AsyncSharedSequence { history.append(element) } - private mutating func sharedIterator() -> SharedIterator { - guard let baseIterator else { + private mutating func sharedIterator() -> SharedIterator { + guard let iterator else { let iterator = SharedIterator(base) - self.baseIterator = iterator + self.iterator = iterator return iterator } - return baseIterator + return iterator } } @@ -548,166 +477,209 @@ private extension AsyncSharedSequence { } func establish() -> Connection { - let (connection, continuation) = state.withCriticalRegion { + let (connection, resume) = state.withCriticalRegion { state in state.establish() } - continuation?.resume() + resume?() return connection } func startRun(_ runnerID: UInt) -> RunRole { - let (role, continuation) = state.withCriticalRegion { + return state.withCriticalRegion { state in state.run(runnerID) } - continuation?.resume() - return role } - func fetch(_ runnerID: UInt, resumedWithResult result: Result) -> RunOutput { - let (output, continuation) = state.withCriticalRegion { state in + func fetch(_ runnerID: UInt, resumedWithResult result: Result) { + let resume = state.withCriticalRegion { state in state.fetch(runnerID, resumedWithResult: result) } - continuation.resume() - return output + resume?() } - func wait(_ runnerID: UInt) async -> RunOutput { + func wait(_ runnerID: UInt) async -> Result { await withUnsafeContinuation { continuation in - let continuation = state.withCriticalRegion { state in + let resume = state.withCriticalRegion { state in state.wait(runnerID, suspendedWithContinuation: continuation) } - continuation?.resume() + resume?() } } func hold(_ runnerID: UInt) async { await withUnsafeContinuation { continuation in - let continuation = state.withCriticalRegion { state in + let resume = state.withCriticalRegion { state in state.hold(runnerID, suspendedWithContinuation: continuation) } - continuation?.resume() + resume?() } } func cancel(_ runnerID: UInt) { - let continuation = state.withCriticalRegion { state in + let resume = state.withCriticalRegion { state in state.cancel(runnerID) } - continuation?.resume() + resume?() } func abort() { - let continuation = state.withCriticalRegion { state in + let resume = state.withCriticalRegion { state in state.abort() } - continuation.resume() + resume?() } } } -// MARK: - Utilities +// MARK: - Shared Iterator -/// A utility to perform deallocation tasks on value types -fileprivate final class DeallocToken: Sendable { - let action: @Sendable () -> Void - init(_ dealloc: @escaping @Sendable () -> Void) { - self.action = dealloc - } - deinit { action() } -} - -/// An asynchronous primitive that synchronizes sending elements between Tasks -fileprivate struct AsyncRelay: Sendable { +fileprivate final class SharedIterator + where Base: Sendable, Base.Element: Sendable { - private enum State { + private struct Relay: Sendable { - case idle - case pendingRequest(UnsafeContinuation<(@Sendable (Element) -> Void)?, Never>) - case pendingResponse(UnsafeContinuation) - case terminal - - mutating func sendHandler( - continuation: UnsafeContinuation<(@Sendable (Element) -> Void)?, Never> - ) -> (() -> Void)? { - switch self { - case .idle: - self = .pendingRequest(continuation) - case .pendingResponse(let receiveContinuation): - self = .idle - return { - continuation.resume { element in - receiveContinuation.resume(returning: element) + private enum State { + + case idle + case pendingRequest(UnsafeContinuation<(@Sendable (Element) -> Void)?, Never>) + case pendingResponse(UnsafeContinuation) + case terminal + + mutating func sendHandler( + continuation: UnsafeContinuation<(@Sendable (Element) -> Void)?, Never> + ) -> (() -> Void)? { + switch self { + case .idle: + self = .pendingRequest(continuation) + case .pendingResponse(let receiveContinuation): + self = .idle + return { + continuation.resume { element in + receiveContinuation.resume(returning: element) + } } + case .pendingRequest(_): + fatalError("attempt to await requestHandler() on more than one task") + case .terminal: + return { continuation.resume(returning: nil) } } - case .pendingRequest(_): - fatalError("attempt to await requestHandler() on more than one task") - case .terminal: - return { continuation.resume(returning: nil) } + return nil + } + + mutating func next(continuation: UnsafeContinuation) -> (() -> Void)? { + switch self { + case .idle: + self = .pendingResponse(continuation) + case .pendingResponse(_): + fatalError("attempt to await next(_:) on more than one task") + case .pendingRequest(let sendContinuation): + self = .idle + return { + sendContinuation.resume { element in + continuation.resume(returning: element) + } + } + case .terminal: + return { continuation.resume(returning: nil) } + } + return nil + } + + mutating func cancel() -> (() -> Void)? { + switch self { + case .idle: + self = .terminal + case .pendingResponse(let receiveContinuation): + self = .terminal + return { receiveContinuation.resume(returning: nil) } + case .pendingRequest(let sendContinuation): + self = .terminal + return { sendContinuation.resume(returning: nil) } + case .terminal: break + } + return nil } - return nil } - mutating func next(continuation: UnsafeContinuation) -> (() -> Void)? { - switch self { - case .idle: - self = .pendingResponse(continuation) - case .pendingResponse(_): - fatalError("attempt to await next(_:) on more than one task") - case .pendingRequest(let sendContinuation): - self = .idle - return { - sendContinuation.resume { element in - continuation.resume(returning: element) - } + private let state = ManagedCriticalState(State.idle) + + init() {} + + func sendHandler() async -> (@Sendable (Element) -> Void)? { + await withUnsafeContinuation { continuation in + let resume = state.withCriticalRegion { state in + state.sendHandler(continuation: continuation) } - case .terminal: - return { continuation.resume(returning: nil) } + resume?() } - return nil } - mutating func cancel() -> (() -> Void)? { - switch self { - case .idle: - self = .terminal - case .pendingResponse(let receiveContinuation): - self = .terminal - return { receiveContinuation.resume(returning: nil) } - case .pendingRequest(let sendContinuation): - self = .terminal - return { sendContinuation.resume(returning: nil) } - case .terminal: break + func next() async -> Element? { + await withUnsafeContinuation { continuation in + let resume = state.withCriticalRegion { state in + state.next(continuation: continuation) + } + resume?() } - return nil } - } - - private let state = ManagedCriticalState(State.idle) - - init() {} - - func sendHandler() async -> (@Sendable (Element) -> Void)? { - await withUnsafeContinuation { continuation in + + func cancel() { let resume = state.withCriticalRegion { state in - state.sendHandler(continuation: continuation) + state.cancel() } resume?() } } - func next() async -> Element? { - await withUnsafeContinuation { continuation in - let resume = state.withCriticalRegion { state in - state.next(continuation: continuation) + typealias Element = Base.Element + + private let relay: Relay> + private let task: Task + + init(_ base: Base) { + let relay = Relay>() + let task = Task.detached(priority: .high) { + var iterator = base.makeAsyncIterator() + while let send = await relay.sendHandler() { + let result: Result + do { + result = .success(try await iterator.next()) + } + catch { + result = .failure(error) + } + send(result) + let terminal = (try? result.get()) == nil + if terminal { + relay.cancel() + break + } } - resume?() } + self.relay = relay + self.task = task } - func cancel() { - let resume = state.withCriticalRegion { state in - state.cancel() - } - resume?() + deinit { + relay.cancel() } + + public func next() async rethrows -> Element? { + guard Task.isCancelled == false else { return nil } + let result = await relay.next() ?? .success(nil) + return try result._rethrowGet() + } +} + +extension SharedIterator: AsyncIteratorProtocol, Sendable {} + +// MARK: - Utilities + +/// A utility to perform deallocation tasks on value types +fileprivate final class DeallocToken: Sendable { + let action: @Sendable () -> Void + init(_ dealloc: @escaping @Sendable () -> Void) { + self.action = dealloc + } + deinit { action() } } diff --git a/Tests/AsyncAlgorithmsTests/Support/GatedSequence.swift b/Tests/AsyncAlgorithmsTests/Support/GatedSequence.swift index 50ede106..99472b54 100644 --- a/Tests/AsyncAlgorithmsTests/Support/GatedSequence.swift +++ b/Tests/AsyncAlgorithmsTests/Support/GatedSequence.swift @@ -9,13 +9,18 @@ // //===----------------------------------------------------------------------===// -public struct GatedSequence { +public struct GatedSequence: Sendable { + let elements: [Element] let gates: [Gate] - var index = 0 - public mutating func advance() { - defer { index += 1 } + let index = ManagedCriticalState(0) + + public func advance() { + let index = self.index.withCriticalRegion { index in + defer { index += 1 } + return index + } guard index < gates.count else { return } @@ -51,5 +56,4 @@ extension GatedSequence: AsyncSequence { } } -extension GatedSequence: Sendable where Element: Sendable { } extension GatedSequence.Iterator: Sendable where Element: Sendable { } diff --git a/Tests/AsyncAlgorithmsTests/Support/GatedStartSequence.swift b/Tests/AsyncAlgorithmsTests/Support/GatedStartSequence.swift new file mode 100644 index 00000000..8b3ea9c0 --- /dev/null +++ b/Tests/AsyncAlgorithmsTests/Support/GatedStartSequence.swift @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Async Algorithms open source project +// +// Copyright (c) 2022 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +/// An `AsyncSequence` that delays publishing elements until an entry threshold has been reached. +/// Once the entry threshold has been met the sequence proceeds as normal. +public struct GatedStartSequence: Sendable { + + private let elements: [Element] + private let semaphore: BasicSemaphore + + /// Decrements the entry counter and, upon reaching zero, resumes the iterator + public func enter() { + semaphore.signal() + } + + /// Creates new ``StartableSequence`` with an initial entry count + public init(_ elements: T, count: Int) where T.Element == Element { + self.elements = Array(elements) + self.semaphore = .init(count: 1 - count) + } +} + +extension GatedStartSequence: AsyncSequence { + + public struct Iterator: AsyncIteratorProtocol { + + private var elements: [Element] + private let semaphore: BasicSemaphore + + init(elements: [Element], semaphore: BasicSemaphore) { + self.elements = elements + self.semaphore = semaphore + } + + public mutating func next() async -> Element? { + await semaphore.wait() + semaphore.signal() + guard let element = elements.first else { return nil } + elements.removeFirst() + return element + } + } + + public func makeAsyncIterator() -> Iterator { + Iterator(elements: elements, semaphore: semaphore) + } +} + +struct BasicSemaphore { + + private struct State { + + var count: Int + var continuations = [UnsafeContinuation]() + + mutating func wait(continuation: UnsafeContinuation) -> (() -> Void)? { + count -= 1 + if count < 0 { + continuations.append(continuation) + return nil + } + else { + return { continuation.resume() } + } + } + + mutating func signal() -> (() -> Void)? { + count += 1 + if count >= 0 { + let continuations = self.continuations + self.continuations.removeAll() + return { + for continuation in continuations { continuation.resume() } + } + } + else { + return nil + } + } + } + + private let state: ManagedCriticalState + + /// Creates new counting semaphore with an initial value. + init(count: Int) { + self.state = ManagedCriticalState(State(count: count)) + } + + /// Waits for, or decrements, a semaphore. + func wait() async { + await withUnsafeContinuation { continuation in + let resume = state.withCriticalRegion { $0.wait(continuation: continuation) } + resume?() + } + } + + /// Signals (increments) a semaphore. + func signal() { + let resume = state.withCriticalRegion { $0.signal() } + resume?() + } +} diff --git a/Tests/AsyncAlgorithmsTests/Support/StartableSequence.swift b/Tests/AsyncAlgorithmsTests/Support/StartableSequence.swift deleted file mode 100644 index 7413d7a0..00000000 --- a/Tests/AsyncAlgorithmsTests/Support/StartableSequence.swift +++ /dev/null @@ -1,126 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift Async Algorithms open source project -// -// Copyright (c) 2022 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// -//===----------------------------------------------------------------------===// - -extension AsyncSequence { - - /// Creates a ``StartableSequence`` that suspends the output of its Iterator until `enter()` is called `count` times. - public func delayed(_ count: Int) -> StartableSequence { - StartableSequence(self, count: count) - } -} - -/// An `AsyncSequence` that delays publishing elements until an entry threshold has been reached. -/// Once the entry threshold has been met the sequence proceeds as normal. -public struct StartableSequence { - - private let base: Base - private let semaphore: BasicSemaphore - - /// Decrements the entry counter and, upon reaching zero, resumes the iterator - public func enter() { - semaphore.signal() - } - - /// Creates new ``StartableSequence`` with an initial entry count - public init(_ base: Base, count: Int) { - self.base = base - self.semaphore = .init(count: 1 - count) - } -} - -extension StartableSequence: AsyncSequence { - - public typealias Element = Base.Element - - public struct Iterator: AsyncIteratorProtocol { - - private var iterator: Base.AsyncIterator - private var terminal = false - private let semaphore: BasicSemaphore - private let id = Int.random(in: 0...100_000) - - init(iterator: Base.AsyncIterator, semaphore: BasicSemaphore) { - self.iterator = iterator - self.semaphore = semaphore - } - - public mutating func next() async rethrows -> Element? { - await semaphore.wait() - semaphore.signal() - if terminal { return nil } - do { - guard let value = try await iterator.next() else { - self.terminal = true - return nil - } - return value - } - catch { - self.terminal = true - throw error - } - } - } - - public func makeAsyncIterator() -> Iterator { - Iterator(iterator: base.makeAsyncIterator(), semaphore: semaphore) - } -} - -extension StartableSequence: Sendable where Base: Sendable { } -extension StartableSequence.Iterator: Sendable where Base.AsyncIterator: Sendable { } - -struct BasicSemaphore { - - struct State { - var count: Int - var continuations: [UnsafeContinuation] - } - - private let state: ManagedCriticalState - - /// Creates new counting semaphore with an initial value. - init(count: Int) { - self.state = ManagedCriticalState(.init(count: count, continuations: [])) - } - - /// Waits for, or decrements, a semaphore. - func wait() async { - await withUnsafeContinuation { continuation in - let shouldImmediatelyResume = state.withCriticalRegion { state in - state.count -= 1 - if state.count < 0 { - state.continuations.append(continuation) - return false - } - else { - return true - } - } - if shouldImmediatelyResume { continuation.resume() } - } - } - - /// Signals (increments) a semaphore. - func signal() { - let continuations = state.withCriticalRegion { state -> [UnsafeContinuation] in - state.count += 1 - if state.count >= 0 { - defer { state.continuations = [] } - return state.continuations - } - else { - return [] - } - } - for continuation in continuations { continuation.resume() } - } -} diff --git a/Tests/AsyncAlgorithmsTests/TestShare.swift b/Tests/AsyncAlgorithmsTests/TestBroadcast.swift similarity index 64% rename from Tests/AsyncAlgorithmsTests/TestShare.swift rename to Tests/AsyncAlgorithmsTests/TestBroadcast.swift index 230eeb96..af4bd594 100644 --- a/Tests/AsyncAlgorithmsTests/TestShare.swift +++ b/Tests/AsyncAlgorithmsTests/TestBroadcast.swift @@ -12,12 +12,12 @@ @preconcurrency import XCTest import AsyncAlgorithms -final class TestShare: XCTestCase { +final class TestBroadcast: XCTestCase { - func test_share_basic() async { + func test_broadcast_basic() async { let expected = [1, 2, 3, 4] - let base = expected.async.delayed(2) - let sequence = base.share() + let base = GatedStartSequence(expected, count: 2) + let sequence = base.broadcast() let results = await withTaskGroup(of: [Int].self) { group in group.addTask { var iterator = sequence.makeAsyncIterator() @@ -35,9 +35,9 @@ final class TestShare: XCTestCase { XCTAssertEqual(expected, results[1]) } - func test_share_iterator_iterates_past_end() async { - let base = [1, 2, 3, 4].async.delayed(2) - let sequence = base.share() + func test_broadcast_iterator_iterates_past_end() async { + let base = GatedStartSequence([1, 2, 3, 4], count: 2) + let sequence = base.broadcast() let results = await withTaskGroup(of: Int?.self) { group in group.addTask { var iterator = sequence.makeAsyncIterator() @@ -57,10 +57,10 @@ final class TestShare: XCTestCase { XCTAssertNil(results[1]) } - func test_share_throws() async { - let base = [1, 2, 3, 4].async.map { try throwOn(3, $0) }.delayed(2) + func test_broadcast_throws() async { + let base = GatedStartSequence([1, 2, 3, 4], count: 2) let expected = [1, 2] - let sequence = base.share() + let sequence = base.map { try throwOn(3, $0) }.broadcast() let results = await withTaskGroup(of: (elements: [Int], error: Error?).self) { group in group.addTask { var iterator = sequence.makeAsyncIterator() @@ -80,44 +80,12 @@ final class TestShare: XCTestCase { XCTAssertNotNil(results[1].error as? Failure) } - func test_share_from_channel() async { - let expected = [0,1,2,3,4,5,6,7,8,9] - let base = AsyncChannel() - let delayedSequence = base.delayed(2) - let sequence = delayedSequence.share() - let results = await withTaskGroup(of: [Int].self) { group in - group.addTask { - var sent = [Int]() - for i in expected { - sent.append(i) - await base.send(i) - } - base.finish() - return sent - } - group.addTask { - var iterator = sequence.makeAsyncIterator() - delayedSequence.enter() - return await iterator.collect() - } - group.addTask { - var iterator = sequence.makeAsyncIterator() - delayedSequence.enter() - return await iterator.collect() - } - return await Array(group) - } - XCTAssertEqual(expected, results[0]) - XCTAssertEqual(expected, results[1]) - XCTAssertEqual(expected, results[2]) - } - - func test_share_concurrent_consumer_wide() async throws { + func test_broadcast_concurrent_consumer_wide() async throws { let noOfConsumers = 100 let noOfEmissions = 100 let expected = (0.. 0) } - func test_share_multiple_consumer_cancellation() async { - let base = Indefinite(value: 1).async - let sequence = base.share() - let gate = Gate() - let task = Task { - var elements = [Int]() - for await element in sequence { - elements.append(element) - gate.open() + func test_broadcast_multiple_consumer_cancellation() async { + let source = Indefinite(value: 1) + let sequence = source.async.broadcast() + var tasks = [Task]() + var iterated = [XCTestExpectation]() + var finished = [XCTestExpectation]() + for _ in 0..<16 { + let iterate = expectation(description: "task iterated") + iterate.assertForOverFulfill = false + let finish = expectation(description: "task finished") + iterated.append(iterate) + finished.append(finish) + let task = Task { + var iterator = sequence.makeAsyncIterator() + while let _ = await iterator.next() { + iterate.fulfill() + } + finish.fulfill() } - return elements + tasks.append(task) } - Task { for await _ in sequence { } } - Task { for await _ in sequence { } } - Task { for await _ in sequence { } } - await gate.enter() - task.cancel() - let result = await task.value - XCTAssert(result.count > 0) + wait(for: iterated, timeout: 1.0) + for task in tasks { task.cancel() } + wait(for: finished, timeout: 1.0) } - func test_share_iterator_retained_when_vacant_if_policy() async { + func test_broadcast_iterator_retained_when_vacant_if_policy() async { let base = [0,1,2,3].async - let sequence = base.share(disposingBaseIterator: .whenTerminated) + let sequence = base.broadcast(disposingBaseIterator: .whenTerminated) let expected0 = [0] let expected1 = [1] let expected2 = [2] @@ -213,9 +163,9 @@ final class TestShare: XCTestCase { XCTAssertEqual(expected2, result2) } - func test_share_iterator_discarded_when_vacant_if_policy() async { + func test_broadcast_iterator_discarded_when_vacant_if_policy() async { let base = [0,1,2,3].async - let sequence = base.share(disposingBaseIterator: .whenTerminatedOrVacant) + let sequence = base.broadcast(disposingBaseIterator: .whenTerminatedOrVacant) let expected0 = [0] let expected1 = [0] let expected2 = [0] @@ -227,10 +177,9 @@ final class TestShare: XCTestCase { XCTAssertEqual(expected2, result2) } - func test_share_iterator_discarded_when_terminal_regardless_of_policy() async { - typealias Event = ReportingAsyncSequence.Event + func test_broadcast_iterator_discarded_when_terminal_regardless_of_policy() async { let base = [0,1,2,3].async - let sequence = base.share(disposingBaseIterator: .whenTerminated) + let sequence = base.broadcast(disposingBaseIterator: .whenTerminated) let expected0 = [0,1,2,3] let expected1 = [Int]() let expected2 = [Int]() @@ -242,9 +191,9 @@ final class TestShare: XCTestCase { XCTAssertEqual(expected2, result2) } - func test_share_iterator_discarded_when_throws_regardless_of_policy() async { + func test_broadcast_iterator_discarded_when_throws_regardless_of_policy() async { let base = [0,1,2,3].async.map { try throwOn(1, $0) } - let sequence = base.share(disposingBaseIterator: .whenTerminatedOrVacant) + let sequence = base.broadcast(disposingBaseIterator: .whenTerminatedOrVacant) let expected0 = [0] let expected1 = [Int]() let expected2 = [Int]() @@ -262,15 +211,16 @@ final class TestShare: XCTestCase { XCTAssertNil(result2.error) } - func test_share_history_count_0() async throws { - let a0 = Array(["a","b","c","d"]).async - let a1 = Array(["e","f","g","h"]).async.delayed(1) - let a2 = Array(["i","j","k","l"]).async.delayed(1) - let a3 = Array(["m","n","o","p"]).async.delayed(1) - let base = merge(a0, a1, merge(a2, a3)) - let sequence = base.share(history: 0) + func test_broadcast_history_count_0() async throws { + let p0 = ["a","b","c","d"] + let p1 = ["e","f","g","h"] + let p2 = ["i","j","k","l"] + let p3 = ["m","n","o","p"] + let base = GatedSequence(p0 + p1 + p2 + p3) + let sequence = base.broadcast(history: 0) let expected = [["e", "f"], ["i", "j"], ["m", "n"]] let gate = Gate() + for _ in 0..>> - let completion = expectation(description: "iteration completes") + func test_broadcast_shutdown_on_dealloc() async { + typealias Sequence = AsyncBroadcastSequence>> + let expected0: [Int] = [1] + let expected1: [Int] = [] let base = Indefinite(value: 1).async - var sequence: Sequence! = base.share() - let iterator = sequence.makeAsyncIterator() - Task { - var i = iterator - let _ = await i.collect() - completion.fulfill() - } + var sequence: Sequence! = base.broadcast() + var iterator = sequence.makeAsyncIterator() + let result0 = await iterator.collect(count: 1) sequence = nil - wait(for: [completion], timeout: 1.0) + let result1 = await iterator.collect() + XCTAssertEqual(expected0, result0) + XCTAssertEqual(expected1, result1) } } From 0a5a788f5ffbec073d6f7bdd8cd65977aa2af1de Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Sun, 20 Nov 2022 17:48:30 +0000 Subject: [PATCH 17/18] fix conflicts --- Tests/AsyncAlgorithmsTests/Support/GatedSequence.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/AsyncAlgorithmsTests/Support/GatedSequence.swift b/Tests/AsyncAlgorithmsTests/Support/GatedSequence.swift index 99472b54..2a757245 100644 --- a/Tests/AsyncAlgorithmsTests/Support/GatedSequence.swift +++ b/Tests/AsyncAlgorithmsTests/Support/GatedSequence.swift @@ -9,7 +9,7 @@ // //===----------------------------------------------------------------------===// -public struct GatedSequence: Sendable { +public struct GatedSequence { let elements: [Element] let gates: [Gate] @@ -56,4 +56,4 @@ extension GatedSequence: AsyncSequence { } } -extension GatedSequence.Iterator: Sendable where Element: Sendable { } +extension GatedSequence: Sendable where Element: Sendable { } From d88c0a0b1036441021e873ab82bbd95d03269d61 Mon Sep 17 00:00:00 2001 From: Tristan Celder Date: Sun, 20 Nov 2022 17:56:58 +0000 Subject: [PATCH 18/18] docs typos --- Evolution/NNNN-broadcast.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Evolution/NNNN-broadcast.md b/Evolution/NNNN-broadcast.md index 13675435..1e96ac5c 100644 --- a/Evolution/NNNN-broadcast.md +++ b/Evolution/NNNN-broadcast.md @@ -129,7 +129,7 @@ extension OrientationMonitor { } Accelerometer.shared.startAccelerometer() } - return stream.share(disposingBaseIterator: .whenTerminated) + return stream.broadcast(disposingBaseIterator: .whenTerminated) }() } ``` @@ -176,9 +176,9 @@ extension OrientationMonitor { Accelerometer.shared.startAccelerometer() } } - // `.whenTerminatedOrVacant` is the default, so we could equally write `.share()` + // `.whenTerminatedOrVacant` is the default, so we could equally write `.broadcast()` // but it's included here for clarity. - return stream.share(disposingBaseIterator: .whenTerminatedOrVacant) + return stream.broadcast(disposingBaseIterator: .whenTerminatedOrVacant) }() } ``` @@ -239,7 +239,7 @@ init( ) ``` -Contructs a shared asynchronous sequence. +Contructs an asynchronous broadcast sequence. - `history`: the number of elements previously emitted by the sequence to prefix to the iterator of a new consumer - `iteratorDisposalPolicy`: the iterator disposal policy applied to the upstream iterator @@ -256,7 +256,7 @@ public enum IteratorDisposalPolicy: Sendable { ``` #### Overview -The iterator disposal policy applied by a shared asynchronous sequence to its upstream iterator +The iterator disposal policy applied by an asynchronous broadcast sequence to its upstream iterator - `whenTerminated`: retains the upstream iterator for use by future consumers until the base asynchronous sequence is terminated - `whenTerminatedOrVacant`: discards the upstream iterator when the number of consumers falls to zero or the base asynchronous sequence is terminated @@ -280,7 +280,7 @@ extension AsyncSequence { Creates an asynchronous sequence that can be shared by multiple consumers. - `history`: the number of elements previously emitted by the sequence to prefix to the iterator of a new consumer - - `iteratorDisposalPolicy`: the iterator disposal policy applied by a shared asynchronous sequence to its upstream iterator + - `iteratorDisposalPolicy`: the iterator disposal policy applied by an asynchronous broadcast sequence to its upstream iterator ## Comparison with other libraries