DEV Community

Cover image for Build a working asyncio event loop in 30 lines of plain Python
Ritesh
Ritesh

Posted on

Build a working asyncio event loop in 30 lines of plain Python

I will keep saying this until it stops being controversial: asyncio is small. The reason it feels large is that every tutorial introduces the keywords before the runtime they describe, leaving you to guess at what await is actually doing. Strip the keywords away, and the runtime fits in 30 lines of plain Python with no asyncio import.

This post walks through the toy event loop end to end. The code runs. Paste it, save, execute. By the end, you will have built a working asyncio runtime in your terminal and watched it interleave three jobs on a single thread.

The shape of the problem

A program with three jobs, each of which spends most of its time waiting. The naive version runs them one after the other and pays the sum of the waits. A program that overlaps the waits pays only the longest one.

serial:     [job A 2s] -> [job B 1s] -> [job C 3s]   total 6s
concurrent: [A 2s] [B 1s] [C 3s]   all overlapping   total 3s
Enter fullscreen mode Exit fullscreen mode

Six seconds versus three. The work is identical. The only thing that changes is whether the jobs queue up behind each other or share the time.

A job is a generator that yields

A generator is a function that can pause itself and be resumed by its caller. The pause point is yield. The caller advances the generator with next(...).

def example():
    print("step 1")
    yield
    print("step 2")
    yield
    print("step 3")
Enter fullscreen mode Exit fullscreen mode
>>> g = example()
>>> next(g)
step 1
>>> next(g)
step 2
>>> next(g)
step 3
Traceback (most recent call last):
  ...
StopIteration
Enter fullscreen mode Exit fullscreen mode

That is the entire mechanism. A yield is a bookmark. The caller picks up a different generator, runs it for a while, and comes back to the bookmarked one when it feels like it. Hold on to that picture; it is what await will do later, in fewer letters.

A timer that needs three ticks

For the toy loop, a "wait" is a generator that yields a target wake-up time. The loop checks the time on each pass; once the wake-up time has passed, the generator is allowed to advance.

import time

def sleep(seconds):
    deadline = time.time() + seconds
    while time.time() < deadline:
        yield deadline
Enter fullscreen mode Exit fullscreen mode

A job that "waits two seconds" is a generator that yields the deadline now + 2 until that deadline passes, then returns. The loop watches deadlines; jobs advance when their deadline arrives.

The loop in 30 lines

Here it is. Save it as toy_loop.py.

import time
from collections import deque

def run(jobs):
    """Run the given jobs until all of them finish.

    Each job is a generator. A job yields a deadline (a time.time()
    value) to mean "wake me up at or after this time". When a job
    returns (StopIteration), it is removed from the queue.
    """
    ready = deque((job, 0.0) for job in jobs)
    while ready:
        job, wake_at = ready.popleft()
        if time.time() < wake_at:
            ready.append((job, wake_at))
            time.sleep(0.001)   # avoid a tight CPU spin
            continue
        try:
            new_wake_at = next(job) or 0.0
            ready.append((job, new_wake_at))
        except StopIteration:
            pass

def sleep(seconds):
    deadline = time.time() + seconds
    while time.time() < deadline:
        yield deadline
Enter fullscreen mode Exit fullscreen mode

Twenty-eight lines of code. No imports beyond time and deque. No asyncio. No threads. No futures. The whole runtime is a queue and a while loop.

The while loop pops the front entry. If the job's wake-up time is in the future, push it to the back, sleep one millisecond, continue. If the job is ready, advance it with next(...); the job runs until its next yield, returns the new wake-up time, and goes back on the queue. When next(job) raises StopIteration, the job is finished and does not return to the queue.

That is the whole runtime.

Run it

Add three jobs and a main block.

def fetch(name, delay):
    print(f"  {name} started, waiting {delay}s")
    yield from sleep(delay)
    print(f"  {name} done")

if __name__ == "__main__":
    start = time.time()
    run([
        fetch("A", 2.0),
        fetch("B", 1.0),
        fetch("C", 3.0),
    ])
    print(f"total: {time.time() - start:.2f}s")
Enter fullscreen mode Exit fullscreen mode

yield from sleep(...) delegates to another generator. It is the same idea await will be later. Each yield from sleep flows up through fetch to the next(job) call in the loop.

$ python toy_loop.py
  A started, waiting 2.0s
  B started, waiting 1.0s
  C started, waiting 3.0s
  B done
  A done
  C done
total: 3.00s
Enter fullscreen mode Exit fullscreen mode

Three seconds. Not six. Three jobs whose waits sum to six seconds finished in the time of the longest one. Single thread. CPU idle for almost all of it. That is concurrency.

This is what asyncio is

Now look at what we built and what asyncio adds on top.

asyncio replaces the time.sleep(0.001) polling with a real selector-based wait on file descriptors (epoll on Linux, kqueue on macOS, IOCP on Windows). The selector tells the OS, "wake me up when any of these sockets has data, or when the next deadline arrives", so the loop sleeps for exactly as long as it needs to and no longer. That is the only meaningful difference between this toy loop and the real one.

async def is def with one extra property: the function returns a coroutine object instead of running its body. The coroutine object is the same shape as a generator. It pauses at await.

await x is yield from x.__await__(). It is yield from with a different name and a slightly tighter contract.

asyncio.run(main()) is the same as while ready: loop, with proper exception handling, signal handling, and the selector mentioned above.

The asyncio source code is more than 30 lines long, but the extra lines cover corner cases (cancellation propagation, exception groups, the lost-task trap), not new mechanisms. The mechanism is what you just built.

Where to go next

If you want to go deeper, my book Asyncio from Ground Up opens with the same toy loop, then takes the lid off at the bytecode level (__await__, coro.send, what a coroutine frame holds), then introduces the real asyncio API as a polished version of what you already have.

The book is on Leanpub: https://leanpub.com/author/book/asyncio/home. More about my work at www.riteshmodi.com.

The technology is small. The runtime fits on a single screen of code. The barrier was the order in which it was taught to you.

Top comments (0)