1.9 — Concurrency: async/await, Tasks, actors, Sendable
Opening scenario
Five years ago, networking code on iOS looked like this:
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data else {
DispatchQueue.main.async { self.handle(error: error) }
return
}
self.queue.async {
let decoded = try? JSONDecoder().decode(User.self, from: data)
DispatchQueue.main.async {
self.user = decoded
self.fetchAvatar(for: decoded) { avatar in
DispatchQueue.main.async { self.avatar = avatar }
}
}
}
}.resume()
Today it looks like this:
Task {
let user = try await api.fetchUser()
let avatar = try await api.fetchAvatar(for: user)
await MainActor.run { self.user = user; self.avatar = avatar }
}
That’s not just syntactic sugar. It’s a complete rebuild of the concurrency story: the compiler now reasons about which thread runs which code, and the type system enforces it. Welcome to the part of Swift that has been Apple’s #1 investment for half a decade.
The five concepts you need
| Concept | What it is |
|---|---|
async / await | A function that may suspend, and the call site that acknowledges the suspension. |
Task | A unit of asynchronous work — the entry point from synchronous to async code. |
Structured concurrency (async let, TaskGroup) | Spawning multiple child tasks whose lifetimes are bounded by the parent. |
actor | A reference type whose mutable state is isolated — only one task touches it at a time. |
Sendable + @MainActor | The type-system rules that prevent data races at compile time. |
Concept → Why → How → Code
async / await
func fetchUser(id: String) async throws -> User {
let (data, _) = try await URLSession.shared.data(from: url(id))
return try JSONDecoder().decode(User.self, from: data)
}
async says “this function may suspend.” await at the call site says “I’m fine with that — pause my function here, resume me when it’s ready.” The thread is free to do other work in between. The cost of a await is roughly the cost of a function call — orders of magnitude cheaper than a thread.
Task — bridging sync and async
You can’t await from synchronous code (a button handler, a viewDidLoad). To start async work, you wrap it in a Task:
// In a SwiftUI button
Button("Refresh") {
Task {
await viewModel.reload()
}
}
A Task is the entry point. Inside it, you can await freely. The closure runs on a background executor by default — unless it inherits an actor context (more in a moment).
Structured concurrency: async let and TaskGroup
Sequential await: ~2× as slow as it needs to be when calls are independent:
// SEQUENTIAL — 2 round trips
let user = try await fetchUser()
let posts = try await fetchPosts() // waits for user first
Parallel with async let:
// PARALLEL — both kick off, you await both
async let user = fetchUser()
async let posts = fetchPosts()
let (u, p) = try await (user, posts)
For dynamic numbers of tasks, use TaskGroup:
let avatars: [Avatar] = try await withThrowingTaskGroup(of: Avatar.self) { group in
for user in users {
group.addTask { try await fetchAvatar(for: user) }
}
var result: [Avatar] = []
for try await avatar in group { result.append(avatar) }
return result
}
The “structured” part is critical. All child tasks must complete (or be cancelled) before the parent returns. No orphan tasks running after the function exits. This is what makes async Swift safer than callback-pyramid code.
Actors — single-threaded mutable state
actor Cache {
private var store: [URL: Data] = [:]
func get(_ url: URL) -> Data? { store[url] }
func set(_ url: URL, data: Data) { store[url] = data }
}
let cache = Cache()
await cache.set(url, data: bytes) // every cross-actor call requires await
let value = await cache.get(url)
The actor type is a reference type (like a class) but with a runtime guarantee: only one task executes any of its methods at a time. Cross-actor calls become await calls (they may suspend if the actor is busy).
This is the right tool for shared mutable state — caches, in-memory stores, accumulators. You stop reaching for NSLock and DispatchQueue.sync.
@MainActor — the UI actor
UIKit and SwiftUI require all UI updates on the main thread. Swift now expresses this in the type system:
@MainActor
class FeedViewModel: ObservableObject {
@Published var items: [Item] = []
func reload() async {
let fresh = try? await api.fetchFeed() // hops to background
self.items = fresh ?? [] // back on main automatically
}
}
Marking the class @MainActor says “everything in here runs on main.” Calls into the class from a Task on a different actor become await calls. You can mark individual functions or properties @MainActor too.
Sendable — race prevention at compile time
struct User: Sendable { let id: String; let name: String } // ✅ all stored properties are value types
final class Logger: Sendable { let prefix: String } // ✅ immutable class
class MutableCache { var store: [String: Data] = [:] } // ❌ not Sendable — has mutable state
To pass a value across actor or task boundaries, Swift requires it to be Sendable. Value types of Sendable properties are automatically Sendable. final classes with only immutable properties can be Sendable. Mutable classes cannot be (use an actor instead).
Under Swift 6 strict concurrency, the compiler enforces all of this. It’s how data races become type errors instead of late-night production crashes.
In the wild
- SwiftUI’s
.task { … }modifier spawns a Task that’s automatically cancelled when the view disappears. That single line is structured concurrency in action. URLSession.shared.data(from:)is the modern replacement fordataTask(with:)— fully async/await.AsyncSequenceandAsyncStreammodel streams of values over time (the async analogue toSequence). Used infor await line in url.lines { … }for line-by-line file reading.- Apple’s own apps (Messages, Mail, Health) have been rewritten incrementally with actors replacing serial queues. WWDC 2024’s “Migrate to Swift 6” talk walks through their internal patterns.
Common misconceptions
-
“
asyncmeans it runs on a background thread.” Not necessarily.asyncmeans the function may suspend. Where it runs depends on the actor context. A@MainActorasync function still runs on main; it just doesn’t block. -
“
Task { … }is the same asDispatchQueue.global().async { … }.” No. ATaskinherits the actor context of its enclosing scope by default (so inside a@MainActormethod, the task runs on main). To go background explicitly, useTask.detached. The default behavior is safer but trips up GCD veterans. -
“Actors solve all my data-race problems.” They solve the intra-actor problem (one actor’s state is safe). Cross-actor data races require
Sendablediscipline, which the Swift 6 compiler enforces. Pre-Swift 6, you can still get races by passing mutable classes between actors. -
“
awaitalways suspends.” It can suspend. Often it doesn’t — if the called function returns synchronously inside its body, no suspension happens.awaitis a marker that suspension is possible, not that it’s guaranteed. -
“Async/await is just sugar for callbacks.” It’s sugar plus a structured lifecycle. Cancellation propagates, errors propagate, child tasks are joined. Callbacks have none of that.
Seasoned engineer’s take
Concurrency is the single most consequential thing to get right in a mobile app. It’s also the area where senior and junior engineers most visibly diverge. Heuristics I rely on:
- Default to
@MainActoron your view models. The cost (occasionalTask.detachedfor heavy work) is small; the benefit (no race conditions on@Publishedproperties) is enormous. - Don’t use
Task.detachedunless you mean it. Detached tasks lose the actor context, the priority inheritance, and the cancellation parent. They’re the equivalent of “fire and forget” — useful but easy to abuse. - Make your models
Sendableearly. AddingSendablelater requires touching every type. Designing for it from day one means structs everywhere, immutable references, no shared mutable globals. - Cancel things. Long-running tasks should call
try Task.checkCancellation()periodically and respectTask.isCancelled. SwiftUI’s.taskmodifier handles this for you, but explicitTask {}instances don’t. - Don’t mix GCD and async/await in new code. Pick a side. The mental model of “this work runs on actor X” doesn’t compose with “this work runs on dispatch queue Y.”
The dirty truth: migrating an old app to Swift 6 strict concurrency is painful. Apple knows this — the migration is being rolled out in waves with @preconcurrency escape hatches. But the destination is right. Apps that finish the migration are dramatically less crashy at the concurrency layer.
TIP: Use SwiftUI’s
.task(id: someID) { … }to automatically re-run async work when an identifier changes (e.g., the route param). It cancels the previous task and starts a new one — exactly what you want for navigation.
WARNING: Never call a blocking synchronous API (file read,
sleep, heavy compute) directly from an async function on@MainActor. The actor is the main thread; you’ll freeze the UI. Wrap CPU-heavy work inTask.detachedor hop to a background actor.
Interview corner
Question: “Explain what an actor is in Swift and when you’d use one.”
Junior answer: “An actor is like a class but thread-safe.” → Right idea, no detail.
Mid-level answer: “An actor is a reference type whose internal state is automatically protected — only one task can execute any of the actor’s methods at a time. Cross-actor calls become async, since they may need to wait their turn. I use actors for shared mutable state that’s accessed concurrently: caches, in-memory stores, accumulators that used to be guarded by NSLock or a serial DispatchQueue.” → Strong.
Senior answer: Plus: “I’d also talk about the cost and the trade-offs. Every cross-actor call has a suspension cost — not huge but real, especially in tight loops. So I wouldn’t make an actor for a hot inner loop; I’d make it for coarse-grained shared state. I’d also distinguish actors from @MainActor-isolated classes: the latter pins state to a specific actor (main), the former creates a new isolation domain. For UI work, @MainActor is what you want; for background mutable state, a custom actor. And I’d mention that under Swift 6’s strict concurrency the compiler enforces Sendable at actor boundaries — so designing my model types as value types up front pays off massively. Finally, if I’m writing library code, I think hard about whether actors should be part of my public surface — they force every caller to be in an async context, which can be a viral constraint.” → Senior signal: cost-aware, considers API impact.
Red-flag answer: “I just wrap everything in Task.detached so it doesn’t block the UI.” → Tells the interviewer the candidate doesn’t understand actor isolation and is going to leak unstructured tasks all over the app.
Lab preview
Lab 1.D (Async fetcher) is the concurrency capstone — build a tiny image-fetching pipeline using URLSession, an actor-based cache, and TaskGroup for parallel fetches.
Next: how Swift actually manages memory under the hood — ARC, retain cycles, weak references. → Memory management