Concurrency with async/await
async/await, Task, async let, TaskGroup, and actors for safe shared mutable state.
Why structured concurrency
Older Swift concurrency relied on completion handlers (closures called once an asynchronous operation finished) or Grand Central Dispatch (GCD) queues — both work, but neither gives the compiler enough information to catch a forgotten completion call, a callback invoked twice, or a task whose cancellation was never checked. Swift 5.5 introduced structured concurrency: async/await for writing asynchronous code that reads top-to-bottom like ordinary code, plus Task, async let, and task groups for expressing "these operations run concurrently" in a way the compiler and runtime can actually track and clean up correctly.
async functions and await
A function marked async can suspend — pause without blocking the underlying thread — at any await point, letting other work run while it waits:
struct User {
let name: String
}
func fetchUser(id: Int) async throws -> User {
try await Task.sleep(nanoseconds: 1_000_000_000) // simulates a 1-second network call
return User(name: "Ali")
}
func fetchUserName(id: Int) async throws -> String {
let user = try await fetchUser(id: id) // suspends here until fetchUser finishes
return user.name
}
await marks every point where the function might suspend — reading the code, you can always tell exactly where control might be handed back to the system, unlike a completion-handler callback that could fire from anywhere.
Task — bridging into async code
async functions can only be called from other async contexts. Task { ... } is the bridge that lets synchronous code (like a button tap handler, or main in a command-line tool) kick off asynchronous work:
Task {
do {
let name = try await fetchUserName(id: 1)
print("Fetched: \(name)")
} catch {
print("Failed: \(error)")
}
}
A Task created this way inherits the priority and (in a UI framework like SwiftUI) actor context of wherever it was created, and is part of Swift's structured concurrency tree — if the surrounding scope is cancelled, the task can observe that and stop cooperatively rather than continuing invisibly in the background. Task.detached { ... } is the escape hatch that opts out of that inherited context; it's rarely needed, since almost all real code benefits from staying inside the structured tree.
Running independent work concurrently with async let
Two sequential await calls run one after the other, even if they're completely independent of each other. async let starts an operation immediately and lets it run concurrently with whatever comes next, only actually waiting for the result at the point it's await-ed:
func fetchPosts(id: Int) async throws -> [String] {
try await Task.sleep(nanoseconds: 1_000_000_000)
return ["First post", "Second post"]
}
func fetchDashboard() async throws -> String {
async let user = fetchUser(id: 1) // starts immediately, doesn't block here
async let posts = fetchPosts(id: 1) // also starts immediately, runs alongside the line above
let (fetchedUser, fetchedPosts) = try await (user, posts) // waits for both together
return "\(fetchedUser.name) has \(fetchedPosts.count) posts"
}
Because fetchUser and fetchPosts each take about a second and run concurrently rather than one after another, fetchDashboard completes in roughly one second total, not two — the same underlying idea as running two independent operations together instead of serializing them, which comes up under different names in most languages with real concurrency support.
TaskGroup — a dynamic number of concurrent children
async let only works for a fixed number of known-in-advance concurrent operations. When the number of concurrent tasks is only known at runtime — say, fetching a user for each of an arbitrary list of IDs — withThrowingTaskGroup (or withTaskGroup for non-throwing work) lets you add children dynamically and collect their results as they complete:
func fetchAllUsers(ids: [Int]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
for id in ids {
group.addTask {
try await fetchUser(id: id)
}
}
var users: [User] = []
for try await user in group {
users.append(user)
}
return users
}
}
Every child task added with group.addTask runs concurrently with the others, and for try await user in group yields each result as soon as it's ready — not necessarily in the order the tasks were added. If any child throws, the whole group's await rethrows, and Swift automatically cancels the remaining children rather than leaving them running to no purpose.
actor — protecting shared mutable state
async/await alone doesn't prevent two concurrent tasks from racing to mutate the same object — that's a separate problem, and Swift's answer is the actor type. An actor behaves like a class, but the compiler guarantees only one task can be executing inside it at a time, automatically serializing access to its mutable state:
actor Counter {
private var value = 0
func increment() {
value += 1
}
var current: Int {
value
}
}
Calling into an actor from outside it requires await, even though increment() itself has no await inside it — that await is exactly the compiler enforcing "this call might have to wait its turn," which is what makes concurrent access to value safe without a manual lock:
func incrementConcurrently(_ counter: Counter) async {
await withTaskGroup(of: Void.self) { group in
for _ in 0..<100 {
group.addTask {
await counter.increment()
}
}
}
}
Every one of the 100 concurrent increment() calls is guaranteed to run one at a time, serialized by the actor — the same guarantee Mutex<T> gives explicitly in Rust, except here it's enforced automatically by the language around the type itself rather than by a lock you acquire and release by hand.
Structured concurrency tools, compared
| Tool | Purpose |
|---|---|
async/await |
Write asynchronous code that reads sequentially, without blocking a thread |
Task { } |
Bridge from synchronous code into an async context, inheriting the caller's priority |
async let |
Run a fixed, known-in-advance number of independent async calls concurrently |
TaskGroup / withTaskGroup |
Run a dynamic number of concurrent child tasks and collect results as they finish |
actor |
Serialize access to shared mutable state, preventing data races without a manual lock |
Common mistakes
- Awaiting independent operations sequentially (
let a = try await fetchA(); let b = try await fetchB()) instead ofasync letor aTaskGroup, needlessly serializing work that could have run concurrently. - Reaching for
Task.detachedout of habit instead of a plainTask { }— detached tasks opt out of inherited priority and structured-concurrency cancellation, which is rarely actually wanted. - Sharing mutable state across multiple
Tasks (a plain class, not anactor) and assumingasync/awaitalone makes it safe —async/awaitonly governs suspension points; it does nothing to prevent a genuine data race on shared mutable state unless that state is protected by anactor(or similar synchronization). - Calling a slow, blocking, purely synchronous API from inside an
asyncfunction instead of a real asynchronous one, stalling the limited cooperative thread pool that all concurrently-runningasynccode shares.
Interview questions
Q: What problem does structured concurrency solve compared to completion handlers or raw GCD?
It lets the compiler track the relationship between a task and the scope that created it — cancellation propagates automatically to child tasks, a task can't accidentally outlive the scope responsible for it, and async/await code reads sequentially instead of nesting callbacks, which also makes error handling possible with ordinary do/catch instead of an error parameter threaded through every completion handler.
Q: What is an actor in Swift, and what specifically does it protect against?
An actor is a reference type, like a class, except the compiler guarantees only one task can execute inside it at a time — every call into it from outside requires await, which is the compiler enforcing that the call might need to wait its turn. This prevents data races on the actor's own mutable state without requiring a manually acquired and released lock, the way Mutex<T> does explicitly in a language like Rust.
Q: What's the difference between async let and a TaskGroup?
async let is for a fixed, known-at-compile-time number of independent concurrent operations — you write one async let per operation, then await them together. A TaskGroup is for a dynamic number of concurrent child tasks, added in a loop with group.addTask, useful exactly when the number of concurrent operations is only known at runtime (like one task per element of an arbitrarily-sized array).