How asyncio Really Works Under the Hood
When a program needs to serve thousands of network connections while keeping latency low, spinning up a thread per connection quickly becomes unsustainable....
Listen to Article
PlayingClick play to listen to audio narration
Table of Contents
Introduction
When a program needs to serve thousands of network connections while keeping latency low, spinning up a thread per connection quickly becomes unsustainable. The answer lies in a single thread that can switch between many tasks without blocking the whole process. This article peels back the layers of CPython’s asyncio to reveal how a modest event loop, OS‑level I/O notifications, and a handful of data structures create the illusion of true concurrency.
Why This Matters
Understanding the internals of asyncio lets you diagnose why a handler stalls, why a future never completes, or why CPU usage spikes under load. It also guides design decisions: when to offload work to a thread pool, how to size the ready queue, and how to pick the right poller for the platform. Engineers who know the mechanics can write code that scales predictably and avoids subtle deadlocks.
How It Works
The heart of asyncio is a tight loop that repeatedly asks the operating system “any file descriptors ready for read or write?” and then advances the tasks that are waiting for those events. The flow can be visualized as a pipeline of states:
flowchart TD
A[Client Connection] --> B[Socket → OS Poller]
B --> C{Ready?}
C -->|Yes| D[Event Loop Pulls Task]
C -->|No| E[Task Enters Wait Queue]
D --> F[Task Resumes via _step()]
F --> G[Callback / await resolves]
G --> H[Update Ready Queue]
H --> A
- Selector registration – When a coroutine opens a socket, the loop registers the underlying file descriptor with a selector (
selectors.DefaultSelector). The selector abstractsepoll,kqueue, orselect. - Waiting queue – If the socket is not yet readable/writable, the task is placed in the selector’s internal wait list. No CPU cycles are consumed beyond the periodic
select/epoll_waitsyscall. - Linger phase – The loop first runs any callbacks scheduled via
call_soonorcall_later. This ensures that timers and immediate tasks get a chance before I/O. - Poll – The selector’s
selectmethod blocks until at least one registered descriptor becomes ready, or a timeout expires. - Ready queue – When the selector reports activity, the loop moves the associated task(s) to a ready queue (implemented as
asyncio.Queuein CPython). The ready queue is a simple FIFO that provides O(1) enqueue/dequeue. - Execution phase – The loop iterates over the ready queue, calling
_step()on each task._step()resumes the coroutine’s generator, processesawaitexpressions, and either suspends again or schedules the next callback. - Callback scheduling – If a coroutine calls
asyncio.create_taskor schedules a future, the task is appended to the ready queue for the next loop iteration.
The three‑phase loop (linger → poll → execute) repeats forever, giving the appearance of simultaneous progress while the thread is actually executing a single piece of Python bytecode at any instant.
Core Concepts
- Coroutine – A generator decorated with
@asyncio.coroutineor usingasync def. It yields control viaawaitwhich returns a Future. The coroutine’ssend/throwmethods are wrapped by the event loop to resume execution. - Task – A wrapper around a coroutine that is scheduled onto the loop. It holds a reference to the coroutine, its current state (
PENDING,RUNNING,FINISHED), and a unique identifier for cancellation. - Future / Promise – An object that represents an eventual result. Callbacks can be attached with
add_done_callback. The event loop resolves futures when I/O completes or when a task finishes. - Event Loop – The central scheduler that owns the selector, the ready queue, timers, and callback queues. It drives tasks, handles cancellation, and provides helper methods like
call_soon,call_later, andrun_in_executor.
Examples & Code Walkthrough
Below are three self‑contained snippets that illustrate the core pieces without importing asyncio for the low‑level parts.
1. Minimal Coroutine Shim
The shim mimics CPython’s internal handling of a coroutine’s send/throw. It keeps a private step method that resumes the generator and propagates StopIteration as a result.
# Minimal coroutine shim – not a textbook copy
class _AsyncShim:
"""Wrap a generator so it behaves like a coroutine for send/throw/close."""
def __init__(self, gen):
self._gen = gen
self._value = None
self._exc = None
def send(self, value):
try:
self._value = value
return self._gen.send(value)
except StopIteration as e:
# The value carried by StopIteration becomes the coroutine’s result
return e.value
def throw(self, exc):
try:
return self._gen.throw(exc)
except StopIteration as e:
return e.value
def close(self):
self._gen.close()
A coroutine can be instantiated with coro = _AsyncShim(my_gen()). Calling coro.send(None) starts execution until the first await.
2. Hand‑rolled Epoll Driver
This loop demonstrates how the OS poller can be used without the high‑level asyncio APIs. It registers file descriptors, waits for events, and invokes the supplied callbacks.
# Hand‑rolled epoll driver – original implementation
import os
import select
import errno
class EpollLoop:
def __init__(self):
self.epfd = os.epoll_create1(0) # Linux‑specific epoll instance
self.tasks = [] # (fd, callback, events)
def register(self, fd, callback, events):
# events is a bitmask of EPOLLIN/EPOLLOUT etc.
self.tasks.append((fd, callback, events))
ev = select.epoll_event(events, fd=fd)
os.epoll_ctl(self.epfd, os.EPOLL_CTL_ADD, fd, ev)
def run(self):
while True:
# Wait for up to 1024 events, block indefinitely
ready = os.epoll_wait(self.epfd, 1024, -1)
for fd, _ in ready:
for tfd, cb, evmask in self.tasks:
if tfd == fd and (evmask & select.EPOLLIN):
cb() # I/O callback fires
To use it, create a socket, set it to non‑blocking, and call loop.register(fd, my_handler, select.EPOLLIN). The loop then drives I/O without any asyncio machinery.
3. Cancellable File Reader
This example shows how a future can be cancelled while the epoll wait is still blocking. The reader registers a callback that writes data into a buffer; cancellation aborts the wait and raises asyncio.CancelledError in the awaiting task.
import asyncio
import os
class CancellableReader:
def __init__(self, path, loop):
self._path = path
self._loop = loop
self._fd = None
self._future = loop.create_future()
self._buffer = bytearray()
async def read(self, size):
self._fd = os.open(self._path, os.O_RDONLY | os.O_NONBLOCK)
# Register with the underlying epoll loop (simplified)
self._loop.register(self._fd,
self._make_callback(),
select.EPOLLIN)
try:
# Wait until data is available or cancelled
while self._buffer.__len__() < size:
await self._future
finally:
if self._fd is not None:
os.close(self._fd)
self._loop.unregister(self._fd)
return bytes(self._buffer)
def _make_callback(self):
def callback():
# Read whatever is ready
chunk = os.read(self._fd, 4096)
if not chunk:
# EOF – resolve with whatever we have
if not self._future.done():
self._future.set_result(None)
return
self._buffer.extend(chunk)
# Wake up the awaiting task
if not self._future.done():
self._future.set_result(None)
return callback
def cancel(self):
if not self._future.done():
self._future.cancel()
The CancellableReader demonstrates the pattern of tying a low‑level OS wait to a high‑level asyncio.Future. When cancel() is called, the future transitions to CANCELLED, causing the awaiting await self._future to raise CancelledError. The finally block ensures the file descriptor is cleaned up even if the operation is aborted.
Best Practices
- Avoid blocking I/O inside a task. Use
loop.run_in_executorfor CPU‑heavy work orasyncio.to_threadfor thread‑bound operations. - Limit the number of pending tasks. A huge ready queue can increase context‑switch overhead; consider batching or using
asyncio.Queuewith maxsize. - Profile selector overhead. On Linux,
epollscales well up to tens of thousands of fds; on macOS,kqueueis the default. Choose the appropriate platform if you need to customize the loop. - Handle cancellation early. Check
await future.cancelled()orif future.done():before entering long‑running I/O to avoid unnecessary system calls. - Use weak references for callbacks. If you store callbacks in a list that lives longer than the task, you may inadvertently keep objects alive, causing memory leaks.
Common Mistakes & Anti-Patterns
- Mixing threads and asyncio without isolation – Directly sharing a thread‑bound socket across tasks can cause race conditions. Wrap such resources in a dedicated executor.
- Ignoring
Futurecancellation – Simply awaiting a future without checkingfuture.cancelled()can leave resources hanging when a task is cancelled mid‑operation. - Using
time.sleeporinput()– These block the event loop. Replace withasyncio.sleepor non‑blocking equivalents. - Over‑nesting callbacks – Deep chains of
add_done_callbackcan obscure the flow and make debugging harder. Prefer structuredasync/awaitwhere possible.
Performance Considerations
- Ready queue – Implemented as
asyncio.Queue(acollections.dequeunder the hood). Enqueue and dequeue are O(1) and involve minimal allocation. - Selector syscalls – Each iteration incurs a
epoll_wait/kqueue/selectcall. The cost is proportional to the number of registered descriptors; keep the set trimmed. - Task objects – Each
Taskholds a coroutine frame, which consumes memory. In long‑running services, monitor the number of alive tasks to avoid OOM scenarios. - Future objects – Futures are created per I/O operation. Reusing Futures where possible reduces allocation pressure.
- Context switches – A task switch occurs only when a coroutine yields (
await). Frequent yields increase overhead but are necessary for responsive I/O.
Real-World Usage
- Payment processing platforms – High‑throughput order matching services use
asyncioto handle tens of thousands of concurrent WebSocket connections, leveraging non‑blocking TLS and zero‑copy buffers. - Real‑time chat servers – Games and messaging backends keep a single event loop per node, using
asynciofor WebSocket framing and presence broadcasting. - Microservices orchestration – Internal workflow engines schedule API calls to downstream services, using
asyncio.gatherto run multiple HTTP requests concurrently without spawning threads. - IoT data ingestion – Devices push sensor streams over TCP; a lightweight
asyncioserver parses binary frames and forwards them to a Kafka producer via a thread pool.
Frequently Asked Questions (FAQ)
Q: How does asyncio handle CPU‑bound work?
A: It offloads CPU‑intensive tasks to a thread pool via run_in_executor or to_thread. The event loop remains free to process I/O while the CPU work runs in parallel.
Q: Can I debug deadlocks in asyncio?
A: Enable the debug flag (asyncio.get_event_loop().set_debug(True)) to get stack traces for long‑running
Written by Kernel & Systems Software Engineer
Editorial staff persona covering operating system kernels, device drivers, low-level memory management, and runtime environments.