Skip to content

gh-158195: Do not hold the type lock during stop-the-world - #159972

Open
nascheme wants to merge 15 commits into
python:mainfrom
nascheme:gh-158195-unpin-type-lock
Open

nascheme wants to merge 15 commits into
python:mainfrom
nascheme:gh-158195-unpin-type-lock

Conversation

@nascheme

@nascheme nascheme commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

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:

  • Slot updates: resolve the affected slots and write them within the same stop-the-world interval. Descriptor lookup and subclass traversal therefore use the current type dictionaries and hierarchy, not updates prepared before the wait. There is no longer a queue of prepared slot updates.
  • __bases__ assignment: perform hierarchy-dependent validation (inheritance cycles and layout compatibility) and store the new tp_bases and tp_base within 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 publication and rollback: custom 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 cause mro() 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:

  • Slot-name lookup uses exact-string comparisons and skips non-string dictionary keys, rather than potentially invoking a key's __eq__ method. String-subclass keys supplied in a class namespace are copied to exact strings, as they already are for class attribute assignment.
  • Dictionary lookup and subclass traversal use borrowed references while the world is stopped, avoiding reference drops that could invoke deallocation and reacquire locks.
  • Type version tags are invalidated after publication while the type lock is held, so cache entries created during the wait cannot remain valid with stale state.
  • __bases__ assignment stores a private copy of the assigned tuple. Its identity distinguishes successive assignments even when they use the same input tuple object.
  • Deferred refcounting is enabled only for an MRO tuple that is actually stored, so a rejected candidate can be freed normally.

Tests

Added coverage includes stop-the-world calls from custom mro() methods under type-lock contention, concurrent bases assignments, and the custom mro() 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.

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
nascheme force-pushed the gh-158195-unpin-type-lock branch from 6a77bee to 7917d6a Compare October 21, 2026 06:03
@nascheme
nascheme marked this pull request as ready for review October 21, 2026 18:08
@nascheme
nascheme requested a review from markshannon as a code owner October 21, 2026 18:08
@nascheme nascheme added the 🔨 test-with-buildbots Test PR w/ buildbots; report in status section label Oct 21, 2026
@bedevere-bot

Copy link
Copy Markdown

🤖 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.

@bedevere-bot bedevere-bot removed the 🔨 test-with-buildbots Test PR w/ buildbots; report in status section label Oct 21, 2026
@nascheme
nascheme requested a review from DinoV October 22, 2026 15:48
@DinoV

DinoV commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

It looks like this causes an infinite loop:

class Other: pass

class Meta(type):
    def mro(cls):
        Other.__repr__ = lambda self: "x"
        return type.mro(cls)


class B: pass

class C(B, metaclass=Meta): pass

C.__bases__ = (B, )

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.
@nascheme
nascheme requested a review from methane as a code owner October 28, 2026 06:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting core review topic-free-threading type-bug An unexpected behavior, bug, or error

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants