Generator-based coroutines, built upon the foundational yield expression, represent the original incarnation of coroutines in Python. Unlike native async/await coroutines, which are designed for explicit asynchronous I/O, generator-based coroutines are a more general-purpose concurrency tool. They operate by suspending their execution state, allowing other code to run, and then resuming precisely where they left off. This suspension and resumption are orchestrated through a trio of special methods on the generator object: send(), throw(), and close().

The send() Method: Two-Way Communication

The yield keyword is not just for output; it’s a two-way street. When a generator yields a value, it pauses. The send() method is used to resume it and send a value back into the generator, which becomes the result of the yield expression where it was paused. This transforms the generator from a simple producer of values into a co-operative routine that can both receive and process input.

Consider a generator that acts as a simple accumulator or running tally:

def accumulator():
    total = 0
    while True:
        value = yield total  # Yields total and pauses. When resumed, value is set by send(x)
        if value is None:
            break
        total += value

# Using the coroutine
acc = accumulator()
next(acc)  # Prime the generator: advance to the first yield (returns 0)

print(acc.send(10))  # Sends 10, value = 10, total=10, yields 10 -> output: 10
print(acc.send(20))  # Sends 20, value = 20, total=30, yields 30 -> output: 30
print(acc.send(5))   # Sends 5, value = 5, total=35, yields 35 -> output: 35

# To close it, we might send a sentinel value like None
try:
    acc.send(None)  # This causes the break and the generator exits normally
except StopIteration:
    print("Generator finished.")

The initial call to next(acc) is crucial. It advances the generator to the first yield statement, making it ready to receive the first value via send(). This process is known as “priming” the coroutine. Without this step, trying to send() a value to a generator that hasn’t reached its first yield would result in a TypeError.

The throw() Method: Injecting Exceptions

The throw() method provides a mechanism for the caller to raise an exception inside the generator at the point where it is currently suspended (at the yield expression). This allows for external control over the generator’s flow, enabling error handling, early termination signals, or other control patterns without relying solely on sentinel values.

def resilient_accumulator():
    total = 0
    try:
        while True:
            try:
                value = yield total
                total += value
            except ValueError as e:
                print(f"Ignoring invalid value: {e}")
    finally:
        print(f"Final total: {total}")

gen = resilient_accumulator()
next(gen)  # Prime to first yield
print(gen.send(10))  # Output: 10
print(gen.throw(ValueError("'foo' is not a number")))  # Exception is raised inside the generator at the `yield`
# The generator's except block catches it, prints the message, and the loop continues
print(gen.send(5))   # Output: 15
gen.close()

When gen.throw(ValueError(...)) is called, the ValueError is raised on the line value = yield total. The generator’s own try...except block then handles it, logging the message and allowing the coroutine to continue its loop. If the generator did not handle the thrown exception, it would propagate out to the caller of throw().

The close() Method: Graceful Termination

The close() method is used to signal to the generator that it should terminate immediately. It works by injecting a GeneratorExit exception into the generator at the suspension point. This exception is intended to trigger the generator’s cleanup code (i.e., any finally blocks or context manager __exit__ methods).

def monitored_coroutine():
    try:
        value = 0
        while True:
            value = yield value
            print(f"Processed: {value}")
    finally:
        print("Performing crucial cleanup...")

gen = monitored_coroutine()
next(gen)
print(gen.send(100))  # Prints "Processed: 100", then yields 100
print(gen.send(200))  # Prints "Processed: 200", then yields 200
gen.close()           # Injects GeneratorExit, triggering the finally block.
# Output:
# Processed: 100
# 100
# Processed: 200
# 200
# Performing crucial cleanup...

It is a best practice to always structure generator-based coroutines with a try...finally block to ensure resources like open files or network connections are properly released, even if the coroutine is closed prematurely. If a generator ignores the GeneratorExit and yields another value (instead of exiting), the close() method will raise a RuntimeError.

Common Pitfalls and Best Practices

  1. Priming the Coroutine: Forgetting to call next() or send(None) first is a very common mistake. The generator must be advanced to its first yield before it can receive values.
  2. State Management: Generator coroutines maintain state naturally within their local variables. This makes them excellent for modeling state machines or complex multi-step processes without needing to create a class.
  3. Limited Use with Async Frameworks: While powerful, generator-based coroutines are largely superseded by native async/await coroutines for I/O-bound concurrency. They cannot be directly awaited in an asyncio event loop without compatibility layers (@asyncio.coroutine decorator, deprecated since Python 3.8).
  4. Exception Handling: Be deliberate about what exceptions your coroutine handles internally versus what it allows to propagate out. The throw() method makes the caller a direct participant in the generator’s error handling.
  5. Finalization: Rely on close() and the resulting finally blocks for cleanup. Do not assume the generator will be exhausted naturally; it might be garbage collected early, which also triggers close().