Backend Development 21 min read

Understanding Python Coroutines: From Generators to Async IO and Event‑Loop Design

This article explains how Python’s coroutine model evolved from Tornado’s single‑threaded async/await support to a generator‑based micro‑framework that uses IO multiplexing, callbacks, futures and stack‑based call‑chain handling to achieve non‑blocking network programming.

Python Programming Learning Circle
Python Programming Learning Circle
Python Programming Learning Circle
Understanding Python Coroutines: From Generators to Async IO and Event‑Loop Design

0x00 Before the Start

This article is not a source‑code walkthrough; instead it explains Python’s standard coroutine implementation by starting from real problems and gradually building a systematic understanding.

Note: The content provides a conceptual direction and does not follow every historical detail of existing implementations.

Readers should be familiar with Python generators.

0x01 IO Multiplexing

IO multiplexing is the performance key. The article only explains the concept, not the low‑level details, which is sufficient for understanding Python coroutines.

All network services run in a huge loop where the business logic is invoked at some point:

<code>def handler(request):
    # process request
    pass

while True:
    request = accept()
    handler = get_handler(request)
    handler(request)
</code>

If a handler needs to call an external API, the traditional approach blocks the whole process until the response arrives, dramatically reducing throughput when the network is slow.

Thread‑based servers move each handler to a separate thread, which avoids blocking the main loop but incurs heavy scheduling overhead under high concurrency.

IO multiplexing solves the problem without threads: the OS notifies the program when registered IO operations complete.

<code># OS IO multiplexing pseudo‑code
io_register(io_id, io_type)
io_register(io_id, io_type)

events = io_get_finished()
for (io_id, io_type) in events:
    if io_type == READ:
        data = read_data(io_id)
    elif io_type == WRITE:
        write_data(io_id, data)
</code>

Integrating this logic into a server looks like:

<code>call_backs = {}

def handler(req):
    # do jobs here
    io_register(io_id, io_type)
    def call_back(result):
        # use result to finish work
        pass
    call_backs[io_id] = call_back

while True:
    # get completed IO events
    events = io_get_finished()
    for (io_id, io_type) in events:
        if io_type == READ:
            data = read(io_id)
            call_back = call_backs[io_id]
            call_back(data)
        else:
            pass
    request = accept()
    handler = get_handler(request)
    handler(request)
</code>

The handler registers a callback and returns immediately; each loop iteration processes completed IO and invokes the stored callbacks.

0x02 Using Generators to Eliminate Callbacks

By turning the handler into a generator, the IO request can be yielded and the result sent back later, keeping business logic linear:

<code>def handler(request):
    # business logic ...
    # need to perform an API request
    def call_back(result):
        print(result)
    asyncio.get_event_loop().io_call(api, call_back)
</code>

This solves the performance problem—no threads are needed and slow API responses no longer block the server.

However, the code is now split: the part before the API call stays in the handler, while the part after the call must be written inside the callback, which becomes cumbersome when multiple APIs or database calls are involved.

Languages with anonymous functions (e.g., JavaScript) can suffer from “callback hell”.

The desired solution is a function that pauses at the network IO point and resumes automatically when the IO completes.

Generators provide exactly this capability:

<code>def example():
    value = yield 2
    print("get", value)
    return value

g = example()
# start generator, get 2
got = g.send(None)
print(got)  # 2
# resume with a new value
got = g.send(got * 2)
</code>

The yield keyword acts like a door that can both send a value out and receive a new one.

Calling send advances the generator until the next yield ; if the generator finishes, a StopIteration exception carries the return value.

Converting the original handler into a generator:

<code>def handler(request):
    # business logic ...
    result = yield io_info          # yield the IO request description
    print(result)                  # use the API result

def on_request(request):
    handler = get_handler(request)
    g = handler(request)
    io_info = g.send(None)         # first yield gives the IO description
    def call_back(result):
        g.send(result)            # resume generator with the result
    asyncio.get_event_loop().io_call(io_info, call_back)
</code>

Now the user‑written handler is no longer fragmented into separate callbacks.

Limitations remain: the example only handles a single network IO, while real code may have many; also, user code may call other coroutines.

Business logic often initiates multiple network IO operations.

