← Home ← Codex ← DEBT
Browse by Category
+ added · updated 7d
← Back to glossary

Structured Concurrency

concurrency PHP 8.1+ Advanced
debt(d8/e7/b7/t7)
d8 Detectability Operational debt — how invisible misuse is to your safety net

Closest to 'silent in production until users hit it' (d9), pulled to d8 because detection_hints.automated is 'no' and the code_pattern regex (create_task/ensure_future/go/spawn) only flags raw spawns, not their lifetime escape — orphaned tasks swallow errors and stay silent until connections leak or tasks die mid-operation in production.

e7 Effort Remediation debt — work required to fix once spotted

Closest to 'cross-cutting refactor across the codebase' (e7). The quick_fix says replace detached spawns with a task group that awaits all children, propagates first error, and cancels siblings — that is not a one-line swap; reshaping fire-and-forget task lifetimes into a scoped nursery touches every spawn site and their error/cancellation handling.

b7 Burden Structural debt — long-term weight of choosing wrong

Closest to 'strong gravitational pull' (b7). applies_to spans web/cli/queue-worker/node contexts and the tags (task-lifecycle, cancellation, error-propagation) are load-bearing — once concurrency is scoped (or not), every async path is shaped by the lifetime model, making it a structural commitment across the system.

t7 Trap Cognitive debt — how counter-intuitive correct behaviour is

Closest to 'serious trap' (t7). The misconception is that it's 'just a tidier way to await a list of promises' when its defining guarantee is that no child escapes the scope and a sibling failure cancels the others — a developer expecting plain promise-fanout will guess wrong about cancellation, error propagation, and lifetime, contradicting how detached spawning behaves elsewhere.

About DEBT scoring →

Also Known As

task groups nursery pattern scoped concurrency structured task scope

TL;DR

A model where child tasks live inside a parent scope that waits for all of them to finish before it exits, so no task is ever orphaned.

Explanation

Structured concurrency binds the lifetime of concurrent tasks to a lexical scope. When you spawn child tasks inside a scope (a nursery, task group, or async with block), the scope does not return until every child has completed, errored, or been cancelled. This mirrors how structured programming replaced goto with blocks: control flow always converges, so you can reason about it locally.

The key property is that there are no orphaned or 'leaked' tasks. In unstructured models you call something like go(), spawn(), or fire a Promise without awaiting it, and that task floats free of any owner. If it crashes, the error vanishes; if the program shuts down, the task may be killed mid-write; if you forget it exists, it leaks resources. Structured concurrency makes that impossible by construction - the parent owns the children.

Error propagation is the second benefit. If one child fails, the scope cancels its siblings and propagates the exception to the parent, rather than letting one failure silently disappear into a detached task. Cancellation is also scoped: cancelling the parent cancels every descendant, giving deterministic teardown.

Concrete implementations include Python's asyncio.TaskGroup (3.11+) and the Trio nursery that inspired it, Java 21's StructuredTaskScope, and Kotlin's coroutine scopes. PHP does not have a first-class primitive, but Amp v3's Future combinators and ReactPHP supervision approximate the pattern by awaiting a known set of children before continuing.

Use it whenever you fan out work and need all results back, or when a request handler spawns helpers that must not outlive the request. The trade-off is that genuinely long-lived background work (a daemon loop, a connection keep-alive) does not fit a request-scoped lifetime and needs an explicitly longer-lived owning scope instead. Structured concurrency is about making task lifetimes visible and bounded, not about forbidding background work.

Common Misconception

Structured concurrency is just a tidier way to await a list of promises. In fact its defining guarantee is that the scope cannot exit while any child is still running, so a sibling failure cancels the others and no task ever escapes its parent.

Why It Matters

Orphaned tasks swallow errors, leak connections, and shut down mid-operation; scoping task lifetimes to a parent makes failures propagate and resources clean up deterministically.

Common Mistakes

  • Spawning fire-and-forget tasks (go/spawn/unawaited Promise) that outlive the scope that created them.
  • Swallowing a child task's exception because nothing ever awaits its result.
  • Forgetting to cancel sibling tasks when one fails, so they keep running after the parent has decided to abort.
  • Putting genuinely long-lived background work inside a short-lived request scope, then wondering why it gets killed.
  • Assuming a task group gives parallelism rather than concurrency - it still depends on the underlying executor.

Avoid When

  • You need a long-lived daemon or background loop whose lifetime must outlast any single request scope.
  • The language or runtime has no cancellation support, making sibling cancellation impossible to honour.
  • Tasks are genuinely independent and you explicitly want them to survive the spawning function (use a dedicated supervised owner instead).

When To Use

  • Fanning out work where the parent must collect every result before continuing.
  • Request handlers that spawn helpers which must not outlive the request.
  • Anywhere you need one child's failure to cancel siblings and propagate cleanly.
  • When deterministic teardown and no leaked tasks are correctness requirements.

Code Examples

✗ Vulnerable
# Unstructured: detached tasks leak and swallow errors
async def handle():
    asyncio.create_task(fetch_user())   # orphaned - nothing awaits it
    asyncio.create_task(fetch_orders()) # if this raises, error vanishes
    return 'done'  # returns while children may still be running
✓ Fixed
# Structured: scope waits for all children, propagates failures
async def handle():
    async with asyncio.TaskGroup() as tg:
        user_t   = tg.create_task(fetch_user())
        orders_t = tg.create_task(fetch_orders())
    # block exits only when both finish; if one raises,
    # the other is cancelled and the error propagates here
    return {'user': user_t.result(), 'orders': orders_t.result()}

Added 10 Jun 2026
Views 2
Rate this term
No ratings yet
🤖 AI Guestbook educational data only
| |
Last 30 days
0 pings T 0 pings W 0 pings T 0 pings F 0 pings S 0 pings S 0 pings M 0 pings T 0 pings W 0 pings T 0 pings F 0 pings S 0 pings S 0 pings M 0 pings T 0 pings W 0 pings T 0 pings F 0 pings S 0 pings S 0 pings M 0 pings T 0 pings W 0 pings T 0 pings F 0 pings S 0 pings S 0 pings M 0 pings T 2 pings W
Google 2
No pings yesterday
Google 2
crawler 2
DEV INTEL Tools & Severity
🟡 Medium ⚙ Fix effort: Medium
⚡ Quick Fix
Replace detached spawns with a task group / nursery that awaits all children, propagates the first error, and cancels siblings on failure.
📦 Applies To
PHP 8.1+ any web cli queue-worker node
🔗 Prerequisites
🔍 Detection Hints
create_task\(|asyncio\.ensure_future\(|go\s+\w+\(|spawn\(
Auto-detectable: ✗ No
⚠ Related Problems
🤖 AI Agent
Confidence: Medium False Positives: Medium ✗ Manual fix Fix: High Context: Function Tests: Update

✓ schema.org compliant