async/await and structured concurrency · advanced

async/await and structured concurrency

What a suspension point really is, why async let is not the same as two awaits, and how task trees make cancellation work.

13 min read16 min practice0/2 exercises5 recall cards

By the end you will be able to

  • Explain what happens at an `await` and why it is not blocking
  • Run work concurrently with async let and task groups, and know when each fits
  • Explain how structured concurrency makes cancellation and error propagation automatic
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

await makes a function wait for a result. Does it block the thread it is running on?

async and await

func fetchUser(id: Int) async throws -> User {
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
}
  • async in the signature: this function may suspend.
  • await at the call site: this line is a suspension point.
  • Only an async context may await — you cannot call one from an ordinary function without creating a Task. The refusal is physical, not stylistic: suspension means "store my state and give the thread back", and a plain synchronous function has nowhere to store that state — its only way to wait would be blocking the thread, the exact outcome await exists to refuse.

The visual result is the important part: asynchronous code reads top to bottom, like synchronous code, with none of the nesting that callbacks force on you.

Completion handlers
func loadProfile(
  id: Int,
  completion: @escaping (Result<Profile, Error>) -> Void
) {
  fetchUser(id: id) { userResult in
    switch userResult {
    case .failure(let e):
      completion(.failure(e))
    case .success(let user):
      fetchAvatar(user.avatarURL) { imgResult in
        switch imgResult {
        case .failure(let e):
          completion(.failure(e))
        case .success(let image):
          completion(.success(
            Profile(user, image)))
        }
      }
    }
  }
}
async/await
func loadProfile(id: Int) async throws -> Profile {
  let user = try await fetchUser(id: id)
  let image = try await fetchAvatar(user.avatarURL)
  return Profile(user, image)
}

Same behaviour. The left version has four places to forget to call completion, and error handling duplicated at every level. The right version cannot forget, because throws and return do the work.

Sequential await is sequential

Two awaits in a row run one after the other. If each takes a second, the whole thing takes two.

Swift

B does not even start until A is done. Often that is what you want — the second request may need the first result. When it is not, you have two better tools.

async let — start now, collect later

async let user = fetchUser(id: id)
async let settings = fetchSettings(id: id)
let profile = Profile(user: try await user, settings: try await settings)

async let starts the work immediately and gives you a placeholder. The await happens where you use the value. Both requests are in flight at once, so the total is the slower of the two rather than the sum.

Swift

Look at the order of the printed lines: B finishes first because it is quicker, even though it was started second. Both were running at the same time.

Task groups — a dynamic number of children

async let is for a fixed, known set of jobs. When the count is dynamic, use a group:

Swift

withTaskGroup guarantees that every child finishes before the group returns. You cannot accidentally leak a task that outlives its scope.

Why "structured" is the important word

The tasks created by async let and withTaskGroup are children of the current task, forming a tree. Three properties fall out of that:

  1. A parent cannot finish before its children. No orphaned work.
  2. Cancelling a parent cancels the whole subtree, automatically.
  3. An error from a child propagates to the parent, and cancels its siblings.

With completion handlers you had to build all three by hand, and most codebases did not.

func loadEverything() async throws -> [Item] {
    try await withThrowingTaskGroup(of: Item.self) { group in
        for id in ids {
            group.addTask { try await fetchItem(id) }
        }
        var items: [Item] = []
        for try await item in group {
            items.append(item)
        }
        return items
    }
}

If one fetchItem throws, the others are cancelled and the error surfaces from loadEverything. You wrote no cancellation logic.

Cancellation is cooperative

Cancellation sets a flag; it does not kill anything. Long-running work has to check:

for item in hugeList {
    try Task.checkCancellation()      // throws if cancelled
    process(item)
}

Task.isCancelled is the non-throwing version. Most of Apple's async functions check for you — but note what they throw: Task.sleep throws CancellationError, while URLSession's async methods throw URLError(.cancelled). A catch block that only matches CancellationError silently misses a cancelled network call — a real production trap.

Task { } — the unstructured escape hatch

To call async code from a synchronous context, such as a SwiftUI button action:

Button("Refresh") {
    Task {
        await viewModel.reload()
    }
}

Task { } creates an unstructured task: nothing waits for it, and it is not cancelled with its surroundings. In SwiftUI prefer .task { }, which ties the work to the view's lifetime and cancels it automatically when the view disappears.

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

async let starts work immediately, but the await is where you collect it. What order do these print?

func step(_ name: String, delay: Int) async -> String {
    try? await Task.sleep(for: .milliseconds(delay))
    print("done \(name)")
    return name
}

func run() async {
    async let slow = step("slow", delay: 40)
    async let quick = step("quick", delay: 10)
    print("both started")
    let first = await slow
    print("collected \(first)")
    let second = await quick
    print("collected \(second)")
}

await run()
Check yourself

When should you prefer withTaskGroup over async let?

Make it concurrent

Write the code

This function loads three things one after another. Make them load at the same time, keeping the printed results in the same order.

Solution unlocks after 3 attempts

Sum with a task group

Write the code

Use a task group to square each number concurrently and print the total.

Solution unlocks after 3 attempts
Teach itSaved on this device. Never graded.

Explain to a colleague who knows completion handlers what async let gives them that DispatchGroup did not. Mention cancellation and error propagation specifically.

Checkpoint

You can now:

  • Explain suspension and why await does not block a thread
  • Choose between sequential awaits, async let and task groups
  • Explain the three guarantees of structured concurrency
  • Say why cancellation is cooperative and where to check for it

Next up: actors — how Swift eliminates data races at compile time rather than hoping you remembered the lock.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.