Backend / Async & concurrency / 12_taskgroup_structured_concurrency.md

TaskGroup and structured concurrency

Updated 7 interview angles 9 min read source
On this page9
  1. The mechanism: where does the exception go
  2. What TaskGroup guarantees
  3. Cancellation
  4. Partial failure: tolerate some errors
  5. Picking the primitive
  6. Where it breaks in production
  7. The trade not taken
  8. Related
  9. Interview angle

TaskGroup and structured concurrency

Article.

asyncio.TaskGroup (3.11) is the safer default for running coroutines concurrently; gather is not deprecated, and the docs present TaskGroup as the alternative with stronger guarantees. The reason is not ergonomics. It is that structured concurrency guarantees every exception has somewhere to go — which the older primitives do not.

Once you can say where an exception ends up in each case, every rule here stops being a rule and becomes obvious.

The mechanism: where does the exception go

Four ways to run a coroutine, four different fates for a failure inside it.

python
# 1. awaited directly - propagates to you, immediately
await fetch()

# 2. gather - the FIRST exception propagates; siblings keep running
await asyncio.gather(fetch_a(), fetch_b(), fetch_c())

# 3. create_task with nobody awaiting - stored on the Task object
asyncio.create_task(fetch())        # nothing observes the result

# 4. TaskGroup - collected, siblings cancelled, raised as a group
async with asyncio.TaskGroup() as tg:
    tg.create_task(fetch())

Measured: one failure, two primitives

Three children, one raising at 0.2 s, the others finishing at 0.6 s and 1.0 s, on CPython 3.14.5:

text
gather                         TaskGroup
0.2s gather raised             0.2s b cancelled
0.6s b finished  (still ran)   0.2s a cancelled
1.0s a finished  (still ran)   0.2s raised ExceptionGroup

gather reported the failure and let the siblings run to completion in the background, holding whatever they held. TaskGroup cancelled them at the moment of failure, waited for their cleanup, and only then raised. Cancelling the gather future after it has raised does not stop the siblings either: it is already done, so the cancel is a no-op.

Case 3 is the dangerous one. An unawaited task that raises does not crash anything. The exception is stored on the Task, and Python only complains when the task is garbage collected:

text
Task exception was never retrieved
future: <Task finished coro=<fetch() done>
         exception=ValueError('boom')>

That message arrives whenever the GC gets round to it — seconds later, or at interpreter shutdown, or never if the process is killed first. So the failure is real, silent, and detached from the code that caused it.

The task that vanishes

Worse than a lost exception. The event loop keeps only a weak reference to tasks, so a task with no strong reference anywhere can be collected while still running:

python
# The task may be garbage collected mid-flight. It just stops.
asyncio.create_task(send_metrics())

# Correct: hold a reference until it completes.
_background: set[asyncio.Task] = set()

task = asyncio.create_task(send_metrics())
_background.add(task)
task.add_done_callback(_background.discard)

That five-line idiom exists purely because of the weak reference, and its absence produces work that silently sometimes does not happen — which is the hardest class of bug to even notice.

What TaskGroup guarantees

python
async with asyncio.TaskGroup() as tg:
    a = tg.create_task(fetch_a())
    b = tg.create_task(fetch_b())
    c = tg.create_task(fetch_c())
# past this line, all three are finished
print(a.result(), b.result(), c.result())

The block does not exit until every task has finished. If any task raises, its siblings are cancelled and the failures are collected. Nothing leaks, because nothing can outlive the async with.

That is what “structured” means: a task’s lifetime is bounded by a syntactic block, exactly as a local variable’s is. You can always answer “where might this still be running” by looking at the indentation.

Trio invented the idea as a nursery; anyio calls it a task group; 3.11 brought it into the standard library.

