AI EngineeringZero to ProductionHome·About·What’s new·Contact
Appendix · Advanced AI Engineering · Part 3

High-Throughput Async & Streaming Pipelines

LLM work is I/O-bound — you spend almost all your time waiting for the API. asyncio lets a single thread juggle thousands of in-flight requests while they wait, and stream tokens back as they arrive. This part builds async from the event loop up, then applies it to the two things AI systems need most: massively concurrent calls and token streaming.

⏱️ ~2 hours🎯 Advanced → Expert⚡ throughputrunnable

Learning objectives

  • Explain the event loop and how await yields control while waiting.
  • Run many coroutines concurrently with gather and TaskGroup.
  • Bound concurrency with a semaphore to respect rate limits (backpressure).
  • Apply timeouts and handle cancellation cleanly.
  • Consume a streaming LLM response with async for.
  • Build an async producer/consumer pipeline with a queue.

Why async for AI systems motivation

Picture your evaluation script the night before a launch. It has 500 golden test cases to run through the model, each call takes about two seconds, and you kick it off expecting to grab a coffee. Twenty minutes later it's still going, one call at a time, each one sitting and waiting for the network while your laptop does absolutely nothing. That dead waiting is the whole problem async solves — and once you see it, you can't unsee it in every AI system you touch.

Here's the mental picture that makes it click. Imagine a chef with one pot of pasta boiling. The naive chef stands and stares at the pot for ten minutes, then chops the vegetables, then stares at the oven. A good chef puts the pasta on, and while it boils chops the vegetables, sets the table, plates the starter. Nothing cooks faster — but the whole meal lands in a fraction of the time, because the chef fills the waiting with other useful work. Async is exactly that chef, and each "waiting for the API to answer" is a pot on the boil. Your program isn't working harder; it's just refusing to stand and stare.

The reason this matters so much for AI specifically is that LLM work is I/O-bound — you spend the overwhelming majority of every request just waiting for bytes to travel to a data center and back. A normal web function might be CPU-bound (crunching numbers), but an LLM call is 99% waiting, 1% doing. When almost all your time is waiting, overlapping that waiting is the single biggest lever you have — bigger than a faster CPU, bigger than more RAM.

The mistake nearly everyone makes first is assuming "faster" means "more machines" or "more threads." You reach for a bigger box, or you spawn 500 threads, and you're surprised when it barely helps or falls over. The truth is you don't need more workers to do more — you need one worker that stops waiting. A single async thread can hold thousands of half-finished requests in the air at once, each parked at its own "waiting for the network" line, resuming the instant its answer arrives. That's a different shape of solution than "add hardware," and getting the shape right is what this whole part teaches.

Say each API call takes 2 seconds. Evaluating 500 golden cases sequentially is ~17 minutes; with async concurrency (respecting rate limits) it's under a minute. Async also powers streaming — showing tokens as the model produces them, which transforms perceived latency in a chat UI. Same CPU, dramatically better throughput and UX, because the thread does useful work instead of blocking on the network.

the same five API calls — each is mostly waiting on the network Sequential one at a time → total = sum … ~17 min for 500 calls Async waits overlap → total ≈ max under 1 min one thread, waits stacked Async collapses the total from a sum to a max. Because each call is almost all waiting, stacking those waits on one thread turns 500 sequential 2-second calls (~17 min) into roughly the length of a single call — no extra CPU, just no more standing and staring.
🗺️ How to read this diagram

Compare the two rows top to bottom: same five calls, drawn along a left-to-right time axis. The top row runs them one after another; the bottom row runs them overlapped.

  • Each amber bar in the top row is one API call, laid end to end — the next can't start until the previous finishes. The long dashed grey arrow is the running clock: total time is the sum of every bar, ~17 min for 500 calls.
  • In the bottom row the same calls are green bars stacked vertically — they all start together and their waiting overlaps. The short blue arrow shows the clock stops after roughly one call's length.
  • The payoff is the gap between the two arrows: the long grey clock versus the short blue one. Nothing runs faster — one thread simply refuses to idle while a call waits.

In short: sequential time = sum of all waits; async time ≈ the single longest wait, because the waits happen on top of each other.

async vs threads (recap from A2)Both handle I/O concurrency. Threads are simpler to bolt onto existing sync code; async scales higher (thousands of concurrent tasks in one thread, no per-thread stack) and gives explicit control over cancellation, timeouts, and streaming. For a service making huge numbers of LLM calls, async is the standard.

1 · The event loop — the core idea intermediate

Everything in async rests on one gear that's always turning, and if you understand it, nothing else surprises you. That gear is the event loop. Skip it and async feels like magic keywords you sprinkle and hope; grasp it and every await, every "why did my task stall" bug, every design choice becomes obvious. So spend a minute here — this is the section the rest of the page depends on.

The clearest analogy is a waiter working several tables in a restaurant. The waiter takes table one's order, walks it to the kitchen, and — crucially — does not stand at the pass waiting for the food. They go take table two's order, refill table three's water, clear table four. When the kitchen bell dings that table one's food is ready, the waiter picks it up and delivers it. One waiter, many tables, never idle — because the moment any table needs waiting, the waiter goes and does something else useful. The event loop is that waiter, your coroutines are the tables, and "waiting for the kitchen" is waiting for the network.

