/swift-async-queue

A queue that enables ordered sending of events from synchronous to asynchronous code

Primary LanguageSwiftMIT LicenseMIT

swift-async-queue

CI Status Swift Package Manager compatible codecov Version License Platform

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:

  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:

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:

dependencies: [
    .package(url: "https://github.com/dfed/swift-async-queue", from: "0.4.0"),
]

CocoaPods

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.