The Puzzle: How an Unreachable await Keeps Objects Alive

Python's asynchronous programming model with asyncio allows developers to write concurrent code using standard sequential syntax. However, subtle mechanics under the hood of CPython can lead to unexpected behaviors—especially regarding object lifetimes, garbage collection, and local variable retention.

Consider this counterintuitive scenario involving a weak reference and an await statement:

import asyncio
import gc
import weakref

class Resource:
    def __init__(self, name):
        self.name = name
    def __del__(self):
        print(f"destroyed: {self.name}")

# Case 1: await runs before return
async def producer_case_1():
    resource = Resource("A")
    reference = weakref.ref(resource)
    await asyncio.sleep(0)
    return reference

# Case 2: await placed after return (dead code)
async def producer_case_2():
    resource = Resource("A")
    reference = weakref.ref(resource)
    return reference
    await asyncio.sleep(0)  # Unreachable!

In Case 1, the resource is destroyed immediately upon returning, and checking reference() yields None. But in Case 2, despite the await statement being seemingly dead code placed after a return, the Resource instance remains alive in memory even after manual invocations of gc.collect().

Why does moving an await statement alter whether an object remains reachable, even without an active strong reference outside the coroutine? Let's dive deep into CPython frame lifecycles, generator mechanics, and bytecode execution.

The Core Reason: How Generators and Coroutines Clear Frames

In standard synchronous Python functions, hitting a return opcode causes the current execution frame to be popped and immediately deallocated. Once the frame goes away, its fast local variable array (fastlocals) is cleared, decrementing the reference count for all local objects.

Coroutines, however, are built on top of Python generator infrastructure:

  • A coroutine object owns a reference to its execution frame (cr_frame in CPython).
  • While a coroutine is executing or suspended at an await point, coroutine.cr_frame remains alive.
  • When a coroutine finishes by reaching the end of its code or hitting a return statement, CPython raises a StopIteration (or internally produces a return value) and transitions the coroutine to the completed state.
  • Crucially, CPython clears the frame reference (cr_frame = NULL) only when the generator or coroutine terminates execution.

Case 1 Breakdown: Natural Coroutine Completion

In Case 1:

await asyncio.sleep(0)
return reference

The coroutine yields execution at await asyncio.sleep(0). Later, the event loop resumes it. The next bytecode instruction executed is the return. Upon reaching the return, the coroutine returns the weak reference and finishes execution. Because the coroutine has completed, its frame (cr_frame) is detached and cleared. Local references inside that frame—including resource—are freed immediately via reference counting.

Case 2 Breakdown: The Compiler's Evaluation Stack and Generator Semantics

Now look at Case 2:

async def producer_case_2():
    resource = Resource("A")
    reference = weakref.ref(resource)
    return reference
    await asyncio.sleep(0)

Because the function contains the keyword await, Python compiles the function body as a coroutine code object. When you call await producer_case_2(), the awaiting mechanism uses the __await__ protocol (which delegates to the coroutine's internal iteration step).

When producer_case_2 runs, it executes up to return reference. In CPython coroutines:

  1. The coroutine raises a StopIteration(reference) exception (or returns the value via C-level opcode RETURN_VALUE).
  2. The consumer (the caller awaiting the coroutine) catches the result and receives reference.
  3. However, depending on the exact CPython version, generator frames that exit via an explicit early return inside generator-based coroutines might not eagerly clear their f_locals slots until the coroutine object itself is deallocated.
  4. If the caller or temporary evaluation stack holds onto the coroutine object (e.g., in a temporary variable or frame wrapper), coro.cr_frame remains non-null. The frame holds a strong reference to resource in its f_localsplus array.

The Reference Graph in Detail

To visualize why the object stays alive, consider the chain of references in memory:

asyncio Event Loop / Task Frame
   └── Temporary Evaluation Stack / Await Wrapper
         └── Coroutine Object (`producer_case_2`)
               └── `cr_frame` (Execution Frame)
                     └── `f_locals` array: index 0 -> Resource("A")

Because Resource("A") still has a strong reference from the suspended or un-cleared frame's local variable slot, its reference count never drops to 0. Moreover, this is not a reference cycle: it is a straight line of strong references, which explains why gc.collect() has no effect on cleaning it up.

Language Guarantee vs. Implementation Detail

Is this behavior guaranteed by the Python language specification? No.

The Python language specification guarantees that objects are garbage collected once they become unreachable. However, the exact timing of when execution frames are released and when local slots are nulled out is an implementation detail of CPython:

  • In CPython 3.11 and 3.12, the internal handling of frames was rewritten as part of the Faster CPython initiative (PEP 659). Frames are now allocated on a chunked stack and evaluated differently than in Python 3.8–3.10.
  • Dead code elimination (DCE) in the CPython peephole and bytecode optimizer may or may not strip out instructions after an unconditional return, but it preserves the coroutine flag on the code object if await was present during parsing.

How to Inspect the Reference Chain Reliably

Using gc.get_referrers(resource) directly often pollutes the results with the inspection frame itself. A cleaner approach is to use Python's traceback and inspect modules or filter out frame referrers:

import gc
import inspect

def inspect_referrers(target):
    referrers = gc.get_referrers(target)
    for ref in referrers:
        if inspect.isframe(ref):
            print(f"Held by Frame: {ref.f_code.co_name} at line {ref.f_lineno}")
            print(f"Locals: {ref.f_locals.keys()}")
        elif isinstance(ref, dict):
            print("Held by dict / namespace")
        else:
            print(f"Held by {type(ref)}")

Best Practices to Avoid Leaking Resources in Coroutines

To avoid unintended lifetime extensions of heavyweight resources (like database handles, sockets, or large memory buffers):

  1. Use Context Managers: Always manage resource teardown explicitly with async with or try...finally blocks.
  2. Explicitly Nullify References: If a coroutine must remain alive across long suspensions or complex branches, set large objects to None explicitly (e.g., resource = None).
  3. Avoid Unreachable Coroutine Code: Remove dead code containing await or yield keywords to prevent altering the compilation mode and lifecycle of the function frame.

Conclusion

Moving an await changes how CPython compiles and yields from a function frame. When an await causes a function to be compiled as a coroutine, its frame lifecycle is tied to the coroutine object rather than standard function exit semantics. Until that coroutine object is thoroughly disposed of, its local variables may stay alive, keeping your objects strongly reachable in memory.