Python Runtime Under the Hood: Iterators, Generators, Context Managers, Exceptions, and Async

Published on
Authors

Python Runtime Under the Hood: Iterators, Generators, Context Managers, Exceptions, and Async

Python Under the Hood — Series 4

The first three articles built the core runtime model:

Series 1
Execution
Memory
Objects
Namespaces
LEGB
Frames
        ↓

Series 2
Functions
Argument binding
Mutability
Closures
Decorators
        ↓

Series 3
Classes
Instances
Attributes
Methods
Descriptors
Inheritance
MRO

This final article adds the missing dimension:

How does Python control execution over time?

So far, most of our examples have followed a simple model:

call function
    ↓
execute body
    ↓
return

But Python also supports execution that can:

pause
resume
delegate
iterate
clean up
propagate exceptions
wait for another operation
schedule concurrent work

Those behaviors power:

for loops
iterators
generators
yield
with statements
context managers
async def
await
asyncio
async iterators
async context managers

The central mental model for Series 4 is:

Python execution is not always “start and finish.” Some execution frames can be suspended and resumed. Iteration repeatedly requests values from objects, generators preserve execution state between yields, context managers structure setup and cleanup around a block, exceptions alter control flow, and async coroutines cooperate with an event loop by suspending at await points.

Python iteration and generator execution

Figure 1 — Iterators expose values one at a time, while generators preserve a suspended execution frame between yield operations.


1. The Missing Runtime Dimension: Execution Over Time

Consider a normal function:

def add(a, b):
    return a + b

Calling:

add(10, 20)

creates an execution frame:

Frame: add()

a → 10
b → 20

The function executes until:

return

Then the frame is normally no longer active.

Conceptually:

call
 ↓
frame created
 ↓
execute
 ↓
return
 ↓
frame finished

Now compare that with a generator:

def numbers():
    yield 1
    yield 2
    yield 3

Calling:

numbers()

does not execute the function body to completion.

Instead, Python creates a generator object that can later drive a suspended execution frame.

Conceptually:

call generator function
        ↓
generator object created
        ↓
execution not completed immediately
        ↓
resume when next value is requested
        ↓
pause again at yield

This ability to suspend and resume execution is one of the most important architectural ideas in modern Python.


2. Iteration Is a Protocol

Consider:

for item in [10, 20, 30]:
    print(item)

The for loop looks like special syntax, but underneath it relies on the iterator protocol.

The important operations are conceptually:

iter(...)

and:

next(...)

A rough expansion is:

items = [10, 20, 30]

iterator = iter(items)

while True:
    try:
        item = next(iterator)
    except StopIteration:
        break

    print(item)

So:

for loop
   ↓
obtain iterator
   ↓
request next value
   ↓
request next value
   ↓
...
   ↓
StopIteration
   ↓
loop ends

The for statement hides this machinery.


3. Iterable vs. Iterator

These two words are related but different.

An iterable is an object that can produce an iterator.

An iterator is the stateful object that produces values one at a time.

For example:

numbers = [10, 20, 30]

The list is iterable.

Then:

it = iter(numbers)

creates an iterator.

Conceptually:

list object
   │
   │ iter(...)
   ▼
iterator object
   │
   ├── current position
   └── reference to iteration source

Now:

next(it)

returns:

10

then:

20

then:

30

then raises:

StopIteration

4. The Iterator Protocol

An iterator conceptually supports:

__iter__()
__next__()

A simple custom iterator:

class Counter:

    def __init__(self, start, stop):
        self.current = start
        self.stop = stop

    def __iter__(self):
        return self

    def __next__(self):
        if self.current >= self.stop:
            raise StopIteration

        value = self.current
        self.current += 1

        return value

Use:

counter = Counter(0, 3)

for value in counter:
    print(value)

Output:

0
1
2

The instance holds iteration state:

current
stop

The for loop repeatedly calls:

next(counter)

until StopIteration.


5. Why __iter__() Often Returns self

For an iterator object:

def __iter__(self):
    return self

means:

This object is already the iterator.

So:

iter(iterator)

returns the same iterator object.

This is different from many container objects.

A list is iterable, but the list itself is not the iterator that tracks one active iteration position.

Conceptually:

list
 │
 ├── iterator A → position 0
 └── iterator B → position 0

Two independent calls:

iter(my_list)

can create two independent iterator objects.


6. Iterators Are Stateful

Consider:

numbers = [10, 20, 30]

it = iter(numbers)

print(next(it))
print(next(it))

The iterator remembers where it is.

Conceptually:

Before next():
position → before first value

After first next():
position → after 10

After second next():
position → after 20

This is why iterators are often consumed.

Once an iterator reaches the end:

next(it)

raises:

StopIteration

and the same iterator normally remains exhausted.


7. Iterables Can Often Be Reused; Iterators Often Cannot

Compare:

numbers = [1, 2, 3]

You can iterate the list multiple times:

for x in numbers:
    ...

for x in numbers:
    ...

because each for loop can obtain a new iterator.

But:

it = iter(numbers)

is one iterator object with one progress state.

After it is exhausted, iterating that same iterator again produces no more values.

