How asyncio Really Works Under the Hood
Python's asyncio is usually introduced through its public API: define a coroutine with async def, suspend it with await, and run several operations concurrently with create_task() or gather(). That is enough to write useful programs, but it does not explain why those programs behave as they do. The difficult questions sit below the API: Why does one blocking function stall every coroutine on the loop? What resumes a coroutine after an await? Why is cancellation cooperative rather than immediate? How can one thread manage thousands of network connections? Why can an apparently harmless CPU loop make an entire service unresponsive? The core execution model is smaller than the production implementation suggests. Strip away transports, protocols, signal handling, context propagation, debugging support, and platform-specific optimizations, and the scheduler reduces to a short cycle: resume a coroutine, observe what it is waiting for, and arrange to resume it when that dependency completes. A minimal event loop makes that cycle concrete. The code below is deliberately incomplete, but it contains the essential relationship between coroutines, Futures, Tasks, the ready queue, timers, and operating-system I/O readiness. Coroutines Build on Python's Suspension Protocol Cooperative scheduling requires code that can suspend in the middle of a function and later continue with its local state intact. Python had that capability before native coroutines: generators already implement it through yield, send(), and throw(). def gen(): print("start") x = yield 1 print("got", x) yield 2 g = gen() print(next(g)) # start -> 1 print(g.send("hi")) # got hi -> 2 next() starts the generator and runs it until the first yield. send() resumes it, injects a value at the suspension point, and runs until the next yield or completion. Native coroutines are distinct from generators at the language level, but they use the same underlying idea: an execution frame can be driven from the outside, suspended, resumed with send(), interrupted with throw(), and closed with close(). async def hello(): return 1 coro = hello() coro.send(None) # raises StopIteration(1) Calling hello() creates a coroutine object; it does not execute the function body. The first send(None) starts it. Because this coroutine returns immediately, the return value is carried by StopIteration, the normal completion signal for the protocol. In an asyncio application, application code rarely calls send() directly. A Task owns that responsibility. What await Actually Does await does not block the event-loop thread. It allows the current coroutine to suspend while preserving its frame, then passes control back through the chain of awaiting coroutines until it reaches the Task driving the outermost coroutine. Consider a socket read: async def fetch(): data = await sock_recv(sock, 1024) return data If the socket already has data available, the operation may complete without suspending. If it is not ready, the awaitable exposes a pending dependency to the scheduler and the coroutine stops running. The thread is then free to execute another ready callback or Task. Conceptually, await expr obtains an iterator from expr.__await__() and delegates to it. Values yielded by that iterator propagate toward the Task. When the dependency becomes ready, the Task resumes the suspended coroutine, and the awaitable produces its result or raises its exception. The important distinction is between a coroutine and a thread. A suspended coroutine consumes no CPU. It is an object holding execution state, waiting for the scheduler to drive it again. Futures Represent Incomplete Work A Future is a state container for a result that may not exist yet. It does not usually perform the operation. Instead, it connects the producer of a result—such as a timer, socket callback, thread-pool completion, or another Task—to the code waiting for that result. A minimal Future needs a completion state, a stored result, completion callbacks, and an await protocol: class MiniFuture: def __init__(self): self._done = False self._result = None self._callbacks = [] def set_result(self, result): self._done = True self._result = result for cb in self._callbacks: cb(self) def add_done_callback(self, cb): self._callbacks.append(cb) def __await__(self): if not self._done: yield self # bubbles up to the event loop return self._result When pending, __await__() yields the Future so the Task can register a wake-up callback. After set_result() runs, those callbacks make the waiting Task runnable again. The resumed await expression then returns _result. Production asyncio.Future adds exception storage, cancellation, state validation, callback scheduling through the loop, context propagation, and safeguards against invalid transitions. Those details matter in real code, but they do not change the Future's architectural role: it is the handoff point between a producer and a suspended consumer. Tasks Drive Coroutines A coroutine describes computation but does not schedule itself. A Task wraps the coroutine, schedules its first step, and continues driving it until it returns or raises. A Task is also a Future, which allows other coroutines to await its eventual result. The control flow is compact: Resume the coroutine. If it completes, store its return value on the Task. If it raises, store the exception on the Task. If it suspends on a Future, register a callback and stop. When that Future completes, schedule the next step. class MiniTask(MiniFuture): def __init__(self, coro, loop): super().__init__() self._coro = coro self._loop = loop loop.call_soon(self._step) def _step(self, exc=None): try: if exc is None: yielded = self._coro.send(None) else: yielded = self._coro.throw(exc) except StopIteration as e: self.set_result(e.value) return except Exception as e: self.set_exception(e) return yielded.add_done_callback(lambda f: self._loop.call_soon(self._step)) This sketch assumes that every suspension yields a compatible Future and omits result and exception propagation from the yielded Future. A real Task validates what the coroutine yielded and uses a dedicated wake-up path that retrieves the dependency's result before scheduling the next step. If that retrieval raises, the exception is thrown into the coroutine. One property remains the same: a Task does not execute continuously. It runs only while its step callback owns the event-loop thread. At the next suspension point, it returns control to the loop and consumes no CPU until another callback makes it ready. There is no operating-system preemption between Tasks on the same loop. This makes many state transitions easier to reason about, but it does not make shared state automatically safe. If an invariant spans an await, another Task can run before the first Task restores that invariant; asyncio.Lock and related primitives exist for exactly these cases. Building the Event Loop The production event loop has a broad API and substantial platform-specific behavior. Its scheduling core can still be modeled with three structures: a ready queue for callbacks that can run now; a min-heap for callbacks scheduled at a future time; an I/O selector that reports file descriptors ready for non-blocking operations. import heapq import time import selectors class MiniLoop: def __init__(self): self._ready = [] self._scheduled = [] self.selector = selectors.DefaultSelector() def call_soon(self, callback): self._ready.append(callback) def call_later(self, delay, callback): heapq.heappush(self._scheduled, (time.monotonic() + delay, callback)) def _run_once(self): now = time.monotonic() while self._scheduled and self._scheduled[0][0]
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to