Callback for subinterpreter shutdown

Dear Python team,

I’ve been experimenting with adding subinterpreter support to the nanobind binding library and ran into the following blocker where the official API seems too limited.

Binding tools will generally have some per-subinterpreter internal state to keep track of types/instances of bindings. This state needs to be cleaned up at some point to avoid leaks. For classic CPython, the place to do so is via a callback at Py_AtExit which runs after Python has shut down, where “shut down” means: the process will segfault if you try to use the CPython API. Incidentally, this is a completely different and much lower-level interface compared to the eponymous Python-level atexit(). These get called when Python is still functional and can service API calls.

Python 3.13 adds PyUnstable_AtExit which adds the ability of firing per subinterpreter. But this API is no good to me: it runs during finalization, and before the GC has finished its sweeps to free remaining objects. This creates a major issue: it’s not safe to delete the internal binding state after this callback, since the subinterpreter may still run arbitrary extension code that needs access to these data structures.

So then the only option is to leak this interpreter state. Which creates a potential for unbounded memory growth in applications that frequently bring up or spin down subinterpreters.

I wish there was an API with “This subinterpreter is bereft of life, it rests in peace. It is an ex-subinterpreter.” kind of semantics.

Could it be added? Am I missing something obvious?

Thanks,
Wenzel

3 Likes

In SIP I use the module state to keep track of things. I have no per-subinterpreter state.

Phil

In general, this seems reasonable, but I have a few questions:

  1. Precisely, at which point during/after finalization do you want a callback? Just after all objects are dead? Or do you want it when the PyInterpreterState has been deallocated too? I am somewhat worried that we’ll eventually have 50 different “at exit” APIs that all run at different phases of finalization.
  2. Usually, in Python, reference counting solves this sort of thing. Why doesn’t that work here?
3 Likes

Thank you for your response.

  1. The guarantee I’d look for is that nothing from that interpreter state will call PyTypeObject callbacks anymore. Anything satisfying this rule is fine.
  2. Reference counting would in principle work, if it could be guaranteed that every last instance and type object of a subinterpreter is deleted.
    Given that we’re talking about state characterizing everything going on in a subinterpreter, it’s a “all or nothing” kind of situation. A single leaked PyObject* or PyTypeObject* would keep a much larger data structure alive.

Besides cleanup, one important purpose of these callbacks is the case where extensions leak references, breaking garbage collection. nanobind installs hooks and warns loudly about this, which requires these hooks firing at the right time where we can be sure that anything left over is truly a leak.

1 Like

Does this include built-in types that won’t call Python code?

A potential workaround would be to make a special object that calls your cleanup code in its tp_dealloc and store it somewhere that gets cleared late during finalization. But since built-in types are cleared last, they’ll still be alive. Psuedo-code:

class Transport:
    def __del__(self):
        delete_nanobind_state()

interp->builtins['_nanobind_special'] = Transport()

This is a last resort kind of workaround I had considered (not so nice because __builtins__ are visible to users). Besides that point, expecting anything from the GC is fragile in my experience. One example that bit me today while tracking down a leak:

Python types don’t list their metaclass in tp_traverse (the reasons for this are not clear to me). If you have a type hierarchy (including metaclasses) with reference cycles that is collectively unreferenced from the outside, a Python GC pass cannot clean it up all at once. The best it can do is to peel one ring, with more cleanup work left for the next pass. Currently, CPython does a fixed number (4 I believe?) of GC sweeps at interpreter shutdown. Even for perfectly valid code, this means that things can leak.

1 Like

To bound this from the other side, is there any Python C-API you still need to call?

No further CPython calls would be needed at that point. Basically the same behavior as Py_AtExit but for subinterpreters.

Should we fold runtime shutdown in? Py_AtExit’s lack of void* arg and the 32 callback limit are very 1994.

A somewhat maximalist proposal:

int Py_AtExitEx(int (*func)(void *arg), void *arg, int batch)
/* batch must be one of: */
// Run when the interpreter is still fully intact; threads are still around.
#define Py_ATEXIT_PRE_SHUTDOWN 1
// Run together with Python `atexit` callbacks (interspersed in LIFO order).
#define Py_ATEXIT_USER 2
// Run at end of interpreter shutdown. No Python C-API may be called.
#define Py_ATEXIT_POST_SHUTDOWN 3
// Run at end of runtime shutdown, all interpreters are gone. No C-API calls.
#define Py_ATEXIT_RUNTIME_SHUTDOWN 4

+ soft-deprecate Py_AtExit.

2 Likes

Yeah, I had the same idea. This would also allow us to add new phases later without a new API every time.

Maybe also remove PyUnstable_AtExit?

1 Like

This looks great! Is it missing a PyInterpreterState *interp argument?

Also, batch seems like an unusual name in this context. How about phase?

2 Likes