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.
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
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.