4.
Embracing Structured Concurrency
Written by Aaqib Hussain
Ever since Apple introduced async/await and actors, writing concurrent code has changed fundamentally. Structured concurrency offers a level of simplicity and safety that was missing in older APIs such as DispatchQueue and OperationQueue. If writing asynchronous code with DispatchQueue was often a matter of “Somehow, I manage”, then writing it with structured concurrency is confidently “Of course, I manage.”
This modern system enables you to write thread-safe code that is less prone to race conditions from the start, as the compiler actively guides you away from potential issues.
This chapter examines Apple’s entire async ecosystem. You will go beyond the basics to master Task hierarchies, ensure UI safety with the Main Actor, and process asynchronous data streams. Brace yourself, an adventure is coming…
Mastering Structured Concurrency
If you often write asynchronous code, you’re likely aware of how it can create a chaotic web of completion blocks and disconnected queues. This makes it difficult to track the lifecycle of work items or to handle cancellations properly.
On the other hand, while async/await introduces clean syntax, its real power lies in the structure it provides. It not only enforces a formal hierarchy but also provides a clear and predictable order to that chaos. Additionally, it provides compile-time safety and a runtime system that automatically manages complex scenarios, such as parallel execution and cancellation, which helps prevent common bugs and resource leaks.
The Task Hierarchy: More Than Just a Closure
In Swift, a Task is not just a closure that runs on a background thread, but a container that concurrently runs work that the system actively manages. Each Task has a priority, can be cancelled, and exists within its own hierarchy, the Task Tree.
When an async method runs, it runs within a Task. If that method creates a new task, the outer task becomes the parent and the new task becomes the child. This can create a tree-like structure consisting of parents and children.
In Swift’s structured concurrency, there are two main ways to create child tasks: async let (implicit) and TaskGroup (explicit). The key difference between them is their use case:
- Use
async letwhen you know the exact number of concurrent operations you need. - Use a
TaskGroupwhen you need to create a varying number of concurrent tasks, often within a loop, which gives you more flexibility over the priority.
In both cases, the tasks created are child tasks. This means they automatically become part of their parent tasks’ lifecycles. They are cancelled when their parent task is cancelled, and they will automatically escalate the parent’s priority if created with a higher priority while the parent awaits their result.
Consider the following example of an implicit child:
struct UserProfile {
var name: String
var handle: String
}
struct ActivityItem {
var description: String
}
func fetchProfile() async throws -> UserProfile {
print("Child 1 (Profile): Fetching...")
try await Task.sleep(for: .seconds(1)) // Simulate work
print("Child 1 (Profile): Finished.")
return UserProfile(name: "Michael Scott", handle: "@michaelscott")
}
func fetchFeed() async throws -> [ActivityItem] {
print("Child 2 (Feed): Starting loop...")
for i in 0..<100 {
// This sleep is a cancellation point
try await Task.sleep(for: .milliseconds(500))
// This line will not be printed after cancellation
print("Child 2 (Feed): Completed iteration \(i)")
}
return []
}
func loadUserProfileAndActivityFeed() async {
print("Parent: Starting to fetch data.")
// 1
async let profileTask = fetchProfile()
async let feedTask = fetchFeed()
do {
let (profile, feed) = try await (profileTask, feedTask) // 2
print("Parent: Successfully loaded profile for \(profile) and \(feed.count) activity items.")
} catch {
print("Parent: One of the child's tasks was cancelled or threw an error.")
}
}
Here’s a breakdown of everything above:
- Creates two implicit children, i.e.,
profileTaskandfeedTask. - This code waits for both children to complete or for either to be cancelled. This waits asynchronously; depending on system resources, the operations may execute concurrently. That
fetchFeed()does not necessarily wait forfetchProfile()to finish.
So if you were to do this:
let mainTask = Task {
await loadUserProfileAndActivityFeed()
}
You’ll see the following printed in the console:
Parent: Starting to fetch data.
Child 1 (Profile): Fetching...
Child 2 (Feed): Starting loop...
Parent: Starting to fetch data.
Child 1 (Profile): Fetching...
Child 2 (Feed): Starting loop...
Child 2 (Feed): Completed iteration 0
Child 2 (Feed): Completed iteration 1
Child 2 (Feed): Completed iteration 2
Child 1 (Profile): Finished.
Child 2 (Feed): Completed iteration 3
Child 2 (Feed): Completed iteration 4
Child 2 (Feed): Completed iteration 5
...
At any point during the execution of the loop, if you cancel the task, you can cancel it like:
mainTask.cancel()
The amazing thing about this is automatic propagating cancellation. If the parent task is cancelled, Swift sends a cancellation signal down to all its children and their subsequent children.
And your console would print:
Parent: One of the child's tasks was cancelled or threw an error.
Now, at // 1. You could alternatively do this:
func loadUserProfileAndActivityFeed() async {
print("Parent: Starting to fetch data.")
do {
let profileTask = try await fetchProfile() // 1
let feedTask = try await fetchFeed() // 2
let (profile, feed) = (profileTask, feedTask)
print("Parent: Successfully loaded profile for \(profile) and \(feed!.count) activity items.")
} catch {
print("Parent: One of the child's tasks was cancelled or threw an error.")
}
}
Although it still works asynchronously, this approach has a disadvantage. Specifically:
-
fetchProfile()must finish before moving on. -
fetchFeed()only starts afterfetchProfile()completes.
Note: Between these two approaches, i.e.,
async-letor awaiting each method directly, choose carefully based on your needs and the situation.
The async-let approach works well when you know the exact number of child tasks you need to execute. When dealing with a dynamic number of child tasks, the best choice is to use TaskGroup. A task group provides a scope that runs tasks in parallel and waits for all of them to finish before exiting.
To better understand this, you can revisit the original loadUserProfileAndActivityFeed() method and rewrite it using a task group. One constraint here is that this approach relies on a single return type, which you can handle cleanly with an enum, like this:
enum FetchResult {
case profile(UserProfile)
case feed([ActivityItem])
}
func loadUserProfileAndActivityFeed() async {
print("Parent: Starting to fetch data.")
do {
// Create variables to hold the results from the group
var profile: UserProfile?
var feed: [ActivityItem]?
try await withThrowingTaskGroup(of: FetchResult.self) { group in // 1
// Add child tasks to the group. They run in parallel.
group.addTask {
return .profile(try await fetchProfile()) // 2
}
group.addTask {
return .feed(try await fetchFeed()) // 3
}
// Collect the results as they complete
for try await result in group { // 4
switch result {
case let .profile(fetchedProfile):
profile = fetchedProfile
case let .feed(fetchedFeed):
feed = fetchedFeed
}
}
}
// The group has finished, and you can now use the results.
print("Parent: Successfully loaded profile for \(profile) and \(feed!.count) activity items.")
} catch {
print("Parent: One of the child's tasks was cancelled or threw an error.")
}
}
Here’s a walkthrough of the logic in this snippet:
- Creates a concurrent work scope and specifies the data type returned by each child task.
- Tries to
fetchProfile(). - Tries to
fetchFeed(). - Iterates over the
groupand retrieves data from every task as it arrives asynchronously.
Understanding Task Priority
Every task you create has a priority, which indicates how important its work is to the system. The system uses this priority to decide which task to schedule on an available thread, especially when there are more tasks ready to run than CPU cores available.
Swift provides a variety of TaskPriority levels, from highest to lowest:
-
.high: For tasks that need to be completed “as soon as possible”. -
.userInitiated: Similar to.high, but semantically tied to work requested by the user and expected to be completed quickly. -
.medium: The default priority when none is specified. -
.low: For tasks that are not time-sensitive, but whose results the user might eventually see. -
.utility: For long-running tasks that the user doesn’t directly track, like background fetch operations. -
.background: For cleanup, maintenance, or other work that can happen when the device is idle.
You can specify the priority of a task like this:
Task(priority: .background) {
// Perform cleanup work here...
print("Cleaning up old files on priority: \(Task.currentPriority)")
}
A key feature of the system is priority escalation. If a low-priority parent task awaits a high-priority child task, the system temporarily escalates the parent’s priority to match the child’s. This helps prevent high-priority work from being blocked by low-priority work. This process is known as priority inversion, where high-priority work is indirectly blocked by lower-priority work.
Task Cancellation
Some asynchronous tasks might take longer than expected. For example, downloading a large image or a PDF could cause the user to cancel the process. In such cases, each task should check for cancellation. There are two ways to do this: using Task.isCancelled or by using try Task.checkCancellation(). Here’s how you do it:
func fetchFeed() async throws -> [ActivityItem] {
print("Child 2 (Feed): Starting loop...")
for i in 0..<100 {
// Cancellation check
if Task.isCancelled { //
throw CancellationError() // 1
} //
// This sleep is a cancellation point
try await Task.sleep(for: .milliseconds(500))
// This line will not be printed after cancellation
print("Child 2 (Feed): Completed iteration \(i)")
}
return []
}
So, what is happening here?
- It checks whether the task is cancelled and throws a cancellation error if it is. To achieve similar results, you can replace this check with
try Task.checkCancellation().
While using a task group, you can also use .addTaskUnlessCancelled to add child tasks. It only adds a new child if the parent task is still running. You can modify the original method as follows:
func loadUserProfileAndActivityFeed() async {
print("Parent: Starting to fetch data.")
do {
// ...
try await withThrowingTaskGroup(of: FetchResult.self) { group in // 1
// Add child tasks to the group. They run in parallel.
group.addTask {
return .profile(try await fetchProfile())
}
group.addTaskUnlessCancelled {
return .feed(try await fetchFeed())
}
// ...
}
} catch {
print("Parent: One of the child's tasks was cancelled or threw an error.")
}
}
Cooperative Cancellation with Task.yield()
In concurrent systems, it’s important for long-running tasks to be considerate. A task that performs heavy CPU-based computation without taking any breaks can monopolize a thread, blocking other tasks from executing. To address this, Swift offers Task.yield().
Task.yield() is an async function that briefly pauses the current task, enabling the system to schedule and run other pending tasks. This is a form of cooperative multitasking.
You should use Task.yield() inside long loops that don’t contain other await calls. This ensures your tasks remain cooperative.
Without yield(), a long-running task is like a person at the gym who sits at the bench press, endlessly scrolling the internet while everyone waits for their turn. Task.yield() is you politely standing up between sets to let someone else work out.
Now, consider the case where you have two loops in two separate tasks: taskA and taskB
let taskA = Task {
print("Task A: Starting a long loop.")
for i in 0..<10 {
print("Task A: Now on iteration \(i)")
}
print("Task A: Finished")
}
let taskB = Task {
print("Task B: Starting a long loop.")
for i in 0..<10 {
print("Task B: Now on iteration \(i)")
}
print("Task B: Finished")
}
If you run the tasks, you’ll see output similar to the following:
Task A: Starting a long loop.
Task A: Now on iteration 0
...
Task A: Finished
Task B: Starting a long loop.
Task B: Now on iteration 0
...
Task B: Finished
Now, if you want to use concurrent execution, Task.yield() comes into play as shown below:
let taskA = Task {
print("Task A: Starting a long loop.")
for i in 0..<5 {
await Task.yield()
print("Task A: Now on iteration \(i)")
}
print("Task A: Finished")
}
let taskB = Task {
print("Task B: Starting a long loop.")
for i in 0..<5 {
await Task.yield()
print("Task B: Now on iteration \(i)")
}
print("Task B: Finished")
}
By adding await Task.yield(), each task voluntarily pauses during each iteration, passing control back to the system scheduler. The scheduler then allows the other task to run, leading to interleaved execution as shown in the example below. Although the exact order can vary, the tasks will share execution time properly.
Task A: Starting a long loop.
Task B: Starting a long loop.
Task A: Now on iteration 0
Task B: Now on iteration 0
Task A: Now on iteration 1
Task B: Now on iteration 1
...
This cooperative behavior is essential to ensure that long-running tasks don’t starve other parts of your program, keeping your app responsive.
Tasks: Breaking the Structure
Swift provides robustness and control through a parent-child hierarchy in structured concurrency, which you learned previously. In addition, Swift provides Unstructured Concurrency. Unlike tasks that are in a parent-child relationship, an unstructured task is independent and doesn’t rely on a parent task. It provides complete flexibility to manage tasks however you need. It inherits the surrounding context; for example, if created in a @MainActor scope, it inherits that isolation. It also inherits priority and task-local values. You can use @TaskLocal static var to create a task-scoped value that is visible to child tasks.
struct RequestInfo {
@TaskLocal static var requestID: UUID?
}
func handleTaskRequest() async {
await RequestInfo.$requestID.withValue(UUID()) {
if let id = RequestInfo.requestID {
print("Processing order with ID: \(id)") // 1
}
// Create a child task
let childTask = Task {
// The child task "gets a copy" of the parent's task-local values
if let id = RequestInfo.requestID {
print("Child task logging for ID: \(id)") // 2
}
}
await childTask.value
}
}
Consider if the UUID is AE9FF928-5638-4877-A0F9-9D231C13D48B, and you run the method handleTaskRequest()
- Prints “Processing order with ID: AE9FF928-5638-4877-A0F9-9D231C13D48B”
- Also prints “Child task logging for ID: AE9FF928-5638-4877-A0F9-9D231C13D48B”
Conversely, Swift offers a task that is entirely independent of the scope in which it’s running. It doesn’t inherit any priority or local task variables. Although you can specify a priority for the task, as with a normal Task, such a task is called a detached task. You create it by calling Task.detached { ... }.
Check the example below:
func handleDetachedRequest() async {
await RequestInfo.$requestID.withValue(UUID()) {
if let id = RequestInfo.requestID {
print("Processing order with ID: \(id)") // 1
}
let detachedTask = Task.detached { // 2
print("Detached Task: Starting...")
if let id = RequestInfo.requestID { // 3
print("Detached Task: Inherited request ID \(id)")
} else {
print("Detached Task: I have no request ID. I am independent.")
}
}
await detachedTask.value
}
}
For simplicity, you can assume the UUID is the same as the previous one: AE9FF928-5638-4877-A0F9-9D231C13D48B. Then, running the method handleDetachedRequest() would produce the following output.
- Prints “Processing order with ID: AE9FF928-5638-4877-A0F9-9D231C13D48B”.
- The
Task.detached {}scope prints “Detached Task: Starting…”. - The detached scope doesn’t affect the parent scope. It prints “Detached Task: I have no request ID. I am independent.”
Data Isolation
Because an app often handles many concurrent tasks, two (or more) tasks can try to update a shared state at the same time, leading to a data race. To prevent this, Swift enforces data isolation to ensure that your data is always correct when accessed and that no other thread modifies it concurrently. There are three ways to isolate data.
- Since immutable data cannot be changed, it is always isolated. This prevents other code from modifying it while you access it.
- A local variable inside a task is always isolated because no other code outside the task has a reference to it. Similarly, Swift ensures a closure is not used concurrently when it captures a variable.
- Data within an actor is isolated, and its methods are the only safe way to access it. If multiple tasks try to call these methods simultaneously, the actor forces them to “wait their turn,” ensuring only one can run at a time.
Advanced Actors and Data Safety
Actors are fundamental to modern Swift concurrency. They offer a robust, compiler-verified way to prevent data races. By isolating state and enforcing serialized access, they address many traditional issues in multithreaded programming. However, actors are not a perfect solution. They introduce their own challenges and complex behaviors that must be understood to maximize efficiency. Below, you’ll learn some of the challenges and advanced techniques for controlling actor execution and understanding their place in the broader ecosystem of thread-safety patterns.
The Reentrancy Problem Explained
An actor’s primary feature is to execute methods one at a time, preventing multiple threads from accessing its state simultaneously. However, there is an exception known as Actor Reentrancy.
Actor Reentrancy is a concurrency concept where a function pauses (for example, at an await) and, while waiting for its completion, another task can enter (or re-enter) the same actor and execute other code, potentially modifying the actor’s shared state before the original function resumes.
The problem is making incorrect assumptions about an actor’s state across an await. To illustrate this, consider the following, which is vulnerable to reentrancy.
actor ProgressTracker {
var loadedValues: [String] = []
func load(_ value: String) async {
// 1
let expectedCount = loadedValues.count + 1
print("Starting load for '\(value)'. Expecting count to be \(expectedCount).")
loadedValues.append(value)
// 2
try? await Task.sleep(for: .seconds(1))
// 4
print("Finished load for '\(value)'. Expected \(expectedCount), but actual count is now: \(loadedValues.count)")
}
}
let tracker = ProgressTracker()
Task { await tracker.load("A") }
Task { await tracker.load("B") } // 3
When you run this code, the output is unpredictable and often incorrect:
Starting load for 'A'. Expecting count to be 1.
Starting load for 'B'. Expecting count to be 2.
Finished load for 'A'. Expected 1, but actual count is now: 2
Finished load for 'B'. Expected 2, but actual count is now: 2
The log for task “A” is incorrect. This happens because:
- Task A starts
load("A"), setsexpectedCountto 1, and appends “A”. - Task A hits
awaitand suspends, allowing the actor to process other work. - Task B starts
load("B"), sees thatloadedValuescontains 1 item, setsexpectedCountto 2, and appends “B”. - Task A resumes and prints its final message, but
loadedValues.countis now 2, which violates A’s original expectation.
This interleaving doesn’t cause a crash but leads to unusual and unexpected behavior if you assume that the state remains unchanged across an await.
Preventing Reentrancy
You can eliminate this problem using the following rules:
- If possible, perform all critical state mutations before the initial
awaitcall within a method. - Never assume that the state you read before an
awaitwill remain the same after it resumes. If you need the latest state, re-read it from the actor’s properties. - For complex operations that require reentrancy avoidance, traditional locking mechanisms may be necessary, even within an
actor.
Customizing Execution with SerialExecutor
By default, an actor’s code runs on a shared global concurrency thread pool managed by the Swift runtime. At any given time, the system determines the most efficient execution strategy. While this generally works well, in certain cases, you might want the actor’s code to execute on a particular thread or a serial queue. This can be achieved with a custom executor.
A common example is performing UI updates safely on the main thread using the global actor attribute @MainActor, either on an actor directly or via a method. This approach is convenient; however, building a custom executor from scratch can help you understand how threads work within an actor. A use case for such an executor is interfacing with an older C library, writing files serially, or working with an API that isn’t thread-safe and requires all interactions to occur on a single, specific DispatchQueue.
@MainActor is a global actor that controls the execution context on the main thread. By annotating functions, classes, or actors, you isolate that code to run on the main thread. This enables the compiler to verify safety at compile time, a key advantage over the older
DispatchQueue.main.
A serial executor requires implementing func enqueue(_ job: UnownedJob).
final class BackgroundQueueExecutor: SerialExecutor {
// A shared instance for all actors that might use it
static let shared = BackgroundQueueExecutor()
// The specific queue you want your actor's code to run on
private let backgroundQueue = DispatchQueue(label: "com.kodeco.background-executor", qos: .background)
func enqueue(_ job: UnownedJob) { // 1
backgroundQueue.async {
job.runSynchronously(on: self.asUnownedSerialExecutor())
}
}
}
- This is the heart of the executor. It executes the submitted job. It’s called “synchronously” because
backgroundQueue.asynchas already handled the asynchronous scheduling.
Now, you can create an actor that uses this executor. By overriding the unownedExecutor computed property inside the actor, you tell the Swift runtime that all work for this actor must be scheduled via the custom executor.
actor LegacyAPIBridge {
private let _unownedExecutor: UnownedSerialExecutor
init(unownedExecutor: UnownedSerialExecutor = BackgroundQueueExecutor.shared.asUnownedSerialExecutor()) {
_unownedExecutor = unownedExecutor
}
nonisolated var unownedExecutor: UnownedSerialExecutor {
_unownedExecutor
}
func performUnsafeWork() {
// Thanks to our custom executor, this code is now guaranteed
// to run on `BackgroundQueueExecutor.shared.backgroundQueue`.
print("Performing work on a specific queue...")
}
}
Custom executors are an advanced feature that offer the ultimate control for integrating Swift actors with specific threading needs, ensuring safety and compatibility with older code.
Bridging Concurrency Realms
Swift Concurrency did not emerge in isolation. For years, Combine served as Apple’s modern, declarative framework for managing asynchronous events. It brought a powerful functional approach to handling streams of values over time. As a result, many mature and reliable codebases have a significant investment in Combine publishers, subscribers, and operators.
A key part of mastering modern Swift is learning how to connect these two realms. You rarely have the time to restart an existing project from scratch. More often, you’ll introduce async/await into your current app. The aim is to provide a practical guide to interoperability that ensures both systems work together smoothly. This includes learning how to use a Combine publisher as a modern AsyncSequence and, conversely, how to wrap an async function for use in an older Combine-based workflow. Lastly, you’ll explore high-level strategies for deciding when to create a bridge and when to perform a full migration.
From Combine to AsyncSequence
The most common situation you might encounter is using an existing Combine publisher from a ViewModel or an API layer in new async/await code. Swift makes this process quite straightforward. Every publisher provided by Combine has a property called values that is inherently an AsyncSequence. Much like the standard Sequence protocol allows you to iterate over a collection with a for...in loop, the AsyncSequence protocol lets you iterate over the values emitted by the publisher with a for await...in loop.
This is perfect for managing streams of data. For example, if you have a PassthroughSubject that emits live updates, you can handle it like this:
import Combine
enum UserActionEvent: String {
case loginButtonTapped
case dismissButtonTapped
case logoutButtonTapped
}
let subject = PassthroughSubject<UserActionEvent, Never>()
// This task will run indefinitely, waiting for new values from the publisher.
let combineListenerTask = Task {
print("Listener: Waiting for values from Combine...")
for await value in subject.values {
print("Listener: Received '\(value)' from the publisher.")
}
print("Listener: Finished.")
}
// In another part of your code, you can send values through the subject.
try await Task.sleep(for: .seconds(1))
subject.send(.loginButtonTapped)
try await Task.sleep(for: .seconds(1))
subject.send(.dismissButtonTapped)
try await Task.sleep(for: .seconds(1))
subject.send(.logoutButtonTapped)
combineListenerTask.cancel()
The for await...in loop pauses execution until the subject publisher emits a new value. When a value is sent, the task resumes, prints the value, and then pauses again, waiting for the next one. This creates a smooth and efficient bridge, allowing your modern concurrent code to subscribe to and respond to any existing Combine stream.
From async/await to Combine
The reverse case is also possible, where you have the latest code written with async/await, and you need to provide compatibility with an older part of the code that is built with Combine and expects a publisher. The standard approach here is to wrap the async call in a Future publisher.
A Future is a special publisher that eventually emits a success (or a failure) and then finishes. This one-time event dispatch makes it a perfect bridge for an async function that returns a single result.
Now consider the following code snippet:
enum FetchError: Error { case networkError }
func fetchUserName(id: Int) async throws -> String {
try await Task.sleep(for: .seconds(1))
if id == 123 {
return "Ray Wenderlich"
} else {
throw FetchError.networkError
}
}
You can wrap this in a Future that correctly propagates either the success value or the failure:
func userNamePublisher(for id: Int) -> Future<String, Error> {
return Future { promise in
Task {
do {
let username = try await fetchUserName(id: id)
promise(.success(username))
} catch {
promise(.failure(error))
}
}
}
}
Usage is as follows:
var cancellable: Set<AnyCancellable> = []
userNamePublisher(for: 123)
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
print("Finished successfully")
case let .failure(error):
print("Failed with error: \(error)")
}
},
receiveValue: { username in
print("Received username: \(username)")
}
).store(in: &cancellable)
This prints “Received username: Ray Wenderlich” and “Finished successfully” because the input is 123. If you pass something else, the failure block is executed.
One unique aspect of the Future publisher is that it starts working as soon as it’s created, not when a subscriber connects. To make its behavior more similar to a typical publisher (which only performs work upon subscription), you can wrap it in a Deferred publisher:
func userNamePublisher(for id: Int) -> AnyPublisher<String, Error> {
return Deferred {
Future<String, Error> { promise in
Task {
do {
let username = try await fetchUserName(id: id)
promise(.success(username))
} catch {
promise(.failure(error))
}
}
}
}.eraseToAnyPublisher()
}
This pattern reliably ensures that your async operation runs only when a subscriber in your Combine chain actually needs it.
Strategic Migration: When to Bridge and When to Rewrite
With these bridging tools, you face a decision when working with a mixed codebase: should you continue bridging the two realms or rewrite older Combine code to async/await?
Bridging is a low-risk, practical approach that enables gradual adoption.
-
Pros: It uses well-established, thoroughly tested code. It enables teams to learn the new system without hindering feature development. It’s ideal for integrating
async/awaitfeatures into a stable, complex Combine core. - Cons: It adds cognitive overhead, as developers have to be proficient in both techniques. The bridge code can sometimes be confusing, and you may not be able to fully utilize the full set of structured concurrency features throughout the code.
Rewriting aims for a modern, consistent codebase.
- Pros: It offers a unified concurrency model, making it easier to read and maintain. It provides full access to modern concurrency features, often resulting in simpler, more direct code. It’s especially appropriate for new projects.
- Cons: It requires more effort and is a high-risk undertaking. Rewriting stable, complex logic can introduce new bugs and demand more time and thorough training for the entire team to understand the system’s new capabilities.
A hybrid or bridged codebase isn’t a sign of weakness; rather, it reflects a mature, evolving project. The best strategy is to use bridges to maintain consistent progress while focusing on smaller, self-contained features for rewriting as resources and time allow.
Best Practices & Testability
The async/await syntax makes writing concurrent code much easier. While the keywords eliminate the complexity of callback hell, they don’t automatically ensure a solid architecture in your implementation. Writing production-quality concurrent code requires following best practices to keep it clean, maintainable, efficient, and performant.
Best Practice 1: Focused async/await Methods
An async method should have a single, clear purpose. It’s often easy to write an async function that handles a long chain of unrelated tasks, which can make the code hard to read, debug, and test.
func setupDashboard() async {
// 1
guard let user = try? await APIClient.shared.fetchUser() else { return }
// 2
let friends = try? await APIClient.shared.fetchFriends(for: user)
// 3
var userImages: [UIImage] = []
if let photoURLs = try? await APIClient.shared.fetchPhotoURLs(for: user) {
for url in photoURLs {
if let data = try? await APIClient.shared.downloadImage(url: url) {
// 4
let processedImage = await processImage(data)
userImages.append(processedImage)
}
}
}
// ... update UI with all this data ...
}
This function is doing too much:
- Fetches the user.
- Fetches their friends.
- Fetches and processes images.
- Processes the data by converting it into an image.
A better approach is to break it down into smaller, focused, and more reusable async functions.
func fetchUser() async throws -> User { /* ... */ }
func fetchFriends(for user: User) async throws -> [Friend] { /* ... */ }
func fetchAllImages(for user: User) async -> [UIImage] { /* ... */ }
func setupDashboard() async {
do {
let user = try await fetchUser()
// Run remaining fetches in parallel for performance
async let friends = fetchFriends(for: user)
async let images = fetchAllImages(for: user)
let (userFriends, userImages) = try await (friends, images)
// ... update UI ...
} catch {
// ... handle error ...
}
}
This way, you’re leveraging the full capabilities of structured concurrency. Once the User is fetched, friends and images are retrieved asynchronously and in parallel.
Best Practice 2: Re-read State After await
This is the most important rule for writing correct code inside an actor. As mentioned earlier, any await is a suspension point where the actor can be re-entered by another task, which may change its state. Never assume that the state you read before an await will stay the same after it resumes. If your logic depends on the most up-to-date state, you must re-read it from the actor’s properties after the await finishes.
Best Practice 3: Be Deliberate with @MainActor
You can annotate entire classes or view models with @MainActor to address UI update issues. While sometimes effective, it can also cause performance problems by forcing non-UI tasks (like data processing or file I/O) onto the main thread, making your app less responsive and more likely to hang. Be precise and only isolate the specific properties or methods that genuinely need to interact with the UI.
Best Practice 4: Make Methods async to Control Execution
Perhaps the biggest challenge async/await introduces is testability. When a function is only called inside a Task within an object, it’s hard to write tests for that function because you’re left testing only the side effects it creates. You don’t have control over the function at all, like when it gets called, exactly when it finishes, and so on. This makes the tests flaky most of the time. To clarify this further, consider a UserProfileViewModel that calls fetchUserProfile().
struct UserProfile {
// ...
}
protocol UserProfileRepository {
func fetchUserProfile() async -> UserProfile
}
class UserProfileViewModel {
private let repository: UserProfileRepository
init(repository: UserProfileRepository) {
self.repository = repository
}
func fetchUserProfile() {
Task {
let userProfile = await repository.fetchUserProfile()
// ...
// display the profile
}
}
}
This implementation works perfectly. It fetches the UserProfile, which you can display. If you write tests for it, they’ll look something like this:
class UserProfileRepositoryMock: UserProfileRepository {
var fetchUserProfileCallsCount = 0
// ...
func fetchUserProfile() async -> UserProfile {
fetchUserProfileCallsCount += 1
return UserProfile()
}
}
func testFetchProfile() throws {
let repository = UserProfileRepositoryMock()
let viewModel = UserProfileViewModel(repository: repository)
viewModel.fetchUserProfile()
XCTAssertEqual(repository.fetchUserProfileCallsCount, 1)
}
This test might seem fine at first, but when you run it, it may sometimes pass and sometimes fail because you have no control over the task inside the function. To fix this issue, you’ll modify the method in the original implementation.
func fetchUserProfile() async {
let userProfile = await repository.fetchUserProfile()
// ...
// display the profile
}
Then you update the test as follows:
func testFetchProfile() async throws {
let repository = UserProfileRepositoryMock()
let viewModel = UserProfileViewModel(repository: repository)
await viewModel.fetchUserProfile()
XCTAssertEqual(repository.fetchUserProfileCallsCount, 1)
}
Now, no matter how many times you run this test, it won’t fail because you now control the order of execution.
Key Points
-
Swift’s structured concurrency establishes a clear hierarchy for asynchronous tasks by using a Task Tree with parent-child links to avoid common bugs such as resource leaks.
-
In a structured Task Tree, cancelling a parent task automatically sends a cancellation signal to all its children and their subsequent children, ensuring a clean and predictable shutdown.
-
When you have a fixed number of asynchronous operations that can run simultaneously, use
async letto create true child tasks. This approach is simpler and more straightforward than aTaskGroupfor this particular case. -
When you need to generate a varying number of child tasks at runtime, often within a loop, a
TaskGroupis the appropriate tool. It offers a scope to handle these dynamic tasks collectively. -
All tasks added to a single
TaskGroupmust produce the same type of result. The common approach to managing different result types is to wrap them in a single enum with associated values. -
To make tasks cancellable, you need to periodically check for the cancellation signal using either
try Task.checkCancellation()or by using a cancellable async function likeTask.sleep(for:). -
Priority is a signal given to the system to help it schedule tasks. High-priority tasks are for immediate user-facing work, while low-priority tasks are for non-critical maintenance.
-
If a low-priority parent task awaits a high-priority child, the parent’s priority is temporarily boosted to match the child’s, preventing the high-priority work from getting stuck.
-
In long-running, CPU-intensive loops without any await calls, use
await Task.yield()to voluntarily pause the task and give the system a chance to run other work, keeping your app responsive. -
Taskvs.Task.detached: A standardTask { … }creates an unstructured task that inherits context, such as actor isolation and priority, but it is not part of the cancellation hierarchy. ATask.detached { … }is completely independent and inherits nothing. -
An actor safeguards its mutable state by ensuring that only one task accesses its data at a time. It queues concurrent calls to enforce mutual exclusion, ensuring serialized access.
-
Any await inside an actor method is a suspension point where another task can “re-enter” the actor and modify its state. Never assume the state remains unchanged across an await.
-
To use an existing Combine publisher in
async/awaitcode, access its.valuesproperty, which exposes it as anAsyncSequencethat you can iterate with afor await…inloop. -
To use a modern async function in an older Combine-based workflow, wrap the call in a
Futurepublisher that emits a single value or failure. -
Each
asyncmethod should have a single, clear responsibility. Avoid creating large, monolithicasyncfunctions that perform many unrelated tasks. -
A function that initiates asynchronous work should be marked
async. This allows your test code to await its completion, giving you control over execution order and preventing flaky tests caused by race conditions.
Where to Go From Here?
You’re no longer just using async/await; you’re equipped with the architectural mindset to build robust concurrent features. The real victory lies in applying these tools in practical scenarios. Consider how you can prevent actor reentrancy, develop systems free of data leaks, and leverage the power of Task Trees.
The critical thinking skills you’ve gained in this chapter are the most valuable tools you’ll carry forward, enabling you to not only write concurrent code but to do it exceptionally well.