ator 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")
>>> g = example()
>>> next(g)
step 1
>>> next(g)
step 2
>>> next(g)
step 3
Traceback (most recent call last):
...
StopIteration
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
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
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")
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
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.
Pitfall Guide
- Keyword-First Mental Model: Treating
async/await as syntactic magic instead of generator delegation obscures the actual scheduling mechanism, making debugging blocking behavior or task starvation nearly impossible.
- Tight CPU Spin Loops: Omitting the micro-sleep (
time.sleep(0.001)) or failing to integrate an OS-level selector causes the event loop to consume 100% CPU on a single core while polling, destroying the efficiency gains of concurrency.
- Ignoring
StopIteration Handling: Failing to catch StopIteration when advancing generators crashes the scheduler instead of gracefully dequeuing completed tasks, leading to unhandled exceptions and loop termination.
- Deadline Drift from Fixed Polling: Relying on fixed polling intervals introduces latency jitter and imprecise wake-ups. Production event loops must use OS-level selectors (
epoll/kqueue/IOCP) to sleep exactly until the next deadline or I/O event.
- Missing Exception & Cancellation Contracts: The toy loop lacks error propagation, task cancellation, and signal handling. Real
asyncio wraps the core queue mechanism with robust exception groups, cancellation scopes, and graceful shutdown procedures that are essential for production reliability.
- Assuming Concurrency Equals Parallelism: Single-threaded cooperative scheduling does not bypass the GIL or utilize multiple cores. CPU-bound tasks will still block the loop; this architecture is strictly optimized for I/O-bound workloads.
Deliverables