Hi,
Over the past several months, I’ve been playing around with a bit of a hobby/research project for immortalizing mutable, arbitrary objects. For a while, it was a solution in search of a problem, but I think there might be some actual use cases now.
My implementation can be found here. You can try it out:
import sys
obj = object()
sys._immortalize(obj)
assert sys._is_immortal(obj)
The cool part is that an immortal object is not leaked, it is deallocated at the end of an interpreter’s lifetime using a virtual garbage collection mechanism. I won’t go into too many details, but there’s some hacking of the allocator to ensure that object structures remain valid while executing destructors. As such, an immortalized object must be allocated using CPython’s object or memory domain (PyObject_Malloc
and PyMem_Malloc
). This contract already exists for the free-threaded build, and I don’t think it would be too hard of a sell to establish this contract for the GIL-ful build. I haven’t ever seen code in the wild that uses the system malloc
or something like that to allocate an object.
The main case that I developed it for (apart from fun) is object sharing between subinterpreters. My design uses an immortal object proxy that wraps any Python object, and then all the methods of that proxy switch to the correct interpreter and call the wrapped object, returning the wrapped result in a new object proxy. This works on both FT and non-FT builds, because if you have an attached thread state for an interpreter, you are allowed to call any object in it. The tradeoff is that the proxy nor the wrapped object can be deallocated for the lifetime of the interpreter. To visualize, here’s some psuedo-code:
class SharedObjectProxy:
def __init__(self, wrapped):
self.interp = PyInterpreterState_Get()
self.wrapped = wrapped
sys._immortalize(self)
# An example using __call__().
# This pattern is used for every dunder method on the type.
def __call__(self, *args, **kwargs):
tstate = None
new_tstate = None
if self.interp != PyInterpreterState_Get():
new_tstate = PyThreadState_New(self.interp)
tstate = PyThreadState_Swap(new_tstate)
result = SharedObjectProxy(self.wrapped.__call__(*args, **kwargs))
if tstate is not None:
PyThreadState_Clear(new_tstate)
PyThreadState_Swap(tstate)
PyThreadState_Delete(new_tstate
return result
Using this design, an instance of SharedObjectProxy
is usable from any interpreter, which is really helpful for sharing objects that cannot (easily) be serialized. I designed a proof of concept for this a while back, but I think I need to update it to work with 3.15. There’s definitely room for improvement here, but conceptually, is this something people would like to see in the standard library, especially with the recent acceptance of PEP 734?
I’m not aware of other places where immortalization would be helpful outside of subinterpreters, but that’s the other reason why I’m opening this thread: are there other cases where safe immortalization would be useful? Or, are there other plans for the core that would be hurt by AOI?