Now the precise version, which will feel natural after the analogy. The event loop runs one task at a time (it's single-threaded — there is genuinely only one waiter). It runs that task forward until the task hits an await on something not yet ready — a network response, a timer, another coroutine. At that point the task politely yields: it says "I'm going to be waiting a while, go run someone else." The loop picks another ready task and runs it forward to its next await, and so on. When an awaited result finally arrives, the loop schedules that task to resume exactly where it paused. Round and round the gear turns, always giving the one thread to whoever can make progress.

The word "cooperative" is the catch that trips people, so plant it now. This scheduling is cooperative, not preemptive: the loop can only switch tasks at an await point. Nothing forcibly interrupts a running coroutine. That's a gift — you never get a task switch in the middle of a delicate update, so most async code is free of the locking headaches threads have. But it's also a loaded gun: if one task does something slow without awaiting — a heavy CPU loop, a blocking call — it never yields, the waiter is frozen at that one table, and every other task starves. That single failure mode is behind most "why is my async program hanging?" tickets, and it's exactly what the warning box a little further down forbids. Hold onto "the loop only switches at await" and you'll debug async faster than most engineers ever do.

one thread, many tasks — the loop only switches at an await event loop runs 1 ready task Task A running …until it hits await await (not ready) task yields → parked Task B running next ready task result arrives A resumes where paused run forward yield give thread to B wake & resume The loop hands its one thread to whoever can make progress. A task runs forward until an await that isn't ready; it yields, the loop runs another ready task, and when the awaited result lands the loop resumes the parked task exactly where it stopped.
🗺️ How to read this diagram

Follow the blue arrows clockwise around the loop in the centre — that circular path is the scheduling cycle, one lap per task switch.

  • Top-left, Task A is running forward on the single thread until it reaches an await. The arrow carries it to the top-right amber box: the awaited thing isn't ready, so the task yields and is parked.
  • The loop immediately picks the next ready task — bottom-right, Task B runs on the same thread. Nothing was interrupted; the switch only happened because A voluntarily hit an await.
  • Bottom-left, the green box is A's result finally arriving: the loop wakes A and resumes it exactly where it paused, and the lap begins again.

In short: the loop is a gear that only turns at await points — which is why one un-awaited slow call freezes every other task.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it — the shape of an async program
pythonimport asyncio

async def main():           # a coroutine function
    print("start")
    await asyncio.sleep(1)   # yields control for 1s instead of blocking
    print("done")

asyncio.run(main())          # create loop, run main() to completion, close loop
▶ How this works

This is the smallest complete async program — the shape every later example follows. async code doesn't run by itself; something has to start the event loop (the scheduler that runs your tasks and decides who waits and who runs). asyncio.run(...) is that starter.

  1. import asyncio brings in Python's built-in async toolkit — the event loop, sleep, gather, and the rest.
  2. async def main(): defines a coroutine function. The word async is what makes it special: it is allowed to contain await, and calling it hands back a coroutine to run later rather than running now.
  3. await asyncio.sleep(1) is the key line. Instead of freezing the program for a second, await yields control back to the event loop, which is free to run other tasks meanwhile. When the second is up, the loop resumes right here.
  4. asyncio.run(main()) creates the event loop, runs main() until it finishes, then closes the loop. This is the one place a normal (non-async) program crosses into async.

What the output means: Prints start, waits about one second, then prints done. In a real program that one second of waiting is where dozens of other tasks could be making progress.

Try this: Change asyncio.sleep(1) to time.sleep(1) (after import time). It still works here, but time.sleep blocks the whole loop — the mistake the next warning box is about.

The cardinal rule of asyncNever call a blocking function inside a coroutine — time.sleep(), requests.get(), heavy CPU loops — it freezes the entire event loop and every other task with it. Use async equivalents (asyncio.sleep, httpx/aiohttp, the async SDK) or push blocking/CPU work to a thread/process pool with asyncio.to_thread(...).

2 · Coroutines & await intermediate → advanced

Here's a bug that has cost every async newcomer at least one baffled afternoon. You write an async function, you call it, the program runs clean with no error — and the work inside simply never happened. No crash, no traceback, just silence where your API call should have been. The cause is a single missing keyword, and understanding why it fails so quietly is the fastest way to make async stop feeling slippery.

The key idea is that an async def function is not like a normal function. A normal function does the work the moment you call it. An async def function, when you call it, does nothing but hand you back a coroutine — think of it as a sealed recipe card, not a cooked meal. The card lists every step, but writing the card down doesn't cook anything. Someone still has to take that card to the stove. That "someone" is either an await or the event loop scheduling it as a task. Calling complete("hi") writes the card; await complete("hi") actually cooks it.

And await is the other half of the pair — it's the verb to the coroutine's noun. Saying await x means two things at once: "cook this recipe and hand me its result" and "while it's cooking, I don't need the stove — let the event loop run someone else." That second half is the whole point. Every await is a voluntary yield point, a place you've marked as "safe to pause me here." This is why you can only use await inside an async def: it's the language making you declare, at each waiting spot, that you're happy to step aside so the one thread stays busy.

So the classic mistake — creating a coroutine and never awaiting it — is really "writing the recipe card and leaving it on the counter." The work is fully described and completely un-done. Python does try to help: at the end of the program it notices the abandoned card and prints RuntimeWarning: coroutine '...' was never awaited. Learn to read that warning as "you forgot an await somewhere," because it's pointing straight at real work that silently didn't run. The lab below shows the correct path and the abandoned-card bug side by side so the difference burns in.

calling complete(...) only writes the recipe — await cooks it async def complete() a coroutine function coroutine object described, NOT run await → runs returns the value no await never runs → warning call() await forgot Calling an async def does no work — it hands back a coroutine. Only await (or scheduling it as a task) actually runs it; skip the await and the work silently never happens, which is the coroutine '...' was never awaited warning.
🗺️ How to read this diagram

Read left to right: the function, what calling it produces, and then the fork between awaiting and forgetting.

  • The blue box on the left is the async def complete() function. Calling it (the call() arrow) does not run its body — it produces the white "coroutine object" box, a described-but-un-run recipe.
  • From there the path forks. The upper solid arrow — await — leads to the green box: the coroutine actually runs and hands back its value.
  • The lower dashed arrow — "forgot" — leads to the red box: with no await, the coroutine is abandoned, the work never happens, and Python prints the "never awaited" warning.

In short: a call writes the recipe card; only await takes it to the stove — the red path is real work that silently didn't run.

Try it — mock async LLM call
pythonimport asyncio

async def complete(prompt):
    await asyncio.sleep(0.5)          # pretend network latency
    return f"answer to: {prompt}"

async def main():
    result = await complete("hello")   # await = run & get the value
    print(result)
    coro = complete("oops")             # NOT awaited -> never runs (warning)
    coro.close()

asyncio.run(main())
▶ How this works

This shows the difference between defining async work, awaiting it, and the classic beginner bug: creating a coroutine but forgetting to await it, so it never runs.

  1. async def complete(prompt): is a fake LLM call. The await asyncio.sleep(0.5) stands in for the half-second you'd really spend waiting on the network.
  2. result = await complete("hello") is the normal path: await runs the coroutine, pauses main while it waits, and hands back the returned string.
  3. coro = complete("oops") only creates a coroutine object — it does not run. Without an await in front, the work inside never happens. Python would warn "coroutine was never awaited"; coro.close() just tidies it up to silence that warning.

What the output means: Prints answer to: hello. The "oops" call produces nothing because it was never awaited — that silence is the bug.

Try this: Delete the coro.close() line and run it — Python prints a RuntimeWarning: coroutine 'complete' was never awaited. Recognising that warning saves hours later.

3 · Running things concurrently — gather & TaskGroup advanced

This is the section where the seventeen-minute evaluation from the top of the page becomes under a minute — the moment async pays for itself. Everything so far has been setup: the loop, coroutines, await. But awaiting one call at a time is still one-at-a-time; you've learned to not-block, but you haven't yet launched many things at once. That launch is what this section is about, and it's the payoff you came for.

The analogy is putting all the pots on the stove before you start staring at any of them. Awaiting calls one after another is boiling pot one to done, then starting pot two, then pot three — the pots never overlap, so total time is the sum. Launching them concurrently is lighting all the burners together and then waiting; now the boils overlap and total time is just the longest single pot. One hundred calls that each wait one second, fired together, finish in about one second — not one hundred — because the hundred waits happen on top of each other instead of end to end. That collapse from sum to max is the entire economics of async fan-out.

Python gives you two tools for this, and knowing when to use which is the mark of someone who's shipped async. asyncio.gather is the classic: hand it a pile of coroutines, it launches them all and gives you back their results in the same order you submitted them — which matters enormously when you need to line each answer back up with its input. asyncio.TaskGroup (Python 3.11+) is the modern, safer form built on "structured concurrency": you create tasks inside an async with block, and the block simply won't exit until every task is done. Its real superpower shows up on failure — if one task blows up, the group automatically cancels its siblings and reports the error cleanly, instead of leaving orphaned tasks running in the background.

The common mistake here is treating a failure in one of many concurrent calls as an afterthought. With plain gather, when one coroutine raises, you get that one exception — but by default the others may keep running, half-finished and unwatched, quietly burning your rate limit or leaking connections. Beginners write a happy-path gather over 1,000 calls, one 500-errors, and they spend the evening puzzling over "zombie" tasks and mystery costs. This is precisely the problem TaskGroup was designed to end: fail one, cancel the rest, surface the error, exit clean. For anything production, prefer it — and when you must use gather, decide deliberately how failures are handled rather than discovering the answer in an incident.

one task raises — what happens to its siblings? gather (default) task ✗ raises sibling runs sibling runs sibling runs siblings keep running unwatched → "zombie" tasks burn rate limit TaskGroup task ✗ raises cancelled cancelled exit clean first failure cancels the rest and the error surfaces — no orphans Same failure, two very different aftermaths. With plain gather the surviving siblings keep running unwatched; TaskGroup cancels them on the first raise and surfaces the error — which is why it is the safer default for production fan-out.
🗺️ How to read this diagram

Compare the two rows: each shows the same four concurrent tasks at the moment one of them (the red box on the left) raises an exception.

  • Top row — gather (default): after the red task fails, the three amber "sibling runs" boxes carry on. The dashed grey arrow is them still executing unwatched — the "zombie" tasks that quietly burn your rate limit.
  • Bottom row — TaskGroup: the same red failure triggers the blue arrows that turn the siblings into white "cancelled" boxes, ending in a green "exit clean".
  • The payoff is the contrast between the two right-hand ends: amber-still-running versus green-clean-exit. That is the whole reason to prefer TaskGroup when failures matter.

In short: gather may leave orphaned tasks after one failure; TaskGroup cancels the rest and surfaces the error cleanly.

Try it — 100 calls concurrently
pythonimport asyncio, time

async def complete(i):
    await asyncio.sleep(1)
    return i * 2

async def main():
    # gather: all at once, results in submission order
    results = await asyncio.gather(*[complete(i) for i in range(100)])
    print(len(results))              # 100, in ~1 second (not 100s)

    # TaskGroup (3.11+): structured — cleaner errors & cancellation
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(complete(i)) for i in range(100)]
    results = [t.result() for t in tasks]   # all done when the block exits

