Why asyncio.gather Leaves Other Tasks Running After Failure
A ValueError from one child can make await asyncio.gather(…) fail while another child is still running. Default gather propagates the exception to its caller without cancelling the other children for that reason. Retrying the entire batch immediately can therefore overlap with work from the previous attempt.
Contents
- A failed aggregate can have an unfinished child
- Control the sequence with Events, not milliseconds
- A complete program with twenty-six checks
- TaskGroup joins failure and cleanup in one scope
- Collecting exceptions still requires handling them
- Why a short script can appear to cancel everything
- Define cleanup before retrying
This matters for concurrent LLM requests, data-source queries, and batch processing. We will use Events to control the sequence, compare continuation, explicit cancellation, TaskGroup, and collection of all results, then isolate cancellation during runner shutdown. No model or remote service is contacted. 中文版.
A failed aggregate can have an unfinished child
The CPython 3.13.0 documentation distinguishes these states: default gather propagates the first exception while other awaitables continue. Cancelling a gather already completed by exception propagation does not cancel its children. This discussion concerns an ordinary child exception, not every case of caller cancellation or a child cancelling itself.
Treat gather as an aggregate result rather than assuming it defines a shared stopping policy. “The caller has received failure” and “all work has ended” are separate observations. The first is enough to enter an except block. The second still requires an explicit decision to inspect, await, or cancel the individual tasks.
A tempting cleanup step is group.cancel() inside that except block. If the aggregate is already done, the call is too late. The example checks all three relevant facts: group.done() is true, sibling.done() is false, and group.cancel() returns false. This exposes the mismatch directly instead of inferring it from exception logs.
Raises ValueError
Done; caller enters except
Waiting for release; unfinished
Control the sequence with Events, not milliseconds
The worker sets ready and then waits for release. The failing coroutine must observe ready before raising ValueError. When the caller catches the error, the worker has entered its workflow but release has not opened. The caller can then either release the worker or cancel it directly. Both paths use the same worker; only the cleanup decision changes.
The phrase “work committed” means an append to a local list. It does not represent a database commit, successful HTTP request, or finished remote inference. It makes one fact inspectable: another task can produce an effect after the exception has reached the caller. No arbitrary sleep is used to guess how far scheduling has progressed.
In the continuation path, the caller opens release, awaits sibling, and gets the value 7 plus completion records. In the explicit-cancellation path, it calls sibling.cancel() and awaits both tasks with return_exceptions=True. That result retains a ValueError and a CancelledError. Avoiding another raised exception does not make either item successful.
A complete program with twenty-six checks
The program uses the standard library. TaskGroup and except* require Python 3.11 or later. The actual local run used Python 3.15.0; the semantic reference is pinned to previously published 3.13.0 material. This is not a claim of execution across multiple Python versions.
import asyncio
checks = 0
def check(condition):
global checks
assert condition
checks += 1
async def worker(ready, release, trace):
ready.set()
try:
await release.wait()
trace.append('work committed')
return 7
except asyncio.CancelledError:
trace.append('cancel observed')
raise
finally:
trace.append('cleanup finished')
async def fail(ready):
await ready.wait()
raise ValueError('synthetic failure')
async def gather_case(cancel_worker):
ready, release = asyncio.Event(), asyncio.Event()
trace = []
sibling = asyncio.create_task(worker(ready, release, trace))
broken = asyncio.create_task(fail(ready))
group = asyncio.gather(broken, sibling)
try:
await group
except ValueError:
check(group.done())
check(broken.done())
check(not sibling.done())
check(not group.cancel()) # Too late: the aggregate is already done.
check(trace == [])
else:
raise AssertionError('expected ValueError')
if cancel_worker:
check(sibling.cancel())
check(not sibling.done()) # A request is not completed cancellation.
settled = await asyncio.gather(broken, sibling, return_exceptions=True)
check(isinstance(settled[0], ValueError))
check(isinstance(settled[1], asyncio.CancelledError))
check(sibling.cancelled())
check(trace == ['cancel observed', 'cleanup finished'])
else:
release.set()
check(await sibling == 7)
check(trace == ['work committed', 'cleanup finished'])
return trace
async def taskgroup_case():
ready, release = asyncio.Event(), asyncio.Event()
trace, errors = [], []
try:
async with asyncio.TaskGroup() as group:
sibling = group.create_task(worker(ready, release, trace))
group.create_task(fail(ready))
except* ValueError as eg:
errors.extend(eg.exceptions)
check(len(errors) == 1 and isinstance(errors[0], ValueError))
check(sibling.done() and sibling.cancelled())
check(trace == ['cancel observed', 'cleanup finished'])
return trace
async def collect_case():
ready, release = asyncio.Event(), asyncio.Event()
trace = []
sibling = asyncio.create_task(worker(ready, release, trace))
broken = asyncio.create_task(fail(ready))
group = asyncio.gather(broken, sibling, return_exceptions=True)
try:
await broken
except ValueError:
pass
check(not group.done()) # It still waits for the blocked sibling.
release.set()
settled = await group
check(isinstance(settled[0], ValueError) and settled[1] == 7)
check(trace == ['work committed', 'cleanup finished'])
return trace
async def main():
print('gather, continue:', await gather_case(False))
print('gather, explicit cancel:', await gather_case(True))
print('TaskGroup:', await taskgroup_case())
print('collect all:', await collect_case())
asyncio.run(main())
# A separate run shows cancellation caused by runner shutdown.
exit_trace = []
async def runner_exit_case():
ready, release = asyncio.Event(), asyncio.Event()
sibling = asyncio.create_task(worker(ready, release, exit_trace))
try:
await asyncio.gather(fail(ready), sibling)
except ValueError:
check(not sibling.done())
# Return without releasing or directly cancelling the sibling.
asyncio.run(runner_exit_case())
check(exit_trace == ['cancel observed', 'cleanup finished'])
print('runner shutdown:', exit_trace)
print(f'{checks} asyncio checks passed')
The final output line was “26 asyncio checks passed”. The default-gather continuation records work committed and cleanup finished. Explicit cancellation and TaskGroup record cancel observed and cleanup finished. Collecting all results still lets the worker complete. The separate asyncio.run at the end demonstrates runner shutdown, which must not be mistaken for gather’s failure policy.
The assertions check task states, exception types, and controlled ordering. They do not measure throughput, latency, or real API cost. The worker cooperates with cancellation while awaiting an Event, and its cleanup finishes immediately. It is not a model of code that suppresses cancellation, blocks the event loop, or performs an irreversible external action.
The explicit-cancellation path also checks a brief but important state: cancel() returns true while sibling.done() remains false. One observation records a request; the other shows that the coroutine has not processed it yet. Only after awaiting cleanup do we inspect cancelled() and the cleanup records. A batch log should likewise distinguish requested cancellation from observed cancellation. This prevents retry logic from treating a task that is still cleaning up as if it had already released its resources.
TaskGroup joins failure and cleanup in one scope
For the ValueError used here, the TaskGroup contract requires cancelling remaining tasks, waiting for termination, and reporting ordinary failures in an exception group. This example schedules one ValueError, so except* ValueError collects one matching exception. It does not assume that every real task group has exactly one failure.
This scope is useful when several pieces of work jointly form one result. For example, an answer may require two data sources, making either missing source sufficient to abandon the combined answer. Letting the other operation run without an owner may not match that lifecycle. This is an application-design example, not a recommendation to put every LLM request in a TaskGroup.
Cancellation remains cooperative. The worker records CancelledError, re-raises it, and executes finally. Other coroutines might swallow cancellation, perform blocking work, or take time to clean up. Scope exit can still wait. TaskGroup does not forcibly terminate arbitrary code, and it is not a transaction that reverses every earlier external effect.
Release B and obtain its value
Request cancellation; await cleanup
Wait and classify each item
Collecting exceptions still requires handling them
Independent requests can have independent outcomes. For a text batch that permits individual failures, collecting every result and classifying each item may fit better. In collect_case, we first observe broken failing, then assert that the aggregate is unfinished because the worker is still blocked. Only after release opens do we receive the exception and numeric result together.
This addresses a common misunderstanding: return_exceptions=True changes how failure appears in the result collection; it does not mean returning immediately after the first failure. The caller still decides which exception items can be retried and which successful values to retain. A result list matching the input length is not proof that the batch succeeded.
In a batch LLM workflow, recording a result or failure for each input is usually easier to reason about than resending everything after one exception. A batch retry overlapping with old unfinished tasks can duplicate work. This is a lifecycle risk derived from the example, not a provider experiment or a claim about how a remote service bills those requests.
Why a short script can appear to cancel everything
If main catches the exception and immediately returns, the runner can enter its shutdown sequence. CPython 3.13.0 Runner.close and _cancel_all_tasks cancel unfinished tasks and await their cleanup during closure. Seeing the worker cancelled at the end is therefore insufficient evidence that gather cancelled its sibling when it failed.
The last part of the program isolates this case. Inside except, runner_exit_case confirms that sibling is unfinished. It neither releases nor directly cancels that task before returning. After asyncio.run exits, code outside the coroutine checks the cancellation and cleanup records. The cancellation belongs to runner shutdown, not the moment gather delivered ValueError.
A long-running service normally does not shut down its event loop whenever a request handler returns. Do not import the exit behavior of a tiny command-line example as a service’s child-task policy. First identify the owner of the event loop and tasks, then determine which layer triggered a cancellation log.
Define cleanup before retrying
With default gather, retain the child references and decide whether failure means continuing to await them or cancelling them and awaiting cleanup. With TaskGroup, account for exception groups and cooperative cancellation. When collecting all outcomes, classify each item instead of passing an exception forward as a normal success value. These are different completion conditions.
For remote calls, also separate a local task ending from server-side work ending; the latter is untested here. The earlier article on asyncio timeouts and background threads concerns another layer where work can continue. This article uses no threads: sibling coroutines can remain active under default gather. Locate the unfinished layer before deciding whether a retry overlaps old work.
Sources are the v3.13.0 documentation source and runner implementation. That release dates to October 7, 2024, before the planned slot. Both diagrams, the Event-driven example, and the twenty-six checks are original. The article retains October 24, 2026 at 23:30 Beijing time and was actually published as a catch-up article on October 26, 2026.
Support
If the explanations or examples helped you solve a problem or save time, you’re welcome to support this site with a voluntary tip.
Click a QR code to enlarge. More options: support page。