This distinction matters in:

data pipelines
file processing
generator expressions
streaming APIs
lazy computation

8. Generators Implement the Iterator Protocol for You

The custom iterator class above required:

state variables
__iter__
__next__
StopIteration

Python generators let us express the same kind of stateful iteration much more naturally.

Example:

def count(start, stop):
    current = start

    while current < stop:
        yield current
        current += 1

Use:

for value in count(0, 3):
    print(value)

Output:

0
1
2

The generator machinery handles the iterator protocol for us.


9. Calling a Generator Function

Consider:

def example():
    print("A")
    yield 1
    print("B")
    yield 2

Now:

g = example()

At this moment, the body has not yet run in the ordinary way a normal function body would run to completion.

g refers to a generator object.

Conceptually:

example
   ↓ call
generator object
   │
   ├── code
   ├── suspended execution state
   └── local state

Then:

next(g)

starts execution.

Output:

A

and the call returns:

1

because execution reaches:

yield 1

10. yield Suspends the Frame

This is the architectural heart of generators.

Consider:

def counter():
    x = 1

    yield x

    x += 1

    yield x

When execution reaches:

yield x

Python does not finish the function completely.

Instead, the generator's execution state is suspended.

Conceptually:

Generator frame

x → 1

instruction position
       ↓
paused immediately after first yield

The value:

1

is returned to the caller.

But the frame is preserved.

When:

next(generator)

is called again, Python resumes from the saved position.


11. Generator State Is More Than Local Variables

A suspended generator needs enough state to continue execution later.

Conceptually, that includes:

local variables
instruction position
evaluation state
exception state
references needed by the frame

So a generator is not simply:

a function returning values one by one

A better architectural description is:

A generator object manages a resumable execution frame.

That is the key distinction.


12. Generator Lifecycle

A generator can be understood through states such as:

created
   ↓
running
   ↓
suspended
   ↓
running
   ↓
suspended
   ↓
...
   ↓
completed

For example:

def values():
    yield 1
    yield 2

Conceptually:

values()
    ↓
CREATED

next()
    ↓
RUNNING
    ↓
yield 1
    ↓
SUSPENDED

next()
    ↓
RUNNING
    ↓
yield 2
    ↓
SUSPENDED

next()
    ↓
RUNNING
    ↓
function ends
    ↓
StopIteration
    ↓
COMPLETED

13. return Inside a Generator

A generator can use:

return

When the generator returns, iteration ends.

For example:

def example():
    yield 1
    return

The next request after the final yield eventually causes:

StopIteration

A generator can also conceptually return a final value:

return result

That value becomes associated with the StopIteration termination mechanism.

Most ordinary for loops ignore that final return value.


14. send() Sends a Value Back Into a Generator

Generators are not only one-way producers.

They can receive values.

Example:

def receiver():
    value = yield "ready"
    yield f"received: {value}"

Use:

g = receiver()

print(next(g))
print(g.send("hello"))

Output:

ready
received: hello

Conceptually:

generator
   ↓
yield "ready"
   ↓
suspend
   ↓
send("hello")
   ↓
resume
   ↓
yield expression evaluates to "hello"

So this line:

value = yield "ready"

has two roles:

yield outward
receive inward when resumed

15. throw() Injects an Exception

A generator can also be resumed by injecting an exception.

Conceptually:

generator.throw(SomeError)

means:

Resume the suspended generator as if the exception were raised at the suspension point.

A generator can catch that exception:

def worker():
    try:
        yield "working"
    except ValueError:
        yield "recovered"

This makes generators capable of richer two-way control flow.


16. close() Terminates a Generator

Calling:

generator.close()

asks the generator to terminate.

Python accomplishes this through generator-specific exception mechanics.

The important architectural lesson is:

generator
   ↓
is not just data
   ↓
it is an execution object with lifecycle and control operations

17. yield from Delegates to Another Iterator

Consider:

def combined():
    yield from [1, 2, 3]

This is more than syntax for:

for item in [1, 2, 3]:
    yield item

although that is a useful first approximation.

yield from delegates generator interaction to another iterable or generator.

Conceptually:

outer generator
      ↓
yield from
      ↓
delegated iterator/generator
      ↓
values flow outward

It also supports deeper propagation of operations such as:

send
throw
return values

through generator delegation.


18. Generator Expressions

Python also provides generator expressions:

squares = (x * x for x in range(10))

Compare:

[x * x for x in range(10)]

The list comprehension eagerly creates a list.

The generator expression creates a lazy iterator-like generator object.

Conceptually:

List comprehension
        ↓
compute all values now
        ↓
store list


Generator expression
        ↓
store computation state
        ↓
produce values on demand

This can reduce memory usage for large streams.


19. Laziness Changes Architecture

Consider:

sum(x * x for x in range(10_000_000))

The generator expression does not need to materialize all squared values at once.

Instead:

range produces value
      ↓
square produced
      ↓
sum consumes it
      ↓
next value requested

This creates a streaming pipeline.

Architecturally:

producer
   ↓
transform
   ↓
consumer

Values flow only when requested.

This is one of the reasons iterator-based designs scale well for:

files
network streams
large datasets
ETL pipelines
parsers
data processing

20. Iteration Is Pull-Based

A traditional iterator pipeline is usually pull-based.

The consumer asks:

next(iterator)

The producer responds with one value.

Conceptually:

consumer
   │
   │ next()
   ▼
producer
   │
   │ value
   ▼
consumer

The consumer controls the pace.

This is an important distinction when we later compare iteration with event-driven asynchronous systems.


21. Exceptions Are Part of Python's Control Flow Architecture

Consider:

def divide(a, b):
    return a / b

If:

b == 0

Python raises:

ZeroDivisionError

An exception changes normal execution flow.

Instead of:

instruction
↓
next instruction
↓
next instruction

we get:

exception raised
      ↓
search for matching handler
      ↓
possibly unwind stack frames
      ↓
handler found or program-level failure

Exceptions therefore belong in the runtime architecture, not only in error-handling syntax.


22. Exception Propagation and Stack Unwinding

Consider:

def c():
    raise ValueError("problem")


def b():
    c()


def a():
    b()


a()

Call stack:

a()
↓
b()
↓
c()

If c() raises an exception and does not handle it:

c frame
   ↓ no handler
unwind

b frame
   ↓ no handler
unwind

a frame
   ↓ no handler
unwind

Python continues searching outward for an applicable exception handler.

This process is called stack unwinding.


23. try / except

Example:

try:
    result = 10 / 0
except ZeroDivisionError:
    result = None

Conceptually:

enter protected block
      ↓
execute
      ↓
exception?
   /       \
 no         yes
 ↓           ↓
continue   match handler
             ↓
          execute handler

Exception handling introduces alternate execution paths into the current frame.


24. finally and Guaranteed Cleanup

Consider:

resource = acquire()

try:
    use(resource)
finally:
    release(resource)

The finally block is designed to execute as control leaves the protected structure, including many cases involving:

normal completion
return
exception
break
continue

This makes it useful for cleanup.

Conceptually:

acquire
  ↓
work
  ↓
leave block for any reason
  ↓
cleanup

This pattern leads naturally to context managers.


25. The Problem Context Managers Solve

Without context managers:

file = open("data.txt")

try:
    data = file.read()
finally:
    file.close()

This works, but the resource-management pattern is repetitive.

Python provides:

with open("data.txt") as file:
    data = file.read()

The with statement turns:

setup
work
cleanup

into a protocol.


26. The Context Manager Protocol

A synchronous context manager conceptually implements:

__enter__()
__exit__()

Example:

class Resource:

    def __enter__(self):
        print("enter")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print("exit")

Use:

with Resource() as resource:
    print("inside")

Conceptually:

create context manager
      ↓
call __enter__
      ↓
bind returned value
      ↓
execute with-block
      ↓
call __exit__

27. What as Receives

Consider:

with Resource() as resource:
    ...

The name:

resource

is bound to whatever:

__enter__()

returns.

So:

def __enter__(self):
    return self

means:

resource → context manager instance

But __enter__() could return a different object entirely.


28. __exit__() Receives Exception Information

If an exception occurs inside the block:

with Resource():
    raise ValueError("problem")

Python conceptually calls:

__exit__(
    ValueError,
    exception_instance,
    traceback_object
)

So a context manager can inspect how the block is exiting.

That makes context managers useful for:

files
database transactions
locks
temporary state
tracing
resource cleanup
test fixtures

29. Context Managers Can Suppress Exceptions

The return value of:

__exit__()

matters.

If it returns a truthy value, the exception can be considered handled/suppressed.

Example:

class IgnoreValueError:

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        return exc_type is ValueError

Then:

with IgnoreValueError():
    raise ValueError("ignored")

can continue after the block.

This demonstrates that context managers are part of exception control flow, not merely cleanup helpers.


30. contextlib.contextmanager

You do not always need to write a class.

Python's standard library can turn a generator-based pattern into a context manager.

Example:

from contextlib import contextmanager


@contextmanager
def resource():
    print("acquire")

    try:
        yield "resource"
    finally:
        print("release")

Use:

with resource() as value:
    print(value)

Here:

code before yield
      ↓
__enter__ behavior

yielded value
      ↓
value bound after as

code after yield
      ↓
__exit__ behavior

This is an elegant connection between:

generators
exceptions
context managers

31. The with Statement as an Execution Boundary

A useful mental model is:

with manager as value:
    body

approximately creates this architecture:

manager object
    ↓
enter protocol
    ↓
body executes
    ↓
normal or exceptional exit
    ↓
exit protocol

So with defines a controlled execution region.

This is why it is commonly used for operations whose correctness depends on entering and leaving a state properly.


32. From Generators to Coroutines

Generators gave us:

pause
resume
preserve execution state

Coroutines build on a related idea.

Consider:

async def fetch_data():
    ...

Calling:

fetch_data()

does not behave like calling an ordinary function to completion.

It creates a coroutine object.

Conceptually:

async function
     ↓ call
coroutine object
     ↓
resumable asynchronous computation

This should feel familiar after understanding generators.


33. async def Creates a Coroutine Function