asyncio.run(main())
▶ How this works

Here is the payoff of async: running many calls at the same time. One hundred calls that each wait one second finish in about one second total, not one hundred, because all the waiting overlaps. Two ways to do it are shown.

  1. [complete(i) for i in range(100)] builds a list of 100 coroutines (still not running yet). The * in gather(*[...]) spreads that list out as separate arguments.
  2. await asyncio.gather(...) launches all 100 at once and waits for every one to finish, returning their results as a list in the order you submitted them — handy when you need to line results back up with inputs.
  3. async with asyncio.TaskGroup() as tg: is the newer (Python 3.11+) way. tg.create_task(...) schedules each call; when the async with block exits, all tasks are guaranteed done. If one fails, the group cancels the rest and reports the error cleanly — safer than gather for production.
  4. [t.result() for t in tasks] then pulls each finished task's return value.

What the output means: len(results) is 100, and it appears after roughly one second — proof the calls overlapped instead of running one after another.

Try this: Wrap one complete call so it raises an error, and compare: gather surfaces just that one exception, while TaskGroup cancels the siblings and bundles the errors. That difference is why TaskGroup is preferred.

🔗 Used in the courseBatch evaluation (Ch 5) and any multi-document RAG fan-out become minutes-to-seconds with gather/TaskGroup. The Anthropic SDK ships an AsyncAnthropic client whose calls you await exactly like complete above.

4 · Backpressure — bounding concurrency with a semaphore expert intermediate

You just learned to fire a thousand calls at once. Now learn why doing exactly that will get your API key throttled, your process OOM-killed, and your on-call phone ringing at 2am. The last section handed you a firehose; this one hands you the nozzle. If you remember only one production pattern from this entire page, make it this one — it's the difference between a demo and a system that survives real load.

Think of a motorway on-ramp with a metering light — that red-green signal that releases one car every few seconds during rush hour. It feels annoying when you're the car waiting, but it's the reason the motorway keeps flowing instead of collapsing into a jam. Dumping all the cars on at once doesn't get anyone there faster; it grinds everyone to a halt. A semaphore is that metering light for your coroutines: it lets a fixed number through at a time and makes the rest wait their turn at the line. The technical name for "make the source slow down so the destination can keep up" is backpressure, and the semaphore is your simplest, sturdiest way to apply it.

