Prevent leaked asyncio workers by making task ownership explicit

2026-11-03来源 dev.to PythonEnglish

When an asyncio child fails, observing its exception is only half the problem: decide who owns the remaining tasks and waits for their cleanup. The linked Python documentation describes gather as an aggregation primitive and TaskGroup as a structured lifetime for related tasks. A TaskGroup normally cancels its remaining children after a non-cancellation failure and waits for them before leaving the scope. Do not treat a raised exception from gather as proof that every sibling stopped.

The runnable example below is a JavaScript conceptual analogy, executed with Node.js. It uses Promise.all, AbortController and Promise.allSettled to illustrate cooperative cancellation and waiting for cleanup. It is not Python code and its tests do not execute or verify asyncio.gather(), TaskGroup, CancelledError or TimeoutError. The Python recommendations in this article come from the linked Python documentation, not from those JavaScript test results.

The fixture exports runNaive and runSafe. runNaive returns Promise.all without signalling its sibling workers after a failure. runSafe creates an AbortController, signals its cooperative workers, waits with Promise.allSettled, and then reports the original error. This demonstrates an application-owned JavaScript cleanup pattern, not a reimplementation of TaskGroup. The tests cover ordered successful results, continued sibling progress in the naive case, and stopped progress after the safe runner returns.

For a Python service, separately test a successful group, a child exception while another child is waiting, and an enclosing cancellation or timeout with the real I/O library. The JavaScript checks cannot establish Python exception-group behavior, CancelledError handling or TimeoutError conversion. Swallowing cancellation and deliberately shielding work also change the native lifetime; inspect those choices before relying on cleanup.

Working example

export class AbortError extends Error { constructor(message = 'Aborted') { super(message); this.name = 'AbortError'; } } function sleep(ms, signal) { return new Promise((resolve, reject) => { const timer = setTimeout(done, ms); function done() { cleanup(); resolve(); } function onAbort() { cleanup(); reject(new AbortError()); } function cleanup() { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); } if (signal?.aborted) { cleanup(); reject(new AbortError()); return; } if (signal) signal.addEventListener('abort', onAbort, { once: true }); }); } export async function worker(id, { steps = 5, stepMs = 10, signal, failAtStep = null, progress = {}, } = {}) { for (let i = 1; i <= steps; i++) { if (signal?.aborted) throw new AbortError(); await sleep(stepMs, signal); progress[id] = (progress[id] ?? 0) + 1; if (failAtStep === i) { throw new Error(`worker:${id} failed at step ${i}`); } } return `done:${id}`; } export async function runNaive(numWorkers, opts = {}) { const { failingWorker = null, progress = {}, steps = 5, stepMs = 10 } = opts; const tasks = Array.from({ length: numWorkers }, (_, id) => worker(id, { steps, stepMs, progress, failAtStep: id === failingWorker ? 2 : null, })); return Promise.all(tasks); } export async function runSafe(numWorkers, opts = {}) { const { failingWorker = null, progress = {}, steps = 5, stepMs = 10 } = opts; const controller = new AbortController(); const tasks = Array.from({ length: numWorkers }, (_, id) => worker(id, { signal: controller.signal, steps, stepMs, progress, failAtStep: id === failingWorker ? 2 : null, })); try { return await Promise.all(tasks); } catch (error) { if (!(error instanceof AbortError)) controller.abort(); await Promise.allSettled(tasks); throw error; } } 

Read the full article: https://chriseugenerodriguez.com/blog/asyncio-failing-task-does-not-leak-workers

原文:https://www.a8q9.cn/en/article/prevent-leaked-asyncio-workers-by-making-task-ownership-explicit-2cba94.html

相关文章