Repository navigation
Conversation
In the free-threaded build, type slot, MRO and __bases__ updates are prepared under TYPE_LOCK and published with the world stopped. The lock was "pinned" so that it stayed held while the thread waited for the stop-the-world mutex. That deadlocks against a thread that owns the stop-the-world mutex and needs TYPE_LOCK to re-attach, which happens when Python code (a custom mro(), a str subclass __eq__ of a class dict key, or an automatic GC) runs under the type lock and stops the world. Remove the pinning and publish optimistically instead. A type publish generation counter is incremented by every mutation that a prepared update might depend on. The preparing thread snapshots it, prepares, stops the world and compares. If the counter changed, nothing is published and the update is prepared again.
This is more clear than passing the "gen" as a pointer and using NULL to indicate that we don't want to check the generation.
Use explicit stop-the-world pauses and finite worker workloads. Replace slot-update stress with a deterministic reentrancy test, and consolidate queue reference-release coverage.
The rollback on error restores the old bases only if tp_bases is still the tuple this assignment stored, identified by object identity. That breaks if another assignment stores the same tuple object in between. Store a private copy so that the identity check is reliable.
Required since a dropped reference could run type_dealloc(), which takes TYPE_LOCK. Move the final slot_update_clear() after the version tags are invalidated.
Both helpers now return 1 when stored, 0 when the expected value was replaced and -1 when the publish generation changed. The check_gen flag is replaced by the TYPES_PUBLISH_GEN_NONE sentinel.
The checks are plain C and do not run Python code.
nascheme
force-pushed
the
gh-158195-unpin-type-lock
branch
from
October 21, 2026 06:03
6a77bee to
7917d6a
Compare
nascheme
marked this pull request as ready for review
October 21, 2026 18:08
|
🤖 New build scheduled with the buildbot fleet by @nascheme for commit c7ac4e7 🤖 Results will be shown at: https://buildbot.python.org/all/#/grid?branch=refs%2Fpull%2F159972%2Fmerge If you want to schedule another build, you need to add the 🔨 test-with-buildbots label again. |
Contributor
|
It looks like this causes an infinite loop: |
mro_internal() now calls mro() once and stores the result with TYPES_PUBLISH_GEN_NONE. The only check inside the stop-the-world is the tp_mro identity test that main already had.
type_new() now copies str subclass keys of the class namespace to exact str, as type_setattro() already does for assigned names. Lookups of a slot name in a type dict then cannot call a user __eq__ through a str subclass key. Non-str keys are still allowed (with a RuntimeWarning) and can still run __eq__; the type_update_dict() comment is corrected to say so. Remove the free-threading tests that relied on str subclass keys in the type dict.
Remove the publish generation counter and the retry loops. Instead of preparing updates under TYPE_LOCK and publishing them with the world stopped (and rebuilding them if they went stale while the lock was released), do the work inside the stop-the-world: - Slot updates after type_setattro() of a slot name and after __bases__ assignment look up the descriptors and store the slots directly with the world stopped. The slot update queue is removed. The lookups use borrowed references and skip dict keys that are not exact str (_PyDict_GetItem_ExactUnicode()), so no user code runs and nothing is deallocated. - __bases__ assignment validates the new bases (inheritance cycle, best base, layout compatibility) and stores tp_bases and tp_base in the same stop-the-world. The checks return an error code and the exception is raised after the world is started. PyType_Ready() of a new base and the Py_TPFLAGS_BASETYPE check move to type_check_new_bases(). ht_slots are compared by string content. In the free-threaded build, slot computation for new types (fixup_slot_dispatchers() and overrides_hash()) also skips non-str keys, using the thread-safe _PyDict_GetItemStackRef_ExactUnicode(), so that slots do not depend on when they were computed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the free-threaded deadlock described in gh-158195 by removing type-lock pinning. The type lock and class dict lock can now be released normally while a thread waits to stop the world, rather than remaining pinned across that wait.
Current design
In the free-threaded build, updates to published type slots, flags, MROs and bases need stop-the-world synchronization. Waiting to stop the world can release
TYPE_LOCK, so state examined before the wait may no longer be current when the world has stopped.This PR handles that without a generation counter or optimistic retry loop:
__bases__assignment: perform hierarchy-dependent validation (inheritance cycles and layout compatibility) and store the newtp_basesandtp_basewithin the same stop-the-world interval. Argument checks and the audit event happen beforehand; errors from hierarchy validation are raised after restarting the world.mro()methods still run outside stop-the-world and may execute arbitrary Python code. Before publishing an MRO, check with the world stopped that the old MRO has not been replaced. Rollback similarly restores an MRO or bases value only if the value being undone is still current. Unrelated type mutations do not causemro()to be rerun.The entire
__bases__operation is not one stop-the-world transaction: in particular, custom MRO computation remains reentrant. The identity checks preserve the existing handling of replacement by a nested operation and also cover replacement by another thread while the type lock is released.Supporting changes
Slot resolution with the world stopped must not run user code, block, or deallocate objects. The lookup and traversal paths are adjusted accordingly:
__eq__method. String-subclass keys supplied in a class namespace are copied to exact strings, as they already are for class attribute assignment.__bases__assignment stores a private copy of the assigned tuple. Its identity distinguishes successive assignments even when they use the same input tuple object.Tests
Added coverage includes stop-the-world calls from custom
mro()methods under type-lock contention, concurrent bases assignments, and the custommro()example that mutated an unrelated type on every call. Additional tests cover dictionary keys whose equality methods must not run during slot lookup, normalization of string-subclass namespace keys, and collection of subclasses after slot updates.Backporting and compatibility
The deadlock affects 3.14 and 3.15, so the fix should be backported to both. This design adds no per-interpreter generation counter or corresponding interpreter-structure ABI change.
Stop-the-world synchronization remains free-threaded-only. Some supporting changes also apply to the default build, notably namespace-key normalization and copying the tuple assigned to
__bases__. Consequently,C.__bases__need not be the same tuple object that was assigned, although its contents are unchanged.I backported this change to the 3.14 branch and tested it using snake pit. Some package tests fail but they fail without the patch applied as well. Tests were run on x86-64 Linux, with both ordinary GIL and free-threaded builds and matched package versions and dependencies. The 33-package catalog was attempted, including pytest, Hypothesis, Django, SQLAlchemy, Pydantic Core, SymPy, Cython, CFFI, NumPy, pandas, matplotlib, packaging, requests, AnyIO, greenlet, uvloop, Pillow, lxml, cryptography, and Sphinx.
If we want to be extra careful, I suggest we use ifdefs so that the default build does not get behavioral changes in the backport (the namespace key normalization and copied tuple seem most risky).
Previous approach
Earlier revisions prepared updates before stopping the world, then checked a per-interpreter generation counter and retried if type state had changed. That could retry indefinitely when a custom
mro()mutated an unrelated type on every call, as Dino's example demonstrated. The current implementation removes generation-based retries in favor of the synchronization described above. Earlier review comments about the counter, retry loops, and slot-update queue refer to that superseded design.