Example:

async def greet():
    return "Hello"

Now:

result = greet()

does not immediately give:

"Hello"

Instead:

result → coroutine object

That coroutine needs to be awaited or scheduled by an asynchronous runtime.


34. await Is a Suspension Point

Consider:

async def load():
    data = await fetch()
    return data

The architectural meaning of:

await fetch()

is roughly:

If the awaited operation is not ready, suspend this coroutine so other work can run; resume this coroutine later when progress can continue.

Conceptually:

coroutine running
      ↓
await
      ↓
operation incomplete
      ↓
coroutine suspended
      ↓
control returns to scheduler/event loop
      ↓
operation becomes ready
      ↓
coroutine resumed

This is cooperative concurrency.


35. Async Is Not Automatically Parallel

This distinction is crucial.

Asynchronous execution does not automatically mean:

multiple Python instructions execute simultaneously

The core async model is:

task A runs
   ↓
task A awaits
   ↓
task B runs
   ↓
task B awaits
   ↓
task C runs

Tasks cooperate by yielding control at suspension points.

This is particularly effective for I/O-bound workloads:

network requests
database calls
socket operations
timers
message queues
many concurrent connections

36. The Event Loop

The event loop is the coordinator.

A simplified architecture:

Tasks / Coroutines
       ↓
   Event Loop
       ↓
wait for readiness
       ↓
resume ready tasks
       ↓
tasks run until next await/completion

The event loop repeatedly asks:

Which work can make progress now?

Then it resumes that work.


37. A Simplified Event Loop Cycle

Conceptually:

1. Check ready tasks
2. Resume one
3. Task executes
4. Task awaits / completes
5. Register pending wait
6. Resume another ready task
7. Poll external events/timers
8. Mark completed waits as ready
9. Repeat

Real event loops are more sophisticated, but this model is enough to understand async architecture.


38. Coroutine State

Like generators, coroutines have lifecycle state.

A conceptual model:

created
  ↓
scheduled
  ↓
running
  ↓
awaiting
  ↓
running
  ↓
awaiting
  ↓
...
  ↓
completed

The runtime must preserve enough state to resume the coroutine correctly.

This includes the logical equivalent of:

local variables
instruction position
exception state
references needed by suspended execution

Again, the core concept is:

Async works because Python can suspend and later resume execution state.


39. await Works with Awaitables

You cannot meaningfully await arbitrary objects.

Python expects an awaitable object.

Common awaitables include:

coroutine objects
Task objects
Future-like objects
custom awaitables

The awaitable protocol ultimately allows Python to coordinate suspension and resumption.

So:

await something

means:

something must participate in the awaitable protocol

40. Tasks

A coroutine object represents asynchronous computation.

A Task schedules a coroutine for execution by the event loop.

Conceptually:

coroutine
   ↓
wrapped/scheduled as Task
   ↓
event loop manages progress

In asyncio:

task = asyncio.create_task(coro())

means:

Schedule this coroutine so the event loop can run it concurrently with other scheduled work.


41. Coroutine vs. Task

A useful distinction:

Coroutine
    ↓
the asynchronous computation itself


Task
    ↓
runtime-managed scheduling wrapper around coroutine execution

Calling:

coro()

creates a coroutine object.

Calling:

asyncio.create_task(coro())

asks the event loop to schedule it.


42. Futures

A Future conceptually represents:

A result that may become available later.

Simplified state:

PENDING
   ↓
DONE

When done, it may hold:

result
or
exception

Conceptually:

Future
 ├── state
 ├── result
 ├── exception
 └── callbacks / waiters

Tasks are closely related to futures in asyncio, because a scheduled coroutine eventually produces either a result or an exception.


43. Awaiting a Future

Conceptually:

result = await future

means:

future ready?
   │
   ├── yes → return result
   │
   └── no
        ↓
suspend current coroutine
        ↓
event loop runs other work
        ↓
future becomes ready
        ↓
resume coroutine

This is the architectural bridge between:

external readiness

and:

resumable Python execution

44. A Simple Async Example

Consider:

import asyncio


async def worker(name, delay):
    print(f"{name}: start")

    await asyncio.sleep(delay)

    print(f"{name}: done")


async def main():
    task_a = asyncio.create_task(worker("A", 2))
    task_b = asyncio.create_task(worker("B", 1))

    await task_a
    await task_b


asyncio.run(main())

Conceptually:

main task starts
   ↓
schedule A
   ↓
schedule B
   ↓
A runs
   ↓
A awaits timer
   ↓
B runs
   ↓
B awaits timer
   ↓
event loop waits
   ↓
B becomes ready
   ↓
B resumes/completes
   ↓
A becomes ready
   ↓
A resumes/completes

No thread is required for this concurrency model.


45. Why Async Scales for I/O

Suppose 10,000 network connections spend most of their time waiting.

A thread-per-connection model can require large numbers of:

threads
stacks
scheduler context switches
OS resources

An event-loop architecture can instead represent many waits as lightweight scheduled states.

Conceptually:

connection A ─┐
connection B ─┤
connection C ─┼──► event loop
...           │
connection N ─┘