Concretely, a semaphore is just a counter with a cap. You create it with a limit — say 10 — and each coroutine must acquire a slot before doing its work and release it after (Python's async with sem: does both for you automatically). When all 10 slots are taken, the eleventh coroutine's async with line simply pauses — yielding to the loop like any other await — until someone finishes and frees a slot. You can still submit all 1,000 items up front; the semaphore quietly guarantees only 10 are ever truly in flight. Submission and execution get decoupled, and you get a smooth, steady stream instead of a thundering herd.

The mistake this prevents is the most seductive one in all of async, because the broken version works perfectly in testing. On your laptop with 20 test items, a naked gather over everything is instant and flawless — so it ships. Then it meets 10,000 real inputs in production: it opens 10,000 sockets simultaneously, blows straight through the provider's requests-per-minute limit into a wall of 429 Too Many Requests, and balloons memory holding 10,000 half-finished responses until the kernel kills the process. Nothing in the happy-path code hinted at this. The fix isn't more hardware — it's the metering light. Pair the semaphore with retry-and-backoff on those 429s (next tip box) and you have the canonical, boring, bulletproof shape every serious LLM fan-out uses.

all 1000 submitted — but the gate lets only limit through at once 1000 submitted worker coroutines waiting their turn Semaphore(10) the gate counter, cap = 10 10 in flight …real API calls… the other 990 blocked with sem acquire release → next in when a call finishes it frees a slot and the next waiter is admitted — steady stream, no thundering herd Submission and execution are decoupled. You may submit all 1000 items, but the semaphore guarantees only limit (here 10) are ever truly in flight — the rest park until a finishing call releases a slot. That is backpressure: the metering light that keeps you under the provider's rate limit.
🗺️ How to read this diagram

Read left to right: the pool of submitted work on the left, the gate in the middle, the small set of calls actually running on the right.

  • The blue box on the left is all 1000 worker coroutines — every item is submitted up front, so they all exist, but most are waiting.
  • The amber gate in the middle is Semaphore(10): a counter with a cap. A worker's async with sem line must acquire a slot (blue arrow) before it may proceed.
  • Only the green boxes on the right are genuinely in flight — never more than 10. The lower white box is the other 990, blocked. When a green call finishes it releases (dashed arrow) and the next waiter is admitted.

In short: 1000 submitted, at most 10 running — the gate turns a firehose into a steady, rate-limit-safe stream.

Try it — cap at N concurrent calls
pythonimport asyncio

async def bounded_map(coro_fn, items, limit=10):
    sem = asyncio.Semaphore(limit)        # at most `limit` inside at once
    async def worker(item):
        async with sem:                   # acquire; blocks if `limit` are running
            return await coro_fn(item)
    return await asyncio.gather(*[worker(i) for i in items])

async def embed(text):
    await asyncio.sleep(0.2); return len(text)

# 1000 items but never more than 10 in flight -> respects rate limits
# asyncio.run(bounded_map(embed, ["doc"] * 1000, limit=10))
▶ How this works

Launching 1000 calls at once (as in §3) would blow past the API's rate limit and your memory. A semaphore is a counter that only lets limit tasks run at a time; the rest wait their turn. This "backpressure" is the single most important production async pattern.

  1. sem = asyncio.Semaphore(limit) creates a gate that allows limit holders (default 10) inside simultaneously.
  2. async with sem: is how a worker acquires a slot. If limit workers already hold the gate, this line pauses until one of them exits the block and frees a slot — automatically throttling everything.
  3. return await coro_fn(item) does the real work only while inside the gate, so no more than limit real calls are ever in flight.
  4. return await asyncio.gather(*[worker(i) for i in items]) still submits all the items — but because each goes through the semaphore, only limit run at once.

What the output means: Nothing prints (the run line is commented out), but the design guarantees: 1000 items submitted, never more than 10 hitting the API at any instant.

Try this: Change limit to 1 and it becomes fully sequential; raise it and throughput climbs — until you hit the provider's rate limit. Tuning that number is real production work.

Combine with a rate limiterA semaphore caps concurrency (how many at once); a token-bucket rate limiter caps rate (how many per second). Production clients use both — plus retry with backoff on 429s (see A6 and the rate limiter in P6). Together they keep you under the provider's limits without manual tuning.

5 · Timeouts & cancellation advanced

Somewhere out there is a request that will never come back — and if you don't plan for it, it takes your whole service down with it. A network blip, an overloaded provider, a connection that half-opens and then goes silent: any of these can leave a coroutine awaiting a response that simply never arrives. Without a deadline, that one stuck call waits forever, and worse, everything depending on it waits forever too. Timeouts are how you refuse to wait forever, and they are not optional in anything real.

The everyday version of this is calling a support line and being told "please hold." You'll happily hold for two minutes; you will not hold for two hours. At some point you hang up and try another approach, because your time is worth more than one stuck call. A timeout gives your code that same self-respect: "I'll wait two seconds for this answer; if it hasn't come, I'm hanging up and moving on." asyncio.timeout(2) (Python 3.11+) — or the older wait_for — wraps a block of async work in exactly that deadline. Beat the clock and everything proceeds normally; overrun it and the wait is abandoned.

What actually happens on overrun is worth understanding, because it's the second half of this section's title: cancellation. When the timeout fires, asyncio doesn't just walk away and leave the stuck task running in the background — it cancels it. Cancellation works by raising a special CancelledError exception inside the paused coroutine, right at its await point. That's a deliberate design: because it surfaces as a normal exception, your coroutine gets a chance to clean up — close a file, release a connection, roll back — in a finally block before it unwinds. Cancellation is cooperative and tidy, not a violent kill.

The trap is treating a timeout as a failure to be swallowed and forgotten. The naive instinct is to wrap everything in try/except, catch the TimeoutError, log nothing, and carry on with a silently missing result — which turns a loud, diagnosable problem into a quiet data hole nobody notices for weeks. And a subtler error: catching the CancelledError yourself and suppressing it, which defeats the cancellation entirely and leaves a zombie task alive. The right posture is to catch the timeout deliberately — record it, decide whether to retry, return a sentinel — and to let CancelledError propagate after any cleanup. A timeout is information: it's your system telling you a dependency is unhealthy, and you want to hear it, not mute it.

the work races a deadline set by asyncio.timeout(2) await work() paused at an await deadline? 2s clock beat the clock returns the value overrun → cancel CancelledError → finally in time too slow cancellation is raised as an exception at the await, so cleanup runs before the task unwinds A timeout is a race the coroutine can lose. Finish before the deadline and you get the value; overrun it and asyncio raises CancelledError inside the coroutine at its await — so a finally can clean up before the task unwinds and the TimeoutError surfaces.
🗺️ How to read this diagram

Follow the work from the left into the deadline decision in the middle, then down whichever branch it takes.

  • The blue box on the left is your coroutine paused at an await. The amber "deadline?" box is the 2-second clock started by asyncio.timeout(2) around it.
  • The upper solid "in time" arrow to the green box is the happy path: the work finishes first and returns its value normally.
  • The lower dashed "too slow" arrows are the overrun: asyncio raises CancelledError inside the coroutine at its await (red box), which flows into the white box where a finally runs cleanup before the TimeoutError propagates out.

In short: beat the deadline → value; miss it → cancellation raised at the await, cleanup runs, timeout surfaces — never a silently stuck task.

Try it — bound a slow call
pythonimport asyncio

async def slow():
    await asyncio.sleep(10)
    return "done"

async def main():
    try:
        async with asyncio.timeout(2):      # give it 2 seconds
            await slow()
    except TimeoutError:
        print("gave up after 2s")          # task is cancelled for us

asyncio.run(main())
▶ How this works

A single request that hangs forever must not freeze your whole service. A timeout puts a ceiling on how long you'll wait; if the work overruns, async cancels it for you.

  1. async def slow(): simulates a stuck call by sleeping ten seconds.
  2. async with asyncio.timeout(2): (Python 3.11+) starts a two-second clock around everything inside the block. If the block isn't done in time, the task is cancelled.
  3. except TimeoutError: catches that expiry so you can react — here it just prints a message. Without the try/except, the timeout would bubble up as an error.

What the output means: After about two seconds (not ten), it prints gave up after 2s — the slow() call was cancelled the moment the clock ran out.

Try this: Lower asyncio.sleep(10) to 1 so the work finishes before the 2-second limit — now the except never fires. That's how you confirm the timeout only triggers on genuine overruns.

6 · Streaming LLM tokens with async for advanced

Two chat apps take the exact same eight seconds to write a paragraph. One feels broken; the other feels alive. The only difference is streaming. The first sits on a blank screen for eight seconds and then dumps the whole reply at once — an eternity of nothing that makes users reload the page. The second starts typing words within a few hundred milliseconds and keeps flowing. Same model, same total time, wildly different experience. That gap is why every LLM chat interface you've ever admired streams, and this section is how.

The insight is about perceived latency, not real latency. Think of a waiter who brings out each dish the moment it's plated — starter, then soup, then main — versus one who hides in the kitchen until the entire multi-course meal is ready and carries it all out on one enormous tray. The total cooking time is identical, but the first table is happily eating and chatting the whole while, and the second is staring at an empty table wondering if they've been forgotten. Streaming serves your user each token as it's cooked instead of making them wait for the full tray. It doesn't make the model faster; it makes the wait disappear because something useful is happening the entire time.

The Python mechanism that models "a sequence that arrives over time" is the async generator: an async def function that uses yield instead of return. Where a normal function hands back one value and is done, and a normal generator yields values on demand, an async generator yields values that each may involve awaiting — perfect for tokens trickling in over a network one at a time. You consume it with async for, a loop that pulls the next item the instant it's ready and runs the loop body immediately, without blocking the event loop between items. The pairing is exact: async generator produces, async for consumes, and the loop stays free to do other work in the gaps.

The mistake that quietly cancels all the benefit is collecting the whole stream before showing any of it. It's tempting to write tokens = [t async for t in stream()] and then print the joined result — and it runs, and it's wrong, because you've reintroduced the exact "wait for the full tray" delay you were trying to kill. Streaming only helps if you render each token inside the loop, as it arrives — hence the print(token, end="", flush=True) in the lab, where flush=True forces each piece to the screen instantly rather than sitting in a buffer. A useful pattern is to do both at once: render each token live and append it to a list, so the user sees the typewriter effect while you still assemble the complete final string for logging or further processing. Get that loop body right and you have the identical shape real Anthropic SDK streaming uses.

identical total time — only perceived latency differs No stream blank, then all at once blank screen — user waits… dump first visible text only at the end Streaming token as it arrives first token in ~ms text flows the whole time — the wait disappears Streaming attacks perceived latency, not total time. Both bars span the same generation time — but without streaming the first text appears only at the end, while streaming shows a token every few hundred milliseconds so the user is reading the whole while.
🗺️ How to read this diagram

Compare the two rows against the same left-to-right time axis — the total width (generation time) is identical; only when text becomes visible differs.

  • Top row — No stream: one long white "blank screen" bar where the user sees nothing, then a single small amber "dump" at the very end. The dashed grey arrow shows the first visible text arrives only when generation finishes.
  • Bottom row — Streaming: the reply is broken into green token bars spread across the same span. The short blue arrow marks the first token appearing within milliseconds.
  • The payoff is the left edge: blank-until-the-end versus text-almost-immediately. Same model, same total time, opposite feel.

In short: total time is the same both rows; streaming just makes the wait feel gone by showing each token the instant it arrives.

Try it — a mock token stream
pythonimport asyncio

async def stream_completion(prompt):        # async generator
    for tok in ["The", " answer", " is", " 42."]:
        await asyncio.sleep(0.1)             # each token arrives over the wire
        yield tok

async def main():
    parts = []
    async for token in stream_completion("q"):   # consume as it streams
        print(token, end="", flush=True)         # render immediately
        parts.append(token)
    return "".join(parts)

asyncio.run(main())
▶ How this works

Streaming makes a slow answer feel fast: instead of waiting for the whole reply, you show each piece (token) the instant the model produces it. An async generator models the stream, and async for consumes it.

  1. async def stream_completion(prompt): plus yield tok makes an async generator — a coroutine that hands back values one at a time instead of returning once. Each await asyncio.sleep(0.1) mimics the delay between tokens on the network.
  2. async for token in stream_completion("q"): is the streaming loop: it pulls the next token as soon as it's ready and runs the loop body immediately — no waiting for the full message.
  3. print(token, end="", flush=True) renders each token right away. flush=True forces it to the screen instantly so the user sees text appear live; parts.append(token) also collects them to rebuild the full answer at the end.

What the output means: You see The answer is 42. appear a word at a time rather than all at once — exactly the typing effect of a chat UI.

Try this: Raise the sleep to 0.5 to exaggerate the streaming, then set it to 0 so it prints instantly — same code, different perceived speed. Real Anthropic SDK streaming uses this identical async for shape.

🔗 Used in the courseThis is exactly the streaming loop over the Anthropic SDK's async stream from Ch 1 — real code iterates stream events and accumulates text deltas. The skill guidance also recommends streaming for any long/high-max_tokens request to avoid request timeouts. Same async for shape.

7 · Async producer/consumer pipeline expert intermediate

So far every pattern has had a start and an end — fire 1,000 calls, collect 1,000 results, done. But real systems often have no end: a feed of documents to ingest, a stream of tasks that never stops arriving. When work flows in continuously and the thing producing it runs at a different speed than the thing processing it, you need a shape that lets both run at their own pace without one crushing the other. That shape is the producer/consumer pipeline, and its heart is a queue.

Picture a kitchen at dinner rush. Waiters (producers) keep clipping new order tickets onto the rail above the line; cooks (consumers) pull tickets off and cook them. The ticket rail is the queue. Its beauty is that waiters and cooks never have to look at each other or move in lockstep — a waiter clips a ticket and immediately goes back to the floor; a cook grabs the next ticket whenever they finish the last dish. The rail absorbs the mismatch in their rhythms. An asyncio.Queue is that rail: producers put items on one end, consumers get them off the other, and neither has to know anything about the other's speed or even how many of each there are.

The subtle, powerful part is what happens when you give the rail a maximum size. A bounded queue is a self-regulating system: when consumers fall behind and the rail fills up, a producer's await q.put(...) simply pauses until a consumer frees a slot. The fast side is automatically forced to wait for the slow side — that's backpressure again, the same principle as the semaphore in §4, but now emerging naturally from the queue's size limit rather than an explicit counter. Set maxsize and you've capped how much unprocessed work can pile up in memory, which is exactly what stops a fast ingest from OOM-ing your process while a slow embedding step catches up.

The classic bug in this pattern is telling the consumers when to stop — or rather, forgetting to. A consumer loops on await q.get() forever; nothing in "the queue is empty" tells it the work is truly finished versus just momentarily drained. The standard fix is a sentinel: after the real items, the producer puts a special "you're done now" marker (often None) — and here's the catch that bites everyone — one sentinel per consumer, because each consumer only consumes one before breaking. Three consumers need three sentinels; send two and one worker waits at an empty rail forever, hanging your program with no error. Count your sentinels to match your consumers and the pipeline shuts down cleanly. Get this right and you can chain stages — ingest → embed → index — each its own producer/consumer pool, which is how production RAG ingestion actually flows.

producer → bounded queue → three consumers draining at their own pace producer put(item) Queue(maxsize=10) bounded buffer full → producer waits consumer 1 consumer 2 consumer 3 backpressure get() shutdown = 3 sentinels (one per consumer) — one short and a worker hangs forever The bounded queue is the rail between fast producer and slow consumers. When it fills, the producer's put blocks (backpressure); shutting down cleanly needs one sentinel per consumer — three consumers, three sentinels, or a worker waits at an empty rail forever.
🗺️ How to read this diagram

Read left to right: the producer feeds the queue in the middle, and the three consumers pull from it on the right.

  • The blue box on the left is the producer calling put(item) — the solid arrow into the amber queue. The dashed "backpressure" arrow back to the producer is what happens when the queue is full: the put pauses until a slot frees.
  • The amber "Queue(maxsize=10)" box is the bounded buffer that absorbs the mismatch in speed between the two sides.
  • The three green consumer boxes each call get() (the blue arrows) and drain the queue concurrently at their own pace. The line beneath is the shutdown catch: one sentinel per consumer, or one worker blocks forever.

In short: the bounded queue throttles a fast producer to consumer speed, and matching sentinels-to-consumers is what lets the pipeline stop cleanly.

Try it — bounded queue pipeline
pythonimport asyncio

async def producer(q, n):
    for i in range(n):
        await q.put(i)               # blocks if queue is full -> backpressure
    for _ in range(3):
        await q.put(None)            # one sentinel per consumer to stop it

async def consumer(q, out):
    while True:
        item = await q.get()
        if item is None:
            break
        await asyncio.sleep(0.05)      # process (e.g. embed) the item
        out.append(item * 2)

async def main():
    q = asyncio.Queue(maxsize=10)      # bounded = backpressure
    out = []
    async with asyncio.TaskGroup() as tg:
        tg.create_task(producer(q, 50))
        for _ in range(3):
            tg.create_task(consumer(q, out))   # 3 workers drain concurrently
    print(len(out))                  # 50

asyncio.run(main())
▶ How this works

For never-ending work (a feed of documents or tasks), a queue lets fast producers and slow consumers run independently. A bounded queue also gives free backpressure: if consumers fall behind, producers are forced to wait.

  1. async def producer(q, n): puts n items on the queue with await q.put(i). If the queue is full, put pauses until a consumer removes something — that's the backpressure.
  2. After the real items, it puts three None values — one sentinel ('stop now') for each of the three consumers, so every worker knows when to quit.
  3. async def consumer(q, out): loops forever with await q.get(), breaks when it sees the None sentinel, and otherwise 'processes' the item (the sleep stands in for real work like embedding).
  4. q = asyncio.Queue(maxsize=10) bounds the queue. The TaskGroup runs one producer and three consumers together; the block exits only when all are finished.

What the output means: Prints 50 — every one of the 50 produced items was drained and processed by the three consumers working in parallel.

Try this: Set maxsize=1 and watch throughput drop — the producer now stalls constantly. Then forget to send enough sentinels (range(2) instead of 3) and one consumer hangs forever waiting for a stop signal.

Exercises advanced

Practice
  1. Rewrite the A2 thread-pool fetch as async with gather; then add a semaphore capping it at 5.
  2. Add a per-call 3-second asyncio.timeout and collect which items timed out vs succeeded.
  3. Make bounded_map return results paired with their input, tolerating individual failures (return exceptions).
  4. Turn the mock token stream into one that also yields a final "usage" summary event at the end.
  5. Add a second stage to the queue pipeline (embed → index) so items flow through two consumer pools.

🎯 Interview practice interview

The interview questions this topic gets asked — worked, with code. For the full pattern catalog see A9 · Big Tech AI-engineering patterns.

Make 1000 API calls without hitting rate limits

Cap concurrency with a semaphore; gather results. The canonical async fan-out.

pythonimport asyncio
async def call(x): await asyncio.sleep(0.2); return x*2
async def bounded(items, limit=10):
    sem = asyncio.Semaphore(limit)
    async def w(x):
        async with sem: return await call(x)
    return await asyncio.gather(*[w(i) for i in items])
▶ How this works

The classic interview question: make 1000 API calls without hitting rate limits. The expected answer is this exact pattern — a semaphore to cap concurrency, then gather to collect results. It's §3 and §4 compressed into the shape interviewers look for.

  1. call(x) is the stand-in API call (waits, then returns x*2).
  2. sem = asyncio.Semaphore(limit) caps how many calls run at once; the inner w(x) wraps each call in async with sem: so it must claim a slot first.
  3. await asyncio.gather(*[w(i) for i in items]) submits every item but lets only limit proceed at a time — safe fan-out over a huge list.

Try this: Say the two knobs out loud in an interview: the semaphore bounds concurrency, and you'd pair it with retry-and-backoff on HTTP 429 to fully respect rate limits. Naming both is what earns the check-mark.

Consume a token stream with async for

Render tokens as they arrive; accumulate for the final text.

pythonimport asyncio

async def stream(prompt):
    for tok in ["The", " answer", " is", " 42."]:
        await asyncio.sleep(0.05); yield tok
async def main():
    parts = []
    async for t in stream("q"):
        print(t, end="", flush=True); parts.append(t)
    return "".join(parts)
▶ How this works

The streaming interview question: consume a token stream and render it live. This is §6 boiled down — an async generator plus an async for loop.

  1. async def stream(prompt): with yield tok is the async generator producing tokens with a tiny delay each.
  2. async for t in stream("q"): pulls each token the moment it's ready and prints it with flush=True so it shows immediately.
  3. parts.append(t) then "".join(parts) reassembles the full answer, so you get both the live typing effect and the complete final string.

Try this: The interview point to make: streaming improves perceived latency (first token appears fast) even though total time is the same — and the consuming code is just an async for loop.

Checkpoint advanced

  • Explain the event loop and why blocking calls are forbidden in coroutines.
  • Run many coroutines with gather/TaskGroup and know the difference.
  • Bound concurrency with a semaphore and add timeouts + cancellation.
  • Consume a token stream with async for and build a backpressured queue pipeline.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Await two coroutines concurrentlyBeginner

Context: asyncio.gather schedules coroutines to run concurrently on the event loop, so total wall time is the slowest task, not the sum — the foundation of every async fan-out.

Your task: Use asyncio.gather to run two asyncio.sleep-based coroutines concurrently and show total time is the max, not the sum.

Requirements:

  • Define two coroutines that each await asyncio.sleep for different durations
  • Run them concurrently with asyncio.gather
  • Measure wall time and show it's ~the longer sleep, not the sum
  • Collect both results

💡 Hint: Concurrency here means the two sleeps overlap on the loop, so elapsed time tracks the longest one.

Show solution

gather schedules coroutines to run concurrently on the event loop; total wall time is the slowest, not the sum.

import asyncio, time

async def task(name, secs):
    await asyncio.sleep(secs)
    return f"{name} done"

async def main():
    t = time.perf_counter()
    res = await asyncio.gather(task("a", 0.2), task("b", 0.3))
    print(res, "in %.2fs" % (time.perf_counter()-t))   # ~0.30s, not 0.50

asyncio.run(main())
Exercise 2 · Bound concurrency with a semaphoreIntermediate

Context: A semaphore caps how many coroutines are in the critical section at once — the standard backpressure knob for ‘don't hit the API with 10k requests.’

Your task: Run 10 tasks but allow at most 3 in flight using asyncio.Semaphore, and print the peak concurrency observed.

Requirements:

  • Launch 10 tasks concurrently
  • Guard the in-flight section with an asyncio.Semaphore(3)
  • Track and print the peak simultaneous count
  • Observed peak concurrency is 3, never more

💡 Hint: Acquire the semaphore around the work and record the max simultaneous holders — it should never exceed the limit.

Show solution

A semaphore caps how many coroutines are inside the critical section at once — the standard backpressure knob for ‘don't hit the API with 10k requests’.

import asyncio

async def worker(i, sem, state):
    async with sem:
        state["cur"] += 1
        state["peak"] = max(state["peak"], state["cur"])
        await asyncio.sleep(0.05)
        state["cur"] -= 1
        return i

async def main():
    sem = asyncio.Semaphore(3)
    state = {"cur": 0, "peak": 0}
    await asyncio.gather(*(worker(i, sem, state) for i in range(10)))
    print("peak concurrency:", state["peak"])   # 3

asyncio.run(main())
Exercise 3 · Timeout + cancellationAdvanced

Context: asyncio.wait_for cancels the awaited coroutine when the deadline passes, and the coroutine sees CancelledError at its next await point — the basis of timeouts and clean cancellation.

Your task: Wrap a slow coroutine in asyncio.wait_for with a timeout, handle the TimeoutError, and prove the coroutine was cancelled.

Requirements:

  • Wrap a slow coroutine in asyncio.wait_for with a short timeout
  • Catch the resulting asyncio.TimeoutError
  • The slow coroutine observes CancelledError at its await point
  • Prove cancellation actually happened (e.g. a flag set in the except branch)

💡 Hint: The cancelled coroutine can catch CancelledError to record that it was stopped — then it should re-raise, not swallow it.

Show solution

wait_for cancels the awaited coroutine when the deadline passes; the coroutine sees CancelledError at its next await point.

import asyncio

cancelled = {"hit": False}

async def slow():
    try:
        await asyncio.sleep(1.0)
        return "finished"
    except asyncio.CancelledError:
        cancelled["hit"] = True
        raise

async def main():
    try:
        await asyncio.wait_for(slow(), timeout=0.1)
    except asyncio.TimeoutError:
        print("timed out; cancelled =", cancelled["hit"])   # True

asyncio.run(main())
Exercise 4 · Stream tokens with an async generatorExpert

Context: An LLM token stream is an async generator; async for consumes each token as it arrives so you can render before generation finishes — the pattern for low time-to-first-token UX.

Your task: Model an LLM token stream: write an async generator yielding tokens, consume it with async for accumulating the text, and show tokens arrive incrementally.

Requirements:

  • Write an async generator that yields tokens one at a time (an await between yields simulates the network)
  • Consume it with async for
  • Show each token is received incrementally, not all at once
  • Accumulate the tokens into the final text

💡 Hint: Yielding with an await between tokens mimics network deltas — the consumer sees them stream in one at a time.

Show solution

Streaming is an async generator; async for consumes each token as it arrives so you can render before generation finishes — the pattern for low time-to-first-token UX.

import asyncio

async def stream_tokens(prompt):
    # real client yields deltas from the network; here we simulate
    for tok in ["The", " GIL", " serial", "izes", " bytecode", "."]:
        await asyncio.sleep(0.02)
        yield tok

async def main():
    out = []
    async for tok in stream_tokens("explain the GIL"):
        out.append(tok)
        print("recv:", repr(tok))     # arrives one at a time
    print("final:", "".join(out))

asyncio.run(main())
Exercise 5 · Async producer/consumer with a bounded queueProfessional

Context: A bounded asyncio.Queue makes put block when full, naturally throttling a fast producer to consumer speed — and a sentinel signals clean shutdown instead of hanging.

Your task: Build a producer/consumer pipeline with asyncio.Queue(maxsize=...) so a fast producer can't outrun slow consumers, using a sentinel to shut down cleanly.

Requirements:

  • Use a bounded queue so put blocks when full (backpressure)
  • Run a producer and multiple consumers concurrently
  • Use a sentinel value to signal completion so consumers exit rather than hang
  • Ensure every consumer can see the shutdown signal
  • Collect the expected results

💡 Hint: The bounded queue throttles the producer; re-enqueueing the sentinel lets sibling consumers each observe the shutdown.

Show solution

A bounded queue makes put block when full, so the producer is naturally throttled to consumer speed. Sentinels signal completion so consumers exit instead of hanging.

import asyncio

async def producer(q, n):
    for i in range(n):
        await q.put(i)          # blocks when queue is full -> backpressure
    await q.put(None)           # sentinel

async def consumer(q, out):
    while True:
        item = await q.get()
        if item is None:
            await q.put(None)   # let siblings see the sentinel too
            return
        await asyncio.sleep(0.01)
        out.append(item * item)

async def main():
    q = asyncio.Queue(maxsize=2)
    out = []
    await asyncio.gather(producer(q, 6), consumer(q, out), consumer(q, out))
    print(sorted(out))          # [0, 1, 4, 9, 16, 25]

asyncio.run(main())
Exercise 6 · Resilient concurrent LLM fan-outIndustry scenario

Context: At scale, a batch's job is to degrade gracefully: bound concurrency, time out stragglers, retry transients with backoff, and return partial results so one bad call doesn't sink the batch — the shape of every production LLM fan-out.

Your task: Design the function an agent uses to call an LLM over 1000 inputs with bounded concurrency, per-call timeout, retries on failure, and partial-result collection.

Requirements:

  • A semaphore bounds in-flight calls (protects the provider and your rate limit)
  • Each call has its own timeout
  • Failures are retried a few times with backoff, then recorded rather than raised
  • Results come back per input as (input, ok, value) so a few failures don't lose the successes
  • Label the actual network call (needs an API key to run for real)
  • State the lesson: degrade gracefully — bound, time out, retry, return partials

💡 Hint: Isolate each call so its failure is captured as a result, not an exception that aborts the whole gather.

Show solution

Design: a semaphore caps in-flight calls (protect the provider + your rate limit); each call has its own timeout; failures are retried a few times then recorded as an error rather than raising; results come back as (input, ok, value) so a few failures don't lose the 996 successes.

import asyncio, random

async def call_llm(text):
    # real: await client.messages.create(...)  -- needs an API key to run
    await asyncio.sleep(0.01)
    if random.random() < 0.1:
        raise RuntimeError("transient 503")
    return text.upper()

async def one(text, sem, retries=3, timeout=2.0):
    async with sem:                       # bound concurrency
        for attempt in range(retries):
            try:
                val = await asyncio.wait_for(call_llm(text), timeout)
                return (text, True, val)
            except (asyncio.TimeoutError, RuntimeError):
                await asyncio.sleep(0.02 * 2**attempt)   # backoff
        return (text, False, "failed after retries")

async def fan_out(inputs, concurrency=50):
    sem = asyncio.Semaphore(concurrency)
    return await asyncio.gather(*(one(t, sem) for t in inputs))

async def main():
    res = await fan_out([f"row{i}" for i in range(20)], concurrency=5)
    ok = sum(1 for _, good, _ in res if good)
    print(f"{ok}/{len(res)} succeeded; failures isolated")

asyncio.run(main())

Lesson: at scale the batch's job is to degrade gracefully — bound concurrency to stay under limits, time out stragglers, retry transients with backoff, and return partial results so the caller decides what to do with the few failures.

Knowledge check check yourself

✓ Knowledge check

The lesson calls bounding concurrency with a semaphore "the single most important production async pattern." What failure does it prevent that a plain asyncio.gather over 10,000 items would cause?

Show answer
gather launches everything at once, blowing past the provider's rate limits and exhausting memory; a semaphore caps how many coroutines run simultaneously (backpressure), so only N calls are ever in flight while the rest wait their turn.
✓ Knowledge check

The "cardinal rule" forbids calling blocking functions like time.sleep() or requests.get() inside a coroutine. Why is that so damaging in an event loop?

Show answer
A blocking call freezes the single event-loop thread, so every other in-flight task stalls with it; you must use async equivalents (asyncio.sleep, an async HTTP client) or offload the blocking/CPU work with asyncio.to_thread.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in