Asynchronous Programming in Python: How the Event Loop, Event Queue, and Thread Pool Fit Together
Asynchronous code has a reputation for being confusing, but the core idea is simple: stop waiting. This post starts from zero — no prior async experience assumed — and builds up to the parts people usually find murky: how the event queue, the event loop, and the thread pool connect, and what coroutines, await, futures, tasks, gather, queues, cancellation, and starvation actually mean. Every concept comes with a small, runnable example.
What “Asynchronous” Actually Means
Asynchronous means “not waiting.” Instead of stopping to wait for a slow job to finish, your program starts it, moves on to other work, and comes back when the result is ready.
Picture two baristas. A synchronous barista takes your order, makes it start to finish, and only then serves the next person — everyone behind you waits. An asynchronous barista takes your order, starts the machine, and while the coffee brews takes the next order. Nobody is blocked just because a machine is busy.
This matters wherever waiting dominates the work:
- Network requests — APIs, web pages, and databases use milliseconds of CPU but make you wait a long time for a reply.
- Disk and file I/O — reading and writing files is slow compared to the CPU.
- Timers and delays — polling, retries, and scheduled work that just sits and waits.
- Many connections at once — thousands of clients that are each mostly idle.
Blocking vs. Non-Blocking
In synchronous code, each slow call stops the whole program until it finishes, so total time is the sum of every wait. Three tasks that each wait three seconds take nine seconds.
In asynchronous code, slow calls run concurrently. While one waits, another makes progress, so total time is roughly the longest single wait rather than the sum. Those same three tasks finish in about three seconds.
The key point that trips people up: async does not make any single task faster. It stops your program from sitting idle while it waits, so many waits overlap instead of stacking up. That overlap is called concurrency.
Concurrency Is Not Parallelism
These two words get used interchangeably, but they are different things.
Concurrency is dealing with many things at once by switching between them quickly. Think of one cook juggling several dishes — stirring one while another simmers. One worker, interleaved progress. This is exactly what Python’s asyncio gives you: a single thread that switches between tasks at await points.
Parallelism is doing many things at literally the same instant, on multiple CPU cores. Think of several cooks, each on their own dish, all working simultaneously. That is multiprocessing or threads spread across cores — a different tool.
Rule of thumb: if your work is I/O-bound (waiting on the network or disk), reach for asyncio. If it is CPU-bound (heavy computation), reach for multiprocessing.
The Architecture: Event Queue, Event Loop, Thread Pool
Almost everything in async Python is three moving parts working together.
The event queue is a list of callbacks and tasks that are ready to make progress, waiting their turn. The event loop is the engine that repeatedly pulls the next ready item off that queue and runs it. The thread pool is where genuinely blocking work gets offloaded so it does not freeze the loop.
The crucial fact is that the loop is single-threaded. It runs one item at a time, executing each until that item hits an await. When something must block — a legacy database driver, a chunk of CPU work — it is handed to the thread pool, which runs it on a real operating-system thread and returns the result to the loop as a future. That keeps the single loop thread free to do other work in the meantime.
The flow, in one line: your code produces tasks → the loop dequeues and runs them → blocking work is offloaded to the pool → results come back into the loop as futures.
The Event Loop
The event loop is one thread running an endless cycle. Each turn of the loop — each “tick” — does roughly this:
- Check what is ready: which tasks and timers are no longer waiting.
- Run those ready tasks, executing each until it awaits or finishes.
- Hand off any blocking work to the thread pool.
- Poll the OS to ask which sockets and files are now ready.
- Loop again, forever, until nothing is left to do.
You rarely touch the loop directly. asyncio.run() creates it, runs your coroutine, and closes it when you are done.
import asyncio
async def main():
print("hello")
await asyncio.sleep(1)
print("world")
# Start the loop, run main(), then clean up:
asyncio.run(main())
From inside a coroutine, asyncio.get_running_loop() hands you the loop object if you need it.
The Event Queue
The loop does not guess what to run — it reads from a queue of things that are ready. Two different ideas are both casually called “queue,” and it helps to keep them apart.
The internal ready-queue is built into the loop. When a task’s await finally resolves, the task is placed back on this queue as “ready to resume,” and the loop pulls items off in order. You never manage it by hand, though you can schedule work onto it:
loop.call_soon(fn) # run fn on the next tick
loop.call_later(2, fn) # run fn after 2 seconds
asyncio.Queue is a queue you use to pass work items between coroutines — a producer puts items in, a consumer takes them out. Its put() and get() methods are awaitable, so they suspend politely when the queue is full or empty instead of blocking. There is a full producer/consumer example further down.
The Thread Pool
Because the loop is single-threaded, one blocking call freezes everything. The thread pool — an executor — is the escape hatch. It runs a blocking call on a separate OS thread and returns the result to the loop as a future, keeping the loop responsive.
The rules are short:
- Never call blocking code directly inside a coroutine.
time.sleep(), heavy CPU loops, and old synchronous libraries all block the loop. - Push that work to a thread with
asyncio.to_thread()(orloop.run_in_executor()), andawaitthe result. - The loop stays free, so other tasks keep running while the thread does the slow work.
import asyncio, time
def blocking_io():
# a slow, synchronous call
time.sleep(3)
return "done"
async def main():
# offload to a thread, stay async
result = await asyncio.to_thread(blocking_io)
print(result)
asyncio.run(main())
Coroutines
A coroutine is a function defined with async def. Unlike a normal function, it can pause itself partway through, hand control back to the event loop, and resume later exactly where it left off.
Two things surprise newcomers. First, calling a coroutine does not run it — it just creates a coroutine object. Nothing happens until the loop runs it, whether through asyncio.run, an await, or a task. Second, a coroutine is a recipe, not the meal: it describes work, and the event loop is what actually cooks it.
async def make_coffee():
print("grinding...")
await asyncio.sleep(2)
return "coffee ready"
c = make_coffee()
# c is a COROUTINE OBJECT, not the string. Nothing has run yet.
print(type(c)) # <class 'coroutine'>
# Now actually run it:
result = asyncio.run(c)
print(result) # coffee ready
await
await is the keyword everything hinges on. It says: “This result isn’t ready yet. Suspend me, give control back to the event loop so other tasks can run, and wake me up when the result arrives.”
Three things to hold onto:
awaitis a suspension point. It is the only place a coroutine can pause and let others run.- You can only await awaitables — coroutines, tasks, and futures.
awaitlives insideasync def. Using it in a normal function is a syntax error.
Awaiting things one after another is still sequential — each await finishes before the next starts:
async def fetch(name, secs):
print(f"{name} start")
await asyncio.sleep(secs) # yield control here
print(f"{name} done")
async def main():
await fetch("A", 2)
await fetch("B", 2)
# A finishes, THEN B starts — 4 seconds total
asyncio.run(main())
To overlap them, you need tasks and gather, covered below.
One await, step by step
It is worth tracing exactly how control moves during a single await:
asyncio.run(main())starts the loop and schedulesmain()on the queue.main()runs until it reaches the firstawait.await asyncio.sleep(1)suspendsmain()and returns control to the loop.- The loop is now free and runs whatever else is ready during that one second.
- After the second passes, the timer fires and
main()is put back on the ready-queue. main()resumes right after theawaitand runs to completion.
The whole point: while your coroutine is paused at an await, the single thread is not idle — it is running everything else that is ready.
Futures
A future is a low-level placeholder for a value that will exist later. Think of it as a claim ticket: you hold it now, and it gets “filled” with a result — or an error — when the work completes.
A future starts pending and empty, then is later resolved. Awaiting it pauses until it is filled, then hands you the value. You rarely create futures by hand; libraries and the loop make them for you (run_in_executor, for instance, returns one). And a task is just a future that wraps a running coroutine — which is why the two feel so similar.
import asyncio
async def main():
loop = asyncio.get_running_loop()
fut = loop.create_future() # an empty future
# fill it after 1 second
loop.call_later(1, fut.set_result, "filled!")
value = await fut # wait until it's filled
print(value) # filled!
asyncio.run(main())
Tasks
create_task() schedules a coroutine to run on the loop right away, in the background. You get a task handle back immediately and keep going — this is how you make work overlap.
The distinction is worth stating plainly:
await coroutineruns it now and waits right here.asyncio.create_task(coroutine)starts it in the background and does not wait.
Two tasks that each sleep two seconds, started together, finish in about two seconds — not four — because both wait at the same time.
async def fetch(name):
await asyncio.sleep(2)
return f"{name} done"
async def main():
# both START immediately
a = asyncio.create_task(fetch("A"))
b = asyncio.create_task(fetch("B"))
# now wait for both — about 2 seconds, not 4
print(await a, await b)
asyncio.run(main())
gather
asyncio.gather(*coros) launches many coroutines at once and waits for every one to finish, returning all results in the original input order — not finish order.
It is the cleanest way to fan out N jobs and collect them with a single await. Pass return_exceptions=True to collect errors as values instead of stopping at the first failure. On Python 3.11+, asyncio.TaskGroup is a more structured alternative with safer cleanup.
async def fetch(url, secs):
await asyncio.sleep(secs)
return f"{url}: ok"
async def main():
results = await asyncio.gather(
fetch("a", 3),
fetch("b", 1),
fetch("c", 2),
)
# about 3 seconds total, order preserved
print(results)
asyncio.run(main())
asyncio.Queue: Producer and Consumer
A queue lets one coroutine hand work to another safely. Because put() and get() are awaitable, they suspend rather than block when the queue is full or empty. That makes the producer/consumer pattern natural in async code.
q = asyncio.Queue()
async def producer():
for i in range(5):
await q.put(i)
await q.put(None) # sentinel: signal "stop"
async def consumer():
while True:
item = await q.get()
if item is None:
break
print("got", item)
async def main():
await asyncio.gather(producer(), consumer())
asyncio.run(main())
For more elaborate pipelines, await q.join() waits until every item that was put in has been marked done with q.task_done() — a clean way to know all the work has been processed.
Cancellation and Timeouts
task.cancel() asks a running task to stop. At its next await, a CancelledError is raised inside the task, which you can catch to clean up before it exits.
A few rules keep cancellation well-behaved. It is cooperative — it takes effect at await points, not mid-line. Catch CancelledError to release resources like files and sockets, but then re-raise it; swallowing it silently breaks the cancellation machinery. Timeouts are built on the same mechanism: asyncio.timeout() and asyncio.wait_for() cancel a task that runs too long.
async def worker():
try:
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
print("cleaning up")
raise # re-raise after cleanup
async def main():
t = asyncio.create_task(worker())
await asyncio.sleep(3)
t.cancel()
asyncio.run(main())
Starvation: Don’t Block the Loop
This is the single most common way async code goes wrong. Because the loop is one thread, any code that runs a long time without awaiting hogs it. Every other task is frozen — “starved” — until that code yields.
# WRONG — starves the loop
async def bad():
time.sleep(5) # blocking sleep, no await
# a big CPU loop with no await inside would do the same:
# nothing else can run until it finishes
# RIGHT — keeps the loop free
async def good():
await asyncio.sleep(5) # async sleep yields control
await asyncio.to_thread(heavy_cpu) # push CPU work to a thread
The fix is always the same: use awaitable equivalents (asyncio.sleep instead of time.sleep), and offload heavy or blocking work to a thread so the loop can keep serving everything else.
The Modules You’ll Actually Use
asyncio(standard library) — the core: event loop, tasks,gather, queues,sleep, timeouts. Everything starts here.aiohttp— async HTTP client and server; the async replacement forrequests.httpx— a modern HTTP client with both sync and async APIs.aiofiles— non-blocking file I/O so disk reads don’t stall the loop.asyncpg/aiomysql— async database drivers for PostgreSQL and MySQL.uvloop— a faster drop-in event loop built on libuv.
The Mental Model in One Paragraph
Async means not waiting: start slow work, do other things, and come back when it is ready. A coroutine (async def) is a function that can pause and resume at an await, which is the point where it yields control to the event loop. The event loop is one thread that keeps picking the next ready task off the event queue and running it; the thread pool is where blocking work gets offloaded so the loop stays free. A future is a pending result, a task is a future wrapping a coroutine, gather fans out many jobs at once, and asyncio.Queue passes work safely between coroutines. One thread, never idle — while one task waits at an await, the loop runs everything else that is ready. That is asynchronous programming.
A Practical Note
asyncio.run() must be the top-level entry point, and it will not work inside an environment that already has a running loop — a Jupyter notebook, for example. In that case, await your coroutine directly in the cell instead of wrapping it in asyncio.run().