Vahid AghajaniA hundred requests, all waiting on the database. Take away their hundred threads and one question is left: where does each half-finished function keep its place? That question is all async/await really answers. A thread is a stack, and a stack exists
๐บ Prefer to watch? 90-second YouTube Short ยท ๐ฌ Telegram
Originally published on software-engineer-blog.com.
Every async tutorial starts at the syntax. Start one level below it instead.
A hundred requests are in flight on your server. Every one of them is waiting on the database. Now take away their hundred threads, and a single question is left standing: where does each half-finished function keep its place?
That question is all async / await really answers. Everything else โ the keywords, the coloured-function arguments, the library churn โ is downstream of it.
Ask what a thread actually buys you and the answer is disappointingly concrete: a stack. That stack exists to hold two things โ your local variables, and the line you are on. That is it. When the CPU switches away from your thread and comes back, the stack is how it knows where "back" was.
Look at a perfectly ordinary handler:
def get_order(order_id):
started = time.time() # a local
row = db.query(order_id) # โ 200 ms of waiting
elapsed = time.time() - started # a local
return render(row, elapsed)
Time it honestly and it spends maybe two milliseconds computing and two hundred milliseconds waiting. For that entire 200 ms the thread is doing nothing at all. It is not slow; it is parked. And it is holding a full stack open the whole time, purely so that when db.query returns, something remembers that started was 1726... and that the next line to run is the one assigning elapsed.
That is the bill nobody itemises. On Linux a pthread reserves 8 MB of address space for its stack by default (ulimit -s), committed lazily page by page; runtimes that choose their own are usually in the ~1 MB neighbourhood. Round it to a megabyte and the arithmetic is brutal:
100 parked requests โ ~100 MB of memory held open to remember three variables and a line number.
You are not paying for work. You are paying for bookkeeping, at the price of a data structure built for something far more general.
await is a suspension point, not "go faster"
Here is the misreading that survives most tutorials: await does not make the database answer sooner. It does not add a thread. It does not parallelise anything.
await marks a suspension point โ the line your function is allowed to stop on.
async def get_order(order_id):
started = time.time()
row = await db.query(order_id) # โ stops HERE
elapsed = time.time() - started
return render(row, elapsed)
When control reaches that await, three things happen in order:
Step 3 is the one that pays. The thread is not parked on your function any more โ it is free, immediately, to go run somebody else's.
But step 1 leaves a debt. A function that stopped halfway still has a started value and a resume point, and those have to live somewhere.
This is the whole trick, and it is smaller than it sounds.
Instead of keeping a megabyte-sized stack alive to hold three values, the runtime copies the locals plus the resume point into a small object on the heap โ a coroutine frame. A few hundred bytes. In CPython you can hold one in your hand:
async def get_order(order_id): ...
coro = get_order(42) # nothing has run yet
print(type(coro)) # <class 'coroutine'>
print(coro.cr_frame.f_locals) # the locals live here, on the heap
Nothing executed when you called it. Calling an async function does not run it โ it builds the bookmark.
A stack is a room you rent to hold your place. A coroutine frame is a slip of paper with your place written on it. Both remember the same two facts. One costs a megabyte of reserved address space; one costs the size of the variables you actually have.
100 parked requests โ ~30 KB of heap frames, rather than ~100 MB of stacks. Same information. Three orders of magnitude apart.
Now the loop. One thread holds a ready queue of those frames and does something almost boringly simple, forever:
await.Step 4 is where the operating system earns its keep. The loop is not spinning and checking each socket in turn โ it blocks in one epoll_wait-style call that says wake me when any of these three hundred sockets is ready, and the kernel hands back exactly the ones that are.
async def main():
# a hundred bookmarks, one thread
await asyncio.gather(*(get_order(i) for i in range(100)))
gather does not start a hundred threads. It creates a hundred frames, and every one of them runs on the same thread, up to its first await, in turn.
await
When your row finally arrives, the frame rejoins the ready queue, the loop reopens it, and execution continues on the line after the await โ with started exactly as you left it.
It did not restart. It did not re-run time.time(). It was parked, with no thread held open to wait for it.
That is the entire mechanism. A suspension point, a bookmark on the heap, and a loop that reopens bookmarks in the order they become ready.
| ย | Thread per request | async / await |
|---|---|---|
| What holds your place | A stack | A frame on the heap |
| Cost per waiting request | ~1 MB (8 MB reserved on Linux) | Hundreds of bytes |
| Who decides to switch | The OS scheduler, at any instruction | Your code, only at an await
|
| Cost of a switch | Kernel context switch | A function return, in user space |
| Realistic concurrent waiters | Thousands, then memory bites | Tens of thousands on one thread |
| Helps CPU-bound work | Yes, across cores | No โ one thread, one core |
| Fails when | You need many more waiters than RAM allows | One task refuses to yield |
Everything above is the part tutorials cover. These three are the part they skip โ and they are exactly what a good interviewer probes.
1. You bought concurrency on waiting, never on computing. There is still one thread. If two tasks both need to hash a password, they do it one after the other. async moved the waiting out of the way; it did not add a core. CPU-bound work needs processes, threads, or a native extension that releases the GIL โ not await.
2. One blocking call freezes everyone. This is the failure mode that actually reaches production. A single synchronous call inside an async function โ requests.get, time.sleep, a driver that is secretly synchronous, a 40 ms JSON parse โ does not just stall that task. It holds the only thread, so every other task on the loop stops too, including ones whose data arrived ten milliseconds ago.
async def handler():
row = await db.query(1) # fine: yields
blob = requests.get(url) # ๐ด blocks the ENTIRE loop
return blob
The fix is to push it off the loop rather than pretend it is async:
blob = await asyncio.to_thread(requests.get, url)
3. Nothing is pre-empted. The OS can interrupt a thread anywhere. The event loop cannot. Scheduling here is cooperative: a task keeps the thread until it chooses to yield, and it only ever chooses at an await. A task that never awaits never lets anyone else run โ a while True: with no suspension point inside it is not a busy task, it is a stopped server.
The mental test for any async code you write is one question: between here and the next await, how long is this thread mine?
This is not a backend-trivia pattern. It is the reason a single modest Python process can sit in front of a GPU cluster and hold thousands of open chat connections โ and it is also the reason people misdiagnose their inference latency.
An LLM request is almost entirely waiting. A completion that streams for eight seconds involves a few milliseconds of your code and eight seconds of the model producing tokens somewhere else. Thread-per-request would mean one megabyte-class stack per open conversation, parked, doing nothing. The gateway in front of a model is the purest possible async workload: thousands of bookmarks, one loop, near-zero CPU.
Streaming makes the suspension point visible. When you iterate a token stream, every async for is an await โ the loop hands the thread away between tokens, so one process can fan hundreds of concurrent streams out to hundreds of clients:
async def stream(prompt):
async for token in client.completions(prompt, stream=True):
yield token # suspends between tokens; loop stays free
But catch #1 sets a hard ceiling on what async can fix. Async gives you concurrency on waiting, and the two numbers everyone tunes โ TTFT (time to first token) and TPOT (time per output token) โ are not waiting. They are GPU work. No amount of await shortens them. What actually raises throughput is continuous batching: the server merges newly arrived requests into the running batch at every decoding iteration, rather than making them queue behind a finished batch. Note the symmetry โ that scheduler is doing the same thing the event loop does, at the level of a token instead of a syscall: keep a queue of half-finished work, advance each one step, never hold an expensive resource open for something that is merely waiting.
And catch #2 is where real inference gateways bleed. Tokenising a 100 KB prompt, a synchronous embedding call, a hefty json.loads of a tool-call payload โ each is pure CPU on the loop thread, and each stalls every in-flight stream, not just its own. The symptom is maddening: the model is fast, the GPU is idle, and p99 latency is terrible. The cause is a blocking call inside an async handler.
You never needed a hundred threads. You needed a hundred bookmarks and one thread to turn the pages.
await is where the runtime takes that memory and writes it on a slip of paper instead.Reach for it when your service is I/O-bound: an API gateway, a scraper, a websocket fan-out, a model proxy. Reach for processes when the work is genuinely CPU-bound. And whichever you choose, the diagnostic question stays the same: who is holding this thread, and when do they give it back?
On what a thread actually costs: the stack
RLIMIT_STACK soft limit (commonly 8 MB on Linux) and that the stack is allocated per thread: man7.org โ pthread_create(3)
On await as a suspension point, and where the state lives
await suspends execution of the coroutine and yields control, and calling a coroutine function returns a coroutine object rather than running it: peps.python.org โ PEP 492
On the loop and non-blocking I/O
select, poll and epoll, the syscall the loop actually blocks in while every task is suspended.On the three catches: blocking, cooperation, and what concurrency is not
to_thread / run_in_executor as the escape hatch: docs.python.org โ developing with asyncio
On the LLM-serving section: why async cannot shorten TTFT
If a reference you would expect is missing, say so in the comments and I will add it.
Watch the reel: async/await: how ONE thread serves a hundred waiting tasks