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
}
}
nonisolated
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 its actor. It is strongly recommended that an ActorQueue be a private let constant on the adopted actor. Enqueuing a task to an ActorQueue instance after its adopted actor has been deallocated will result in a crash.
An actor utilizing an ActorQueue should set the adopted execution context of the queue to self within the actor’s init. Failing to set an adopted execution context prior to enqueuing work on an ActorQueue 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
}
}
nonisolated
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()
}
Sending ordered asynchronous tasks to the @MainActor from a nonisolated context
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
}
}
nonisolated
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 14.3 or later.
iOS 13 or later.
tvOS 13 or later.
watchOS 6 or later.
macOS 10.15 or later.
Swift 5.8 or later.
Installation
Swift Package Manager
To install swift-async-queue in your iOS project with Swift Package Manager, the following lines can be added to your Package.swift file:
To install swift-async-queue in your iOS project with CocoaPods, add the following to your Podfile:
platform :ios, '13.0'
pod 'AsyncQueue', '~> 0.3.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.
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:
Despite the spawned
Taskinheriting the serial@MainActorexecution 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
FIFOQueueto 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. AFIFOQueueexecutes tasks in a similar manner to aDispatchQueue: enqueued tasks executes atomically, and the program will deadlock if a task executing on aFIFOQueueawaits results from the queue on which it is executing.A
FIFOQueuecan easily execute asynchronous tasks from a nonisolated context in FIFO order: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
ActorQueueto send ordered asynchronous tasks to anactor’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 aFIFOQueue, execution order is guaranteed only until the first suspension point within the enqueued task. AnActorQueueexecutes tasks within the its adopted actor’s isolated context, resulting inActorQueuetask execution having the same properties asactorcode execution: code between suspension points is executed atomically, and tasks sent to a singleActorQueuecan await results from the queue without deadlocking.An instance of an
ActorQueueis designed to be utilized by a singleactorinstance: tasks sent to anActorQueueutilize the isolated context of the queue‘s adoptedactorto serialize tasks. As such, there are a couple requirements that must be met when dealing with anActorQueue: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.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
ActorQueuecan easily enqueue tasks that execute on an actor’s isolated context from a nonisolated context in order:Sending ordered asynchronous tasks to the
@MainActorfrom a nonisolated contextUse a
MainActorQueueto 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 anActorQueue, execution order is guaranteed only until the first suspension point within the enqueued task. AMainActorQueueexecutes tasks within its adopted actor’s isolated context, resulting inMainActorQueuetask execution having the same properties as a@MainActor‘s’ code execution: code between suspension points is executed atomically, and tasks sent to a singleMainActorQueuecan await results from the queue without deadlocking.A
MainActorQueuecan easily execute asynchronous tasks from a nonisolated context in FIFO order:Requirements
Installation
Swift Package Manager
To install swift-async-queue in your iOS project with Swift Package Manager, the following lines can be added to your
Package.swiftfile:CocoaPods
To install swift-async-queue in your iOS project with CocoaPods, add the following to your
Podfile: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.swiftin the root of the repository to open the project in Xcode.