How it works

  • On the first failure other than CancelledError, the group cancels every child and the task running the async with, so the body stops at its next await too.
  • __aexit__ waits for every child, then calls uncancel() on the parent task to undo its own cancellation, which is why a TaskGroup composes inside asyncio.timeout() without confusing whose cancellation is whose.
  • KeyboardInterrupt and SystemExit from a child are re-raised as they are, not wrapped in an ExceptionGroup.
  • create_task on a group that has exited raises RuntimeError and closes the coroutine, so a task cannot be added to a scope that no longer exists.
  • Eager start. With the eager task factory (3.12) or, since 3.14, tg.create_task(coro, eager_start=True), a child runs inside create_task until its first real suspension, which changes ordering and where an early exception surfaces.

except* and ExceptionGroup

Because several tasks can fail at once, TaskGroup raises an ExceptionGroup, and PEP 654 added syntax to match inside one:

python
try:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch_a())
        tg.create_task(failing())
        tg.create_task(slow())          # cancelled when failing() raises
except* ValueError as eg:
    for e in eg.exceptions:
        log.error("ValueError in subtask", exc_info=e)
except* TimeoutError as eg:
    ...

This is the migration trap. Existing code written as except ValueError: around a gather will not catch a ValueError raised inside a TaskGroup, because what propagates is an ExceptionGroup containing one. The handler silently stops matching, and the error escapes to whatever is above it.

Cancellation

Cancel the surrounding coroutine and every task in the group is cancelled, given the chance to run its finally blocks, and awaited before the group re-raises. CancelledError is a BaseException, so except Exception does not swallow it, and code that catches it must re-raise; the details are in Cancellation, timeouts and error handling in asyncio.

Stopping a group early

Python 3.15 adds TaskGroup.cancel(). On 3.14 and earlier, the documented recipe is to raise a private exception inside the group and swallow it outside:

python
class Done(Exception): ...

async def first_good(candidates):
    found = None
    async def try_one(c):
        nonlocal found
        result = await check(c)
        if good(result):
            found = result
            raise Done              # cancels the rest of the group
    try:
        async with asyncio.TaskGroup() as tg:
            for c in candidates:
                tg.create_task(try_one(c))
    except* Done:
        pass
    return found

Partial failure: tolerate some errors

TaskGroup’s all-or-nothing is wrong when, say, 5,000 URLs must be fetched with 20 in flight and a few failures are acceptable. Make each child return its error instead of raising it, and keep the group for its lifetime guarantee:

python
async def safe_fetch(url, sem):
    async with sem:
        try:
            return url, await fetch(url)
        except Exception as e:          # an ordinary failure becomes a result
            return url, e

sem = asyncio.Semaphore(20)
async with asyncio.TaskGroup() as tg:
    tasks = [tg.create_task(safe_fetch(u, sem)) for u in urls]
failures = [(u, r) for u, r in (t.result() for t in tasks) if isinstance(r, Exception)]

gather(..., return_exceptions=True) gives the same result list in one line. Two differences: it also puts a CancelledError in the list for any child that was cancelled, so check for it, and it gives no lifetime guarantee for work started elsewhere. Cancellation still propagates through safe_fetch, because CancelledError is not an Exception.

Picking the primitive

Primitive Use it when Watch out for
await coro you need the result now no concurrency at all
TaskGroup default for N concurrent things raises ExceptionGroup
gather(*coros) quick all-or-nothing fan-out siblings run on after a failure
gather(return_exceptions=True) partial failure is fine you must inspect every result
create_task you need a handle to cancel later weak ref; keep a strong one
asyncio.timeout(n) bound a whole block catch TimeoutError at the boundary
as_completed act on results as they land (an async iterator since 3.13) does not cancel the losers
asyncio.Queue producer/consumer with backpressure unbounded unless you set maxsize
to_thread(fn) a blocking call you cannot avoid see the thread-offload note
Semaphore cap concurrency on a dependency acquire inside the task

Where it breaks in production

The semaphore in the wrong place

The most common concurrency-limiting bug, and it looks correct:

python
# WRONG - limits how fast tasks are created, not how many run.
async with asyncio.TaskGroup() as tg:
    for url in urls:
        async with sem:                      # released immediately
            tg.create_task(fetch(url))