Only work that is ready needs immediate execution.

That is why async architectures are common in:

web servers
API gateways
chat systems
proxies
crawlers
network clients
high-concurrency services

46. Async Does Not Make CPU Work Disappear

Consider:

async def expensive():
    compute_for_10_seconds()

If:

compute_for_10_seconds()

never awaits and performs CPU-heavy Python work, it can monopolize the event loop thread.

Conceptually:

event loop
   ↓
task starts CPU-heavy work
   ↓
no suspension point
   ↓
other tasks cannot run

Async helps when work naturally waits.

CPU-heavy work usually needs different strategies such as:

process pools
multiprocessing
native/vectorized code
dedicated worker systems

depending on the architecture.


47. Blocking Code Inside Async Code

This is one of the most important practical mistakes.

Bad architecture:

async def handler():
    time.sleep(10)

time.sleep() blocks the thread.

The event loop cannot use that time to schedule other tasks.

Async-friendly:

async def handler():
    await asyncio.sleep(10)

Now the coroutine suspends cooperatively.

Conceptually:

blocking call
    ↓
event loop stuck


awaitable wait
    ↓
coroutine suspended
    ↓
event loop free

48. Concurrency vs. Parallelism

These concepts should not be confused.

Concurrency means:

multiple tasks can make progress over overlapping time

Parallelism means:

multiple computations literally execute at the same time

Asyncio primarily provides a concurrency architecture.

Parallel execution may involve:

multiple CPU cores
multiple processes
native threads
accelerators
external services

A system can be:

concurrent but not parallel
parallel but not asynchronous
both
neither

49. Structured Async Work

A good async architecture should have clear ownership of tasks.

Instead of creating arbitrary background work and forgetting it, prefer structures where:

parent operation
    ↓
creates child work
    ↓
waits for / supervises child work
    ↓
handles errors
    ↓
finishes when children are accounted for

Modern async APIs increasingly encourage this kind of structured concurrency.

The architectural goal is:

Task lifetime should follow program structure rather than becoming invisible global background state.


50. asyncio.gather()

One common way to wait for several awaitables is:

results = await asyncio.gather(
    fetch_a(),
    fetch_b(),
    fetch_c(),
)

Conceptually:

schedule / coordinate A
schedule / coordinate B
schedule / coordinate C
        ↓
allow each to progress
        ↓
wait until combined operation completes
        ↓
collect results

This is useful when several independent asynchronous operations can proceed concurrently.


51. Task Groups

Task groups provide a more structured way to manage related async tasks.

Conceptually:

enter task group
     ↓
create child tasks
     ↓
children run concurrently
     ↓
wait for children
     ↓
propagate/manage failures
     ↓
exit only when group is resolved

The key architectural benefit is explicit task ownership.


52. Async Exceptions

Async tasks can fail just like ordinary functions.

If a coroutine raises:

raise ValueError(...)

that exception becomes part of the task/future outcome.

Conceptually:

Task
 ├── DONE with result
 or
 └── DONE with exception

When another coroutine awaits that task, the exception can be re-raised into the awaiting coroutine.

So async error flow still uses Python's normal exception architecture.


53. Cancellation

Async systems also need a way to stop work that is no longer needed.

Cancellation is not equivalent to:

instantly destroy this task anywhere

Instead, asynchronous cancellation is typically cooperative.

Conceptually:

request cancellation
       ↓
task reaches cancellation-aware suspension/resumption point
       ↓
cancellation exception participates in control flow
       ↓
finally / cleanup can run

This is why cleanup logic remains important in async programs.


54. Cleanup Still Matters in Async Code

Consider:

async def worker():
    resource = await acquire()

    try:
        await use(resource)
    finally:
        await release(resource)

Even in asynchronous code, resource ownership needs clear lifecycle boundaries.

This leads to asynchronous context managers.


55. Async Context Managers

A synchronous context manager uses:

__enter__
__exit__

An asynchronous context manager uses:

__aenter__
__aexit__

Example:

class AsyncResource:

    async def __aenter__(self):
        await connect()
        return self

    async def __aexit__(self, exc_type, exc_value, traceback):
        await disconnect()

Use:

async with AsyncResource() as resource:
    await resource.do_work()

Conceptually:

await __aenter__
      ↓
execute async block
      ↓
await __aexit__

56. Why Async Context Managers Exist

Some cleanup operations are themselves asynchronous.

Examples:

opening/closing network sessions
database transactions
distributed locks
async streams
remote resource leases

A normal:

with

cannot await its entry/exit protocol.

So Python provides:

async with

for resource lifecycles that require asynchronous operations.


57. Async Iteration

Normal iteration uses:

__iter__
__next__

Asynchronous iteration uses:

__aiter__
__anext__

Example:

async for item in stream:
    process(item)

Conceptually:

obtain async iterator
      ↓
await next asynchronous value
      ↓
process value
      ↓
await next asynchronous value
      ↓
...
      ↓
async iteration complete

This is useful when the next value may not be available immediately.


58. Why async for Exists

A normal iterator assumes:

next(iterator)

can synchronously produce the next result or raise StopIteration.

