swift-async-queue
A library of queues that enable sending ordered tasks from synchronous to asynchronous contexts.
Task Ordering and Swift Concurrency
Tasks sent from a synchronous context to an asynchronous context in Swift Concurrency are inherently unordered. Consider the following test:
@MainActor
func testMainActorTaskOrdering() async {
actor Counter {
func incrementAndAssertCountEquals(_ expectedCount: Int) {
count += 1
let incrementedCount = count
XCTAssertEqual(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
}
}
Despite the spawned Task
inheriting the serial @MainActor
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 synchronous context.
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 executes 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:
func testFIFOQueueOrdering() async {
actor Counter {
nonisolated
func incrementAndAssertCountEquals(_ expectedCount: Int) {
queue.enqueue {
await self.increment()
let incrementedCount = await self.count
XCTAssertEqual(incrementedCount, expectedCount) // always succeeds
}
}
func flushQueue() async {
await queue.enqueueAndWait { }
}
func increment() {
count += 1
}
var count = 0
private let queue = FIFOQueue()
}
let counter = Counter()
for iteration in 1...100 {
counter.incrementAndAssertCountEquals(iteration)
}
// Wait for all enqueued tasks to finish.
await counter.flushQueue()
}
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 the 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
ActorQueue
should not exceed the lifecycle of itsactor
. It is strongly recommended that anActorQueue
be aprivate let
constant on the adoptedactor
. Enqueuing a task to anActorQueue
instance after its adoptedactor
has been deallocated will result in a crash. - An
actor
utilizing anActorQueue
should set the adopted execution context of the queue toself
within theactor
’sinit
. Failing to set an adopted execution context prior to enqueuing work on anActorQueue
will result in a crash.
An ActorQueue
can easily enqueue tasks that execute on an actor’s isolated context from a nonisolated context in order:
func testActorQueueOrdering() async {
actor Counter {
init() {
// Adopting the execution context in `init` satisfies requirement #2 above.
queue.adoptExecutionContext(of: self)
}
nonisolated
func incrementAndAssertCountEquals(_ expectedCount: Int) {
queue.enqueue { myself in
myself.count += 1
XCTAssertEqual(expectedCount, myself.count) // always succeeds
}
}
func flushQueue() async {
await queue.enqueueAndWait { _ in }
}
private var count = 0
// Making the queue a private let constant satisfies requirement #1 above.
private let queue = ActorQueue<Counter>()
}
let counter = Counter()
for iteration in 1...100 {
counter.incrementAndAssertCountEquals(iteration)
}
// Wait for all enqueued tasks to finish.
await counter.flushQueue()
}
@MainActor
from a nonisolated context
Sending ordered asynchronous tasks to the Use a MainActorQueue
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. Like an ActorQueue
, execution order is guaranteed only until the first suspension point within the enqueued task. A MainActorQueue
executes tasks within its adopted actor’s isolated context, resulting in MainActorQueue
task execution having the same properties as a @MainActor
's' code execution: code between suspension points is executed atomically, and tasks sent to a single MainActorQueue
can await results from the queue without deadlocking.
A MainActorQueue
can easily execute asynchronous tasks from a nonisolated context in FIFO order:
@MainActor
func testMainActorQueueOrdering() async {
@MainActor
final class Counter {
nonisolated
func incrementAndAssertCountEquals(_ expectedCount: Int) {
MainActorQueue.shared.enqueue {
self.increment()
let incrementedCount = self.count
XCTAssertEqual(incrementedCount, expectedCount) // always succeeds
}
}
func flushQueue() async {
await MainActorQueue.shared.enqueueAndWait { }
}
func increment() {
count += 1
}
var count = 0
}
let counter = Counter()
for iteration in 1...100 {
counter.incrementAndAssertCountEquals(iteration)
}
// Wait for all enqueued tasks to finish.
await counter.flushQueue()
}
Requirements
- Xcode 15.0 or later.
- iOS 13 or later.
- tvOS 13 or later.
- watchOS 6 or later.
- macOS 10.15 or later.
- Swift 5.9 or later.
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: "0.5.0"),
]
CocoaPods
To install swift-async-queue in your project with CocoaPods, add the following to your Podfile
:
platform :ios, '13.0'
pod 'AsyncQueue', '~> 0.5.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.