Skip to content

Instantly share code, notes, and snippets.

@graingert
Created February 7, 2026 13:27
Show Gist options
  • Select an option

  • Save graingert/52897f37e39965e18447b93d2457d411 to your computer and use it in GitHub Desktop.

Select an option

Save graingert/52897f37e39965e18447b93d2457d411 to your computer and use it in GitHub Desktop.
Py->Py vs send and throw

Elaboration on the Key Difference

The Fundamental Distinction

The core difference is about who holds responsibility for the reference during a function call:

1. Python-to-Python Calls (Reference Move)

When Python code calls a Python function through the bytecode interpreter:

  • The caller's evaluation stack transfers ownership of the argument references to the callee
  • The caller's stack slots are cleared immediately after the arguments are passed
  • No additional Py_INCREF is needed because the reference is moved, not copied
  • Once the call begins, the caller no longer has those references on its stack

2. C-to-Python Calls (Reference Duplicate/Hold)

When C code calls a Python function (including coroutine.send() and coroutine.throw()):

  • The C caller retains a strong reference to the arguments
  • The arguments are incremented (Py_INCREF) before being passed to the Python callee
  • The C caller must keep these references until the call completes and returns
  • This is necessary because C code operates outside the Python interpreter's normal reference transfer mechanism

Why coroutine.send() and coroutine.throw() Hold References

Looking at the CPython source code in Objects/genobject.c, both send() and throw() explicitly increment the reference count of their arguments:

// In gen_throw:
Py_INCREF(typ);
Py_XINCREF(val);
Py_XINCREF(tb);

These methods must hold onto their arguments until they return because:

  1. They're C API entry points: When you call coro.send(value) or coro.throw(exc) from Python, you're crossing from Python bytecode into C extension code

  2. Resume semantics: The coroutine/generator resumes execution and may run for an arbitrary amount of time before yielding back. During this time:

    • The send() or throw() method frame on the C stack needs to maintain valid references
    • If the Python code inside the coroutine were the only holder of these references, they could be prematurely collected
    • The C function needs to handle error cases and cleanup, requiring stable references
  3. Error handling: As shown in the code, there's a failed_throw: label that decrements the references if the operation fails before completing. The C code needs to maintain these references for proper cleanup.

The Practical Impact

This is why your test case behaves differently:

def other(a):
    a = weakref.ref(a, lambda _: v.append(True))
    while not v:
        gc.collect()  # Hangs in old PyPy

def main():
    other(A())  # Regular Python-to-Python call
  • CPython 3.11+ and your PyPy fix: The reference to A() is moved onto other's stack, not copied. When other executes a = weakref.ref(a, ...), the last strong reference disappears and the weakref callback fires immediately.

  • Old PyPy: The reference to A() stayed on the caller's stack throughout the call, creating an extra reference that prevented immediate garbage collection.

Coroutine Example

If you tried to do the same thing with coroutine.send():

async def coro():
    obj = yield
    obj = weakref.ref(obj, callback)
    # obj won't be collected here even in CPython 3.11
    # because send() holds a C-level reference

c = coro()
c.send(None)  # prime it
c.send(A())  # send() must hold this reference until it returns

The reference to A() persists in send()'s C stack frame until the method returns, regardless of Python 3.11's optimizations. This is an inherent difference between the C API boundary and pure Python-to-Python calls.

@graingert
Copy link
Author

Now let me look at issue #47 that this PR is trying to fix:Now let me look at the test file changes to understand the practical demonstration:Let me search for the specific test changes:Based on the information from the PRs and issues, I can now explain the relevance:

Why the PyPy Frame Stack Reference Fix is Relevant to outcome PR #49

The connection between your PyPy PR #5087 and the outcome PR #49 is about reference lifetime management in async/concurrent contexts, particularly involving coroutine.send() and coroutine.throw().

The Core Problem in outcome

The outcome library is used heavily in Trio (an async framework) to pass results between coroutines. Here's the typical pattern:

# In Trio's scheduler
outcome_obj = capture(fn, *args)  # Wraps result or exception
# ... later ...
outcome_obj.unwrap()  # Extracts the result or re-raises the exception

The issue is that even after unwrap() is called, the Outcome object keeps holding references to:

  • For Error: The exception object (which includes the full traceback and all local variables)
  • For Value: The value object (which might be large, like megabytes of data)

Why This Matters Specifically for Coroutines

This becomes particularly problematic with coroutine.send() and coroutine.throw(), as mentioned in issue #47:

  1. coroutine.send(value) scenario:

    # Trio sends an Outcome containing a large bytes object
    coro.send(Value(large_bytes_data))

    Inside the coroutine:

    outcome = yield
    decoded_text = outcome.unwrap()  # Gets the bytes, decodes it
    # But outcome.value still holds large_bytes_data!
    await some_other_operation()  # Both decoded and original stay in memory
  2. coroutine.throw(exc) scenario:

    # Trio sends an Error with full traceback
    coro.throw(Error(exception_with_traceback))

    Inside the coroutine:

    try:
        outcome.unwrap()  # Re-raises the exception
    except SomeException:
        handle_it()
    # But outcome.error still holds the exception + traceback + all locals!

How Your PyPy Fix Connects

Your PyPy fix improves reference management for Python-to-Python calls by moving references instead of duplicating them. However, coroutine.send() and coroutine.throw() are C-to-Python calls that must hold duplicate references.

This means:

  1. When Trio calls coro.send(outcome_obj):

    • The C implementation of send() holds a reference to outcome_obj throughout the entire execution until send() returns
    • Even if the coroutine code does value = outcome.unwrap() and stops using outcome, the C stack frame keeps it alive
    • This is unavoidable due to the C API boundary
  2. The practical impact:

    • Even with your PyPy fix, async frameworks like Trio can't rely on immediate garbage collection of Outcome objects after unwrapping
    • The Outcome object persists until the coroutine yields control back through send() or throw()
    • This means memory bloat: large values or exception tracebacks stay in memory longer

The Solution in PR #49

The unwrap_and_destroy() method explicitly clears the internal reference:

class Value:
    def unwrap_and_destroy(self):
        value = self.value
        self.value = None  # Explicitly clear the reference!
        return value

class Error:
    def unwrap_and_destroy(self):
        try:
            raise self.error
        finally:
            self.error = None  # Clear the exception + traceback!

This way, even though the Outcome object itself is kept alive by send()/throw()'s C stack frame, the actual data (the large value or the traceback) is released immediately.

Summary

Your PyPy fix addresses Python-to-Python call reference management, but coroutine.send() and coroutine.throw() are C-to-Python calls that inherently hold references longer. The outcome PR #49 works around this limitation by providing an explicit API to clear internal references, ensuring that even when the container object is kept alive by the C stack, the expensive payload is freed promptly. This is particularly critical in async frameworks where coroutines may be suspended for extended periods while holding onto Outcome objects.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment