PEP 795: Add deep immutability to Python

Thanks for this framing. Our underlying motivation has been too improve sharing between sub-interpreters. In developing that work, we felt that the deep immutability concept was interesting in its own right, and should be considered in its own right as a potential feature for Python.

We believed that providing an intermediate proposal would allow us to deliver value to Python earlier. But in doing so, we have not made as clear as we should have the underlying difficult runtime constraints on sharing objects between sub-interpreters.

Focussing on just deep immutability has also been extremely useful as we have gained a massive amount of feedback from all of you about where deep immutability would and would not be useful. We are still digesting all of that feedback, and will be updating the PEP to reflect it.

This is a great suggestion. We have a lot of material and work on the next step, so we could do this. Our concern was that would be a very large PEP, and we wanted to get something out that was smaller and more focused. But based on the feedback we have received, it seems that this is a more useful framing.

which I believe is referring to

In the sub-interpreters work, this would not consistute a data-race, as the import would get the interpreter local version of the sys module state. However, in the free-threaded world then this would allow a frozen function object to mutate the state of the sys module, and potentially data-race.

I think overall, moving more things towards being interpreter/thread local is a good idea to reduce the issues around data-races. If the caching in various modules/types was moved to be interpreter/thread local, then there wouldn’t be data-races.

As @steve.dower pointed out:

Sub-interpreters allow us to gradually enable more things to be safely shared. Our initial aim would be to be able to pass immutable JSon like data between sub-interpreters to allow more efficient messaging than currently exists.

Based on the feedback we have received, we will

  • move to a completely opt-in model (we have gradually been moving towards this, and this discussion has helped us to clarify that this is the right approach)
  • failure to freeze will be completely backtracked, so that objects can add attributes of unfreezable types, and then not become frozen. Hence, the sys module would have an attribute of an unfreezable type, and you would never be able to freeze the sys module.
  • actively consider expanding the scope of the PEP to include the sub-interpreter work, and we would really appreciate feedback on this as an option from the community.
6 Likes

I think the issue with Fraction is that is uses ABCMeta (abstract base class). The abstract base class library use a cached lookup for checking various for subtyping. This is an area where sharing the type between sub-interpreters would be problematic and would require making the caching interpreter/thread local. This is not work we have done yet.

We will add a section about this. We couldn’t see how to make this work in a safe way with sub-interpreters.

We have been looking at your example on discussion on Gauging interest in arbitrary object immortalization and shared object proxies.

I am bit unsure about two things

  • Are calls to SharedObjectProxys asynchronous, or synchronous? If synchronous is there a possiblity of deadlock?
  • Are the parameters passed to a method on a SharedObjectProxy also proxied? If not, can that lead to sharing?

@ZeroIntensity should I post these questions on the other discussion (not sure on the etiquette of overlapping conversions on DPO)?

You can post on the other thread, it’s easier to keep track of things. To answer your questions:

  1. They’re synchronized based on the GIL, because all it does is switch to the interpreter. On FT, it’s sort of asynchronous through the per-object locking. I don’t think there’s any chance of deadlock.
  2. Parameters can use the same sharing mechanisms as everything else with subinterpreters. So, things like strings can be shared directly, and only objects that have no other option will be put in a proxy. As of now, my POC implementation always shares, but that’s just because I was lazy.

If you work with a proxy approach, then sharing classes across interpreters may not be needed at all:
WHen retrieving data in another interpreter, it could make use the other interpreter’s version of the same class.

Note that having interpreter local classes is the current behavior both for subinterpreter as for multi-processing shared data, using pickle and passing byte-serialized data around, and no one is surprised or harmed by that.

This problem with abc caching preventing deep freeze refers to a more fundamental issue with the proposal: deep-freezing has a large blast radius; it introduces action-at-a-distance. Deep-freezing affects pure Python code, in a way more than free-threading or subinterpreters themselves would – it changes the behavior of pure Python objects.

Real-life Python object graphs in non-trivial programs (that would benefit the most from race-free data sharing) are unlikely to be as clean as what the examples in the PEP would suggest. The existence of metaclasses, __dunders__, etc. in the object graph mean that drawing a line between freezable and non-freezable parts of pure Python, given the reality of how object graphs look like scenarios where the use of subinterpreters will likely be beneficial, will be a lot of work. Notably, the standard library has not been designed with freezing in mind, and it may as well be impossible to make most of the stdlib work with freezing.

For now, we can limit deep freezing to just a small, predetermined number of classes (such as exact list, dict, set, tuple), whose deep-frozen behavior can remain fully under our control. Or, we don’t need deep freezing to be so strict. To quote the PEP:

A strict interpretation of deep immutability does not permit an immutable object to reference a mutable object. This model is both easy to explain and understand, and an object’s immutability can be “trusted” — it is not possible for an immutable object to change through some nested mutable state [1]. At the same time it limits the utility of freezing as many Python objects contain types outside of the standard library defined in C, which must opt-in immutability before they can be frozen.

It isn’t likely that we can forgo all mutable states altogether. Some mutable states will still live in our object graphs, injected by the interpreter or stdlib, and that’s probably fine – we have per-object locks for that (though we want to avoid locking as much as possible). We “trust” the interpreter and stdlib not to mess with them in ways that break desirable behavior of user objects (this does not mean that interpreter/stdlib have to always support freezing, just that they behave in responsible ways with mutable states along the consenting adults principle).

For a realistic assessment of this proposal, we need a freezing model that

  1. extracts the most benefit (performance or otherwise) out of freezing (we need ways to measure such real-world benefit),
  2. has a clear model to work with (and preferably leading to a future specification),
  3. fits well with the existing designs of [C]Python,
  4. allows the separation of responsibility between users, stdlib contributors and third-party library authors,
  5. doesn’t place too much burden of maintenance and evolution on the above parties.

These same basic points are valid for all proposed features, but especially in the case of deep freezing where the blast radius in Python-land is large.

1 Like

Sorry — I wasn’t clear enough! If we want to be able to safely share immutable objects by references between subinterpreters (which is one of our goals), then we need to fortify the Python interpreter against data-races, just like what is happening in free-threaded Python. Our hope here has been to enable the same kind of shared-memory parallelism that free-threaded Python supports with subinterpreters.

I hope that makes more sense. I agree that the world won’t end but it also prevents us from doing something which would be very useful and efficient for subinterpreters.

All that is happening in free-threaded Python is that core data structures are being altered to do their own locking, rather than using the GIL. The free threaded changes have no user visible effect, they are purely internal to the object implementation. As far as I can see, that’s nothing like what is being proposed here, which does have a user API, which when used changes object semantics fundamentally.

I repeat - why? Why isn’t it sufficient to trust people to use data structures correctly? I accept that having something that doesn’t require you to ensure safety yourself when using it is nice, but it’s far from necessary. That’s my point - this proposal seems to be based on unfounded claims that it’s not possible to write data race free code without deep immutability. And therefore, that significant disadvantages are justified because the benefits are so significant.

I’d really like the convenience of immutable data structures if I’m writing concurrent code. But I’m not willing to pay for that convenience by having to write all of my code defensively, because I can’t be sure that I won’t be passed an object that (contrary to its declared/inferred type) could fail if a mutating method is called on it.

3 Likes

I’ve been wondering the same thing. I was also looking through PEP 734 which proposes subinterpreters in the stdlib, the section on Interpreter Isolation says

CPython’s interpreters are intended to be strictly isolated from each other. That means interpreters never share objects (except in very specific cases with immortal, immutable builtin objects). Each interpreter has its own modules (sys.modules ), classes, functions, and variables. Even where two interpreters define the same class, each will have its own copy.

So it seems the motivation might be primarily to address this restriction. However the PEP 795 draft doesn’t really make this explicit, the most direct reference is possibly in Motivation - Immutable Objects can be Freely Shared…

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 free-threaded Python efforts), data races on shared mutable objects become a more pressing concern.

The python api is slated for 3.14 so I don’t know what restrictions the runtime places on data sharing between subinterpreters. As subinterpreters have been in the C api since 1.5, it’s possibly a historical decision that I’m unaware of the context on.

The 3.14 concurrent.interpreters docs say

By default, most objects are copied with pickle when they are passed to another interpreter.

And

There is a small number of Python types that actually share mutable data between interpreters:

So I think this PEP makes more sense through the lens of subinterpreters and less sense from the perspective of free threading.

This is mostly true. In free-threading, there is a lot of heavy lifting going on behind the scenes to ensure that the Python interpreter would not crash or corrupt itself if a Python program is poorly synchronised. However, if you want to make use of the multi-threading, your have to follow certain protocols, like proper synchronisation, or your programs might silently compute a bogus result, crash, etc. Multithreaded programming with mutable state changes how you need to program.

Immutable objects on the other hand “behave the same” regardless of whether they are used in a single thread or shared across multiple threads. So from that perspective, there is less of an API after you have frozen the object.

I hope this makes sense. I am not trying to argue that one is better or worse than the other — my point is that multithreaded programming always comes with a cost, and it is a question of where you want that cost to appear.