But imagine values arriving from:

network socket
message broker
remote API
database cursor
streaming service

The next value may require waiting.

So async iteration lets the consumer suspend while waiting:

request next value
      ↓
not ready
      ↓
suspend coroutine
      ↓
event loop does other work
      ↓
value arrives
      ↓
resume iteration

59. Async Generators

Python can combine:

async def

and:

yield

to create asynchronous generators.

Example:

async def stream():
    for i in range(3):
        await asyncio.sleep(1)
        yield i

Use:

async for item in stream():
    print(item)

Architecturally, async generators combine:

generator-style value production
+
coroutine-style waiting

They are powerful for streaming async pipelines.


60. Sync Iterator vs. Generator vs. Coroutine vs. Async Generator

A useful comparison:

| Construct | Produces values | Can suspend | Primary driver | | --------------- | -----------------------: | ---------------------: | -------------------------- | | Iterator | Yes | Between next() calls | caller | | Generator | Yes | At yield | caller | | Coroutine | Usually one final result | At await | event loop / awaiting task | | Async generator | Many values | At yield and await | async consumer/event loop |

This table captures a large part of Python's execution architecture.


61. The Common Architectural Principle

These constructs may look unrelated:

iterator
generator
context manager
coroutine
async generator

But they share a deeper pattern:

Python defines protocols that let objects participate in execution control.

Examples:

iteration
    ↓
__iter__ / __next__

context management
    ↓
__enter__ / __exit__

asynchronous iteration
    ↓
__aiter__ / __anext__

asynchronous context management
    ↓
__aenter__ / __aexit__

awaiting
    ↓
awaitable protocol

Python's architecture is heavily protocol-driven.


62. Protocols Instead of Special Cases

This is one of the deepest lessons in Python.

Instead of hard-coding behavior only for built-in types, Python often asks:

Does this object implement the required protocol?

Examples:

Can I iterate it?
→ iterator protocol

Can I use it in with?
→ context manager protocol

Can I await it?
→ awaitable protocol

Can I use it in async for?
→ async iterator protocol

This design enables user-defined objects to participate in language syntax.


63. Series 3 and Series 4 Connect Through Special Methods

In Series 3, we saw:

attribute access
descriptors
methods
MRO

In Series 4, we see more special methods:

__iter__
__next__
__enter__
__exit__
__aiter__
__anext__
__aenter__
__aexit__

This reveals a broader architectural pattern:

Python syntax often maps to object protocol methods.

For example:

for x in obj
      ↓
iteration protocol


with obj
      ↓
context-manager protocol


async for x in obj
      ↓
async-iteration protocol

Language features and the object model are deeply integrated.


64. Series 2 and Series 4 Connect Through Suspended Frames

In Series 2, function calls created execution frames.

Series 4 extends that model.

Ordinary function:

frame created
   ↓
runs
   ↓
returns
   ↓
done

Generator:

frame created
   ↓
runs
   ↓
yield
   ↓
suspended
   ↓
resumed
   ↓
yield / return

Coroutine:

coroutine execution state
   ↓
runs
   ↓
await
   ↓
suspended
   ↓
event loop resumes it

So generators and coroutines are not completely separate ideas.

They are specialized forms of resumable execution.


65. The Event Loop Is a Runtime Coordinator, Not Magic

It is tempting to think:

asyncio = automatic speed

A better model is:

event loop
    ↓
coordinate many computations
    ↓
run each when it can make progress
    ↓
avoid blocking while waiting

Async improves architecture when there is useful work to do while another operation waits.

If there is no waiting and everything is CPU-bound, async may add complexity without helping throughput.


66. A Full Architectural Example

Consider:

import asyncio
from contextlib import asynccontextmanager


@asynccontextmanager
async def connection():
    print("connect")

    try:
        yield "db"
    finally:
        print("disconnect")


async def fetch_rows():
    for value in range(3):
        await asyncio.sleep(1)
        yield value


async def process():
    async with connection() as db:

        async for row in fetch_rows():
            print(db, row)


asyncio.run(process())

This small example combines several major runtime systems.

Let's trace it.


67. Step 1 — asyncio.run()

Calling:

asyncio.run(process())

creates and manages an event-loop environment for the top-level coroutine.

Conceptually:

process coroutine
      ↓
event loop
      ↓
run until top-level work completes

68. Step 2 — Enter Async Context

This line:

async with connection() as db:

uses asynchronous context-manager behavior.

Conceptually:

connection context manager
        ↓
await entry
        ↓
yield / return resource
        ↓
db → "db"

The context manager establishes a resource lifecycle.


69. Step 3 — Async Iteration

This line:

async for row in fetch_rows():

creates an asynchronous generator.

The runtime repeatedly requests the next async value.

Conceptually:

request next item
      ↓
fetch_rows executes
      ↓
await sleep
      ↓
suspend
      ↓
event loop runs
      ↓
timer ready
      ↓
resume
      ↓
yield row

70. Step 4 — Generator Suspension

Inside:

fetch_rows()

the local variable:

value

is preserved between suspensions.

The async generator's state survives while it waits and between yielded values.

Conceptually:

