Why does concurrent() using two create_task calls finish in roughly one second while sequential() using two bare awaits takes about two seconds, when each fetch sleeps one second?
create_task runs the coroutines on separate CPU cores in parallel.
Both tasks are scheduled onto the loop before either is awaited, so their sleep waits overlap.
create_task makes each coroutine run twice as fast internally.
A bare await always adds a fixed one-second scheduling penalty.
What does a asyncio.TaskGroup guarantee about the tasks created inside its async with block?
They are all scheduled but may keep running after the block exits.
Every task is guaranteed to complete or be cancelled before the block exits.
They run one at a time, strictly in creation order.
They are discarded unless you manually call await on each handle.
Inside a TaskGroup, one child task raises a ValueError. What happens to the sibling tasks and how does the failure surface?
Siblings keep running to completion, and only the ValueError is raised.
Siblings are cancelled and awaited, and failures are re-raised combined into an ExceptionGroup handled with except*.
The whole program exits immediately with no cleanup.
The ValueError is silently swallowed and the block exits normally.
Why can a fire-and-forget asyncio.create_task(coro) whose returned handle you discard vanish silently mid-flight?
The loop keeps only a weak reference to a Task, so an unreferenced task can be garbage-collected.
Discarded tasks are always cancelled by the loop after one second.
A Task cannot run unless it is inside a TaskGroup.
create_task returns None, so there is nothing to reference.
What is the recommended idiom for a truly detached background task that must not be garbage-collected?
Call time.sleep() after creating it to keep it alive.
Add the task to a module-level set and remove it with add_done_callback when it finishes.
Store its coroutine object in a local variable inside the function.
Run it with asyncio.gather without keeping the result.
Tasks and Structured Concurrency
Key Points
- A bare
await coro runs sequentially; asyncio.create_task(coro) schedules work concurrently — the "schedule first, await later" move that lets I/O waits overlap.
asyncio.TaskGroup (3.11+) enforces structured concurrency: every child completes or is cancelled before the block exits.
- On any child failure a TaskGroup cancels the siblings and re-raises the failures combined into an
ExceptionGroup, handled with except*.
- The loop keeps only a weak reference to a Task, so a discarded fire-and-forget handle can be garbage-collected and lost.
- Keep a strong reference (a
set + add_done_callback) or, better, wrap the work in a TaskGroup.
Awaiting a coroutine directly runs it to completion before the next line executes — useful, but sequential. To get concurrency you must schedule coroutines as Tasks: independent units of work the event loop can interleave. A Task is a subclass of Future that wraps a coroutine and drives it forward on the loop, running it "in the background" relative to whatever created it.
create_task vs bare coroutines
The primitive for scheduling is asyncio.create_task(coro). It hands the coroutine to the loop immediately and returns a Task handle you can later await for its result. In sequential(), the first await fully completes before the second begins. In concurrent(), both tasks are launched onto the loop before either is awaited, so their sleep intervals overlap and the whole thing finishes in roughly one second. This is the essential move: schedule first, await later. For our gateway, this is how one inbound Slack event can trigger a database lookup, a user-profile fetch, and an AI call that all wait simultaneously.
Visual animation — coming soon
asyncio.TaskGroup (3.11+) for structured concurrency
Scheduling loose tasks works, but raises a governance problem: who guarantees they all finish, and what happens if one fails? Structured concurrency is the discipline that answers this — the rule that concurrent tasks are bounded to a visible region of code, so they cannot outlive the block that spawned them. Python 3.11 introduced asyncio.TaskGroup, an asynchronous context manager that enforces exactly this: every task created inside the async with block is guaranteed to complete (or be cancelled) before the block exits. You enter with async with asyncio.TaskGroup() as tg: and schedule work via tg.create_task(coro). When the block exits, it implicitly awaits every task in the group — no manual bookkeeping, no forgotten handles.
The safety guarantee is what makes TaskGroup the recommended default. If any task raises an exception other than CancelledError, the group cancels all remaining tasks, waits for them to unwind, and then re-raises. The collected failures (excluding cancellations) are combined into an ExceptionGroup per PEP 654, which you handle with the new except* syntax. KeyboardInterrupt and SystemExit are special-cased — re-raised immediately rather than being bundled into the group.
Figure 3.1: TaskGroup structured-concurrency lifecycle
stateDiagram-v2
[*] --> GroupOpen: "async with TaskGroup() as tg"
GroupOpen --> Running: "tg.create_task() adds children"
Running --> Running: "schedule more children"
Running --> AllSucceeded: "block body finished, all tasks OK"
Running --> ChildRaised: "a child raises (not CancelledError)"
AllSucceeded --> AwaitChildren: "implicitly await every task"
AwaitChildren --> ExitNormally: "read .result() on each task"
ChildRaised --> CancelSiblings: "cancel all remaining tasks"
CancelSiblings --> AwaitUnwind: "await siblings to unwind"
AwaitUnwind --> RaiseExceptionGroup: "raise ExceptionGroup (handle with except*)"
ExitNormally --> [*]
RaiseExceptionGroup --> [*]
Visual animation — coming soon
Fire-and-forget pitfalls and keeping strong references to tasks
There is a sharp edge with detached tasks. The event loop keeps only a weak reference to a Task. If you call create_task but do not store the returned handle anywhere, the task can be garbage-collected mid-flight, and it may simply vanish — silently, with no error. The idiom is to add each task to a module-level set and remove it via add_done_callback when it completes. For most gateway work, though, the better answer is to not fire-and-forget at all: wrap the work in a TaskGroup, which holds strong references for you and surfaces exceptions instead of eating them. Truly detached background tasks (a periodic metrics flush, say) are the exception, not the rule.
Why does concurrent() using two create_task calls finish in roughly one second while sequential() using two bare awaits takes about two seconds, when each fetch sleeps one second?
create_task runs the coroutines on separate CPU cores in parallel.
Both tasks are scheduled onto the loop before either is awaited, so their sleep waits overlap.
create_task makes each coroutine run twice as fast internally.
A bare await always adds a fixed one-second scheduling penalty.
What does a asyncio.TaskGroup guarantee about the tasks created inside its async with block?
They are all scheduled but may keep running after the block exits.
Every task is guaranteed to complete or be cancelled before the block exits.
They run one at a time, strictly in creation order.
They are discarded unless you manually call await on each handle.
Inside a TaskGroup, one child task raises a ValueError. What happens to the sibling tasks and how does the failure surface?
Siblings keep running to completion, and only the ValueError is raised.
Siblings are cancelled and awaited, and failures are re-raised combined into an ExceptionGroup handled with except*.
The whole program exits immediately with no cleanup.
The ValueError is silently swallowed and the block exits normally.
Why can a fire-and-forget asyncio.create_task(coro) whose returned handle you discard vanish silently mid-flight?
The loop keeps only a weak reference to a Task, so an unreferenced task can be garbage-collected.
Discarded tasks are always cancelled by the loop after one second.
A Task cannot run unless it is inside a TaskGroup.
create_task returns None, so there is nothing to reference.
What is the recommended idiom for a truly detached background task that must not be garbage-collected?
Call time.sleep() after creating it to keep it alive.
Add the task to a module-level set and remove it with add_done_callback when it finishes.
Store its coroutine object in a local variable inside the function.
Run it with asyncio.gather without keeping the result.
In what order does asyncio.gather(*aws) return its results?
In the order the awaitables actually finished.
In the same order you passed them, regardless of which finished first.
In reverse order of completion.
In a random order each run.
With default settings (return_exceptions=False), what happens to the other awaitables when one of gather's awaitables raises?
The first exception propagates, but the other awaitables keep running in the background.
All other awaitables are immediately cancelled by gather.
The exception is added to the result list and nothing propagates.
The event loop shuts down entirely.
You want to react to results the moment each task finishes, so a slow AI call does not hold up the fast ones. Which tool fits best?
asyncio.gather
asyncio.as_completed, which yields awaitables in completion order.
A plain for loop of bare awaits.
asyncio.Lock
Which statement about asyncio.wait(tasks, ...) is correct?
It returns a single ordered list of results.
It returns a (done, pending) tuple of sets, does not raise on timeout, and does not cancel pending tasks for you.
It automatically cancels every pending task when its timeout fires.
It raises TimeoutError as soon as its timeout elapses.
You schedule 1000 tasks but wrap each protected call in async with asyncio.Semaphore(5). What is the effect?
Only the first 5 tasks ever run; the other 995 are dropped.
All 1000 are scheduled, but at most 5 execute the protected region at once; the rest queue for a free slot.
The semaphore has no effect on tasks created with create_task.
Each task waits exactly 5 seconds before running.
Coordinating Many Coroutines
Key Points
gather runs awaitables concurrently and returns results in input order; by default the first exception propagates but siblings keep running, while return_exceptions=True folds failures into the result list.
- Use
as_completed to consume results in the order tasks finish — ideal for streaming the fastest replies first.
wait is low-level: it returns (done, pending) sets, does not raise on timeout, and does not cancel pending tasks.
- An
asyncio.Semaphore(n) caps in-flight work to n concurrent holders — essential for respecting upstream rate limits during fan-out.
BoundedSemaphore is the safer default because it raises on accidental over-releases instead of silently raising the ceiling.
TaskGroups excel when you want all-or-nothing completion. But often you need finer control: collect results in order, react to whichever finishes first, or process a hundred items while never exceeding ten in flight. asyncio offers gather, wait, as_completed, and the Semaphore for these patterns.
asyncio.gather for parallel fan-out
asyncio.gather(*aws) runs many awaitables concurrently and returns their results as a list, in the same order you passed them — regardless of which finished first. This is the workhorse for fan-out: our gateway broadcasting one AI response to a user's Slack, Discord, and WebEx channels at once.
Its behavior on failure is the critical detail. By default (return_exceptions=False), the first exception propagates to the caller immediately — but the other awaitables keep running in the background; gather does not cancel them. This is precisely the safety gap that TaskGroup closes. With return_exceptions=True, exceptions are instead returned as elements of the result list alongside successful values, so one failed delivery does not abort the others.
Table 3.1: gather vs. TaskGroup
| Concern | asyncio.gather | asyncio.TaskGroup (3.11+) |
| Result order | Preserved, as a list | Read each task's .result() |
| On first failure | Propagates; siblings keep running | Cancels siblings, then raises |
| Multiple failures | First one only (default) | Combined into ExceptionGroup |
| Tolerate partial failure | return_exceptions=True | Not the design goal |
| Best for | Heterogeneous, independent fan-out | All-or-nothing scoped work |
as_completed and wait for streaming results
Sometimes you do not want to wait for the slowest task — you want to react to results the moment each arrives. asyncio.as_completed(aws) yields awaitables in completion order, so a slow AI call does not hold up processing of the fast ones (as of Python 3.13 it is also directly async-iterable). asyncio.wait(tasks, ...) is a lower-level tool that returns a tuple of two sets, (done, pending), and — importantly — does not raise on timeout and does not cancel the pending tasks for you; you inspect and manage them yourself. It shines with return_when=asyncio.FIRST_COMPLETED for "wake me when any of these finishes" designs, such as racing a primary websocket read against a shutdown signal.
Bounding concurrency with Semaphores
Fan-out has a danger: launching one task per inbound message is fine at ten messages and catastrophic at ten thousand, where you would open ten thousand simultaneous connections and get rate-limited or memory-crushed. An asyncio.Semaphore solves this. It holds an internal counter; Semaphore(n) permits at most n coroutines to hold it at once. Acquiring decrements the counter (and blocks at zero); releasing increments it and wakes a waiter. Used as an async context manager, acquire and release happen automatically — even on exceptions. A thousand tasks may be scheduled, but only five ever execute the protected region at any instant; the rest queue at the async with for a free slot. This is the canonical way to respect an upstream's concurrency budget.
Figure 3.3: Semaphore-bounded fan-out
flowchart LR
IN["1000 inbound messages"] --> SPAWN["create_task per message
(all scheduled at once)"]
SPAWN --> GATE{"async with sem
Semaphore(5)"}
GATE -->|"5 permits held"| ACTIVE["Active AI calls
(at most 5 in flight)"]
GATE -.->|"permits exhausted"| WAIT["995 tasks queued
awaiting a free slot"]
ACTIVE --> RELEASE["release permit on exit"]
RELEASE -->|"wakes one waiter"| WAIT
WAIT -.->|"slot freed"| ACTIVE
ACTIVE --> DONE["gather collects all results"]
Visual animation — coming soon
One subtlety worth internalizing: prefer asyncio.BoundedSemaphore. A plain Semaphore lets you call release() more often than acquire(), quietly raising the ceiling above its intended limit and permitting more concurrency than you designed for. BoundedSemaphore raises ValueError on such an over-release, turning a silent bug into a loud one.
In what order does asyncio.gather(*aws) return its results?
In the order the awaitables actually finished.
In the same order you passed them, regardless of which finished first.
In reverse order of completion.
In a random order each run.
With default settings (return_exceptions=False), what happens to the other awaitables when one of gather's awaitables raises?
The first exception propagates, but the other awaitables keep running in the background.
All other awaitables are immediately cancelled by gather.
The exception is added to the result list and nothing propagates.
The event loop shuts down entirely.
You want to react to results the moment each task finishes, so a slow AI call does not hold up the fast ones. Which tool fits best?
asyncio.gather
asyncio.as_completed, which yields awaitables in completion order.
A plain for loop of bare awaits.
asyncio.Lock
Which statement about asyncio.wait(tasks, ...) is correct?
It returns a single ordered list of results.
It returns a (done, pending) tuple of sets, does not raise on timeout, and does not cancel pending tasks for you.
It automatically cancels every pending task when its timeout fires.
It raises TimeoutError as soon as its timeout elapses.
You schedule 1000 tasks but wrap each protected call in async with asyncio.Semaphore(5). What is the effect?
Only the first 5 tasks ever run; the other 995 are dropped.
All 1000 are scheduled, but at most 5 execute the protected region at once; the rest queue for a free slot.
The semaphore has no effect on tasks created with create_task.
Each task waits exactly 5 seconds before running.
When the deadline of an async with asyncio.timeout(10): block passes, what does asyncio do?
It cancels the block's current task and transforms the resulting CancelledError into a TimeoutError caught outside the block.
It silently returns None and continues inside the block.
It raises TimeoutError that can only be caught inside the block.
It blocks the entire event loop until the awaited call finishes.
How is cancellation delivered to a running task, and why does a broad except Exception: not accidentally swallow it?
Cancellation is a signal handled by the OS, not visible to Python code.
A CancelledError is raised at the task's next await; it subclasses BaseException, not Exception.
Cancellation sets a boolean flag the coroutine must poll manually.
CancelledError subclasses Exception, so except Exception does catch it.
You catch CancelledError in a consumer to flush partial state. What must you do afterward, and why?
Return normally; the loop treats a caught cancellation as success.
Re-raise it, because swallowing it silently can break the TaskGroup and timeout machinery that rely on cancellation internally.
Call time.sleep() to let the cancellation settle.
Convert it into a ValueError before re-raising.
Why does an asyncio.Lock keep a read-modify-write sequence atomic across an await without freezing other coroutines?
It uses OS-level thread preemption to guarantee exclusivity.
Only one coroutine holds it at a time, and a coroutine waiting on it yields to the loop rather than blocking it.
It disables the event loop while held.
It runs the protected code on a separate CPU core.
In an asyncio.Queue producer/consumer pipeline, what is the role of pairing every get() with task_done() and calling queue.join()?
join() blocks until every put item has a matching task_done(), letting you drain all work before cancelling idle workers.
task_done() restarts any failed worker automatically.
join() immediately cancels all producers.
They are optional cosmetic calls with no effect on shutdown.
Timeouts, Cancellation, and Synchronization
Key Points
- The nestable
asyncio.timeout(delay) context manager bounds a region and surfaces a TimeoutError caught outside the block; wait_for(aw, timeout) bounds a single awaitable and raises immediately.
- Cancellation is delivered as a
CancelledError at a task's next await; because it subclasses BaseException, ordinary except Exception handlers won't swallow it.
- Put cleanup in
try/finally so it survives cancellation, and if you catch CancelledError, re-raise it — TaskGroup and timeout rely on cancellation internally.
- Use
Lock to keep a read-modify-write atomic across an await, Event to broadcast one-to-many, and Condition to wait on a predicate — all yield to the loop while waiting.
asyncio.Queue implements producer/consumer with maxsize backpressure; pair each get() with task_done() and use join() to drain before cancelling idle workers.
An AI backend that hangs must not hang the user. Every I/O operation in a gateway needs a bounded lifetime, and bounding it means learning how cancellation flows through asyncio. asyncio also runs coroutines cooperatively on one thread, so you never see torn-write races — but you can still get logical races when a coroutine reads shared state, awaits, then writes.
asyncio.timeout and wait_for
Python 3.11 added asyncio.timeout(delay), an async context manager that bounds the time spent inside its block. When the deadline passes, it cancels the block's current task and transforms the resulting CancelledError into a TimeoutError, which you catch outside the async with. These context managers nest safely, and a sibling, asyncio.timeout_at(when), takes an absolute deadline on the loop's clock. The older asyncio.wait_for(aw, timeout) wraps a single awaitable, cancels it on expiry, and raises TimeoutError directly. The distinction: timeout() scopes a region and lets code after the block continue; wait_for() targets one awaitable and raises immediately. To protect a critical task from being cancelled by an outer timeout, wrap it in asyncio.shield().
How cancellation propagates via CancelledError
Both timeouts and explicit task.cancel() work through one mechanism: asyncio.CancelledError. When a task is cancelled, this exception is raised at its next await point, unwinding the coroutine stack like any exception. The design detail that makes cancellation robust is that CancelledError subclasses BaseException, not Exception — so a broad except Exception: handler will not accidentally swallow a cancellation. Cancellation is cooperative: a coroutine that never awaits cannot be cancelled, because there is no yield point at which to inject the exception.
Figure 3.4: Cancellation and timeout propagation
stateDiagram-v2
[*] --> AwaitingIO: "coroutine parked at an await"
AwaitingIO --> Cancelling: "task.cancel() OR asyncio.timeout deadline fires"
Cancelling --> RaiseAtNextAwait: "CancelledError (a BaseException) injected at next await"
RaiseAtNextAwait --> Unwinding: "stack unwinds like any exception"
Unwinding --> FinallyCleanup: "try/finally releases resources"
FinallyCleanup --> ReRaise: "re-raise CancelledError"
ReRaise --> WasTimeout: "raised inside asyncio.timeout block?"
WasTimeout --> TimeoutError: "converted to TimeoutError, caught outside block"
WasTimeout --> PropagateCancel: "plain cancellation propagates to caller"
TimeoutError --> [*]
PropagateCancel --> [*]
Visual animation — coming soon
Writing cancellation-safe cleanup with try/finally
Because a CancelledError can fire at any await, resources acquired before that point must be released even when the coroutine is torn down mid-flight. The rule is: use try/finally for cleanup, and if you catch CancelledError to do work, re-raise it so the cancelled state propagates. This matters doubly in structured code: TaskGroup and asyncio.timeout are implemented using cancellation internally. A coroutine that catches CancelledError and silently swallows it can break these mechanisms — the timeout never "takes," the group never unwinds. For our gateway, try/finally around a websocket consumer is what guarantees connections close and in-flight messages flush when systemd sends SIGTERM.
asyncio.Lock, Event, and Condition
An asyncio.Lock guarantees mutual exclusion across an await: only one coroutine holds it at a time, so a read-modify-write sequence stays atomic. Without the lock, two coroutines could both read count as 5 across an await and both write 6, losing an update. An asyncio.Event is a one-to-many signal: coroutines await event.wait() and all wake when someone calls event.set() — perfect for broadcasting "the connection is ready" or "shutdown requested." An asyncio.Condition combines a lock with waiting, letting consumers block until a predicate holds and be woken by notify(). Crucially, all of these yield to the loop while waiting, so a coroutine parked on a lock never blocks other coroutines.
asyncio.Queue for in-process producer/consumer pipelines
The most important primitive for a gateway is asyncio.Queue, the standard implementation of the producer/consumer pattern. Ingress coroutines (producers) put inbound events; a fixed pool of worker coroutines (consumers) get and process them. This decouples fast receipt from slow processing. A bounded queue (maxsize=N) additionally applies backpressure, blocking producers when full so a burst cannot exhaust memory. Two coordination methods make graceful shutdown possible: a consumer calls queue.task_done() after finishing each item, and queue.join() blocks until every put item has a matching task_done(). Because the workers block forever on get(), cancelling them after join() is the standard drain-then-shutdown sequence.
Figure 3.2: Producer/consumer pipeline via asyncio.Queue
flowchart LR
P["Producers
(ingress coroutines)"] -->|"await queue.put(event)"| Q["asyncio.Queue(maxsize=N)"]
Q -.->|"full: backpressure blocks producer"| P
Q -->|"await queue.get()"| W1["worker w0"]
Q -->|"await queue.get()"| W2["worker w1"]
Q -->|"await queue.get()"| W3["worker w2"]
W1 -->|"process, then task_done()"| C["unfinished-task counter"]
W2 -->|"process, then task_done()"| C
W3 -->|"process, then task_done()"| C
C -->|"counter reaches 0"| J["queue.join() unblocks"]
J --> S["cancel idle workers,
gather for clean exit"]
Avoiding race conditions without blocking the loop
The golden rule ties this chapter to the last: never protect shared state with a blocking primitive. A threading.Lock or time.sleep inside a coroutine freezes the entire loop and every other coroutine with it. Always reach for the asyncio.* primitives, which cooperate with the loop. And favor bounded designs — a Semaphore or a maxsize queue — over unbounded fan-out, so a traffic spike degrades into a growing queue you can observe rather than an out-of-memory crash you cannot. For genuinely CPU-bound work that must not stall the loop, offload it with run_in_executor or to_thread rather than a lock.
When the deadline of an async with asyncio.timeout(10): block passes, what does asyncio do?
It cancels the block's current task and transforms the resulting CancelledError into a TimeoutError caught outside the block.
It silently returns None and continues inside the block.
It raises TimeoutError that can only be caught inside the block.
It blocks the entire event loop until the awaited call finishes.
How is cancellation delivered to a running task, and why does a broad except Exception: not accidentally swallow it?
Cancellation is a signal handled by the OS, not visible to Python code.
A CancelledError is raised at the task's next await; it subclasses BaseException, not Exception.
Cancellation sets a boolean flag the coroutine must poll manually.
CancelledError subclasses Exception, so except Exception does catch it.
You catch CancelledError in a consumer to flush partial state. What must you do afterward, and why?
Return normally; the loop treats a caught cancellation as success.
Re-raise it, because swallowing it silently can break the TaskGroup and timeout machinery that rely on cancellation internally.
Call time.sleep() to let the cancellation settle.
Convert it into a ValueError before re-raising.
Why does an asyncio.Lock keep a read-modify-write sequence atomic across an await without freezing other coroutines?
It uses OS-level thread preemption to guarantee exclusivity.
Only one coroutine holds it at a time, and a coroutine waiting on it yields to the loop rather than blocking it.
It disables the event loop while held.
It runs the protected code on a separate CPU core.
In an asyncio.Queue producer/consumer pipeline, what is the role of pairing every get() with task_done() and calling queue.join()?
join() blocks until every put item has a matching task_done(), letting you drain all work before cancelling idle workers.
task_done() restarts any failed worker automatically.
join() immediately cancels all producers.
They are optional cosmetic calls with no effect on shutdown.