async/await is sugar over futures — not a new execution model
Topic 8 introduced futures/promises: a handle to a value that doesn't exist yet, with callbacks (.then) attached for when it resolves. Chaining several of those by hand is exactly the pain point async/await exists to solve. async/await doesn't add any new concurrency primitive underneath — it's syntax that lets you write code against futures/promises/coroutines that reads top-to-bottom like ordinary synchronous code, while the compiler/runtime transforms it into the equivalent chain of continuations for you.
Concretely, an async function is a function that, when called, immediately returns a future/promise and begins running its body. Every await inside it is a suspension point: execution of that function pauses there, control returns to the event loop (Topic 10's first subtopic) so other work can run, and when the awaited future resolves, the function's execution resumes exactly where it left off — as if the intervening time simply didn't exist from the function's point of view. This is why the mental model "it looks synchronous but it isn't blocking anything" is accurate: the thread is never blocked (it's free to run other callbacks while waiting), but the function's local control flow looks and behaves like ordinary sequential code, including try/catch around a failed await working exactly like it would around a synchronous throw.
The important consequence for concurrency: writing await on every call, one after another, runs those calls sequentially — each one waits for the previous one to fully resolve before starting. To actually run independent operations concurrently, you must explicitly say so: start them all first (without awaiting yet), then await all of them together — asyncio.gather(...) in Python, Promise.all([...]) in JavaScript, a set of async { ... } coroutine builders followed by .await() calls in Kotlin. This is a very common interview trip-wire: candidates write three sequential awaits and are then surprised their "concurrent" version isn't any faster.
Structured concurrency: giving spawned tasks a lifetime
"Structured concurrency" is a discipline (not a language feature per se, though some languages bake it in) that treats concurrent tasks the same way structured programming taught us to treat control flow decades ago: a task spawned inside some scope cannot outlive that scope. Concretely, structured concurrency means:
- A concurrently-spawned unit of work (a coroutine, a task) is owned by the code region that spawned it — typically a block or a function call — and that owning region cannot return/complete until every task it spawned has itself completed (or been explicitly cancelled).
- If a child task throws, the failure propagates up to the owning scope in a predictable, catchable way, instead of vanishing silently.
- Cancelling the owning scope recursively cancels every child, and every grandchild, automatically — you don't have to manually track down and cancel each one.
Compare this to the "fire and forget" style that most concurrency APIs default to, historically: you spawn a task (go func() {...} in Go without ever joining it, a bare new Thread(...).start(), a "detached" promise you never await or attach a .catch to), and the function that spawned it returns immediately, with no relationship left between the spawning code and the thing it spawned. Nathaniel J. Smith's influential essay on this ("Go statement considered harmful") makes the analogy explicit: an un-joined, un-owned spawn is concurrency's version of goto — it breaks the property that a function call is a black box with a well-defined beginning and end. The spawned task might still be running long after its "parent" returned; if it throws, there may be nobody left listening; if the caller wants to cancel it because it's no longer needed, there's no handle to do that with. These are exactly the classes of real production bugs — leaked background work, swallowed exceptions, resources that never get cleaned up — that structured concurrency is designed to make structurally impossible rather than merely "a discipline you have to remember."
Kotlin coroutines as the reference example done well
Kotlin's kotlinx.coroutines library is one of the clearest, most widely-taught implementations of this discipline, which is why it's worth knowing by name even outside Kotlin shops. Every coroutine builder (launch, async) requires a CoroutineScope receiver — you cannot spawn a coroutine that belongs to nothing. coroutineScope { ... } creates a new scope tied to the calling coroutine: the block doesn't return until every coroutine launched inside it (and their children, transitively) has completed, and if any child fails, the scope cancels all its siblings and rethrows. That's structured concurrency's three rules, directly encoded in the type system and the standard library's core builder — not a convention you have to remember to follow.
Python's asyncio.TaskGroup (3.11+, async with asyncio.TaskGroup() as tg: tg.create_task(...)) and Java's StructuredTaskScope (finalized via JEP 505 in JDK 25, built on top of virtual threads from Project Loom) bring the identical discipline to their respective ecosystems — a scope you enter, tasks you fork inside it, and a guarantee that the scope won't exit until every forked task is accounted for, with any failure cancelling the rest. That convergence across three very different language ecosystems within the same few years is a strong signal that this is a real, durable idea and not a Kotlin-specific quirk.
Pitfalls and interview gotchas
- Sequential
awaits masquerading as concurrency. As above — if you want N operations to run concurrently, launch all N before awaiting any of them (gather/Promise.all/multiple async {} builders), don't await each one in a straight line.
- Forgetting error propagation semantics of the "gather all" APIs.
Promise.all rejects as soon as the first promise rejects, but the other promises keep running in the background (JavaScript has no automatic cancellation) — a subtle unstructured-concurrency leak hiding inside an otherwise structured-looking call. asyncio.gather(..., return_exceptions=False) (the default) behaves similarly. Contrast this with asyncio.TaskGroup or Kotlin's coroutineScope, which do cancel the remaining siblings on the first failure — a genuine structured-concurrency guarantee the plain "gather" APIs don't give you.
- Treating "async" as inherently structured.
async/await is just syntax for sequencing continuations; you can absolutely write unstructured, leaky, fire-and-forget concurrency with it (e.g., calling an async function and never awaiting or storing its returned promise/task). Structure is a property of how you use the primitives, or a guarantee a specific API (TaskGroup, coroutineScope) chooses to give you — not a property of the keyword itself.
- Not knowing what cancellation actually does under the hood. In cooperative systems, cancellation is typically cooperative too: cancelling a task usually just arranges for the next suspension point (the next
await) inside it to raise a cancellation exception. Code that never awaits (e.g., a tight synchronous loop inside an async function) will not observe the cancellation until it finally yields.
- Conflating "structured concurrency" with "no concurrency bugs." It eliminates a specific, well-defined class of bugs (leaked tasks, swallowed failures, un-owned lifetimes) — it does not eliminate race conditions on shared mutable state if your concurrent tasks actually touch the same data without synchronization.
Structured vs. unstructured concurrency at a glance
| Structured (coroutineScope, TaskGroup, StructuredTaskScope) | Unstructured ("fire and forget") |
|---|
| Task lifetime | Bound to the scope that spawned it | Independent — can outlive its spawner |
| On child failure | Propagates to the scope; siblings cancelled | May vanish silently, or crash the whole process unpredictably |
| Cancelling the parent | Recursively cancels all children | No automatic relationship — must track handles yourself |
| Mental model | Function call: black box with one entry, one exit | goto: control (and lifetime) can jump anywhere, indefinitely |
| Example APIs | Kotlin coroutineScope/supervisorScope, Python asyncio.TaskGroup, Java StructuredTaskScope | Bare Thread.start(), detached promises, Go's unjoined go statement |