async generator state

value → current loop value
instruction → suspended after await/yield

71. Step 5 — Context Cleanup

When the loop completes, execution leaves the:

async with

block.

Python executes the asynchronous exit protocol.

The finally block inside the generator-based context manager runs:

disconnect

This demonstrates coordinated cleanup across:

coroutines
async iteration
exceptions
context managers
generators

72. Step 6 — Completion

Once:

process()

finishes, the top-level async execution completes.

The event loop environment created by:

asyncio.run()

is then shut down according to its lifecycle.

This tiny program therefore uses nearly every architectural idea from Series 4.


73. The Full Python Runtime Mental Model

We can now combine all four articles.

SOURCE CODE
    ↓
Parsing / Compilation
    ↓
Bytecode / Interpreter Execution
    ↓
Objects in Memory
    ↓
Names and Namespaces
    ↓
LEGB / Scope
    ↓
Execution Frames
    ↓
Function Calls
    ↓
Argument Binding
    ↓
Mutation / Rebinding
    ↓
Closures / Decorators
    ↓
Classes / Instances
    ↓
Attributes / Descriptors
    ↓
Inheritance / MRO
    ↓
Iteration Protocol
    ↓
Generators / Suspended Frames
    ↓
Exceptions / Stack Unwinding
    ↓
Context Managers
    ↓
Coroutines / Awaitables
    ↓
Tasks / Futures
    ↓
Event Loop
    ↓
Async Iteration / Async Context Management

This is the architectural picture the four-part series has been building toward.

Python runtime architecture: context managers and async event loop

Figure 2 — Python’s runtime protocols connect exceptions, resource lifecycles, coroutines, tasks, futures, and the event loop into a cooperative execution model.


74. A Layered Architectural View

It is useful to organize the whole runtime into layers.

Layer 1 — Execution

source
AST
bytecode
interpreter
frames

Question:

How does Python execute code?


Layer 2 — Object and Memory Model

objects
identity
type
mutability
references
garbage collection

Question:

What exists at runtime?


Layer 3 — Name Resolution

names
namespaces
scope
LEGB

Question:

How does Python find a name?


Layer 4 — Callable Model

functions
parameters
arguments
closures
decorators

Question:

How does Python invoke behavior?


Layer 5 — Object-Oriented Model

classes
instances
attributes
methods
descriptors
inheritance
MRO

Question:

How does Python resolve behavior on objects?


Layer 6 — Execution-Control Protocols

iterators
generators
exceptions
context managers
coroutines
async protocols

Question:

How can execution pause, resume, clean up, and cooperate?


Layer 7 — Async Coordination

tasks
futures
event loop
readiness
cancellation
structured concurrency

Question:

How does Python coordinate many pieces of waiting work?


75. Five Rules Worth Memorizing

Rule 1 — for is protocol-driven

A for loop fundamentally relies on:

iter()
next()
StopIteration

Rule 2 — A generator is resumable execution state

yield does not merely return a value.

It:

produces a value
+
suspends generator execution

Rule 3 — Exceptions modify control flow across frames

An unhandled exception can propagate outward while Python unwinds the call stack.


Rule 4 — with defines a managed execution boundary

Context managers formalize:

enter
work
exit

and integrate with exception handling.


Rule 5 — await cooperatively suspends a coroutine

The event loop can run other ready tasks until the awaited operation allows the coroutine to continue.


76. Exercise 1 — Iterator State

Predict:

numbers = [1, 2, 3]

it = iter(numbers)

print(next(it))
print(next(it))

for value in it:
    print(value)
Answer

Output:

1
2
3

The iterator is stateful.

After two calls to:

next(it)

its position is already after 2.

The for loop continues from the same iterator state.


77. Exercise 2 — Generator Execution

Predict:

def example():
    print("A")
    yield 1

    print("B")
    yield 2

    print("C")


g = example()

print("X")
print(next(g))
print("Y")
print(next(g))
Answer

Output:

X
A
1
Y
B
2

Calling:

example()

creates the generator object.

Execution begins only when:

next(g)

resumes it.

Each yield suspends the frame.

C is not printed until the generator is resumed again after the second yield.


78. Exercise 3 — Context Manager Order

Given:

class Context:

    def __enter__(self):
        print("enter")
        return "value"

    def __exit__(self, exc_type, exc_value, traceback):
        print("exit")


with Context() as value:
    print(value)

Predict the output.

Answer
enter
value
exit

The flow is:

__enter__
   ↓
bind returned value
   ↓
execute block
   ↓
__exit__

79. Exercise 4 — Async Suspension

Consider conceptually:

async def task():
    print("A")
    await wait()
    print("B")

What happens if wait() is not yet ready?

Answer

The coroutine can suspend at:

await wait()

Control returns to the async scheduler/event loop.

Other ready tasks can run.

When the awaited operation becomes ready, the coroutine can resume and eventually print:

B

80. Exercise 5 — Blocking vs. Awaiting

Which is more appropriate inside an async function when you want to wait without blocking the event loop?

time.sleep(5)

or:

await asyncio.sleep(5)
Answer

Use:

await asyncio.sleep(5)

