A library of queues that enable sending ordered tasks from nonisolated to asynchronous contexts.
Task Ordering and Swift Concurrency
Tasks sent from a nonisolated context to an asynchronous context in Swift Concurrency are inherently unordered. Consider the following test:
@Test func actorTaskOrdering() async { actor Counter { func incrementAndAssertCountEquals(_ expectedCount: Int) { count += 1 let incrementedCount = count #expect(incrementedCount == expectedCount) // often fails } private var count = 0 } let counter = Counter() var tasks = [Task<Void, Never>]() for iteration in 1...100 { tasks.append(Task { await counter.incrementAndAssertCountEquals(iteration) }) } // Wait for all enqueued tasks to finish. for task in tasks { _ = await task.value } }
Because the Task is spawned from a nonisolated execution context, the ordering of the scheduled asynchronous work is not guaranteed.
While actors are great at serializing tasks, there is no simple way in the standard Swift library to send ordered tasks to them from a nonisolated synchronous context, or from multiple execution contexts.
Executing asynchronous tasks in FIFO order
Use a FIFOQueue to execute asynchronous tasks enqueued from a nonisolated context in FIFO order. Tasks sent to one of these queues are guaranteed to begin and end executing in the order in which they are enqueued. A FIFOQueue executes tasks in a similar manner to a DispatchQueue: enqueued tasks execute atomically, and the program will deadlock if a task executing on a FIFOQueue awaits results from the queue on which it is executing.
A FIFOQueue can easily execute asynchronous tasks from a nonisolated context in FIFO order:
@Test func fIFOQueueOrdering() async { actor Counter { nonisolated func incrementAndAssertCountEquals(_ expectedCount: Int) -> Task<Void, Never> { Task(on: queue) { await self.increment() let incrementedCount = await self.count #expect(incrementedCount == expectedCount) // always succeeds } } func increment() { count += 1 } private var count = 0 private let queue = FIFOQueue() } let counter = Counter() var tasks = [Task<Void, Never>]() for iteration in 1...100 { tasks.append(counter.incrementAndAssertCountEquals(iteration)) } // Wait for all enqueued tasks to finish. for task in tasks { _ = await task.value } }
FIFO execution has a key downside: the queue must wait for all previously enqueued work – including suspended work – to complete before new work can begin. If you desire new work to start when a prior task suspends, utilize an ActorQueue.
Sending ordered asynchronous tasks to Actors from a nonisolated context
Use an ActorQueue to send ordered asynchronous tasks to an actor's isolated context from nonisolated or synchronous contexts. Tasks sent to an actor queue are guaranteed to begin executing in the order in which they are enqueued. However, unlike a FIFOQueue, execution order is guaranteed only until the first suspension point within the enqueued task. An ActorQueue executes tasks within its adopted actor's isolated context, resulting in ActorQueue task execution having the same properties as actor code execution: code between suspension points is executed atomically, and tasks sent to a single ActorQueue can await results from the queue without deadlocking.
An instance of an ActorQueue is designed to be utilized by a single actor instance: tasks sent to an ActorQueue utilize the isolated context of the queue‘s adopted actor to serialize tasks. As such, there are a couple requirements that must be met when dealing with an ActorQueue:
- The lifecycle of any
ActorQueueshould not exceed the lifecycle of itsactor. It is strongly recommended that anActorQueuebe aprivate letconstant on the adoptedactor. Enqueuing a task to anActorQueueinstance after its adoptedactorhas been deallocated will result in a crash. - An
actorutilizing anActorQueueshould set the adopted execution context of the queue toselfwithin theactor’sinit. Failing to set an adopted execution context prior to enqueuing work on anActorQueuewill result in a crash.
An ActorQueue can easily enqueue tasks that execute on an actor’s isolated context from a nonisolated context in order:
@Test func actorQueueOrdering() async { actor Counter { init() { // Adopting the execution context in `init` satisfies requirement #2 above. queue.adoptExecutionContext(of: self) } nonisolated func incrementAndAssertCountEquals(_ expectedCount: Int) -> Task<Void, Never> { Task(on: queue) { myself in myself.count += 1 #expect(expectedCount == myself.count) // always succeeds } } private var count = 0 // Making the queue a private let constant satisfies requirement #1 above. private let queue = ActorQueue<Counter>() } let counter = Counter() var tasks = [Task<Void, Never>]() for iteration in 1...100 { tasks.append(counter.incrementAndAssertCountEquals(iteration)) } // Wait for all enqueued tasks to finish. for task in tasks { _ = await task.value } }
Sending ordered asynchronous tasks to the @MainActor from a nonisolated context
Use MainActor.queue to send ordered asynchronous tasks to the @MainActor’s isolated context from nonisolated or synchronous contexts. Tasks sent to this queue type are guaranteed to begin executing in the order in which they are enqueued. The MainActor.queue is an ActorQueue that runs within the @MainActor global context: execution order is guaranteed only until the first suspension point within the enqueued task. Similarly, code between suspension points is executed atomically, and tasks sent to the MainActor.queue can await results from the queue without deadlocking.
A MainActor.queue can easily execute asynchronous tasks from a nonisolated context in FIFO order:
@MainActor @Test func mainActorQueueOrdering() async { @MainActor final class Counter { nonisolated func incrementAndAssertCountEquals(_ expectedCount: Int) -> Task<Void, Never> { Task(on: MainActor.queue) { self.increment() let incrementedCount = self.count #expect(incrementedCount == expectedCount) // always succeeds } } func increment() { count += 1 } private var count = 0 } let counter = Counter() var tasks = [Task<Void, Never>]() for iteration in 1...100 { tasks.append(counter.incrementAndAssertCountEquals(iteration)) } // Wait for all enqueued tasks to finish. for task in tasks { _ = await task.value } }
Cancelling all executing and pending tasks
Use a CancellableQueue to wrap a FIFOQueue or ActorQueue when you need the ability to cancel all currently executing and pending tasks at once. This is useful for scenarios like cancelling in-flight network requests when a view disappears, or abandoning a batch of operations when a user initiates a new action.
A CancellableQueue wraps an underlying queue and tracks all tasks enqueued on it. Calling cancelTasks() will cancel both the currently executing task and any tasks waiting in the queue. Tasks that have already completed are unaffected, and tasks enqueued after cancelTasks() is called will execute normally.
@Test func cancellableQueueExample() async { actor ImageLoader { init() { let actorQueue = ActorQueue<ImageLoader>() cancellableQueue = CancellableQueue(underlyingQueue: actorQueue) actorQueue.adoptExecutionContext(of: self) } nonisolated func loadImage(from url: URL) -> Task<UIImage?, Never> { Task(on: cancellableQueue) { myself in guard let image = try await myself.fetchImage(from: url) else { return nil } try Task.checkCancellation() return myself.processImage(image) } } nonisolated func cancelAllLoads() { // Cancels the currently loading image and any queued load requests. cancellableQueue.cancelTasks() } private func fetchImage(from url: URL) async throws -> UIImage? { // Fetch image implementation… } private func processImage(_ image: UIImage) async -> UIImage { // Expensive image processing implementation… } private let cancellableQueue: CancellableQueue<ActorQueue<ImageLoader>> } let loader = ImageLoader() // Enqueue several image load tasks. let task1 = loader.loadImage(from: URL(string: "https://example.com/1.png")!) let task2 = loader.loadImage(from: URL(string: "https://example.com/2.png")!) let task3 = loader.loadImage(from: URL(string: "https://example.com/3.png")!) // Cancel all pending and executing loads. loader.cancelAllLoads() // All tasks are now cancelled. #expect(task1.isCancelled) #expect(task2.isCancelled) #expect(task3.isCancelled) }
A CancellableQueue can also wrap a FIFOQueue for FIFO-ordered cancellable tasks:
let cancellableQueue = CancellableQueue(underlyingQueue: FIFOQueue()) Task(on: cancellableQueue) { // This work can be cancelled via cancellableQueue.cancelTasks() await performWork() }
Installation
Swift Package Manager
To install swift-async-queue in your project with Swift Package Manager, the following lines can be added to your Package.swift file:
dependencies: [ .package(url: "https://github.com/dfed/swift-async-queue", from: "1.0.0"), ]
CocoaPods
To install swift-async-queue in your project with CocoaPods, add the following to your Podfile:
pod 'AsyncQueue', '~> 1.0.0'
Contributing
I’m glad you’re interested in swift-async-queue, and I’d love to see where you take it. Please read the contributing guidelines prior to submitting a Pull Request.
Thanks, and happy queueing!
Developing
Double-click on Package.swift in the root of the repository to open the project in Xcode.