Business logic may call other asynchronous functions.

0x03 Solving the Full Call Chain

A more complex example with nested generators:

<code>def func1():
    ret = yield request("http://test.com/foo")
    ret = yield func2(ret)
    return ret

def func2(data):
    result = yield request("http://test.com/" + data)
    return result

def request(url):
    # simulate an IO job description
    result = yield "iojob of %s" % url
    return result
</code>

Each function yields the request operation so the framework can register it.

Running the whole chain requires a stack‑based driver that pushes generators onto a stack, walks down the call chain, yields IO jobs, and pops generators when they finish:

<code>def wrapper(gen):
    stack = Stack()
    stack.push(gen)
    while True:
        item = stack.peak()
        result = None
        if isgenerator(item):
            try:
                child = item.send(result)
                stack.push(child)
                continue
            except StopIteration as e:
                result = e.value
        else:
            # IO operation
            result = yield item
        stack.pop()
        if stack.empty():
            print("finished")
            return result
</code>

Using the wrapper:

<code>w = wrapper(func1())
# first send returns the first IO description
w.send(None)
# after the first IO completes, send its result
w.send("bar")
# continue until the whole chain finishes
w.send("barz")
</code>

The framework maintains a ready list of generators whose IO has completed and processes them each loop iteration:

<code>ready = []

def on_request(request):
    handler = get_handler(request)
    g = wrapper(func1())
    ready.append((g, None))

def process_ready(self):
    def callback(g, result):
        ready.append((g, result))
    for g, result in self.ready:
        io_job = g.send(result)
        asyncio.get_event_loop().io_call(io_job, lambda r: ready.append((g, r)))
</code>

A Future class represents a placeholder for a result that will be set later:

<code>class Future:
    def set_result(self, result):
        pass
    def result(self):
        pass
    def done(self):
        pass
    def add_done_callback(self, callback):
        pass
</code>

Re‑implementing request to return a Future :

<code>def request(url):
    fut = Future()
    def callback(result):
        fut.set_result(result)
    asyncio.get_event_loop().io_call(url, callback)
    return fut
</code>

The process_ready function now works with futures:

<code>def process_ready(self):
    def callback(fut):
        ready.append((g, fut.result()))
    for g, result in self.ready:
        fut = g.send(result)
        fut.add_done_callback(callback)
</code>

0x05 Development and Change

Early Tornado versions only supported the yield keyword for coroutines; combining yield and return required raising StopIteration , which was awkward.

The later introduction of yield from allowed a generator to delegate to another generator, acting as syntactic sugar for the stack‑based wrapper.

With yield from the previous example becomes much cleaner:

<code>def func1():
    ret = yield from request("http://test.com/foo")
    ret = yield from func2(ret)
    return ret

def func2(data):
    result = yield from request("http://test.com/" + data)
    return result
</code>

When request returns a Future , yield from still works because the event loop automatically awaits the future.

Later Python versions introduced the dedicated async / await syntax, which provides the same functionality with clearer semantics.

0x06 Summary and Comparison

Python’s native coroutine implementation consists of two parts:

IO multiplexing that makes the whole application non‑blocking on network operations.

Generators (or async / await ) that turn scattered callback code into linear, readable business logic.

The same pattern appears in JavaScript (Promise → async/await). Go’s goroutine model differs because it does not rely on generators; it uses a runtime‑level scheduler and channels.

Libraries like gevent patch system calls to implement their own coroutine runtime, focusing on network IO, while Go provides a full‑featured, multi‑core scheduler with channels for communication.

Overall, the article demonstrates how Python’s coroutine model evolved from low‑level generator tricks to the modern async / await syntax, highlighting the underlying concepts of IO multiplexing, futures, and call‑chain management.

Source: https://zhuanlan.zhihu.com/p/330549526

coroutinesevent-loopIO MultiplexingGeneratorsAsyncIO
Python Programming Learning Circle
Written by

Python Programming Learning Circle

A global community of Chinese Python developers offering technical articles, columns, original video tutorials, and problem sets. Topics include web full‑stack development, web scraping, data analysis, natural language processing, image processing, machine learning, automated testing, DevOps automation, and big data.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.