Python asyncio event loop
Anatomy of the asyncio loop: coroutines, tasks, futures, the selector, transports and protocols, structured concurrency with TaskGroup, and the trap of mixing sync and async.
Origin and design
asyncio shipped in Python 3.4 (2014). It was designed by Guido van Rossum and described in PEP 3156. The goal: a standard, portable async I/O framework for Python, replacing the dozen incompatible third-party event loops (Twisted, Tornado, gevent).
The model is cooperative single-threaded: one OS thread runs the event loop, which schedules many coroutines. Coroutines yield to the loop at every await, letting other coroutines run. The loop talks to the OS via a selector (epoll, kqueue, etc.) to wait for I/O events.
async/await syntax came in Python 3.5 (2015) via PEP 492. Before that, coroutines were written with generators using @asyncio.coroutine and yield from. The new syntax is sugar but is the way everyone writes asyncio today.
Coroutines, tasks, futures
Three closely related concepts:
A coroutine is what async def foo() returns when you call it. It is not running. It is an object that holds the function's state and can be resumed step by step. Coroutines are lazy: foo() does nothing until you await it or schedule it as a task.
A Task is a coroutine that the loop is actively running. asyncio.create_task(coro) wraps a coroutine in a Task and schedules it. Tasks are themselves awaitable - you can await task to wait for it to finish and get its result.
A Future is a low-level placeholder for a value that will be set later. Tasks are a subclass of Future. You rarely create raw Futures in app code; libraries like loop.run_in_executor return them.
import asyncio
async def slow():
await asyncio.sleep(1)
return 42
async def main():
coro = slow() # Coroutine object, not yet running
task = asyncio.create_task(coro) # Now running, scheduled on loop
result = await task # Wait for it, get 42
print(result)
asyncio.run(main())The distinction matters: a coroutine that is never awaited produces a RuntimeWarning at GC time ("coroutine was never awaited"). It's a memory leak and a logic bug.
The event loop
The loop is the scheduler. It owns:
- A heap of timers (sorted by expiry).
- A queue of ready callbacks (microtask-equivalent).
- A selector watching file descriptors.
- A set of running Tasks.
The main loop algorithm:
- Run all ready callbacks until the queue is empty.
- Find the next timer expiry time.
- Poll the selector for I/O events, blocking until either I/O is ready or the next timer expires.
- For each ready I/O event, queue its callback. For each expired timer, queue its callback.
- Go to 1.
Callbacks are functions registered with loop.call_soon, loop.call_later, loop.add_reader, etc. Coroutines run inside Task wrappers that use callbacks to drive their progress.
When a coroutine awaits something not yet ready, the Task suspends and registers a callback: "when this future resolves, resume the coroutine." The callback is added to the future's done callbacks. When the future is set (by I/O completion, timer, or another task), the callback runs and re-queues the coroutine.
Transports and protocols
For network code, asyncio has a transport/protocol model.
A transport abstracts the underlying connection: TCP socket, UDP socket, pipe, subprocess pipe. It exposes methods like write and close and handles the low-level I/O via the selector.
A protocol is your code. It receives callbacks from the transport: connection_made, data_received, connection_lost. You implement these to handle the protocol logic.
class EchoServer(asyncio.Protocol):
def connection_made(self, transport):
self.transport = transport
def data_received(self, data):
self.transport.write(data)
loop = asyncio.get_event_loop()
server = loop.run_until_complete(loop.create_server(EchoServer, '0.0.0.0', 8888))
loop.run_forever()Above the transport/protocol layer is the streams API: asyncio.open_connection, StreamReader, StreamWriter. Streams are easier to use and what most application code touches.
Selectors and platform differences
asyncio uses Python's selectors module under the hood:
EpollSelectoron Linux (usesepoll).KqueueSelectoron macOS and BSD (useskqueue).IocpProactoron Windows (uses I/O Completion Ports via a different event loop).SelectSelectoras fallback (uses POSIXselect, scales poorly past ~1000 fds).
On Windows, asyncio supports two event loops: SelectorEventLoop (limited) and ProactorEventLoop (full, IOCP-based, the default since 3.8). Subprocess support is only on the ProactorEventLoop on Windows.
For higher performance, the uvloop package replaces the default event loop with a libuv-based one (the same libuv that powers Node.js). uvloop is 2-4x faster on most workloads and is a drop-in replacement:
import uvloop
uvloop.install() # Now asyncio uses libuvFile I/O: the asynchronous escape hatch
Most operating systems don't support truly async file I/O for regular files (Linux has io_uring now, but it's not the default). asyncio handles this by running file operations on a thread pool via loop.run_in_executor.
async def read_file_async(path):
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, open(path).read)The aiofiles package wraps this pattern. Be aware: the thread pool is bounded (default 32 threads in 3.8+), so many concurrent file ops will queue.
The same escape hatch lets you run any sync code without blocking the loop:
import asyncio
def cpu_heavy():
return sum(i*i for i in range(10_000_000))
async def main():
result = await asyncio.to_thread(cpu_heavy)
print(result)asyncio.to_thread (3.9+) is the modern, simple version of run_in_executor. The work runs on a thread (paying GIL costs) but doesn't block the event loop.
Structured concurrency with TaskGroup
asyncio.TaskGroup (3.11+) is the modern way to spawn concurrent tasks. It guarantees that all child tasks are awaited or cancelled when the block exits, and exceptions from children are propagated (collected into an ExceptionGroup).
async def main():
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch("a"))
tg.create_task(fetch("b"))
tg.create_task(fetch("c"))
# All three tasks have finished here, or the block raisedCompare to the older asyncio.gather:
results = await asyncio.gather(fetch("a"), fetch("b"), fetch("c"))gather has surprising error-handling semantics (one failure doesn't cancel siblings by default). TaskGroup fixes this. Use TaskGroup in new code.
This is "structured concurrency" - the term comes from Nathaniel J. Smith's work on Trio. The principle: when a block of code starts concurrent tasks, the block doesn't return until all tasks are done. No tasks leak out of their parent scope. It maps async control flow onto the call stack the same way function calls do.
The sync/async coloring problem
A function in Python is either sync or async. You cannot call an async function from sync code (the call returns a coroutine, doesn't run it). You cannot await in sync code. This is the "what color is your function" problem from Bob Nystrom's blog post.
If you have a sync function that needs to call an async one, your options are:
- Make the sync function async too (color it).
- Run a new event loop with
asyncio.run(coro)(slow, can't be nested). - Use
asyncio.run_coroutine_threadsafe(coro, loop)from another thread (requires a loop in another thread). - Use
anyio.from_thread.runfor cross-thread bridging.
The practical advice: pick one color and stay there. Mixing causes pain.
Common pitfalls
Blocking calls in async functions. time.sleep(1), requests.get(url), open(f).read() all block the loop. Use asyncio.sleep, aiohttp/httpx.AsyncClient, aiofiles.
Forgetting to await. foo() where foo is async returns a coroutine. The "coroutine was never awaited" RuntimeWarning tells you. Always await or schedule with create_task.
Sharing futures across loops. A Future is bound to the loop it was created in. Don't pass them between threads or loops without run_coroutine_threadsafe.
Forgetting to cancel tasks. A task that's running when the program exits gets cancelled, but you should explicitly cancel and await tasks you no longer need. Use TaskGroup to make this automatic.
Synchronous code in callbacks. loop.call_soon(callback) runs sync. If the callback does heavy work, it blocks the loop. Schedule a task instead.
CPU work in asyncio
asyncio gives you concurrency for I/O. It does not give you parallelism for CPU. If you have a CPU-bound function, awaiting it does nothing useful. The whole point of await is to yield control while waiting; if there's nothing to wait for, you're just calling it synchronously.
For CPU work in an async program:
asyncio.to_thread(cpu_func)runs it on a thread. Subject to GIL, but doesn't block the loop.loop.run_in_executor(ProcessPoolExecutor(), cpu_func, args)runs it in a separate process. True parallelism.- Move the CPU code to a C/Cython/Rust extension that releases the GIL, then call it normally.
Why asyncio looks the way it does
asyncio's design borrows from Node.js (single-threaded event loop, callbacks turned into promises turned into async/await), Twisted (deferreds, protocols, transports), and Tornado (futures). It is a synthesis of patterns that worked in other languages and frameworks.
The transport/protocol split is from Twisted. The future-based coroutine model is from Tornado. The async/await syntax follows C#'s. The loop's selector-based core is the standard event-driven server pattern from the 1990s nginx and squid lineage.
What asyncio added: an official, in-stdlib version of these patterns, with a coroutine model that integrates with Python's existing generator infrastructure. Before asyncio, every async framework reinvented these wheels incompatibly. Now there's a common substrate.
Mental model
asyncio is a state machine driven by an event loop. Each await is a state transition: save where we are, yield to the loop, resume when the awaited thing is ready. The loop is a dispatcher: it picks the next ready state machine, advances it one step (until the next yield), repeats.
Concurrency comes from interleaving many state machines on one thread. The OS selector tells the loop which external events are ready. Timers fire on schedule. Tasks resume when their awaited futures complete.
No parallelism. No magic. Just cooperative scheduling at well-defined yield points.
Learn more
- DocsPython docs: asynciopython.org
- Talk
- Article
- Talk