dfed/swift-async-queue
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:
```swift
@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](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html#ID645) 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:
```swift
@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](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html#ID639) 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`:
1. 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.
2. 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:
```swift
@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](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html#ID639) 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:
```swift
@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.
```swift
@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:
```swift
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.
Package Metadata
Repository: dfed/swift-async-queue
Default branch: main
README: README.md