for an asynchronous delay.

It suspends the coroutine and lets the event loop run other work.

time.sleep(5) blocks the current thread.


81. Final Mental Picture

Whenever you see:

for item in source:

think:

iterable
   ↓
iterator
   ↓
next()
   ↓
value
   ↓
StopIteration

Whenever you see:

yield

think:

produce value
   +
suspend execution frame

Whenever you see:

with resource:

think:

enter protocol
   ↓
managed execution region
   ↓
exit protocol

Whenever you see:

await operation

think:

operation ready?
      │
      ├── yes → continue
      │
      └── no
           ↓
      suspend coroutine
           ↓
      event loop runs other work
           ↓
      resume later

And whenever you see:

asyncio.create_task(...)

think:

coroutine
   ↓
scheduled execution
   ↓
event loop coordinates progress

82. The Complete Four-Part Architecture

We can now summarize the entire series.

Series 1 — Execution, Memory, Namespaces, and LEGB

We learned:

how source code becomes executable work
how objects exist in memory
how names refer to objects
how namespaces organize names
how LEGB resolves ordinary names
how execution frames form the call stack

Series 2 — Functions, Arguments, Mutability, Closures, and Decorators

We learned:

functions are objects
calls create frames
arguments bind to parameter names
mutation differs from rebinding
closures preserve enclosing bindings
decorators transform callable behavior

Series 3 — Classes, Instances, Attributes, Methods, Inheritance, and MRO

We learned:

classes are objects
instances hold per-object state
attribute lookup is layered
functions become bound methods
descriptors customize lookup
inheritance follows the MRO
super() continues MRO lookup

Series 4 — Iteration, Generators, Context Managers, Exceptions, and Async

We learned:

iteration is protocol-driven
iterators preserve progress
generators preserve suspended execution
exceptions redirect control flow
context managers structure lifecycle
coroutines suspend at await
tasks schedule coroutines
futures represent later results
the event loop coordinates readiness
async protocols extend Python's execution model

83. Is This Enough to Understand Python's Internal Architecture?

For core Python runtime architecture, yes.

After these four articles, you should have a strong conceptual model for the machinery behind most Python code you write.

You should be able to reason about:

why names behave the way they do
why mutating a list differs from rebinding it
why closures retain state
why decorators work
why self is supplied automatically
why methods are descriptors
why properties work without parentheses
why inheritance follows MRO
why generators can pause and resume
why for loops work on custom objects
why with guarantees structured cleanup
why exceptions unwind frames
why async functions return coroutine objects
why await suspends instead of blocking
why an event loop can coordinate many I/O operations

More importantly, these are no longer isolated Python features.

They fit one architectural model.


84. What Remains Beyond the Core Series?

There is still a deeper implementation layer.

That layer is less about:

How Python language semantics fit together

and more about:

How CPython implements those semantics internally

That could become a separate future series:

Advanced CPython Internals

Topics could include:

PyObject memory layout
reference counts at the C level
pymalloc
arena / pool / block allocation
cyclic GC internals
code objects
frame objects
bytecode specialization
adaptive interpreter behavior
opcode dispatch
import machinery
module caching
the GIL
free-threaded CPython
C extensions
vectorcall
metaclasses in depth
descriptor implementation details
asyncio selector/proactor internals

Those are valuable topics, but they are implementation-specific and belong after the core architectural model is already clear.


85. The Final Architecture in One Diagram

The four articles reduce to one connected system:

                    PYTHON PROGRAM
                         │
                         ▼
                 Source / Bytecode
                         │
                         ▼
                   Execution Frames
                         │
          ┌──────────────┼──────────────┐
          ▼              ▼              ▼
       Objects         Names         Control Flow
          │              │              │
          ▼              ▼              ▼
     Mutability       Namespaces      Exceptions
     Identity         LEGB            Iteration
     References                       Generators
          │              │             Contexts
          └──────┬───────┘             Async
                 ▼                        │
             Functions                    ▼
                 │                    Event Loop
                 ▼                        │
        Arguments / Closures              ▼
          / Decorators               Tasks/Futures
                 │
                 ▼
              Classes
                 │
                 ▼
        Instances / Attributes
                 │
                 ▼
      Descriptors / Methods / MRO

That is the conceptual architecture behind modern Python.


Conclusion

Python looks simple because the language hides a large amount of runtime machinery behind readable syntax.

A line such as:

for item in stream:
    ...

may involve:

protocol lookup
iterator creation
stateful iteration
exception-based termination

A line such as:

bob.save()

may involve:

instance lookup
class lookup
descriptor binding
MRO
function call
argument binding
execution frame creation

A line such as:

await fetch()

may involve:

coroutine objects
awaitable protocol
suspended execution state
tasks
futures
event-loop scheduling
I/O readiness
resumption

The syntax is compact.

The runtime architecture underneath is rich.

The central lesson from the entire series is:

Python is a protocol-driven object runtime built around objects, names, namespaces, frames, attribute lookup, resumable execution, and well-defined control-flow protocols.

Once that model becomes intuitive, advanced Python stops feeling like a collection of tricks.

It becomes one connected system.