From yield to await: The evolution of Python coroutines
Introduction
Today, `async/await` feels completely natural — it's the standard for asynchronous programming in Python.
But this elegant construct is the result of a 20‑year technical evolution: from the earliest generators, through community "patchwork" solutions like `@wrappertask`, to language‑level features (`yield from`, native coroutines) and finally the modern async framework.
This guide follows the historical timeline to reconstruct that journey and answer:
> Why do we need coroutines?
> How should nested tasks be scheduled?
> Who was `@wrappertask` standing in for?
> And in what way is `await` stronger than `yield from`?
---
Stage 1 — The Nature of Generators|Arrival of `yield`
1.1 What Is a Generator?
Python 2.2 (2001) introduced the `yield` keyword, enabling lazily evaluated sequences:
def counter():
i = 0
while True:
yield i
i += 1
gen = counter()
print(next(gen)) # 0
print(next(gen)) # 1At first, `yield` was simply a memory‑efficient iterator mechanism for large or infinite sequences.
---
1.2 Two‑Way Communication with `.send()` (Python 2.5)
In 2006, `.send(value)` let external code pass data into a generator:
def echoer():
while True:
msg = yield
print(f"Echo: {msg}")
e = echoer()
next(e) # prime generator
e.send("Hi") # Echo: HiImpact: Generators could now receive input, maintain state, and pause/resume — fulfilling the definition of a coroutine.
---
Stage 2 — Practical Challenges: Nested Generators
Using generators to model complex workflows revealed a problem:
How can one generator “call” another and forward control transparently?
2.1 Example — Parent/Child Tasks
def child_task():
for i in range(3):
print(f" Child step {i}")
yield f"data_{i}"
return "child_done"
def parent_task():
print("Parent start")
for data in child_task():
yield data
print("Parent end")While functional, this is verbose and fragile, and doesn’t cleanly handle return values or exceptions.
---
2.2 Pain Points of Manual Forwarding
- Opaque Control Flow — Can't send data directly to the child.
- No Exception Propagation — Exceptions thrown externally aren’t delegated.
- Return Values Lost — Child’s `return` is trapped in `StopIteration.value`.
---
Ideal Mechanism:
def parent_task():
print("Parent start")
result = yield from child_task()
print(f"Child returned: {result}")
print("Parent end")Before Python 3.3, this syntax wasn’t available — leading to community “simulation” solutions.
---
Stage 3 — Community Patch: OpenStack's `@wrappertask`
Large projects like OpenStack Heat implemented `@wrappertask` to simulate `yield from` for Python 2.
- `yield from` released: Sep 2012 (Python 3.3)
- `@wrappertask` added: May 2013 — for Python 2.7 compatibility
---
3.1 Design Goals
Allow a generator to start another generator as a subtask:
@wrappertask
def parent_task():
self.setup()
yield child_task()
self.cleanup()---
3.2 Implementation (Simplified)
def wrappertask(task):
def wrapper(*args, **kwargs):
parent = task(*args, **kwargs)
for subtask in parent:
if subtask is not None:
for step in subtask:
yield step
else:
yield
return wrapperFeatures:
- Drives subtasks
- Forwards yielded data
- Propagates exceptions
---
Stage 4 — Standardization: `yield from` (Python 3.3)
PEP 380 introduced `yield from` to delegate generator control elegantly:
def parent_task():
result = yield from child_task()
print(f"Child returned: {result}")Advantages:
- Automatic exception handling
- Return value capture
- Concise syntax
---
Stage 5 — Native Coroutines (`async/await`) (Python 3.5)
PEP 492 delivered explicit coroutine syntax:
async def child_coro():
await asyncio.sleep(1)
return "child_done"
async def parent_coro():
result = await child_coro()
print(f"Child returned: {result}")Benefits:
- Clear distinction from generators
- Restricted to awaitable objects
- Better type checking and safety
---
Relationship:
`await` is essentially a specialized `yield from` designed for async contexts.
---
Stage 6 — The Async Ecosystem (`asyncio`)
With `async/await`, Python introduced `asyncio`:
import asyncio
async def main():
task1 = asyncio.create_task(work1())
task2 = asyncio.create_task(work2())
await task1
await task2
asyncio.run(main())Components:
- EventLoop — Schedules coroutines
- Task — Concurrent execution units
- Future — Pending results
- gather/wait — Batch management
---
Evolution Timeline
- 2001 — Generators (`yield`)
- 2006 — `.send()` (true coroutines)
- 2012 — `yield from` (PEP 380)
- 2013 — `@wrappertask` for Python 2
- 2015 — `async/await` (PEP 492)
- Modern — Full async ecosystem (`asyncio`)
---
Summary — From “Patch” to “Standard”
Lessons Learned:
- Pain Points Drive Innovation — e.g., `@wrappertask`
- Abstraction Layers — hack → pattern → library → syntax → ecosystem
- Clarity Over Flexibility — `async/await` prioritizes safety & readability
---
> Key Insight: Every elegant API started life as rough prototypes, refined through years of real‑world use.