diff --git a/pep-9999.rst b/pep-9999.rst new file mode 100644 index 00000000000..1e99f32b3a1 --- /dev/null +++ b/pep-9999.rst @@ -0,0 +1,669 @@ +PEP: 9999 +Title: Deep Immutability in Python +Author: Matthew Johnson , Matthew Parkinson , Sylvan Clebsch , Fridtjof Peer Stoldt , Tobias Wrigstad +Sponsor: TBD +Discussions-To: TBD +Status: Draft +Type: Standards Track +Content-Type: text/x-rst +Created: 27-Feb-2025 +Python-Version: 3.12 +Post-History: +Resolution: + + +Abstract +======== + +This PEP proposes adding a mechanism for deep immutability to +Python via a new builtin called ``freeze(obj)``. This builtin +takes a reference to an object and recursively renders the object +and all objects it references immutable. (This is *deep* +immutability --- just making the first object immutable is called +*shallow* immutability.) + +Deep immutability will provide stronger guarantees against +unintended modifications, improving correctness, security, and +parallel execution safety. + +In this PEP, we rely +on the GIL to ensure the correctness of reference counts of +immutable objects, but we have several planned memory management +extensions. + +Immutability in action: + +.. code-block:: python + + class Foo: pass + + f = Foo() + g = Foo() + h = Foo() + + f.f = g + g.f = h + h.f = g # cycles are OK! + + g.x = "African Swallow" # OK + freeze(f) # Makes, f, g and h immutable + g.x = "European Swallow" # Throws immutability exception + + +Motivation +========== + + +Ensuring Data Integrity +----------------------- + +Python programs frequently manipulate large, interconnected data +structures such as dictionaries, lists, and user-defined objects. +Unintentional mutations can introduce subtle and +difficult-to-debug errors. By allowing developers to explicitly +freeze objects and their transitive dependencies, Python can +provide stronger correctness guarantees for data processing +pipelines, functional programming paradigms, and API boundaries +where immutability is beneficial. + + +Immutable Objects can be Freely Shared Without Risk of Data Races +----------------------------------------------------------------- + +Python’s Global Interpreter Lock (GIL) mitigates many data race +issues, but as Python evolves towards improved multi-threading and +parallel execution (e.g., subinterpreters and the exciting free-threaded Python +efforts), data races on shared mutable objects become a more +pressing concern. A deep immutability mechanism ensures that +shared objects are not modified concurrently, enabling safer +multi-threaded and parallel computation. Safe sharing of immutable +objects across multiple threads require deep immutability. +Consider the following code: + +.. code-block:: python + + import threading + + data = [1, 2, 4, 8] + length = len(data) + pair = (data, length) + + threading.Thread(target=print, args=(pair,)).start() + + del data[2] + +The shallow immutability of the ``pair`` tuple prevents the +``data`` list from being swapped for another list, but the +list is not immutable. Thus, the ``print`` function in the +newly spawned thread will be racing with the deletion. In +Python 3.12, this is not a problem as the GIL prevents this +race. To ensure `container thread-safety +`_, +PEP 703 proposes per-object locks instead. If ``pair`` was +frozen, the deletion would have caused an error. + +The following image illustrates that as soon as an object *a* +is reachable by two threads, then all other objects that +*a* can reach are also reachable by both threads. The dashed +red references to *c* and *d* are not possible because then +*c* and *d* would not be in areas where only a single thread +could reach them. + +To map the code example above to the figure -- ``pair`` is *a* and ``list`` is *b*. + +.. image:: pep-9999/sharing1.png + :width: 50% + +See also the discussion about extensions further down in this +document. + +Deep immutability can be implemented efficiently. We discuss +a read-barrier based approach in the alternatives. + + +Optimisations and Caching Benefits +---------------------------------- + +Immutable objects provide opportunities for optimisation, such as +structural sharing, memoization, and just-in-time (JIT) +compilation techniques (specialising for immutable data, e.g. +fixed shape, fewer barriers, inlining, etc.). Freezing objects can +allow Python to implement more efficient caching mechanisms and +enable compiler optimisations that rely on immutability +assumptions. This PEP will permit such opportunities to go +beyond today's immutable objects (like ``int``, ``string``) and +*shallow* immutable objects (``tuple``, ``frozenset``). + + + +Specification +============= + +Changes to Python objects +------------------------- + +Every Python object will have a flag that keeps track of its +immutability status. Details about the default value of +this flag is discussed further down in this document. + +The flag can be added without extending the size of the +Python object header. + + +Implementation of Immutability +------------------------------ + +Immutability is enforced through run-time checking. The macro +``Py_CHECKWRITE(op)`` is inserted on all paths that are guaranteed +to end up in a write to ``op``. The macro inspects the immutability +flag in the header of ``op`` and signals an error if the immutability +flag is set. + +A typical use of this check looks like this: + +.. code-block:: c + + if (!Py_CHECKWRITE(op)) { // perform the check + PyErr_WriteToImmutable(op); // raise the error if the check fails + return NULL; // abort the write + } + ... // code that performs the write + + +As writes are common but lack a common path that most writes to through +the PEP requires a ``Py_CHECKWRITE`` call, there are several places in +the CPython code base that are changed as a consequence of this PEP. +So far we have identified around 70 writes spread across a dozen files. + + +New Obligations on C Extensions +------------------------------- + +Because our implementation builds on information available to the CPython +cycle detector, types defined through C code will support immutability +"out of the box" as long as they use Python standard types to store +data. + +C extensions with functions that directly writes to data which can be made +immutable should add the ``Py_CHECKWRITE`` macro shown above on all paths in the +code that lead to writes to that data. If C extensions manage their +data through Python dictionary objects, no changes are needed. + +Python objects may define a ``__freeze__`` method which will be called +after an object has been made immutable. This hook could then be used +to freeze or otherwise manage any other state on the side that is +introduced through a C-extension. + +C extensions that define data that is outside of the heap traced by the +CPython cycle detector should either manually implement freezing by using +``Py_CHECKWRITE`` and the ``__freeze__`` hook, or alternatively ensure +that all accesses to this data is *thread-safe*. + + +Freezing Type Which are Not Immutability-Aware +---------------------------------------------- + +**TODO** Solicit feedback from extension authors. + +We could take a conservative stance and require that any C extension +that defines a type must explicitly opt-in to declare support for +immutability. Furthermore, we might interpret freezing strictly and +have freeze fail if freezing encounters a type that does not support +immutability. This gives us a "sound model" where a successful freeze +call results in deep immutability that can be trusted (modulo bugs in +the C extensions). The obvious downside is more work for the Python +community in order to leverage deep immutability. + +That model can be relaxed by permitting strict and lax versions of +freeze to co-exist. For example if strict freezing is the default, +``freeze(o)`` will fail if ``o`` reaches a type ``T`` which does not support +freezing, whereas ``freeze(o, strict=False)`` would permit the resulting +object structure, which can still be mutated in ``T``. + +Making strict false by default is additionally permissive, and still +allows programmers to opt-in to strict freezing when they so wish. + +If freezing can fail to freeze an object we must decide whether to: + +1. Abort and throw an exception +2. Leave it and live with the unsoundness +3. Nullify the reference to the non immutability-aware object +4. ...something else + +While alternative 3 will ensure that frozen objects are truly +immutable, it has the ugly side-effect that making something +immutable can mutate the heap. This seems to violate the "principle +of least surprise". + +If freezing can be aborted, we also need to take a decision on in what +state we leave the system. Do we leave some structures *partially* +frozen? If we don't want that (which we probably don't), we need to +save a log of things to unfreeze in case of an error. This will add +to the cost of freezing things. + + +Examples of Uses of CHECKWRITE +------------------------------ + +Inspiration and examples can be found by looking at existing +uses of ``Py_CHECKWRITE`` in the CPython codebase. Two good +starting places are ``object.c`` `[1]`_ and ``dictobject.c`` `[2]`_. + +.. _[1]: https://github.com/mjp41/cpython/pull/51/files#diff-ba56d44ce0dd731d979970b966fde9d8dd15d12a82f727a052a8ad48d4a49363 +.. _[2]: https://github.com/mjp41/cpython/pull/51/files#diff-b08a47ddc5bc20b2e99ac2e5aa199ca24a56b994e7bc64e918513356088c20ae + +Deep Freezing Semantics +----------------------- + +The ``freeze(obj)`` builtin works as follows: + +1. It recursively marks ``obj`` and all objects reachable from ``obj`` + immutable. +2. If ``obj`` is already immutable (e.g., an integer, string, or a + previously frozen object), the recursion terminates. +3. The freeze operation follows object references (relying on ``tp_traverse`` + in the type structs of the objects involved), including: + + * Object attributes (``__dict__`` for user-defined objects, + ``tp_dict`` for built-in types). + * Container elements (e.g., lists, tuples, dictionaries, + sets). + * The ``__class__`` attribute of an object (which makes freezing + instances of user-defined classes also freeze their class + and its attributes). + * The ``__bases__`` chain in classes (freezing a class freezes its + base classes). + +5. Attempting to mutate a frozen object raises an exception (``NotWriteableError``). + + +Illustration of the Deep Freezing Semantics +------------------------------------------- + +Consider the following code: + +.. code-block:: python + + class Foo: pass + x = Foo() + x.f = 42 + +If we glance over some implementation details such as the classes of strings and ints being +implemented in C, and the naming of fields (the ``__metaclass__`` fields are named ``__class__`` etc.), +the code above gives rise to the following object graph: + +.. image:: pep-9999/freeze1.svg + :width: 66% + +The ``Foo`` instance pointed to by ``x`` consists of several objects: its fields are stored in a +dictionary object, and the assignment ``x.f = 42`` adds two objects to the dictionary in the form +of a string key ``"f"`` and its associated value ``42``. These objects each have a class pointers +pointing to the ``string`` and ``int`` classes respectively. Similarly, the ``foo`` instance has +a pointer to the ``Foo`` class. Finally, all these classes have a pointer to the same meta class +object. + +Calling ``freeze(x)`` will freeze **all** of these objects. + +(Note that in this particular case, both ``"f"`` and ``42`` will be frozen already, see +default immutability below.) + + +Default (Im)Mutabiliy +--------------------- + +Interned strings, numbers in the small integer cache, and tuples of +immutable objects are made immutable in this PEP. This is either +consistent with current Python semantics or backwards-compatible. + +A reasonable design would make all numbers immutable, not just those +in the small integer cache. This should be properly investigated. + + +Consequences of Deep Freezing +============================= + +* The most obvious consequence of deep freezing is that it can lead + to surprising results when programmers fail to reason correctly + about the object structures in memory and how the objects reference + each other. For example, consider ``freeze(x)`` followed by + ``y.f = 42``. If the object in ``x`` can reach the same object that + ``y`` points to, then, the assignment will fail. +* Class Freezing: Freezing an instance of a user-defined class + will also freeze its class. Otherwise, sharing an immutable object + across threads would lead to sharing its *mutable* type object. Thus, + freezing an object also freezes the type type object of its super + classes. +* Metaclass Freezing: Since class objects have metaclasses, + freezing a class may propagate upwards through the metaclass + hierarchy. +* Global State Impact: Freezing an object that references global + state (e.g., ``sys.modules``, built-ins) could inadvertently + freeze critical parts of the interpreter. + +As the above list shows, a side-effect of freezing an object is +that its type becomes frozen too. Consider the following program, +which is not legal in this PEP because it modifies the type of an +immutable object: + +.. code-block:: python + + class Counter: + def __init__(self, initial_value): + self.value = initial_value + def inc(self): + self.value += 1 + def dec(self): + self.value -= 1 + def get(self): + return self.value + + c = Counter(0) + c.get() # returns 0 + freeze(c) # (1) -- this locks the value of the counter to 0 + ... + Counter.get = lambda self: 42 # OK in CPython 3.12, throws exception with this PEP + c.get() # returns 42 in CPython 3.12 + +With this PEP, the code above throws an exception on +Line (1) because the type object for the ``Counter`` type +is immutable. Our freeze algorithm takes care of this as +it follows the class reference from ``c``. If we did not +freeze the ``Counter`` type object, the above code would +work and the counter will *appear* to be mutable because +of the change to its class. + +The dangers of not freezing the type is apparent when considering +avoiding data races in a concurrent program. If a frozen counter +is shared between two threads, the threads are still able to +race on the ``Counter`` class type object. + +As types are frozen, this problem is avoided. Note that +freezing a class needs to freeze its superclasses as well. + + +Subclassing Immutable Classes +----------------------------- + +CPython classes hold references to their subclasses. +If immutability it taken literally, it would not be +permitted to create a subclass of an immutable type. +Because this reference is "accidental" and does not +get exposed to the programmer in any dangerous way, +we permit frozen classes to be subclassed (by mutable +classes). C.f. `Sharing Immutable Data Across Subinterpreters`_. + + +Freezing Function Objects +------------------------- + +Freezing function objects and lambdas is suprisingly involved +because all function objects have a pointer to ``globals``. +Function objects can be thought of as regular objects whose +fields are its local variables -- some of which may be captured +from enclosing scopes. + +Consider the following scenario: + +.. code-block:: python + + x = 0 + def foo(): + return x + + freeze(foo) + ... # some code, e.g. pass foo to another thread + x = 1 + foo() + +In the code above, the ``foo`` function object captures the ``x`` +variable from its enclosing scope. While ``x`` happens to point to +an immutable object, the variable itself (the frame of the function object) +is mutable. Unless something is done to prevent it (see below!), passing +``foo`` to another thread will make the assignment ``x = 1`` a potential +data race. + +We consider freezing of a function to freeze that function's +meaning at that point in time. In the code above, that means that +``foo`` gets its own copy of ``x`` which will have value of the +original at the time of freezing, in this case 0. + +Thus, the assignment ``x = 1`` is still permitted as it will not affect +``foo``, and it may therefore not contribute to a data race. Furthermore, +the result of calling ``foo()`` will be 0 -- not 1! + +This can be implemented by having ``x`` in ``foo`` point to a +fresh cell and then freezing the cell (and similar for global capture). + +We believe that this design is a sweet-spot that is intuitive and +permissive. + +Now consider freezing the following function: + +.. code-block:: python + + x = 0 + def foo(a = False): + if a: + a = a + 1 + return a + else: + x = x + 1 + return x + + freeze(foo) + foo(41) # OK, returns 42 + foo() # Throws NotWriteableError + +This example illustrates two things. The first call to ``foo(41)`` +shows that local variables on the frame of a frozen function are +mutable. The second call shows that captured variables are not. +Note that the default value of ``a`` will be frozen when ``foo`` +is frozen. Thus, the problem of side-effects on default values +on parameters is avoided. + +Frozen function objects that access globals, e.g. through a call +to ``globals()`` will throw an exception when called. + + +Implementation Details +====================== + +1. Introduce an ``is_immutable`` property on objects that tracks whether or + not they are frozen. The status is accessible through ``_Py_ISIMMUTABLE`` + in the C API and in Python code through the ``isimmutable`` builtin. +2. Modify object mutation operations (``PyObject_SetAttr``, + ``PyDict_SetItem``, ``PyList_SetItem``, etc.) to check the + flag and raise an error when appropriate. +3. Implement ``freeze(obj)``, ensuring it traverses object references + safely, including cycle detection. +4. The builtin ``freezeglobals()`` freezes the globals dictionary and + builtins. This is needed for multi-threaded scenarios such as + `PEP 703`_ and our planned extensions. + + +Backward Compatibility +====================== + +This proposal is fully backward-compatible, as no existing Python +code will be affected unless it explicitly calls ``freeze(obj)``. +Frozen objects will raise errors only when mutation is attempted. + + +Performance Implications +======================== + +The cost of checking for immutability violations is +an extra dereference of checking the flag on writes. +There are implementation-specific issues, such as +various changes based on how and where the bit is stolen. + + +Alternatives Considered +======================= + +1. Shallow Freezing: Only mark the top-level object as immutable. + This would be less effective for ensuring true immutability + across references. In particular, this would not make it safe + to share the results of ``freeze()`` across threads without risking + data-race errors. Shallow immutability is not strong enough to support + sharing immutable objects across subinterpreters (see extensions). +2. Copy-on-Write Immutability: Instead of raising errors on + mutation, create a modified copy. However, this changes object + identity semantics and is less predictable. Support for copy-on-write + may be added later, if a suitable design can be found. +3. Immutable Subclasses: Introduce ImmutableDict, ImmutableList, + etc., instead of freezing existing objects. However, this does + not generalize well to arbitrary objects and adds considerable + complexity to all code bases. +4. Deep freezing immutable copies as proposed in `PEP 351: The + freeze protocol `_. That PEP + is the spiritual ancestor to this PEP which tackles the + problems of the ancestor PEP and more (e.g. meaning of + immutability when types are mutable, immortality, etc). +5. Deep freezing replaces data races with exceptions on attempts to + mutate immutable objects. Another alternative would be to keep + objects mutable and build a data-race detector that catches read--write + and write--write races. This alternative was rejected for two main + reasons: + + 1. It is expensive to implement: it needs a read-barrier to + detect what objects are being read by threads to capture + read--write races. + 2. While more permissive, the model suffers from non-determinism. + Data races can be hidden in corner cases that require complex + logic and/or temporal interactions which can be hard to + test and reproduce. + + +Open Issues +=========== + +1. How does deep freezing interact with weak references? + + +Future Extensions +================= + +This PEP is the first in a series of PEPs with the goal of delivering +a Data-Race Free Python that is theoretically compatible with, but +notably not contigent on, `PEP 703`_ -- despite delivering +multicore performance in Python. + +This work will take place in the following discrete steps: + +1. Atomic reference counting of immutable objects to permit + concurrent increments and decrements on shared object RC's. +2. Support for identifying and freeing cyclic immutable garbage + using reference counting. +3. Support for sharing immutable data across subinterpreters. +4. Support for sharing mutable data across subinterpreters, + with dynamic ownership protecting against data races. +5. Support for behaviour-oriented concurrency. + + +Support for Atomic Reference Counting +------------------------------------- + +As preparation for the extension `Sharing Immutable Data Across Subinterpreters`_, +we will add support for atomic reference counting for immutable objects. This +will complement work in `Simplified Garbage Collection for Immutable Object Graphs`_, +which aims to make memory management of immutable data more efficient. + +When immutable data is shared across threads we must ensure that +concurrent reference count manipulations are correct, which in turns +requires atomic increments and decrements. Note that since we are only +planning to share immutable objects across different GIL's, it is +*not* possible for two threads to read--write or write--write race +on a single field. Thus we only need to protect the reference counter +manipulations, avoiding most of the complexity of `PEP 703`_ + + +Simplified Garbage Collection for Immutable Object Graphs +--------------------------------------------------------- + +In `previous work `_, +we have identified that objects that make up cyclic immutable +garbage will always have the same lifetime. This means that a +single reference count could be used to track the lifetimes of +all the objects in such a strongly connected component (SCC). + +We plan to extend the freeze builtin with a SCC analysis that +creates a designated (atomic) reference count for the entire +SCC, such that reference count manipulations on any object in +the SCC will be "forwarded" to that shared reference count. +This can be done without bloating objects by repurposing the +existing reference counter data to be used as a pointer to +the shared counter. + +This technique permits handling cyclic garbage using plain +reference counting, and because of the single reference count +for an entire SCC, we will detect when all the objects in the +SCC expire at once. + +This approach requires a second bit. Our `reference implementation`_ +already steals this bit in preparation for this extension. + + +Sharing Immutable Data Across Subinterpreters +--------------------------------------------- + +We plan to extend the functionality of `multiple subinterpreters `_ +to *share* immutable data without copying. This is safe and +efficient as it avoids the copying or serialisation when +objects are transmitted across subinterpreters. + +This change will require reference counts to be atomic (as +discussed above) and the subclass list of a type object to +be made thread-safe. Additionally, we will need to change +the API for getting a class' subclasses in order to avoid +data races. + +This change requires modules loaded in one subinterpreter to be +accessible from another. Implementation details here are to be +discussed, but the version we have been working on is one in which +a side effect of calling ``freezeglobals()`` is that all +subsequent module imports are imported into the main +module and immediately frozen and shared across all subinterpreters. + + + +Data-Race Free Python +--------------------- + +While useful on their own, all the changes above are building +blocks of Data-Race Free Python. Data-Race Free Python will +borrow concepts from ownership (namely region-based ownership, +see e.g. `Cyclone `_) to make Python programs data-race free +by construction. Which will permit multiple subinterpreters to +share *mutable* state, although only one subinterpreter at a time +will be able to access (read or write) to that state. In theory, +this work could also be authored on-top of free-theaded Python (PEP 703). + +It is important to point out that Data-Race Free Python is different +from `PEP 703`_, but aims to be fully compatible with that PEP, and +we believe that both PEPs can benefit from each other. In essence +`PEP 703`_'s focus is on making the CPython run-time resilient against +data races in Python programs: a poorly synchronized Python program +should not be able to corrupt reference counts, or other parts of +the Python interpreter. The complementary goal pursued by this PEP +is to make it impossible for Python programs to have data races. +Support for deeply immutable data is the first important step +towards this goal. + + +Reference Implementation +======================== + +`Available here `_. + + +References +========== + +* `PEP 703: Making the Global Interpreter Lock Optional in CPython `_ +* `PEP 351: The freeze protocol `_ +* `PEP 734: Multiple Interpreters in the Stdlib `_ +* `PEP 683: Immortal Objects, Using a Fixed Refcount `_ + +.. _PEP 703: https://peps.python.org/pep-0703 + diff --git a/pep-9999/freeze1.svg b/pep-9999/freeze1.svg new file mode 100644 index 00000000000..cb4868e7d07 --- /dev/null +++ b/pep-9999/freeze1.svg @@ -0,0 +1,3 @@ + + +

__dict__

value

__class__

__metaclass__

key

__class__

__metaclass__

__class__

__metaclass__

Foo instance

Foo instance's field dict

42

Int class obj

Meta class obj

'f'

String class obj

Foo class obj

\ No newline at end of file diff --git a/pep-9999/sharing1.png b/pep-9999/sharing1.png new file mode 100644 index 00000000000..d8484a3bf24 Binary files /dev/null and b/pep-9999/sharing1.png differ