Ah, this is a great question. The answer is that this would be like removing the GIL without adding all of the amazing stuff that the free-threaded Python people have added. The subinterpreters build keeps the GIL and achieves parallelism by letting each subinterpreter have its own GIL, and furthermore each subinterpreter believes that it is running in complete isolation so that its actions will never race with any action carried out in another subinterpreter.

Sharing objects across subinterpreters breaks this isolation which subinterpreters require for soundness. If the objects are immutable, it is easy to maintain the invariants that the subinterpreters rely on simply by making reference count manipulations on shared objects atomic. But if the shared objects can be mutated, the we also need something like the per-object lock that free-threaded Python uses to ensure that poorly synchronised programs don’t accidentally blow up the interpreter or worse.

Oh, no absolutely not! I think this is a mistake on our part in the way we tried to divide our big idea that we presented at the Language Summit into different PEPs. The immutability stuff was the easiest bit to carve out that made sense on its own (to us). The reason why trust is not sufficient is the one that I pointed out above — the same reasons why Python simply isn’t removing the GIL and trusting that all Python program’s were correctly synchronised (more or less).

It is possible to extend the optional type system to capture things like immutability, but we would like to punt on it for now to limit the scope of this PEP.

1 Like

Hi Marc — see my response to Paul which tries to unpack this. You are right in your answer. The isolation between subinterpreters remains the same.

In the case of subinterpreters we enable object sharing (as you point out) and that is clearly already possible in free-threaded Python. Still, as also pointed out by Paul who clearly has reservations, having immutability is very convenient when writing concurrent code. So we think that immutable objects are useful also in free-threaded Python.

I assume you mean the isolation would be relaxed for frozen objects? (Yes from your third comment)

Agreed. I think part of the friction with this proposal is it’s quite heavy handed if you’re just considering traditional threading because those same data races were probably there before they’re now just more likely to happen with free threading. Not to dismiss that, but it’s unlikely to truly be the introduction of a bug, I guess.

I disagree with a lot of that. CUDA is the classic example, IIRC some of their matrix multiplication functions aren’t exactly IEEE754 compliant and only promise accuracy to within a couple of bits of precision because the GPU threading can change the order of operations and float math is a whole thing. In that case even if you’re doing everything perfectly you can get different results each time you run a program (I’ve had to fix this).

I would argue multithreaded programming changes how you need to program. Sure, avoid mutable state as much as possible but it will always be there and will always bite you in the ass if it can.

Ah, yes — indeed. Thanks for making that point clear!

Maybe, but also if Python programmers start to embrace threading in a big way, we are going to see more of these bugs. For example, if you import a library that uses threads internally — it can be very hard to know whether an object you get from that library is safe to access directly or if you need to figure out what is the appropriate lock to hold before access. And now we are back to the problem of virality or blast-radius that this PEP is getting pushback for — in order to reason about what objects you can safely access or not, you are going to have to understand the object graphs, and how objects are reachable across threads, and if you get something wrong, your program could just silently compute slightly wrong results.

1 Like

I don’t think you disagree with me? You are simply clarifying — and if so I agree with you — that just because you get synchronisation right it does not mean that your programs won’t compute a bogus result.

Did I get that right?

1 Like

I think that you emphasize that the mutable state is the problem and your proposal reduces that impact. My point is threading is hard.

Got it! I will say that mutable state is part of what makes threading hard, but not the only thing.

1 Like

EG there are already tools in the stdlib for creating “immutable” classes - @dataclass(frozen=True) is probably good enough for most use cases to pass an instance between threads. I keep hoping to get a better frozen dictionary than creating a dictionary and immediately wrapping it in a collections.MappingView, but one can dream.

I can only speak from my experience.

I used to maintain a threaded library used by broadcasters, we had a function for the user to give us data and a function for the user to get the result. Everything else was hidden from the user.

I suspect numpy/pandas/scipy etc make some level of use of threading, there’s also RAPIDS which we use now, it’s a similar input/output model. If you mess with the internals, you’ve got to know what you’re doing so reading of manuals is required. I don’t know how much this will change with free threading.

I think the PEP should clarify the benefit of sharing types/modules/functions between sub-interpreters as the primary benefit, highlight that the current solution is “everything gets pickled or duplicated” and that would definitely reduce the friction. It’s unclear how much use this will see in free threading. I’ve not tried the free threading builds, it took a while for numpy and such to get a compatible build. concurrent.interpreters is slated for 3.14 so again probably not something usable yet. I’m definitely more in favour in hindsight. I probably wouldn’t use it much unless I’m using subinterpreters but only time will tell.