KyoMel / Yogyakarta, Indonesia

Term 314314 / 344

Structured Concurrency

Month
September 2026
Day
4
Source
2026/sept-term.md

Definition:

Structured Concurrency is a programming model that guarantees no concurrent task can outlive the code block that created it: whenever a block spawns child tasks, those children must finish — or be cancelled — before the block is allowed to exit. It applies the same nesting discipline that structured programming gave to control flow back in the 1960s (no more goto, everything nests inside if/while/function calls) to the world of threads, goroutines, and coroutines, which never got that discipline — they were the last goto in modern code.

In the classic unstructured model, a task’s lifetime is best described as “whoever spawned it hopes it finishes.” A child routinely outlives its parent (a leak), or its parent dies first and leaves it an orphan that happily writes to closed databases and logs after the response was already sent, or it fails and the error vanishes because nothing is attached to it to hear the scream. Structured Concurrency makes all three impossible by construction — not by discipline, but by the language runtime:

  1. Scope: tasks can only be spawned inside a delimited scope — a StructuredTaskScope (Java), a coroutineScope { } (Kotlin), an errgroup.Group (Go), a Trio/AnyIO nursery (Python).
  2. Fork-join: the scope cannot return until every child has joined. No code after the scope runs while children are still in flight.
  3. Cancel-on-failure: the first child error automatically cancels all remaining siblings, then propagates up like a normal exception — no zombie fan-out silently half-succeeding.

UNSTRUCTURED vs STRUCTURED — what happens to the children:

UNSTRUCTURED CONCURRENCY            STRUCTURED CONCURRENCY
(fire-and-forget)                   (scoped fork-join)
══════════════════════════          ══════════════════════════

 main() ─ spawn ──►┌ worker A ┐     main() ──► ┌──────────────────────────┐
        │          │ (slow)   │                │ scope {                  │
        │          └──────────┘                │   fork A ──┐             │
        │          ┌ worker B ┐                │   fork B ──┼─┐           │
        │          │ (fails!)  │               │   fork C ──┼─┼─┐         │
        │          └──────────┘                │            │ │ │         │
        ▼          nobody hears                │   join A ◄─┘ │ │         │
   main RETURNS    B's error                   │   join B ◄───┘ │         │
   (frame gone)                                │   join C ◄─────┘         │
        │                                     │ }  ── error? cancel       │
        │   worker A STILL RUNNING ──►        │      remaining siblings   │
        │   writes to a DB pool the           └──────────────────────────┘
        │   parent already closed                     │
        ▼                                            ▼
   GHOST WORK after "done":               main() returns ONLY after all
   leaked memory, late panics,            children are joined — the task
   silent partial failure                 tree mirrors the call stack:
                                          no orphans, no ghosts, no
   CHILD LIFETIME: unbounded              swallowed errors.
   CHILD LIFETIME: bounded by scope ──►   ▲
                                          └─ error handling in ONE place

The mental model: the tree of running tasks should look exactly like the tree of the call stack — a parent should never be able to move on, or die, while its descendants are still alive somewhere in the dark. If a piece of work genuinely must outlive its caller (background telemetry, a confirmation email), structured concurrency doesn’t forbid it — it forces you to make that detachment explicit (hand it to a queue or a separate process) instead of letting it happen by accident.

Structured Concurrency went mainstream through Project Loom: previewed in Java since Java 19, it was finalized as JEP 507 in Java 26 (2026) via StructuredTaskScope. Kotlin (coroutines), Swift (task groups), Python (Trio/AnyIO nurseries) and Go (errgroup + context) ship the same idea — each with its own flavor.

Example:

“TokoOnline” adds a checkout endpoint. Before returning an order confirmation it must do three independent remote calls in parallel: reserve inventory (RPC), charge the payment (RPC), and run a fraud check. The tempting first version is three fire-and-forget goroutines:

NAIVE VERSION — handler exits while workers are still flying:

 request ──► handler
              ├─ go reserveInventory() ──(150 ms)──► done
              ├─ go chargePayment()    ──(300 ms)──► done
              └─ go fraudCheck()       ──(fails @ 200 ms,
              │                          nobody is watching)
              ▼
   ~15 ms later : handler returns "200 OK ✓"  ← LIES,
                  nothing actually finished
   200 ms later : fraud check FAILS → order ships anyway,
                  money moves, no one ever learns
   310 ms later : workers touch the request-scoped DB pool
                  the handler already closed → PANIC
                  in the logs AFTER the response was sent
                  (the ghost crash — your on-call pager
                   ringing about a request that "succeeded")

All three classic failures in one screenshot: parent returned before children (lie), a child failed silently (swallowed error), and children outlived the parent (ghost work). The fix is to put the fan-out inside a scope — in Go, errgroup from golang.org/x/sync:

g, ctx := errgroup.WithContext(r.Context())   // scope begins

g.Go(func() error { return reserveInventory(ctx) })  // fork
g.Go(func() error { return chargePayment(ctx) })     // fork
g.Go(func() error { return fraudCheck(ctx) })        // fork

if err := g.Wait(); err != nil {   // join: waits for ALL children
    return 502                      // first error cancels the rest
}                                   // via ctx before it returns
return 200                          // "OK" is now a TRUE statement:
                                    // all three really completed
WITH ERRGROUP — the scope cannot exit until every child is joined:

 scope ──► ┌────────────────────────────────────────────────┐
           │ g.Go(reserve) ──(150 ms)──► ok ───────────────┐ │
           │ g.Go(charge)   ──(300 ms)──► ok ────────────┐ │ │
           │ g.Go(fraud)    ──(200 ms)──► ERROR ────► ┌──┘ │ │
           └──────────────────────────────────────────┴────┘ │
                      │  errgroup cancels ctx ────────────────┘
                      ▼
           g.Wait() returns the fraud error
                      ▼
           handler → 502, order rolled back,
           no ghost goroutines, no silent success

Same three properties, now enforced by the runtime instead of by hope: the handler’s return 200 physically cannot run before all three children joined; the fraud error is impossible to lose — g.Wait() either returns nil (all OK) or the first failure; and on failure the context cancels the two siblings still in flight, so no half-charged order limps on.

The punchline: structured concurrency is “the call stack, but for parallel work.” Its three guarantees — no orphaned tasks, no swallowed errors, cleanup in exactly one place — turn concurrency bugs from a class of mystery (races you debug at 2 AM) into plain control flow you can read top to bottom. The remaining honest use of fire-and-forget isn’t “we’ll deal with it later” — it’s work you deliberately detach to a queue or background process where outliving the request is the correct, visible design. Concurrency stopped being special the moment we stopped letting it escape the block that owns it.

Keep reading