5. Concurrency: async/await & structured concurrency¶
What & why¶
Swift's concurrency model lets you write asynchronous code that reads like synchronous code, with the compiler checking safety. Three ideas carry it:
async/await— functions that can suspend without blocking a thread.- Structured concurrency — child tasks live inside a scope and can't outlive it, so errors and cancellation propagate automatically.
- Isolation (next lesson) — actors and
Sendablemake data races a compile-time error.
async and await¶
func fetchTemperature(city: String) async throws -> Double {
try await Task.sleep(for: .milliseconds(200)) // suspends; doesn't block a thread
return city == "Oslo" ? 3.5 : 21.0
}
func report() async throws {
let oslo = try await fetchTemperature(city: "Oslo")
let rome = try await fetchTemperature(city: "Rome") // runs AFTER the first finishes
print("Oslo \(oslo)°, Rome \(rome)°")
}
asyncmarks a function that may suspend.awaitmarks each suspension point — a place where other work may run and where shared state may have changed.- Async functions can only be called from async contexts: another async function, a
Task, or@main/top-level code. - A suspended function holds no thread. The runtime resumes it (possibly on a different thread) when the awaited work finishes.
Entry points¶
// main.swift (top-level await works)
let t = try await fetchTemperature(city: "Oslo")
// or with @main
@main
struct App {
static func main() async throws {
try await report()
}
}
Running work concurrently: async let¶
Sequential awaits take the sum of the times. async let starts child tasks immediately and lets you await their results later:
func report2() async throws {
async let oslo = fetchTemperature(city: "Oslo")
async let rome = fetchTemperature(city: "Rome")
// both are running now
let (a, b) = try await (oslo, rome) // wait for both
print(a, b)
}
If the scope exits before you await a child, the child is cancelled and awaited implicitly. That's what "structured" means: no child task outlives the scope that created it.
Task groups: a dynamic number of children¶
func averageTemperature(of cities: [String]) async throws -> Double {
let total = try await withThrowingTaskGroup(of: Double.self) { group in
for city in cities {
group.addTask { try await fetchTemperature(city: city) }
}
var sum = 0.0
for try await value in group { // results arrive in completion order
sum += value
}
return sum
}
return total / Double(cities.count)
}
- Results come back as tasks finish, not in submission order. Return a
(index, value)or(key, value)pair if order matters. - If a child throws, the group is cancelled and the error propagates out of
withThrowingTaskGroup. - To bound concurrency (don't start 10,000 tasks at once), prime the group with N tasks and add one more each time one finishes — exactly what the capstone does:
await withTaskGroup(of: Int.self) { group in
var pending = items.makeIterator()
for _ in 0..<4 { if let item = pending.next() { group.addTask { await work(item) } } }
while let _ = await group.next() {
if let item = pending.next() { group.addTask { await work(item) } }
}
}
withDiscardingTaskGroup is the variant for fire-and-forget children whose results you don't need (servers handling connections); it doesn't accumulate results, so it uses constant memory.
Unstructured tasks¶
When you need to start async work from a non-async context, or detach it from the current scope:
func buttonTapped() { // synchronous
Task { // inherits actor, priority, and task-locals of the caller
let t = try? await fetchTemperature(city: "Oslo")
print(t as Any)
}
}
let handle = Task.detached(priority: .background) { // inherits NOTHING: no actor, no task-locals
await expensiveJob()
}
let value = await handle.value // or handle.cancel()
func expensiveJob() async -> Int { 42 }
Prefer async let and task groups. Use Task { } as the bridge from synchronous code (UI event handlers, main). Use Task.detached rarely — usually to deliberately leave an actor.
Unstructured tasks aren't cancelled when the surrounding function returns. Keep the handle and cancel it yourself, or you'll leak work.
Cancellation is cooperative¶
Cancelling a task only sets a flag. Your code must look:
func processAll(_ items: [Int]) async throws -> [Int] {
var results: [Int] = []
for item in items {
try Task.checkCancellation() // throws CancellationError if cancelled
results.append(try await transform(item))
}
return results
}
func transform(_ x: Int) async throws -> Int { x * 2 }
let task = Task { try await processAll(Array(1...1_000)) }
task.cancel()
do { _ = try await task.value } catch is CancellationError { print("cancelled") }
Task.isCancelled— read the flag (don't throw).try Task.checkCancellation()— throw if set.- Most standard async APIs (
Task.sleep,URLSession) check cancellation for you. withTaskCancellationHandler(operation:onCancel:)lets you react immediately (for example, cancel a network request).
Cancelling a parent cancels all its children, recursively.
Timeouts¶
There is no built-in timeout function; a task group race expresses it clearly:
func withTimeout<T: Sendable>(_ limit: Duration, _ work: @escaping @Sendable () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await work() }
group.addTask {
try await Task.sleep(for: limit)
throw CancellationError() // or a TimeoutError
}
defer { group.cancelAll() }
return try await group.next()! // first to finish wins
}
}
Task-local values¶
Carry context (request IDs, loggers) down the task tree without passing parameters:
enum RequestContext {
@TaskLocal static var id: String = "none"
}
func handle() async {
await RequestContext.$id.withValue("req-42") {
await childWork() // sees "req-42", as do its child tasks
}
}
func childWork() async { print(RequestContext.id) }
Priorities and the cooperative thread pool¶
Swift runs tasks on a fixed-size pool (roughly one thread per CPU core). That has an important consequence:
Never block a pool thread.
Thread.sleep,DispatchSemaphore.wait(), synchronous network or heavy file I/O, and long CPU loops all stall the pool and can starve other tasks — or deadlock.
- Prefer
await Task.sleep, async APIs, andawaitanywhere you wait. - For unavoidable blocking calls, run them on a dedicated
DispatchQueueor thread and bridge with a continuation (lesson 7). - Insert
await Task.yield()in very long CPU-bound loops to be a good citizen.
(The capstone does blocking file reads inside tasks but caps concurrency at the core count — fine for a tool, not for a server.)
Bridging old code: continuations¶
func legacyFetch(completion: @escaping (Result<Int, Error>) -> Void) { completion(.success(7)) }
func fetch() async throws -> Int {
try await withCheckedThrowingContinuation { continuation in
legacyFetch { result in continuation.resume(with: result) }
}
}
You must resume exactly once. More in lesson 7.
Common mistakes¶
- Awaiting sequentially when the operations are independent — use
async letor a task group. - Assuming state is unchanged after an
await. Other work may have run; re-check invariants (especially in actors). - Blocking inside async code with semaphores or
Thread.sleep. - Spawning unbounded numbers of tasks.
- Forgetting that
Task { }inherits the current actor (it may serialize onto the main actor). - Ignoring cancellation in long loops, making
cancel()ineffective. - Swallowing
CancellationErrorand reporting it as a failure. - Fire-and-forget
Task { }with no error handling — thrown errors disappear silently.
Exercises¶
- Write an async
download(_:) -> Datathat sleeps 100–500 ms to simulate work. Fetch five of them first sequentially, then withasync let, and then with a task group; compare the elapsed times withContinuousClock. - Convert the task-group example to return results in the original order.
- Add a concurrency limit of 3 to a task group processing 20 items; log the maximum number running at once (hint: use an
actorcounter from the next lesson, or atomics). - Implement
withTimeoutand test it with one fast and one slow operation. - Write a cancellable
retry(times:operation:)that stops promptly when the surrounding task is cancelled. - Use a
@TaskLocalrequest ID and print it from three nested async functions.
Recap¶
asyncmarks suspendable functions;awaitmarks suspension points.async letand task groups run work concurrently inside a scope; children can't outlive it.- Cancellation is cooperative — check for it, and it propagates down the task tree.
Task { }bridges from sync code and inherits context;Task.detacheddoesn't.- Never block the cooperative thread pool; use async APIs or continuations.