Async Python for AI
Concurrency without threads for I/O-bound work
LLM calls, vector lookups and web fetches are all I/O-bound. Async Python runs thousands of them concurrently on one thread — the difference between minutes and seconds in a batch embedding pipeline.
What you will learn
- Explain how async I/O achieves concurrency on one thread
- Apply asyncio.gather and semaphores to batch model calls
- Add timeouts and retries that respect concurrency limits
What is this?
Async Python (`asyncio`) runs I/O-bound code concurrently on a single thread using an event loop. When a coroutine awaits an I/O operation, the loop schedules other coroutines until the I/O completes. For AI work, where latency hides behind network calls to model and vector APIs, async lets you fire many requests and collect results quickly — with careful concurrency limits.
Why do you need it?
- Embedding 100k documents synchronously can take hours of wall time; async batches cut it to minutes.
- A single awaiting thread scales far better than hundreds of OS threads for network-bound work.
- Timeouts and retries compose naturally with `asyncio.timeout` and gather's error handling.
- Most AI SDKs expose async clients (httpx-based), so the tooling is already async-first.
Mental model
A chef juggling many orders
An asyncio event loop
The chef doesn't cook each order start-to-finish; they kick a pot off, move to the next prep while it boils, and return when it bubbles. The event loop does the same with I/O: progress on many requests interleaved, not parallelism on one.
How it works
- A coroutine (async def) awaits I/O; the event loop parks it and runs other coroutines during the wait.
- `asyncio.gather` runs several coroutines concurrently and collects their results or first exceptions.
- A semaphore caps how many in-flight requests exist, protecting provider rate limits.
- `asyncio.timeout` (py3.11+ / anyio) bounds how long a group may wait, preventing hangs.
- Results return in order with gather, or as they complete with asyncio.as_completed.
Interleaved awaits on one thread.
Examples
Bounded-concurrency embedding fan-out
import asyncio, random async def embed(chunk_id: str) -> str: await asyncio.sleep(0.01 * random.randint(1, 5)) # pretend model call return f"vec({chunk_id})" async def main(ids): sem = asyncio.Semaphore(5) # max 5 in flight async def one(i): async with sem: return await embed(i) return await asyncio.gather(*[one(i) for i in ids], return_exceptions=True) asyncio.run(main([f"c{i}" for i in range(20)]))Twenty embeds run with at most five in flight. `asyncio.gather` collects all results or surfaces exceptions instead of killing the batch.
In production
- A backfill job embeds 50k documents with a semaphore of 20 concurrent calls, respecting the provider's rate limit and finishing in minutes instead of hours.
- An agent loop awaits two independent tools (web search + vector search) concurrently, cutting perceived latency in half.
- A gateway proxies many model providers with per-provider semaphores so one slow upstream doesn't exhaust the pool.
Best practices
- Always bound concurrency with a semaphore or a pool; unbounded gather hammers rate limits.
- Wrap every external call in a timeout — a missing timeout is the classic async hang.
- Use `return_exceptions=True` in gather and handle failures per-item.
- Keep I/O in the async layer and move heavy CPU work (decoding, parsing) to workers or off the loop.
Common mistakes
- Calling blocking (`requests`, sync DB) inside async and stalling the whole loop.
- No semaphore — firing 10,000 simultaneous embedding requests and getting 429s.
- Passing one coroutine per gather but forgetting the list unpack, so they never start.
- Ignoring exceptions in gather and watching a batch half-complete silently.
Common issues
Rate limit 429s during a gather batchBlasting the provider from one process with no concurrency cap
Add a semaphore sized to the provider's documented limit and retry with backoff.
Whole event loop appears frozenA synchronous library blocks the loop, serializing all coroutines
Route blocking work to a thread (`asyncio.to_thread`) or swap to the async client.
One failed embed makes the entire batch failgather propagated the first exception and cancelled the rest
Use return_exceptions=True, then filter and retry the failures.
Debugging tips
- A single slow coroutine making the whole batch slow usually means a blocking call in the loop or a shared semaphore held too long.
- Timeouts that fire spuriously often come from a sync call hidden behind an async wrapper.
- Use `asyncio.CancelledError` handling to clean up partial batches on cancellation.
- Log in-flight counts (semaphore value) to prove concurrency is actually bounded.
Interview checkpoint
Hands-on lab
- Fan out many simulated model calls concurrently with gather
- Bounds in-flight requests with a semaphore
- Add a timeout and handle partial failures gracefully
Parallel Embedding-Batch Emulator
25 min · intermediate
Quick quiz
What happens when an async coroutine awaits I/O?
A semaphore is used to bound how many requests may be in flight.