Lesson 19 / 25

async/await and Structured Concurrency

Write asynchronous code with async/await, async let and task groups.

Asynchronous code that reads like synchronous code

Swift's concurrency model (Swift 5.5+) replaces nested completion handlers with async/await. An async function can suspend at each await, freeing its thread to do other work, and resume when the awaited operation completes; the code still reads top to bottom. Errors work naturally with async throws and try await. Structured concurrency runs work in parallel with clear lifetimes: async let starts child tasks that run concurrently and are awaited later, and withTaskGroup or withThrowingTaskGroup handle a dynamic number of child tasks. Child tasks cannot outlive their parent scope, and cancellation and errors propagate automatically. Task { } starts new top-level asynchronous work from synchronous code (for example from a button action), inheriting the current actor context, while Task.detached does not. Apple's frameworks offer async APIs such as URLSession.data(from:). Avoid blocking threads inside async code with semaphores or sleeps; use try await Task.sleep(for: .seconds(1)) when you need a delay.

Parallel child tasks

A parent task starts several child tasks, awaits them all, and they cannot outlive it.

A long horizontal bar for the parent with three shorter parallel bars branching below it and rejoining before the parent ends.
Figure 7.1 — Structured concurrency with child tasks.

Fetching several resources concurrently

async let runs requests in parallel; the function awaits them together.

import Foundation

struct Product: Decodable { let id: String; let title: String }
struct Review: Decodable { let rating: Int; let text: String }

func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T {
    let (data, response) = try await URLSession.shared.data(from: url)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) }
    return try JSONDecoder().decode(T.self, from: data)
}

func loadProductPage(id: String) async throws -> (Product, [Review]) {
    let base = URL(string: "https://api.example.com")!
    async let product = fetch(Product.self, from: base.appending(path: "products/\(id)"))
    async let reviews = fetch([Review].self, from: base.appending(path: "products/\(id)/reviews"))
    return try await (product, reviews)          // both requests run concurrently
}

func loadPrices(for ids: [String]) async throws -> [String: Int] {
    try await withThrowingTaskGroup(of: (String, Int).self) { group in
        for id in ids {
            group.addTask { (id, try await fetchPrice(id)) }
        }
        var prices: [String: Int] = [:]
        for try await (id, price) in group { prices[id] = price }
        return prices
    }
}

await is a suspension point

Anything can change while a function is suspended at await: other code may run and modify shared state. Re-check assumptions after an await rather than relying on values read before it.

Quick check: What does `async let` do?

  • Starts a child task that runs concurrently and is awaited later
  • Runs code synchronously
  • Creates a detached task with no parent
  • Blocks the thread until done
Answer

Starts a child task that runs concurrently and is awaited later — async let creates a concurrent child task bound to the current scope.