Concurrency
async/await, Actors, Sendable, structured concurrency, and thread safety.
PART 3 — CONCURRENCY: Mastering Async Swift
Learning Objectives
By the end of this chapter, you will understand the profound shift in Swift's concurrency model. You will be able to design, implement, and debug robust asynchronous systems using async/await, structured concurrency, Tasks, Task Groups, Actors, and Sendable. You will know exactly how to prevent data races and how to manage the MainActor for safe UI updates. Finally, you will understand how modern Swift concurrency interacts with legacy GCD.
Prerequisites
Before proceeding, ensure you have a firm grasp of:
- Value vs. Reference Semantics
- Closures and escaping closures
- Error handling (`throws` and `Result`)
- Basic iOS Application Lifecycle (understanding the Main Thread)
Why Does This Exist?
Real applications don't do just one thing at a time. They fetch data from a server, decode JSON, read from a local database, and render UI frames at 60 or 120 FPS. If you perform a heavy task (like a network request) on the main thread, the UI freezes. The app appears dead to the user. We need a way to say: "Go do this work in the background, and let me know when you are done, while I keep the UI responsive."
The Problem Before the Solution
Historically, iOS engineers used Grand Central Dispatch (GCD) and closures (completion handlers) to achieve asynchronous execution.
func fetchUserProfile(userId: String, completion: @escaping (Result<UserProfile, Error>) -> Void) {
let request = makeRequest(for: userId)
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NetworkError.noData))
return
}
do {
let profile = try JSONDecoder().decode(UserProfile.self, from: data)
DispatchQueue.main.async {
completion(.success(profile))
}
} catch {
completion(.failure(error))
}
}.resume()
}
This works, but it's incredibly noisy. It forces inversion of control. The function doesn't return a value; it calls a closure later.
Why the Old Approach Breaks
The callback approach scales poorly. When you need to chain multiple async operations (fetch a token, then fetch a user, then fetch their friends), you end up in the "Pyramid of Doom."
Worse, the compiler cannot enforce correctness. Did you remember to call `completion` on every single execution path? If you forget one `return`, the closure might be called twice. If you forget to call it entirely, the caller hangs forever. Did you remember to dispatch back to the main thread before updating the UI? The compiler won't save you.
Furthermore, manual threading with GCD introduces Data Races—when two threads access the same memory simultaneously, and at least one is a write. This causes undefined behavior and random crashes that are impossible to reproduce.
History
For years, iOS concurrency evolved from `NSThread` to `NSOperationQueue`, and then to `GCD` (Grand Central Dispatch). GCD was a massive leap forward, giving us lightweight queues instead of heavy manual threads. Combine (Apple's Reactive framework) was introduced later to handle streams of asynchronous values. However, all these systems lacked language-level support for safe state mutation across boundaries. Swift 5.5 introduced native concurrency (async/await, Actors, Structured Concurrency), moving concurrency from a library (GCD) directly into the compiler.
Mental Model
Imagine a busy restaurant kitchen. In the GCD model, you (the chef) start cooking a steak, hand a timer to a waiter, and say, "When this goes off, plate the steak, but I'm going to start making a salad now." The kitchen becomes a chaotic mess of callbacks.
In the `async/await` model, you say, "I am going to put this steak in the oven. While it cooks, I am suspending my work on this specific order. I will step away and work on a different order. When the steak is done, I (or another available chef) will resume this exact order right where I left off."
Now remove the analogy. Here is what Swift actually does: When an asynchronous function encounters `await`, it suspends. The thread executing it is not blocked; it is immediately freed up to execute other tasks in the system. Later, when the awaited work completes, the runtime schedules the continuation of that function on an available thread.
Internal Working
Swift's concurrency uses a cooperative thread pool. Its size is typically limited to the number of CPU cores. This avoids "thread explosion"—a huge problem in GCD where hundreds of blocked threads consume memory and incur massive context-switching overhead.
When you call `await`, Swift packages up the current state of the function (its local variables) into a continuation on the heap. The thread is then released back to the pool. When the result is ready, Swift takes the continuation and assigns it to a thread from the pool to resume execution. This means a function might start executing on Thread A, suspend, and resume later on Thread B. This is why you must never use thread-local storage or assume locks held across an `await` will be safe.
Visual Explanation
Here is how a suspended task yields the thread back to the pool:
[Thread 1] -> Executing Task A -> hits `await`
-> Task A state saved to Heap
-> [Thread 1] becomes free!
[Thread 1] -> Picks up Task B from queue
... Later ...
Task A's network request finishes.
[Thread 2] -> is free -> restores Task A state from Heap -> resumes Task A
Syntax
func fetchUser() async throws -> User {
let (data, response) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
async: Marks the function as asynchronous. It can suspend.
await: Marks the exact potential suspension point. It tells the reader and the compiler: "Execution might yield here."
Tiny Example
func fetchNumber() async -> Int {
try? await Task.sleep(nanoseconds: 1_000_000_000) // Sleep 1 second
return 42
}
Task {
print("Starting...")
let number = await fetchNumber()
print("Got number: \(number)")
}
Walkthrough
In the tiny example, we create an unstructured Task. This bridges the synchronous world (where the `Task` is created) into the asynchronous world. Inside the Task, we call `fetchNumber()` and `await` its result. The current flow of execution suspends. The thread running the Task is released. One second later, the timer fires, the system wakes the task up, assigns it a thread, assigns `42` to `number`, and prints it.
Break It
Let's introduce a Data Race. A Data Race occurs when two concurrent tasks access the same memory location, and at least one is a write.
class Counter {
var value = 0
func increment() { value += 1 }
}
let counter = Counter()
Task {
for _ in 0..<1000 { counter.increment() }
}
Task {
for _ in 0..<1000 { counter.increment() }
}
Because `Counter` is a class (reference type), both tasks point to the same memory. `value += 1` is not atomic. It is a read, add, and write. The tasks will clobber each other. The final value will almost certainly not be 2000. It might even crash.
Debug It
If you suspect a data race, turn on Thread Sanitizer (TSan) in Xcode's scheme settings. Run the app. TSan will aggressively flag the exact line where the race occurs at runtime.
To fix this natively in Swift, we use Actors. An actor is a reference type that isolates its state. Only one task can access its mutable state at a time.
actor SafeCounter {
var value = 0
func increment() { value += 1 }
}
let counter = SafeCounter()
Task {
for _ in 0..<1000 { await counter.increment() }
}
Notice the `await`. Because the actor guarantees isolated, sequential access, calling `increment()` from outside the actor must be asynchronous. If the actor is busy, the calling task suspends until the actor is free.
Mini Project
Goal: Write a function that fetches three distinct images concurrently using `async let`, and returns an array of them.
View Solution
func fetchThreeImages() async throws -> [UIImage] {
async let img1 = fetchImage(id: "1")
async let img2 = fetchImage(id: "2")
async let img3 = fetchImage(id: "3")
// They are all fetching in parallel now!
// Await them all at once:
return try await [img1, img2, img3]
}
This is Structured Concurrency. The child tasks (`img1`, `img2`, `img3`) are bound to the scope of the parent function. If the function errors and exits early, the inflight tasks are automatically cancelled.
Real Application Feature
Consider a Profile Screen that needs to fetch User Data, their Recent Posts, and their Settings. If any of these fail, the whole fetch should fail. We also need to update the UI on the main thread.
Production Implementation
@MainActor
class ProfileViewModel: ObservableObject {
@Published var state: ViewState = .loading
private let networkService: NetworkService
init(networkService: NetworkService) {
self.networkService = networkService
}
func loadProfile(userId: String) {
// Unstructured task bound to the ViewModel's lifecycle
Task {
do {
self.state = .loading
// Structured concurrency for parallel fetching
async let user = networkService.fetchUser(id: userId)
async let posts = networkService.fetchPosts(userId: userId)
// Await both results
let profile = try await ProfileData(user: user, posts: posts)
// Because ViewModel is @MainActor, this mutation is safe
self.state = .loaded(profile)
} catch {
self.state = .error(error.localizedDescription)
}
}
}
}
The `@MainActor` attribute guarantees that all properties and methods of `ProfileViewModel` execute on the Main Thread. Even after an `await` resumes, Swift ensures it hops back to the Main Actor before continuing, making `@Published` UI state updates 100% safe.
Production Usage
This pattern is pervasive in modern SwiftUI architectures. ViewModels (or Observable objects) are marked `@MainActor`. They launch `Task`s to perform work. Services that hold shared mutable state (like a token cache or a local database connection) are modeled as `actor`s to prevent data races. Data passed between these boundaries MUST conform to the `Sendable` protocol, ensuring it is safe to cross concurrency boundaries (usually by being value types like `structs`).
Performance
Swift Concurrency's cooperative thread pool drastically reduces context switching overhead compared to GCD. However, you must avoid blocking the thread pool. Never use `sleep()`, `DispatchSemaphore.wait()`, or synchronously wait on locks inside an asynchronous function. If you block a thread in the cooperative pool, it cannot be reused. If you block all of them, your app deadlocks.
Best Practices
- Use Structured Concurrency: Prefer `async let` and `TaskGroup` over unstructured `Task {}`. Structured concurrency gives you automatic cancellation propagation and predictable scoping.
- MainActor UI: Always mark UI-driving types (like ViewModels) with `@MainActor`.
- Value Types for Sendable: Rely on immutable structs to pass data between actors and tasks. The compiler will enforce `Sendable` conformance.
- Respect Cancellation: Long-running tasks should periodically check `Task.isCancelled` and throw `CancellationError` to halt work if the user navigates away.
Mini Project
Goal: Implement a simple actor to manage a shared resource safely.
Task: Create a BankActor that holds a balance. Implement an async method to deposit and withdraw money safely, ensuring no data races occur even when accessed concurrently by multiple tasks.
Bigger Project
Goal: Build an async image downloader with parallel execution and UI updates.
Task: Create an application that downloads multiple images concurrently using TaskGroup. The ViewModel should be annotated with @MainActor to safely publish updates as images finish downloading, displaying them progressively.
Interview Questions
[Easy] What is the difference between a struct and an actor?
A struct is a value type; it is copied when passed around, naturally avoiding data races. An actor is a reference type (like a class) that provides synchronized, isolated access to its mutable state, protecting it from data races in concurrent environments.
[Medium] What happens when you call `await`?
The current task suspends. Its state is saved on the heap, and the underlying OS thread is released back to the cooperative thread pool to do other work. When the awaited operation completes, a thread from the pool picks up the continuation and resumes execution.
[Hard] How does Swift Concurrency handle Thread Explosion differently than GCD?
GCD maintains queues and spins up new OS threads when tasks block on concurrent queues, leading to thread explosion. Swift Concurrency uses a fixed-size cooperative thread pool (typically equal to CPU cores). Tasks suspend rather than block, so threads are never waiting idly; they instantly pivot to other suspended tasks.
Revision Sheet
async: Function can suspend.await: Suspension point; yields the thread.Task: Unstructured bridge to start async work.async let/TaskGroup: Structured concurrency (parallelism + automatic cancellation).actor: Reference type with isolated state (prevents data races).@MainActor: Global actor bound to the main thread (for UI).Sendable: Protocol proving a type is safe to cross isolation boundaries.
Connections
Swift Concurrency relies heavily on the Value Semantics taught in Part 2. Because structs are Sendable by default, they form the backbone of safe data transfer between Actors. When we move to SwiftUI (Part 5), you will see `@MainActor` applied extensively to `@StateObject` and `@Observable` types to seamlessly bridge background processing with main-thread rendering.
Mini Project (20-30 min)
Implement an asynchronous image fetcher using async/await and properly handle potential network errors.
View Solution
// Async Image Fetcher
func fetchImage(from url: URL) async throws -> UIImage {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw URLError(.badServerResponse)
}
return image
}
Bigger Project (1-2 hours)
Create a thread-safe caching system using an Actor to prevent data races when multiple tasks read or write concurrently.
View Solution
// Thread-safe Cache using Actor
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
return cache[url]
}
func insert(_ image: UIImage, for url: URL) {
cache[url] = image
}
}