create_task schedules and returns at once, so the semaphore is acquired and released a thousand times in a few milliseconds and a thousand fetches run concurrently. The limit does nothing: measured with an in-flight counter, the wrong placement peaked at 1,000 concurrent fetches, the right one at 10.

python
# RIGHT - the semaphore is held for the duration of the work.
async def guarded(url):
    async with sem:
        return await fetch(url)

async with asyncio.TaskGroup() as tg:
    for url in urls:
        tg.create_task(guarded(url))

One blocking call stalls everything

time.sleep, requests, a sync database driver or a heavy CPU loop stops every coroutine in the process, because they all share one thread. It presents as latency rising with concurrency while CPU sits idle — see CPU-bound versus I/O-bound.

Unbounded fan-out

TaskGroup will happily start fifty thousand tasks. Each is cheap, and fifty thousand concurrent connections to one dependency is a denial-of-service attack you wrote yourself. Bound it with a semaphore, or process in batches.

as_completed leaving work running

Taking the first result and breaking out leaves the losers running, still holding connections:

python
for coro in asyncio.as_completed(candidates):
    result = await coro
    if good(result):
        break                    # the rest are still running

Cancel them explicitly, or run them in a TaskGroup and stop it early as shown above (TaskGroup.cancel() on 3.15).

The trade not taken

gather is not deprecated and does not need replacing everywhere. For a one-line fan-out where all-or-nothing is genuinely the semantic you want, it is shorter and clearer:

python
user, prefs, flags = await asyncio.gather(get_user(), get_prefs(), get_flags())

The case for TaskGroup is when failure handling matters — when a sibling must not keep running after another fails, when tasks are created dynamically inside the block, or when you need the guarantee that nothing outlives the scope. On a happy-path fan-out of three independent reads, gather is fine and the ExceptionGroup migration cost is real.

Interview angle 7

  • “Why TaskGroup instead of gather?” — because it bounds every task’s lifetime to a syntactic block, so siblings are cancelled when one fails and nothing can leak past the async with. gather propagates the first exception and leaves the others running invisibly.
    • Follow-up: “Prove it.” — one child failing at 0.2 s: gather raised at 0.2 s while its siblings finished at 0.6 s and 1.0 s; a TaskGroup cancelled them at 0.2 s and raised after their cleanup.
    • Follow-up: “What actually happens to an exception in an unawaited task?” — it is stored on the Task object and only surfaces as “Task exception was never retrieved” when the task is garbage collected, which may be much later or never. The failure is real and silent.
  • “What is the bug in asyncio.create_task(send_metrics())?” — the loop holds only a weak reference, so the task can be garbage collected while running and simply stop. Keep it in a set and discard on done.
  • “What breaks when you migrate gather to TaskGroup?” — exception handlers. A TaskGroup raises an ExceptionGroup, so except ValueError no longer matches and the error escapes. You need except*.
  • “Your semaphore does not limit anything.” — it was acquired around create_task, which returns immediately, so it is released before the work starts. Acquire it inside the coroutine so it is held for the duration.
    • Follow-up: “How would you notice?” — concurrency against the dependency matches the number of URLs rather than the semaphore value; the limit has no effect on observed throughput or on the dependency’s rate-limit errors.
  • “Should you ever catch CancelledError?” — to clean up, then re-raise. Swallowing it breaks the contract, because the caller believes the task stopped. Note it inherits from BaseException, so except Exception does not catch it, which is deliberate.
  • “5,000 URLs, 20 in flight, tolerate failures. How?” — a semaphore of 20 held inside each child, children that return their exception instead of raising it, and a TaskGroup around them; or gather(return_exceptions=True).
  • “Is gather obsolete?” — no. For an all-or-nothing fan-out of a few independent reads it is shorter and clearer. TaskGroup earns its place when failure handling matters, tasks are spawned dynamically, or you need the no-leak guarantee.