The follow up point you skipped over was that under -X lazy_modules=all or sys.set_lazy_imports("all") which is a feature that is now about to be in a released version of python this is all modules.
Also note that any module with submodules will be a bad candidate as import a.b adds ‘b’ to the module dict of ‘a’ (in the case where it’s not already imported).
Yes, it’s a good way to find out how many downstream projects currently rely on being able to mock or patch your module for their tests.
The point was not that “immutable means you can’t mutate it” the point was that there are common, stdlib supported features like mocking that rely on being able to mutate modules (and classes) that this works against.
The trouble of this model deepened when they tried to stablize the language’s M:N work-stealing coroutine runtime onto this restrictive object sharing model, while also having to interoperate with host platforms (iOS) that uses M:N work-stealing runtimes (Darwin GCD). Ran into troubles both on memory management front, and on the developer uptake front (considering a primarily JVM/Swift/ObjC audience).
From my personal experience with that era of Kotlin/Native, it pushed a lot of simple data objects and simple containers that would have been semantically isolated (e.g. can inherit isolation from owner/caller/coroutine/actors) to be unnecessarily proliferated to lock-free atomic data structures and algorithms, only to fit in the thread-bound paradigm. Though this PEP counteracts that somewhat with the concept of “thread groups”.
Kotlin learned a lesson through real world usage in mobile app development to move away from freezing. While this is not a direct parallel, a whole lot of Python applications is similarly driven by event loops and executors, and they also intertwined with C extensions of which many are dealing with their own Rust/C/C++ M:N runtimes more often than not.
Unfortunately users may violate that rule by accident, which can lead to crashes that are difficult to debug.
What you do for your package is up to you, of course, but it wouldn’t be acceptable for any part of the stdlib, including extensions (except for ctypes, which breaks all guarantees – it’s the closest we have to Rust’s “unsafe”).
It hasn’t come up yet but it’s possible. I would like a better solution but, as I said, with free-threading it doesn’t seem that there is a clear option to keep things safe without the GIL but without impacting performance in the single-threaded case (by far the common case for current users). The proposal here seems like it might be better in that sense but I don’t know what other overheads it would bring so it is difficult to comment without measurements.
ujson is in the same boat. So far, no-one but claude after being asked specifically to look for such “issues”[1] has even noticed.
I’m glad it’s not just me who thinks this best practice of smothering everything in locks to the detriment of everybody who was using it as intended is bonkers…
I’ve updated the PEP to use copying instead of relying on unique references.
If there is a unique reference, stealing that reference and returning a new object is equivalent to changing the state in-place, so can be implemented without copying.
If there are multiple references, it still works correctly, just a bit less efficiently.
Also, it should be implementable by MicroPython and GraalPython.
I don’t know if that has to be the case. There’s a common expectation that CPython doesn’t crash, but otherwise data structures are not necessarily expected to be thread-safe in the sense that using them from multiple threads produces deterministic results.
The “smothering everything in locks” dilemma seems to apply mostly to mutable data structures implemented in C, and even then, perhaps only if they are resizable (to avoid accessing a free’d pointer).
It remains to be seen what a C-implemented data structure would look like with PEP 805 instead of free-threading.
If declared as synchronized, it would remain essentially the same. This PEP gives more options for implementations to avoid adding internal protection for everything, relying on VM protections instead, when preferred.
In writing that this PEP builds on top of PEP 703, instead of competing with it, we also mean that the important work done in the ecosystem for FT should stay.
If there is no mechanism to prevent the size of lists or dictionaries from changing or borrowed references from disappearing when an extension module receives them, many additional checks and incref/decref operations will be required to avoid potential C-level crashes, which can significantly degrade performance.
The particular problem is that even if you lock a list or dictionary in a critical section, there is no guarantee that the locked object has not been modified after calling a Python function. This is because the lock may have been temporarily released to avoid deadlocks. If there were a mechanism that would raise a RuntimeError when another function called from other threads or callback functions tries to lock the same object instead of temporarily releasing the lock, it would make it easier to write safe extension modules in a freethreaded version of Python without sacrificing performance.
However, it is unclear whether SynchronizedList would be helpful in this regard. If the cost of converting a list to a synchronized list is extremely low, it might be possible to convert the list to a synchronized list before iterating over it. Could it be made fast enough to be acceptable for serializers where speed is critical, such as ujson or msgpack?
Many extension modules are already unsafe even with the GIL. When supporting free threads, it is unlikely that developers will suddenly be expected to write safe code at the expense of maintainability and performance. Convenient and high-performance mechanisms such as garbage collection that does not rely on reference counting and locks that cannot be released arbitrarily are needed.
Not related to this thread, but in reviewing this I noticed what I’m pretty sure is a race the GIL doesn’t protect against between __getitem__ and __delitem__ where KeyError is raised even if __missing__ is defined on the class.
My initial reading of the proposal for say ujson.dumps() (which doesn’t expose C-implemented data structures but accepts list/dict which are – I think the problem is ultimately the same) would be that under sensible usage, its inputs would always either have .__shareable__ of LOCAL and be local to the right thread or IMMUTABLE. Being local to the wrong thread would be blocked by a IllegalThreadAccessException so we’d no longer be expected to add all these defensive locks to accommodate this pathological case because said case is no longer possible. [1]
But reading the PEP again, I think I was just seeing what I wanted to see rather than what’s actually there. Protected and Syncronized objects look like they’d do just as much damage as local to the wrong thread objects if mutated. And smuggling shared multiple objects across thread boundaries by putting them inside immutable containers still looks like a very possible way to bypass these protections.
It’s exactly the expectation we’ve been given by the free threading team (and its Claude-wielding advocates who’ve inherited this stance of correctness := lock everything). We’re there already…
And I want it to be blocked rather than silently allowing data races or inconsistent results. Currently the most effective way to do that is what PyQt have done for years – that is to do nothing and let it segfault so users know they screwed up – that’s what I’d rather keep doing… ↩︎
I don’t understand why you want to add defensive locks. Surely if the GIL is released during usjon.dumps, you can already get a race condition if another thread mutates the dumps inputs, right?
def dump(mapping: dict):
if mapping.__shareable__ is SYNCHRONIZED:
raise ValueError("cannot cope with data races.")
# other states are fine:
# LOCAL -- no concurrent accesses
# PROTECTED -- mutual exclusion prevents races
# IMMUTABLE -- no concurrent modifications
for key, value in mapping.items():
dump_one(key, value)
Using a protectedmapping here is fine, say dump(x) where x is protected. There’s no race in this function call, because when the VM correctly fetches a reference to x there is no other thread that can concurrently fetch another reference to x, and this is enforced by mutual exclusion.
No, that is not the case. “Smuggling” references to local objects to non-owning threads via immutable objects (or protected or synchronized for that matter) is not possible, if I understand correctly what you’re thinking. Say you have a frozendict d which contains a reference to a local object y, stored under key k. Then this call is forbidden: dump(d[k]). But the function is never called: the VM raises an exception when trying to push a reference to y onto its evaluation stack. Therefore, dump needs not care about this case, be it written in Python or in C. The reference to y is still stored in d, but only its owner can call d[k] without getting an illegal access exception.
It seems like it wouldn’t: “The Main ThreadGroup is analogous to the GIL, in that it serializes execution of all threads. It is only when threads are explicitly marked as belonging to another ThreadGroup, that there is parallelism.”
In other words everything stays the same for the vast majority of use cases (which are single threaded), except that every library has to care for the potentiality of being used in a ft manner, and one of the proposed best practices is to add locks (where needed, whatever that means) to make it safe?
If my reading is correct, it seems to me we have not gained much, yet made everything exponentially more complex. In that case I propose nogil might not be ready for wide spread adoption in the ecosystem.