├── .github └── workflows │ └── swift.yml ├── .gitignore ├── .spi.yml ├── .swiftpm └── SwiftAsyncSerialQueue.xctestplan ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── AsyncSerialQueue │ ├── AsyncCoalescingQueue.swift │ ├── AsyncSerialQueue.swift │ └── Internal │ ├── Executor.swift │ └── SimpleLock.swift ├── Tests └── AsyncSerialQueueTests │ ├── CoalescingQueue │ └── CoalescingQueueTests.swift │ ├── ExecutorRetainCycleTests.swift │ ├── ReentrancyTests.swift │ ├── RetainCycleTests.swift │ ├── SwiftAsyncSerialQueueTests.swift │ ├── SyncTests.swift │ ├── TestConfiguration.swift │ └── Utilities │ ├── ArrayExtension.swift │ ├── DispatchGroupExtension.swift │ ├── ThreadSafeCounter.swift │ ├── ThreadSafeIntArray.swift │ └── WeakBox.swift └── scripts └── run_all_tests.sh /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: macos-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Build 20 | run: swift build -v 21 | - name: Run tests 22 | run: swift test -v 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [AsyncSerialQueue] 5 | scheme: SwiftAsyncSerialQueue 6 | 7 | -------------------------------------------------------------------------------- /.swiftpm/SwiftAsyncSerialQueue.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "F677E124-B4D0-4D4D-BD53-E2902FED0E4F", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "environmentVariableEntries" : [ 13 | { 14 | "enabled" : false, 15 | "key" : "RUN_ALL_TESTS", 16 | "value" : "1" 17 | } 18 | ] 19 | }, 20 | "testTargets" : [ 21 | { 22 | "target" : { 23 | "containerPath" : "container:", 24 | "identifier" : "AsyncSerialQueueTests", 25 | "name" : "AsyncSerialQueueTests" 26 | } 27 | } 28 | ], 29 | "version" : 1 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Danny Sung 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | var swiftSettings: [SwiftSetting] = [ 7 | // Do not release with this enabled as upstream packages will not be able to import 8 | // 9 | // .unsafeFlags([ 10 | // "-Xfrontend", "-warn-concurrency", "-Xfrontend", "-enable-actor-data-race-checks", 11 | // ]) 12 | ] 13 | 14 | let package = Package( 15 | name: "SwiftAsyncSerialQueue", 16 | platforms: [ .iOS(.v16), .macOS(.v13), .tvOS(.v16), .watchOS(.v9) ], 17 | // platforms: [ .iOS(.v14), .macOS(.v11), .tvOS(.v15), .watchOS(.v8) ], 18 | products: [ 19 | // Products define the executables and libraries a package produces, making them visible to other packages. 20 | .library( 21 | name: "AsyncSerialQueue", 22 | targets: ["AsyncSerialQueue"]), 23 | ], 24 | targets: [ 25 | // Targets are the basic building blocks of a package, defining a module or a test suite. 26 | // Targets can depend on other targets in this package and products from dependencies. 27 | .target( 28 | name: "AsyncSerialQueue", 29 | swiftSettings: swiftSettings 30 | ), 31 | .testTarget( 32 | name: "AsyncSerialQueueTests", 33 | dependencies: ["AsyncSerialQueue"], 34 | swiftSettings: swiftSettings 35 | ), 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftAsyncSerialQueue 2 | 3 | [![APIDoc](https://img.shields.io/badge/docs-AsyncSerialQueue-1FBCE4.svg)](https://swiftpackageindex.com/dannys42/SwiftAsyncSerialQueue/main/documentation/asyncserialqueue) 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdannys42%2FSwiftAsyncSerialQueue%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/dannys42/SwiftAsyncSerialQueue) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdannys42%2FSwiftAsyncSerialQueue%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/dannys42/SwiftAsyncSerialQueue) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 7 | 8 | 9 | `AsyncSerialQueue` is a library provides some useful patterns using Swift Concurrency: 10 | 11 | `AsyncSerialQueue` is a class provides [serial queue](https://www.avanderlee.com/swift/concurrent-serial-dispatchqueue/)-like capability using [Swift Concurrency](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/). Tasks placed in an `AsyncSerialQueue` are guaranteed to execute sequentially. 12 | 13 | `AsyncCoalescingQueue` is a companion class that has properties similar to [DispatchSource](https://www.mikeash.com/pyblog/friday-qa-2009-09-11-intro-to-grand-central-dispatch-part-iii-dispatch-sources.html). 14 | 15 | AsyncSerialQueue is currently only available on Apple platforms (i.e. not on Linux). This is because of the need for locking and Swift [currently does not have a standard cross-platform locking mechnism](https://forums.swift.org/t/shared-mutable-state-sendable-and-locks/64336). 16 | 17 | 18 | # `AsyncSerialQueue` 19 | 20 | ## Example 21 | 22 | ### Simple Example 23 | ```swift 24 | let serialQueue = AsyncSerialQueue() 25 | 26 | func example() { 27 | serialQueue.async { 28 | print("1") 29 | } 30 | serialQueue.async { 31 | print("2") 32 | } 33 | serialQueue.async { 34 | print("3") 35 | } 36 | print("apple") 37 | } 38 | ``` 39 | 40 | Note that `example()` does not need to be declared `async` here. 41 | 42 | The numbers will always be output in order: 43 | ``` 44 | 1 45 | 2 46 | 3 47 | ``` 48 | However, there is no guarantee where `apple` may appear: 49 | 50 | ``` 51 | apple 52 | 1 53 | 2 54 | 3 55 | ``` 56 | or 57 | 58 | ``` 59 | 1 60 | 2 61 | apple 62 | 3 63 | ``` 64 | or any other combination 65 | 66 | ### Waiting for the queue 67 | 68 | There may be some cases (e.g. unit tests) where you need to wait for the serial queue to be empty. 69 | 70 | 71 | ```swift 72 | func example() async { 73 | serialQueue.async { 74 | print("1") 75 | } 76 | 77 | await serialQueue.wait() 78 | 79 | serialQueue.async { 80 | print("2") 81 | } 82 | } 83 | ``` 84 | 85 | `example()` will not complete return `1` is printed. However, it could return before `2` is output. 86 | 87 | 88 | ### Mixing sync and async - Barrier Blocks 89 | 90 | Similarly, if looking for something similar to [barrier blocks](https://developer.apple.com/documentation/dispatch/dispatch_barrier): 91 | 92 | ```swift 93 | func example() async { 94 | serialQueue.async { 95 | print("1") 96 | } 97 | await serialQueue.sync { 98 | print("2") 99 | } 100 | serialQueue.async { 101 | print("3") 102 | } 103 | print("apple"") 104 | } 105 | ``` 106 | 107 | In this case, `apple` will never appear before `2`. And `example()` will not return until `2` is printed. 108 | 109 | 110 | # `AsyncCoalescingQueue` 111 | 112 | [Coalescing Queues](https://www.mikeash.com/pyblog/friday-qa-2009-09-11-intro-to-grand-central-dispatch-part-iii-dispatch-sources.html) can be a useful technique especially in flows where you only care about the first and last event, but would like to drop interim events if processing is still in play. For example when processing user input, perhaps you want the first event in order to kick off processing and provide user immediate feedback, and you also want the last event because that represents the most up-to-date user state requested. For example, consider a scrubber for an audio player. 113 | 114 | In the GCD approach, coalescing queues acted on a trigger but could not take input very easily. In this Swift implementation, the API is kept simply by relying on scoped variables at the point of call. However, if there are multiple `.run()` blocks, make sure that it is acceptable for any of them to get dropped. If multiple `.run()` blocks are required, consider placing the common code in a separate function that is called within the multiple `.run()` blocks. Also consider whether multiple `AsyncCoalescingQueue()` instances or `AsyncSerialQueue()` may be a better fit. 115 | 116 | `AsyncCoalescingQueue` is somewhat similar to `debounce` and `throttle` in Combine. `debounce` has a fixed lag before the first event is emitted, requiring an additional `Concatenate` publisher if you do not wish to miss the first event. `throttle` works on fixed time intervals, but may require tuning to balance the task with the speed of the hardware. In some UI cases, you may wish to provide "best effort" responsiveness to the user. `AsyncCoalescingQueue` (like GCD coalescing queues) will ensure responsiveness by automatically scaling to the workload and hardware it is running on. 117 | 118 | ## Example 119 | 120 | The following code: 121 | 122 | ```swift 123 | let coalescingQueue = AsyncCoalescingQueue() 124 | 125 | coalescingQueue.run { 126 | try? await Task.sleep(for: .seconds(5)) 127 | print("Run 1") 128 | } 129 | coalescingQueue.run { 130 | try? await Task.sleep(for: .seconds(5)) 131 | print("Run 2") 132 | } 133 | coalescingQueue.run { 134 | try? await Task.sleep(for: .seconds(5)) 135 | print("Run 3") 136 | } 137 | coalescingQueue.run { 138 | try? await Task.sleep(for: .seconds(5)) 139 | print("Run 4") 140 | } 141 | coalescingQueue.run { 142 | try? await Task.sleep(for: .seconds(5)) 143 | print("Run 5") 144 | } 145 | ``` 146 | Will output the following: 147 | 148 | ``` 149 | Run 1 150 | Run 5 151 | ``` 152 | 153 | And take 10 seconds to complete executing. 154 | 155 | 156 | # Alternatives 157 | 158 | Some related libraries: 159 | 160 | * [swift-async-queue](https://github.com/dfed/swift-async-queue) 161 | * [Queue](https://github.com/mattmassicotte/Queue) 162 | * [Semaphore](https://github.com/groue/Semaphore) 163 | 164 | # References 165 | * [GCD: Coalescing Dispatch Queues](https://www.mikeash.com/pyblog/friday-qa-2009-09-11-intro-to-grand-central-dispatch-part-iii-dispatch-sources.html) 166 | * [Stack Overflow: Combine debounce and throttle ](https://stackoverflow.com/questions/60295544/how-do-you-apply-a-combine-operator-only-after-the-first-message-has-been-receiv) 167 | 168 | -------------------------------------------------------------------------------- /Sources/AsyncSerialQueue/AsyncCoalescingQueue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftAsyncSerialQueue 4 | // 5 | // Created by Danny Sung on 1/23/25. 6 | // 7 | 8 | import Foundation 9 | import os 10 | 11 | /// ``AsyncCoalescingQueue`` provides behavior similar to DispatchSource using Swift Concurrency 12 | /// When the queue is idle, the first task queued is guaranteed to run. Subsequent tasks will only execute when the queue is idle. If more the one task is pending execution, only the last one will execute. 13 | /// 14 | /// For example, if executing: 15 | /// ```swift 16 | /// let coalescingQueue = AsyncCoalescingQueue() 17 | /// coalescingQueue.run { await slowFunction(1) } 18 | /// coalescingQueue.run { await slowFunction(2) } 19 | /// coalescingQueue.run { await slowFunction(3) } 20 | /// coalescingQueue.run { await slowFunction(4) } 21 | /// ``` 22 | /// This is equivalent to: 23 | /// ```swift 24 | /// await slowFunction(1) 25 | /// await slowFunction(4) 26 | /// ``` 27 | public class AsyncCoalescingQueue: @unchecked Sendable { 28 | public let label: String? 29 | private let serialQueue: AsyncSerialQueue 30 | 31 | private let taskList = TaskList() 32 | private let priority: TaskPriority? 33 | 34 | public enum TimeOutResult { 35 | case timedOut 36 | case completed 37 | case canceled 38 | } 39 | 40 | /// Initialize a new ``AsyncCoalescingQueue`` instance 41 | /// - Parameters: 42 | /// - label: An optional string that can be used to identify the queue 43 | /// - priority: Optional ``TaskPriority`` used when executing tasks 44 | public init(label: String? = nil, priority: TaskPriority? = nil) { 45 | self.label = label 46 | self.priority = priority 47 | self.serialQueue = AsyncSerialQueue(label: label, priority: priority) 48 | } 49 | 50 | /// Attempt to execute a given block. 51 | /// The block will not be executed if a new block is queued before it runs. 52 | /// Subsequent blocks are guaranteed to not run at the same time. 53 | /// - Parameter block: block to execute 54 | public func run(_ block: @escaping () async -> Void) { 55 | self.serialQueue.async { 56 | let taskBox = TaskBox(block) 57 | await self.taskList.upsertTask(taskBox) 58 | 59 | if await self.taskList.isAnyTaskRunning == false { 60 | self.triggerProcessNextTask() 61 | } 62 | } 63 | } 64 | 65 | /// Wait for all pending blocks to execute. 66 | @discardableResult 67 | public func wait(timeout: C.Instant.Duration? = nil, clock: C = ContinuousClock()) async -> TimeOutResult where C: Clock { 68 | let minSleepMilliseconds = 125 69 | 70 | let start = clock.now 71 | do { 72 | return try await self.serialQueue.sync { 73 | while await self.taskList.isEmpty == false { 74 | try await Task.sleep(for: .milliseconds(minSleepMilliseconds)) 75 | 76 | if let timeout { 77 | let current = clock.now 78 | if start.duration(to: current) >= timeout { 79 | return TimeOutResult.timedOut 80 | } 81 | } 82 | } 83 | return TimeOutResult.completed 84 | } 85 | } catch { 86 | return .canceled 87 | } 88 | } 89 | } 90 | 91 | // MARK: - Private 92 | fileprivate extension AsyncCoalescingQueue { 93 | 94 | func triggerProcessNextTask() { 95 | Task(priority: self.priority) { 96 | await self.processNextTask() 97 | } 98 | } 99 | 100 | func processNextTask() async { 101 | guard let task = await self.taskList.firstTask else { 102 | return 103 | } 104 | 105 | if await self.taskList.isAnyTaskRunning { 106 | return 107 | } 108 | 109 | switch task.state { 110 | case .running: 111 | break 112 | case .waiting: 113 | Task(priority: self.priority) { 114 | await task.run() 115 | 116 | await self.processNextTask() 117 | } 118 | 119 | case .complete: 120 | await self.taskList.removeTask(task) 121 | await self.taskList.removeAllButLastTask() 122 | self.triggerProcessNextTask() 123 | 124 | } 125 | } 126 | } 127 | 128 | fileprivate actor TaskList: Sendable { 129 | private var tasks: [TaskBox] = [] 130 | 131 | var count: Int { 132 | tasks.count 133 | } 134 | 135 | var isEmpty: Bool { 136 | self.tasks.isEmpty 137 | } 138 | 139 | var isAnyTaskRunning: Bool { 140 | for task in self.tasks { 141 | if task.state == .running { 142 | return true 143 | } 144 | } 145 | return false 146 | } 147 | 148 | var firstTask: TaskBox? { 149 | tasks.first 150 | } 151 | 152 | func upsertTask(_ task: TaskBox) { 153 | if self.tasks.count > 1 { 154 | if let lastTask = self.tasks.last { 155 | if lastTask.state != .running { 156 | self.tasks.removeLast() 157 | } 158 | } 159 | } 160 | 161 | self.tasks.append(task) 162 | } 163 | 164 | func removeAllButLastTask() { 165 | if let lastTask = self.tasks.last { 166 | self.tasks = [ lastTask ] 167 | } 168 | } 169 | 170 | func removeTask(_ task: TaskBox) { 171 | self.tasks.removeAll { $0 === task } 172 | } 173 | } 174 | 175 | fileprivate actor TaskBox: Sendable { 176 | enum State { 177 | case waiting 178 | case running 179 | case complete 180 | } 181 | 182 | nonisolated 183 | public private(set) var state: State { 184 | get { 185 | _state.withLock { $0 } 186 | } 187 | set { 188 | _state.withLock { $0 = newValue } 189 | } 190 | } 191 | nonisolated 192 | private let _state: OSAllocatedUnfairLock 193 | 194 | private let block: () async -> Void 195 | 196 | init(_ block: @escaping () async -> Void) { 197 | self._state = .init(initialState: .waiting) 198 | 199 | self.block = block 200 | } 201 | 202 | func run() async { 203 | guard self.state == .waiting else { 204 | return 205 | } 206 | 207 | self.state = .running 208 | await self.block() 209 | self.state = .complete 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /Sources/AsyncSerialQueue/AsyncSerialQueue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncSerialQueue.swift 3 | // 4 | // 5 | // Created by Danny Sung on 11/3/23. 6 | // 7 | 8 | import Foundation 9 | import os 10 | 11 | /// ``AsyncSerialQueue`` provides behavior similar to serial `DispatchQueue`s while relying solely on Swift concurrency. 12 | /// In other words, queued async blocks are guaranteed to execute in-order. 13 | public final class AsyncSerialQueue: @unchecked Sendable { 14 | public enum Failures: Error { 15 | case queueIsCanceled 16 | case queueIsNotRunning 17 | } 18 | 19 | public enum State: Sendable { 20 | case setup 21 | case running 22 | case stopping 23 | case stopped 24 | 25 | var isRunning: Bool { 26 | self == .setup || self == .running 27 | } 28 | } 29 | public typealias closure = @Sendable () async -> Void 30 | public private(set) var state: State { 31 | get { 32 | _state.withLock { $0 } 33 | } 34 | set { 35 | _state.withLock { $0 = newValue } 36 | } 37 | } 38 | 39 | private var _state: OSAllocatedUnfairLock 40 | 41 | private var _currentRunningTasks: OSAllocatedUnfairLock>> 42 | private var currentRunningTasks: Set> { 43 | get { 44 | _currentRunningTasks.withLock { $0 } 45 | } 46 | set { 47 | _currentRunningTasks.withLock { $0 = newValue } 48 | } 49 | } 50 | 51 | private let taskPriority: TaskPriority? 52 | private let executor: Executor 53 | public let label: String? 54 | 55 | public init(label: String?=nil, priority: TaskPriority?=nil) { 56 | self.label = label 57 | self._state = .init(initialState: .setup) 58 | self._currentRunningTasks = .init(initialState: []) 59 | self.taskPriority = priority 60 | 61 | self.executor = Executor(priority: priority) { 62 | // completion 63 | } 64 | 65 | self.executor.async { 66 | self.state = .running 67 | } 68 | } 69 | 70 | deinit { 71 | if self.state == .running { 72 | self.state = .stopping 73 | } 74 | self.executor.cancel() 75 | self._currentRunningTasks.withLock({ tasks in 76 | tasks.forEach { task in 77 | task.cancel() 78 | } 79 | }) 80 | } 81 | 82 | /// Add a block to the queue 83 | /// - Parameter closure: Block to execute 84 | /// If the ``AsyncSerialQueue`` is cancelled, the `closure` will not be queued. 85 | public func async(_ closure: @escaping closure) { 86 | guard self.state.isRunning else { 87 | return 88 | } 89 | 90 | self.executor.async { 91 | await closure() 92 | } block: { [weak self] state, task in 93 | guard let self else { return } 94 | 95 | switch state { 96 | case .didQueue: 97 | self.currentRunningTasks.insert(task) 98 | case .didComplete: 99 | self.currentRunningTasks.remove(task) 100 | } 101 | } 102 | } 103 | 104 | /// Cancel all queued blocks and prevent additional blocks from being queued. 105 | /// - Parameter completion: An optional completion handler will be called after all blocks have been cancelled and finished executing. 106 | public func cancel(_ completion: @Sendable @escaping ()->Void = { }) { 107 | switch self.state { 108 | case .setup, .running: 109 | self.state = .stopping 110 | // TODO: cancel running tasks here 111 | self.executor.async { 112 | self.state = .stopped 113 | completion() 114 | } 115 | case .stopping: 116 | self.executor.async { 117 | completion() 118 | } 119 | case .stopped: 120 | completion() 121 | } 122 | } 123 | 124 | /// Cancel all queued blocks and prevent additional blocks from being queued. 125 | /// This method will return after all blocks have been cancelled and finished executing. 126 | public func cancel() async { 127 | await withCheckedContinuation { continuation in 128 | self.cancel { 129 | continuation.resume() 130 | } 131 | } 132 | } 133 | 134 | 135 | /// Queue a block, returning only after it has executed 136 | /// - Parameter closure: block to queue 137 | /// Note: If `AsyncSerialQueue` is cancelled, then `closure` is never executed. 138 | public func sync(_ closure: @escaping closure) async { 139 | guard self.state.isRunning else { 140 | return 141 | } 142 | 143 | await withCheckedContinuation { continuation in 144 | self.executor.async { 145 | await closure() 146 | 147 | continuation.resume() 148 | } 149 | } 150 | } 151 | 152 | /// Queue a block, returning only after it has executed 153 | /// - Parameter closure: block to queue 154 | /// Note: If `AsyncSerialQueue` is cancelled, then `closure` is never executed. 155 | /// - Returns: Result of closure 156 | /// - Throws: ``Failures/queueIsNotRunning`` if queue is not in a running state. 157 | public func sync(_ closure: @escaping @Sendable () async throws -> T) async throws -> T { 158 | guard self.state.isRunning else { 159 | throw Failures.queueIsNotRunning 160 | } 161 | 162 | return try await withCheckedThrowingContinuation { continuation in 163 | self.executor.async { 164 | do { 165 | let result = try await closure() 166 | 167 | continuation.resume(returning: result) 168 | } catch { 169 | continuation.resume(throwing: error) 170 | } 171 | 172 | } 173 | } 174 | } 175 | 176 | /// Wait until all queued blocks have finished executing 177 | @discardableResult 178 | public func wait(for duration: C.Instant.Duration?=nil, tolerance: C.Instant.Duration? = nil, clock: C = ContinuousClock()) async -> State where C : Clock { 179 | await self.sync({}) 180 | 181 | let startTime = clock.now 182 | let delayDurations: BackoffValues 183 | 184 | if duration == nil { 185 | delayDurations = BackoffValues(1_000, 125_000, 250_000, 500_000) 186 | } else { 187 | delayDurations = BackoffValues(10, 25, 50, 100, 1_000, 10_000) 188 | } 189 | 190 | // If we were in the middle of cancelling, try to wait a bit until cancel has completed 191 | while (Task.isCancelled && self.state != .stopped) { 192 | try? await Task.sleep(for: .microseconds(delayDurations.next)) 193 | 194 | if let duration { 195 | if startTime.duration(to: clock.now) > duration { 196 | break 197 | } 198 | } 199 | } 200 | 201 | return self.state 202 | } 203 | 204 | #if false 205 | /// Restart a queue that was previously cancelled 206 | public func restart() async throws { 207 | guard self.executor.isCancelled else { 208 | throw Failures.queueIsCanceled 209 | } 210 | 211 | self.executor = self.createExecutor() { 212 | self.state = .stopped 213 | } 214 | } 215 | #endif 216 | 217 | // MARK: Private Methods 218 | 219 | } 220 | 221 | 222 | fileprivate actor BackoffValues { 223 | private let values: [T] 224 | private var index: Int 225 | 226 | 227 | init(_ values: T...) { 228 | self.values = values 229 | self.index = 0 230 | } 231 | 232 | var next: T { 233 | let nextIndex: Int 234 | 235 | if (self.index+1) >= self.values.count { 236 | nextIndex = self.index 237 | } else { 238 | nextIndex = self.index+1 239 | } 240 | self.index = nextIndex 241 | 242 | return self.values[nextIndex] 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /Sources/AsyncSerialQueue/Internal/Executor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Executor.swift 3 | // 4 | // 5 | // Created by Danny Sung on 6/18/24. 6 | // 7 | 8 | import Foundation 9 | 10 | class Executor: @unchecked Sendable { 11 | 12 | let taskPriority: TaskPriority? 13 | 14 | public typealias closure = @Sendable () async -> Void 15 | private var task: Task! 16 | private var taskStream: AsyncStream! 17 | private var continuation: AsyncStream.Continuation? 18 | 19 | init(priority: TaskPriority?=nil, _ completion: @Sendable @escaping () async -> Void = {}) { 20 | self.taskPriority = priority 21 | 22 | let taskStream = AsyncStream(bufferingPolicy: .unbounded) { continuation in 23 | self.continuation = continuation 24 | } 25 | self.taskStream = taskStream 26 | 27 | self.task = Task.detached(priority: priority) { 28 | for await closure in taskStream { 29 | await closure() 30 | 31 | if Task.isCancelled { 32 | break 33 | } 34 | } 35 | 36 | await completion() 37 | } 38 | } 39 | 40 | 41 | func cancel() { 42 | self.task.cancel() 43 | self.continuation!.finish() 44 | } 45 | 46 | enum TaskState { 47 | case didQueue 48 | case didComplete 49 | } 50 | 51 | func async(_ closure: @escaping closure, block: @Sendable @escaping (TaskState, Task) async -> Void = { _,_ in }) { 52 | self.continuation!.yield { [weak self] in 53 | guard let self else { return } 54 | 55 | let task = Task.detached(priority: self.taskPriority) { 56 | await closure() 57 | } 58 | 59 | await block(.didQueue, task) 60 | _ = await task.value 61 | await block(.didComplete, task) 62 | } 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /Sources/AsyncSerialQueue/Internal/SimpleLock.swift: -------------------------------------------------------------------------------- 1 | #if false 2 | // 3 | // SimpleLock.swift 4 | // 5 | // 6 | // Created by Danny Sung on 6/5/24. 7 | // 8 | 9 | import Foundation 10 | import os 11 | 12 | protocol SimpleLockInterface: Sendable { 13 | associatedtype Value 14 | 15 | init(initialState: Value) 16 | 17 | func withLockUnchecked(_ body: (inout Value) throws -> R) rethrows -> R 18 | 19 | func withLock(_ body: @Sendable (inout Value) throws -> R) rethrows -> R where R : Sendable 20 | } 21 | 22 | @propertyWrapper 23 | struct SimpleLock: SimpleLockInterface { 24 | var wrappedValue: Value { 25 | get { 26 | self.withLock({ $0 }) 27 | } 28 | set { 29 | self.withLock({ $0 = newValue }) 30 | } 31 | } 32 | 33 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) 34 | private var unfairLock: SimpleUnfairLock! 35 | 36 | private var semaphoreLock: SimpleLockSemaphore! 37 | 38 | init(initialState: Value) { 39 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) { 40 | self.unfairLock = SimpleUnfairLock(initialState: initialState) 41 | self.semaphoreLock = nil 42 | } else { 43 | self.semaphoreLock = nil 44 | self.semaphoreLock = SimpleLockSemaphore(initialState: initialState) 45 | } 46 | } 47 | 48 | func withLockUnchecked(_ body: (inout Value) throws -> R) rethrows -> R { 49 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) { 50 | return try self.unfairLock.withLockUnchecked(body) 51 | } else { 52 | return try self.semaphoreLock.withLockUnchecked(body) 53 | } 54 | } 55 | 56 | func withLock(_ body: @Sendable (inout Value) throws -> R) rethrows -> R where R : Sendable { 57 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) { 58 | return try unfairLock.withLock(body) 59 | } else { 60 | return try self.semaphoreLock.withLock(body) 61 | } 62 | } 63 | 64 | 65 | } 66 | 67 | 68 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) 69 | final class SimpleUnfairLock: @unchecked Sendable, SimpleLockInterface { 70 | 71 | private var unfairLock: OSAllocatedUnfairLock 72 | 73 | init(initialState: V) { 74 | self.unfairLock = .init(initialState: initialState) 75 | } 76 | 77 | func withLockUnchecked(_ body: (inout V) throws -> R) rethrows -> R { 78 | return try self.unfairLock.withLockUnchecked(body) 79 | } 80 | 81 | func withLock(_ body: @Sendable (inout V) throws -> R) rethrows -> R where R : Sendable { 82 | return try self.unfairLock.withLock(body) 83 | } 84 | 85 | 86 | } 87 | 88 | final class SimpleLockSemaphore: @unchecked Sendable, SimpleLockInterface { 89 | private let semaphore: DispatchSemaphore 90 | private var value: V 91 | 92 | 93 | init(initialState: V) { 94 | self.value = initialState 95 | self.semaphore = DispatchSemaphore(value: 1) 96 | } 97 | 98 | public func withLockUnchecked(_ body: (inout V) throws -> R) rethrows -> R { 99 | self.semaphore.wait() 100 | defer { self.semaphore.signal() } 101 | 102 | return try body(&self.value) 103 | } 104 | 105 | func withLock(_ body: @Sendable (inout V) throws -> R) rethrows -> R where R : Sendable { 106 | return try self.withLockUnchecked(body) 107 | } 108 | } 109 | 110 | #endif 111 | -------------------------------------------------------------------------------- /Tests/AsyncSerialQueueTests/CoalescingQueue/CoalescingQueueTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Testing) 2 | // 3 | // CoalescingQueueTests.swift 4 | // SwiftAsyncSerialQueue 5 | // 6 | // Created by Danny Sung on 1/23/25. 7 | // 8 | 9 | import Foundation 10 | import Testing 11 | @testable import AsyncSerialQueue 12 | 13 | struct CoalescingQueueTests { 14 | 15 | @Test("Executing 1 run will execute") 16 | func testThat_OneRun_WillExecuteOnce() async throws { 17 | let coalescingQueue = AsyncCoalescingQueue() 18 | var value: Int = 0 19 | 20 | coalescingQueue.run { 21 | value += 1 22 | } 23 | 24 | await coalescingQueue.wait() 25 | 26 | 27 | 28 | #expect(value == 1) 29 | } 30 | 31 | @Test("Executing 2 runs will execute both") 32 | func testThat_TwoRuns_WillExecuteTwo() async throws { 33 | let coalescingQueue = AsyncCoalescingQueue() 34 | var observedValues: [String] = [] 35 | let expectedValues = [ "one", "two" ] 36 | 37 | 38 | coalescingQueue.run { 39 | observedValues.append("one") 40 | } 41 | coalescingQueue.run { 42 | observedValues.append("two") 43 | } 44 | 45 | await coalescingQueue.wait() 46 | 47 | #expect(observedValues == expectedValues) 48 | } 49 | 50 | @Test("Executing 3 long running tasks, only execute the first and last") 51 | func testThat_ThreeRuns_WillExecuteTwo() async throws { 52 | let coalescingQueue = AsyncCoalescingQueue() 53 | var observedValues: [String] = [] 54 | let expectedValues = [ "one", "three" ] 55 | 56 | 57 | coalescingQueue.run { 58 | observedValues.append("one") 59 | try? await Task.sleep(for: .seconds(1)) 60 | } 61 | coalescingQueue.run { 62 | observedValues.append("two") 63 | try? await Task.sleep(for: .seconds(2)) 64 | } 65 | coalescingQueue.run { 66 | observedValues.append("three") 67 | try? await Task.sleep(for: .seconds(2)) 68 | } 69 | 70 | await coalescingQueue.wait() 71 | 72 | #expect(observedValues == expectedValues) 73 | 74 | } 75 | 76 | @Test("Executing many long running tasks, only the first and last one should run") 77 | func testThat_ManyRuns_WillExecuteTwo() async throws { 78 | let coalescingQueue = AsyncCoalescingQueue() 79 | var value: Int = 0 80 | let numberOfIterations = 20 81 | let numberOfSecondsPerIteration = 2 82 | struct RunValue: Equatable, CustomStringConvertible { 83 | let iteration: Int 84 | let value: Int 85 | 86 | var description: String { 87 | "(\(iteration),\(value))" 88 | } 89 | } 90 | 91 | // Expect that only the first and last task will always execute 92 | // Assumption: tasks execution take longer than the queue time 93 | let expectedValue: [RunValue] = [ 94 | RunValue(iteration: 0, value: 1), 95 | RunValue(iteration: numberOfIterations-1, value: numberOfSecondsPerIteration), 96 | ] 97 | var observedValue: [RunValue] = [] 98 | 99 | let startTime = Date.now 100 | 101 | for n in 0.. { 24 | Executor() 25 | } 26 | 27 | XCTAssertTrue(weakBox.isNil) 28 | } 29 | 30 | func testThat_QueuedExecutor_WillRelease() async throws { 31 | let weakBox = await WeakBox { 32 | let executor = Executor() 33 | 34 | await withCheckedContinuation { continuation in 35 | executor.async { 36 | try? await Task.sleep(for: .milliseconds(125)) 37 | 38 | continuation.resume() 39 | } 40 | } 41 | 42 | return executor 43 | } 44 | 45 | XCTAssertTrue(weakBox.isNil) 46 | } 47 | 48 | 49 | func testThat_HoldStrongReference_WillNotBeNil() async throws { 50 | let executor = Executor() 51 | 52 | let weakBox = await WeakBox { 53 | return executor 54 | } 55 | 56 | XCTAssertFalse(weakBox.isNil) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tests/AsyncSerialQueueTests/ReentrancyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReentrancyTests.swift 3 | // 4 | // 5 | // Created by Danny Sung on 11/4/23. 6 | // 7 | 8 | import XCTest 9 | import AsyncSerialQueue 10 | 11 | final class ReentrancyTests: XCTestCase { 12 | 13 | /// Not checking for order here, just that we're thread-safe 14 | func testThat_QueueingBlocks_IsThreadSafe() async throws { 15 | let shouldRun = await TestConfiguration.shared.shouldRunAllTests() 16 | try XCTSkipUnless(shouldRun, "Don't run in CI as this can be resource hungry") 17 | 18 | let numberOfThreads = 20 19 | let numberOfIterations = 10_000 20 | let expectedValue = numberOfThreads * numberOfIterations 21 | let serialQueue = AsyncSerialQueue() 22 | let g = DispatchGroup() 23 | let counter = ThreadSafeCounter() 24 | 25 | for _ in 0.. { 57 | AsyncSerialQueue() 58 | } 59 | 60 | // Need some time for internal async task to finish executing 61 | try? await Task.sleep(for: .milliseconds(100)) 62 | 63 | XCTAssertTrue(weakBox.isNil) 64 | } 65 | 66 | func testThat_AsyncedQueue_WillRelease() async throws { 67 | let weakBox = await WeakBox { 68 | let serialQueue = AsyncSerialQueue() 69 | serialQueue.async { 70 | // This async block may not always be executed because serialQueue may be deinit'd before this gets a chance to run. 71 | } 72 | return serialQueue 73 | } 74 | 75 | // Need to wait here. Don't care if the block above gets called or not with this test. This test is only to check for retain cycles. 76 | try? await Task.sleep(for: .milliseconds(100)) 77 | 78 | XCTAssertTrue(weakBox.isNil) 79 | } 80 | 81 | func testThat_SyncedQueue_WillRelease() async throws { 82 | let weakBox = await WeakBox { 83 | let serialQueue = AsyncSerialQueue() 84 | await serialQueue.sync { 85 | // This sync block may not always be executed because serialQueue may be deinit'd before this gets a chance to run. 86 | } 87 | return serialQueue 88 | } 89 | 90 | // Need to wait here. Don't care if the block above gets called or not with this test. This test is only to check for retain cycles. 91 | try? await Task.sleep(for: .milliseconds(100)) 92 | 93 | XCTAssertTrue(weakBox.isNil) 94 | } 95 | 96 | func testThat_UnusedWaitedQueue_WillRelease() async throws { 97 | let weakBox = await WeakBox { 98 | let serialQueue = AsyncSerialQueue() 99 | await serialQueue.wait() 100 | return serialQueue 101 | } 102 | 103 | try? await Task.sleep(for: .milliseconds(100)) 104 | XCTAssertTrue(weakBox.isNil) 105 | } 106 | 107 | 108 | 109 | 110 | } 111 | -------------------------------------------------------------------------------- /Tests/AsyncSerialQueueTests/SwiftAsyncSerialQueueTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import AsyncSerialQueue 3 | 4 | final class SwiftAsyncSerialQueueTests: XCTestCase { 5 | fileprivate var dataHelper = ThreadSafeIntArray() 6 | 7 | override func setUp() async throws { 8 | self.dataHelper.clear() 9 | } 10 | 11 | func testThat_NormalTasks_WillBeNotOrdered() async throws { 12 | let numberOfIterations = 200 13 | let expectedValue: [Int] = .sequence(numberOfIterations) 14 | 15 | let dataHelper = self.dataHelper 16 | 17 | await withTaskGroup(of: Void.self) { group in 18 | for n in 0.. Bool { 14 | ProcessInfo.processInfo.environment["RUN_ALL_TESTS"] == "1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/AsyncSerialQueueTests/Utilities/ArrayExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArrayExtension.swift 3 | // 4 | // 5 | // Created by Danny Sung on 11/3/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array where Element == Int { 11 | 12 | static func sequence(_ count: Int) -> [Int] { 13 | var array: [Int] = [] 14 | 15 | for n in 0.. Int { 17 | self._counter.withLock { counter in 18 | counter = counter + 1 19 | return counter 20 | } 21 | } 22 | 23 | func clear() { 24 | self._counter.withLock { counter in 25 | counter = 0 26 | } 27 | } 28 | 29 | var value: Int { 30 | self._counter.withLock { $0 } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/AsyncSerialQueueTests/Utilities/ThreadSafeIntArray.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThreadSafeIntArray.swift 3 | // 4 | // 5 | // Created by Danny Sung on 11/3/23. 6 | // 7 | 8 | import Foundation 9 | import os 10 | 11 | /// A thread-safe helper class to add elements to an array 12 | class ThreadSafeIntArray: @unchecked Sendable { 13 | private var someArray = OSAllocatedUnfairLock<[Int]>(initialState: []) 14 | 15 | func append(_ value: Int) { 16 | self.someArray.withLock { array in 17 | array.append(value) 18 | } 19 | } 20 | 21 | func values() -> [Int] { 22 | self.someArray.withLock { array in 23 | array 24 | } 25 | } 26 | 27 | func clear() { 28 | self.someArray.withLock { array in 29 | array.removeAll(keepingCapacity: false) 30 | } 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /Tests/AsyncSerialQueueTests/Utilities/WeakBox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeakBox.swift 3 | // 4 | // 5 | // Created by Danny Sung on 6/17/24. 6 | // 7 | 8 | import os 9 | 10 | /// A simple container to hold a weak reference to an object 11 | class WeakBox: @unchecked Sendable { 12 | private let semaphore: DispatchSemaphore 13 | private weak var _value: T? 14 | 15 | public var value: T? { 16 | get { 17 | self.semaphore.wait() 18 | defer { self.semaphore.signal() } 19 | 20 | return _value 21 | } 22 | set { 23 | self.semaphore.wait() 24 | defer { self.semaphore.signal() } 25 | 26 | return _value = newValue 27 | } 28 | } 29 | 30 | var isNil: Bool { 31 | self.value == nil 32 | } 33 | 34 | init() { 35 | self.semaphore = DispatchSemaphore(value: 1) 36 | } 37 | 38 | convenience init(_ block: () async throws -> T) async rethrows { 39 | self.init() 40 | 41 | self.value = try await block() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /scripts/run_all_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | export RUN_ALL_TESTS=1 4 | 5 | #opts=( --parallel ) 6 | opts=( ) 7 | 8 | 9 | echo "* Testing without sanitizer" 10 | swift test "${opts[@]}" "${@}" 11 | 12 | for sanitizer in address thread undefined; do 13 | echo "* Testing with '${sanitizer}' sanitizer" 14 | swift test --sanitize "${sanitizer}" "${opts[@]}" "${@}" 15 | done 16 | --------------------------------------------------------------------------------