Structured Concurrency with Swift
As described in Part-IV, Swift APIs previously used completion handlers for asynchronous methods that suffered from:
- Poor error handling because you could not use a single way to handle errors/exceptions instead separate callbacks for errors were needed
- Difficult to cancel asynchronous operation or exit early after a timeout.
- Requires a global reasoning of shared state in order to prevent race conditions.
- Stack traces from the asynchronous thread don’t include the originating request so the code becomes hard to debug.
- As Swift/Objective-C runtime uses native threads, creating a lot of background tasks results in expensive thread resources and may cause excessive context switching.
- Nested use of completion handlers turn the code into a callback hell.
Following example shows poor use of control flow and deficient error handling when using completion handlers:
Though, use of Promise libraries help a bit but it still suffers from dichotomy of control flow and error handling. Here is equivalent code using async/await:
As you can see, above code not only improves control flow and adds uniform error handling but it also enhances readability by removing the nested structure of completion handlers.
Tasks Hierarchy, Priority and Cancellation
When a new task is created using async/await, it inherits the priority and local values of the parent task, which are then passed to the entire hierarchy of child tasks from the parent task. When a parent task is cancelled, the Swift runtime automatically cancels all child tasks, however Swift uses cooperative cancellation so child tasks must check for cancellation state otherwise they may continue to execute, however the results from cancelled tasks are discarded.
Continuations and Scheduling
Swift previously used native threads to schedule background tasks, where new threads were automatically created when a thread is blocked or waiting for another resource. The new Swift runtime creates native threads based on the number of cores and background tasks use continuations to schedule the background task on the native threads. When a task is blocked, its state is saved on the heap and another task is scheduled for processing on the thread. The await syntax suspends current thread and releases control until the child task is completed. This cooperative scheduling requires runtime support for non-blocking I/O operations and system APIs so that native threads are not blocked and continue to work on other background tasks. This also limits background tasks from using semaphores and locks, which are discussed below.
In above example, when a thread is working on a background task “updateDatabase” that starts a child tasks “add” or “save”, it saves the tasks as continuations on heap. However, if current task is suspended then the thread can work on other tasks as shown below:
Multiple Asynchronous Tasks
The async/await in Swift also allows scheduling multiple asynchronous tasks and then awaiting for them later, e.g.
The async let syntax is called concurrent binding where the child task executes in parallel to the parent task.
The task groups allow dispatching multiple background tasks that are executed concurrently in background and Swift automatically cancels all child tasks when a parent task is cancelled. Following example demonstrates use of group API:
As these features are still in development, Swift has recently changed group.async API to group.addTask. In above example, images are downloaded in parallel and then for try await loop gathers results.
Swift compiler will warn you if you try to mutate a shared state from multiple background tasks. In above example, the asynchronous task returns a tuple of image-id and image instead of mutating shared dictionary. The parent task then mutates the dictionary using the results from the child task in for try await loop.
You can also cancel a background task using cancel API or cancel all child tasks of a group using group.cancelAll(), e.g.
The Swift runtime automatically cancels all child tasks if any of the background task fails. You can store reference to a child task in an instance variable if you need to cancel a task in a different method, e.g.
As cancellation in Swift is cooperative, you must check cancellation state explicitly otherwise task will continue to execute but Swift will reject the results, e.g.
The task or async/await APIs don’t directly support timeout so you must implement it manually similar to cooperative cancellation.
Semaphores and Locks
Swift does not recommend using Semaphores and Locks with background tasks because they are suspended when waiting for an external resource and can be later resumed on a different thread. Following example shows incorrect use of semaphores with background tasks:
You can annotate certain properties with TaskLocal, which are stored in the context of Task and is available to the task and all of its children, e.g.
Above tasks and async/await APIs are based on structured concurrency where parent task is not completed until all child background tasks are done with their work. However, Swift allows launching detached tasks that can continue to execute in background without waiting for the results, e.g.
The legacy code that use completion-handlers can use following continuation APIs to support async/await syntax:
In above example, the getPersistentPosts method used completion-handler and persistPosts method provides a bridge so that you can use async/await syntax. The resume method can only called once for the continuation.
You may also save continuation in an instance variable when you need to resume in another method, e.g.
Implementing WebCrawler Using Async/Await
The crawl method takes a list of URLs with timeout that invokes doCrawl, which crawls list of URLs in parallel and then waits for results using try await keyword. The doCrawl method recursively crawls child URLs up to MAX_DEPTH limit. The main crawl method defines boundary for concurrency and returns count of child URLs.
Following are major features of the structured concurrency in Swift:
- Concurrency scope — The async/await defines scope of concurrency where all child background tasks must be completed before returning from the asynchronous function.
- The async declared methods in above implementation shows asynchronous code can be easily composed.
- Error handling — Async-await syntax uses normal try/catch syntax for error checking instead of specialized syntax of Promise or callback functions.
- Swift runtime schedules asynchronous tasks on a fixed number of native threads and automatically suspends tasks when they wait for I/O or other resources.
Following are the major shortcomings in Swift for its support of structured concurrency:
- The most glaring omission in above implementation is timeout, which is not supported in Swift’s implementation.
- Swift runtime manages scheduling of tasks and you cannot pass your own execution dispatcher for scheduling background tasks.
Actor Model is a classic abstraction from 1970s for managing concurrency where an actor keeps its internal state private and uses message passing for interaction with its state and behavior. An actor can only work on one message at a time, thus it prevents any data races when accessing from multiple background tasks. I have previously written about actors and described them Part II of the concurrency series when covering Erlang and Elixir.
Instead of creating a background task using serial queue such as:
The actor syntax simplifies such implementation and removes all boilerplate e.g.
Above syntax protects direct access to the internal state and you must use await syntax to access the state or behavior, e.g.
Priority Inversion Principle
The dispatch queue API applies priority inversion principle when a high priority task is behind low priority tasks, which bumps up the priority of low priority tasks ahead in the queue. The runtime environment then executes the high priority task after completing those low priority tasks. The actor API instead can choose high priority task directly from the actor’s queue without waiting for completion of the low priority tasks ahead in the queue.
If an actor invokes another actor or background task in its function, it may get suspended until the background task is completed. In the meantime, another client may invoke the actor and modify its state so you need to check assumptions when changing internal state. A continuation used for the background task may be scheduled on a different thread after resuming the work, you cannot rely on DispatchSemaphore, NSLock, NSRecursiveLock, etc. for synchronizations.
Following code from WWDC-2021 shows how reentrancy can be handled safely:
The ImageDownloader actor in above example downloads and caches the image and while it’s downloading an image. The actor will be suspended while it’s downloding the image but another client can reenter the downloadAndCache method and download the same image. Above code prevents duplicate requests and reuses existing request to serve multiple concurrent clients.
The actors in Swift prevent invoking methods directly but you can annotate methods with nonisolated if you need to call them directly but those methods cannot mutate state, e.g.
The actors requires that any data structure used in its internal state are thread safe and implement Sendable protocol such as:
- Value types
- Immutable classes
- Synchronized classes
- @Sendable Functions
The UI apps require that all UI updates are performed on the main thread and previously you had to dispatch UI work to DispatchQueue.main queue. Swift now allows marking functions, classes or structs with a special annotations of @MainActor where the functions are automatically executed on the main thread, e.g.
Following example shows how a view-controller can be annotated with the @MainActor annotations:
In above example, all methods for MyViewController are executed on the main thread, however you can exclude certain methods via nonisolated keyword.
The @globalActor annotation defines a singleton global actor and @MainActor is a kind of global actor. You can also define your own global actor such as:
Message Pattern Matching
As actors in Swift use methods to invoke operations on actor, they don’t support pattern matching similar to Erlang/Elixir, which offer selecting next message to process by comparing one or more fields in the message.
Unlike actors in Erlang or Elixir, actors in Swift can only communicate with other actors in the same process or application and they don’t support distributed communication to remote actors.
The actor protocol defines following property to access the executor:
However, unownedExecutor is a read-only property that cannot be changed at this time.
Implementing WebCrawler Using Actors and Tasks
Above implementation uses actors for processing crawling requests but it shares other code for parsing and downloading web pages. As an actor provides a serialize access to its state and behavior, you can’t use a single actor to implement a highly concurrent web crawler. Instead, you may divide the web domain that needs to be crawled into a pool of actors that can share the work.
Following table from Part-IV summarizes runtime of various implementation of web crawler when crawling 19K URLs that resulted in about 76K messages to asynchronous methods/coroutines/actors discussed in this blog series:
Note: The purpose of above results was not to run micro-benchmarks but to show rough cost of spawning thousands of asynchronous tasks.
You can download full code for Swift example from https://github.com/bhatti/concurency-katas/tree/main/swift.
Overall, Swift’s new features for structured concurrency including async/await and actors is a welcome addition to its platform. On the downside, Swift concurrency APIs lack support for timeouts, customized dispatcher/executors and micro benchmarks showed higher overhead than expected. However, on the positive side, the Swift runtime catches errors due to data races and the new async/await/actors syntax prevents bugs that were previously caused by incorrect use of completion handlers and error handling. This will help developers write more robust and responsive apps in the future.