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.
Learning objectives
- Explain the event loop and how
awaityields control while waiting. - Run many coroutines concurrently with
gatherandTaskGroup. - 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
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.
1 · The event loop — the core idea intermediate
An event loop runs one task until it hits an await on something not-yet-ready (a network response). At that point the task yields, and the loop runs other ready tasks. When the awaited result arrives, the loop resumes the first task. One thread, no blocking — cooperative multitasking.
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
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.
import asynciobrings in Python's built-in async toolkit — the event loop,sleep,gather, and the rest.async def main():defines a coroutine function. The wordasyncis what makes it special: it is allowed to containawait, and calling it hands back a coroutine to run later rather than running now.await asyncio.sleep(1)is the key line. Instead of freezing the program for a second,awaityields control back to the event loop, which is free to run other tasks meanwhile. When the second is up, the loop resumes right here.asyncio.run(main())creates the event loop, runsmain()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.
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
An async def function returns a coroutine — it doesn't run until awaited or scheduled as a task. await means "pause here until this finishes, letting others run." Calling a coroutine without awaiting it does nothing (a classic bug).
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())
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.
async def complete(prompt):is a fake LLM call. Theawait asyncio.sleep(0.5)stands in for the half-second you'd really spend waiting on the network.result = await complete("hello")is the normal path:awaitruns the coroutine, pausesmainwhile it waits, and hands back the returned string.coro = complete("oops")only creates a coroutine object — it does not run. Without anawaitin 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
The payoff: launch many coroutines and await them together. asyncio.gather collects results in order; asyncio.TaskGroup (Python 3.11+) is the modern, safer form with structured concurrency — if one task fails, siblings are cancelled and errors propagate cleanly.
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())
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.
[complete(i) for i in range(100)]builds a list of 100 coroutines (still not running yet). The*ingather(*[...])spreads that list out as separate arguments.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.async with asyncio.TaskGroup() as tg:is the newer (Python 3.11+) way.tg.create_task(...)schedules each call; when theasync withblock exits, all tasks are guaranteed done. If one fails, the group cancels the rest and reports the error cleanly — safer thangatherfor production.[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.
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
Firing 10,000 requests at once will blow past API rate limits and exhaust memory. A semaphore caps how many run simultaneously — the essential "backpressure" control. This is the single most important production async pattern.
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))
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.
sem = asyncio.Semaphore(limit)creates a gate that allowslimitholders (default 10) inside simultaneously.async with sem:is how a worker acquires a slot. Iflimitworkers already hold the gate, this line pauses until one of them exits the block and frees a slot — automatically throttling everything.return await coro_fn(item)does the real work only while inside the gate, so no more thanlimitreal calls are ever in flight.return await asyncio.gather(*[worker(i) for i in items])still submits all the items — but because each goes through the semaphore, onlylimitrun 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.
5 · Timeouts & cancellation advanced
A hung request must not hang your whole system. asyncio.timeout (3.11+) or wait_for bounds how long you'll wait, cancelling the task if it overruns. Cancellation raises CancelledError inside the task so it can clean up.
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())
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.
async def slow():simulates a stuck call by sleeping ten seconds.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.except TimeoutError:catches that expiry so you can react — here it just prints a message. Without thetry/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
Streaming turns a long wait into a live feel: the model sends tokens as it generates them, and you render each as it arrives. An async generator (async def + yield) models the stream; you consume it with async for.
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())
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.
async def stream_completion(prompt):plusyield tokmakes an async generator — a coroutine that hands back values one at a time instead of returning once. Eachawait asyncio.sleep(0.1)mimics the delay between tokens on the network.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.print(token, end="", flush=True)renders each token right away.flush=Trueforces 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.
max_tokens request to avoid request timeouts. Same async for shape.7 · Async producer/consumer pipeline expert intermediate
For continuous work — ingesting a document stream, processing a task feed — an asyncio.Queue decouples producers from consumers, giving natural backpressure (a bounded queue makes fast producers wait for slow consumers).
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())
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.
async def producer(q, n):putsnitems on the queue withawait q.put(i). If the queue is full,putpauses until a consumer removes something — that's the backpressure.- After the real items, it puts three
Nonevalues — one sentinel ('stop now') for each of the three consumers, so every worker knows when to quit. async def consumer(q, out):loops forever withawait q.get(),breaks when it sees theNonesentinel, and otherwise 'processes' the item (thesleepstands in for real work like embedding).q = asyncio.Queue(maxsize=10)bounds the queue. TheTaskGroupruns 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
- Rewrite the A2 thread-pool fetch as async with
gather; then add a semaphore capping it at 5. - Add a per-call 3-second
asyncio.timeoutand collect which items timed out vs succeeded. - Make
bounded_mapreturn results paired with their input, tolerating individual failures (return exceptions). - Turn the mock token stream into one that also yields a final "usage" summary event at the end.
- 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.
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])
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.
call(x)is the stand-in API call (waits, then returnsx*2).sem = asyncio.Semaphore(limit)caps how manycalls run at once; the innerw(x)wraps each call inasync with sem:so it must claim a slot first.await asyncio.gather(*[w(i) for i in items])submits every item but lets onlylimitproceed 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.
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)
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.
async def stream(prompt):withyield tokis the async generator producing tokens with a tiny delay each.async for t in stream("q"):pulls each token the moment it's ready and prints it withflush=Trueso it shows immediately.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/TaskGroupand know the difference. - Bound concurrency with a semaphore and add timeouts + cancellation.
- Consume a token stream with
async forand build a backpressured queue pipeline.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
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.sleepfor 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())
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())
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_forwith a short timeout - Catch the resulting
asyncio.TimeoutError - The slow coroutine observes
CancelledErrorat 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())
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())
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
putblocks 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())
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
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
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?