What does async/await actually do, and what is a suspension point?

10.3.1 · L1 · Interview

Predict first

Two async let values are added after both finish. What does this print?

func fetch(_ x: Int) async -> Int { x * 2 }
async let p = fetch(1)
async let q = fetch(2)
print(await p + q)
  • 3
  • 6
  • 4

Predict first

Can DispatchGroup.wait() be called from an async function?

import Foundation

func work() async {
    let g = DispatchGroup()
    g.wait()
}
  • Yes, it blocks the task
  • No: it is unavailable from asynchronous contexts

Mental model. await is leaving a message and going to do something else until the reply arrives, instead of standing at the phone. The code reads like you waited, but nobody was held up.

An async function can suspend: at each await it may give up its thread and be resumed later, possibly on a different one. That is the whole idea. Compared with callbacks it keeps the code linear (results flow with let x = try await f(), errors with throws and try) while the thread is free to run other work during the wait. await marks a potential suspension point: other code, including other tasks touching the same actor, can run before it returns, so any assumption about state must be re-checked after it. An async function can only be called from async code (or from a Task), which is why the top of every async chain is a Task { }, an async main, or a framework entry point such as SwiftUI’s .task.

Sequential awaits run one after another. To run independent operations at the same time use async let (a fixed number of child tasks that start immediately and are awaited when you need their values) or a task group (a dynamic number). Because a suspended task does not hold a thread, async code must not block: calls like DispatchGroup.wait(), DispatchSemaphore.wait() and Thread.sleep are marked unavailable in async contexts, since blocking a pool thread can starve the cooperative pool. Use try await Task.sleep(for:), an async sequence, or a continuation to bridge an old callback. The compiler turns an async function into a state machine, so each suspension is cheap (no thread, just a saved frame), and the runtime schedules the continuation on the right executor when the awaited work completes.

The two child tasks run independently, and the sum is 2 + 4. Reading q awaits it too.

Blocking a thread in async code is diagnosed, because it can starve the cooperative thread pool.

Common traps

  • Assuming nothing changes across an await. State can be mutated by other tasks while you are suspended.
  • Blocking inside async code with a semaphore or wait. It blocks a pool thread and can deadlock the pool.

They might follow up with

  • How do you run two independent requests in parallel? async let a = f(); async let b = g(), then await both.
  • Where does an async call start from non-async code? A Task { }.

Related questions

  • 10.3.2 How do tasks form a tree: Task, task groups, async let and detached tasks, and what does a child inherit?
  • 10.6.1 How do continuations bridge callback APIs, and what is the difference between checked and unsafe?
  • 8.4.1 When do onAppear, task and onChange run, and which one do you use for async work?

How do tasks form a tree: Task, task groups, async let and detached… →