We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Laziness, Iteration and Pipelines step 8 of 14
Bounded concurrency and backpressure
Unbounded fan-out is the most common way a service takes down its own
dependency. It is also the most common way an async rewrite makes throughput
worse: asyncio.gather(*(fetch(u) for u in urls)) over 100,000 URLs is a
memory bug, not a strategy — every coroutine is materialised as a Task up
front, before a single one of them runs.
Two limits people conflate:
- Concurrency — how many calls are in flight at once. A counter, or a semaphore. No clock needed.
- Rate — how many calls start per unit time. Needs a clock, and is a different problem.
This item is about concurrency, plus the third thing people skip entirely: backpressure. Bounding in-flight work is not enough if you read the source as fast as it will go. A pipeline that pulls a million rows into a queue in front of a bounded worker pool has moved the memory problem, not solved it. A bounded pipeline stops reading when the consumer stops consuming.
Why not a TaskGroup
Because a TaskGroup cannot be held open across a yield — see
py-batched-async-stream-cleanup. Yielding results as they complete means
yielding while tasks are still running, so this implementation owns its task
set directly: asyncio.wait(..., return_when=FIRST_COMPLETED), explicit
cancellation in a finally, and a BaseExceptionGroup raised by hand.
What to write
def bounded_map[T, R](
fn: Callable[[T], Awaitable[R]],
items: AsyncIterator[T],
*,
concurrency: int,
) -> AsyncGenerator[R, None]
A plain def wrapper that validates eagerly, plus the _bounded_map async
generator that does the work.
-
concurrency < 1raisesValueError(f"concurrency must be at least 1, got {concurrency}")at call time. -
Yield results as they complete. When several tasks land in the same
asyncio.waitbatch, break the tie by submission order — deterministic ordering is part of the contract, not an accident. -
Never more than
concurrencycalls in flight. -
Never pull more than
concurrencyitems ahead of the consumer. Because the loop only refills after await, and only yields between refills, this falls out of the structure — but only if you refill after draining, not before. -
On the first failure: cancel every outstanding task, await them, and raise
BaseExceptionGroup("bounded_map failed", failures). Cancelling without awaiting leaves a “Task exception was never retrieved” warning and, worse, a half-cancelled dependency. - Leave no pending task behind.
Note asyncio.ensure_future, not create_task: the parameter is
Awaitable[R], and create_task demands a coroutine specifically.
How the tests stay deterministic without a clock
fn awaits asyncio.sleep(0) exactly hops[i] times. asyncio’s ready queue
is FIFO, so hop counts give a reproducible completion order with no wall-clock
dependency at all. That is also the general technique for testing async
ordering: encode “slower” as “more scheduling turns”, never as “more
milliseconds”.
What the report proves
-
results— completion order. Withhops=[24, 8, 16]the answers arrive as index 1, index 2, index 0. A solution that yields in submission order fails. -
max_inflight— the peak number offnbodies running at once. Must equalconcurrencyexactly when there is enough work, andlen(hops)when there is not. -
max_lookahead— the peak ofitems_pulled - results_delivered. This is the backpressure measurement, and it must not exceedconcurrencyeven when the consumer is slow (consumer_laginserts scheduling turns per result). -
pulled— total items read from the source. After a failure it must stop. -
pending_tasks— outstanding tasks after the stream is closed. Must be0.Loading visualization…
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.