| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
On free-threaded builds, the interpreter can deadlock when Python code running under an unpinned TYPE_LOCK stops the world, directly or through an automatic GC, while another thread holds TYPE_LOCK pinned and waits to stop the world. The callbacks are a metaclass mro() (from type_ready()) and a str-subclass class-dict key's __eq__ (from type_setattro()).
Observed on fresh free-threaded builds of main a000129 (which includes PR #152826), main 8c056c3 and 3.15 13b1659 (macOS arm64, Clang, 2026-09-25), and earlier on main 6757482, 3.15 2dafe2a and 3.14 1d8c9f8. The default build is not affected.
This is the same lock-order family as gh-158194 (PyType_Freeze()), gh-156910 and gh-155400, but a distinct bug. Those were fixed by pinning at C call sites, which cannot cover arbitrary Python code or the GC. The broader approach below might also cover gh-158194; gh-158194's pin does not fix this.
Trimmed from the attached repro.py (mro bug):
import faulthandler, sys, threading, time
print("sys._is_gil_enabled() =", sys._is_gil_enabled())
if sys._is_gil_enabled() is not False:
sys.exit("needs a free-threaded build with the GIL disabled")
faulthandler.dump_traceback_later(20, exit=True) # dump stacks and exit if hung
def f(): pass
class Meta(type):
def mro(cls): # called by type_ready() with TYPE_LOCK held, not pinned
f.__qualname__ = "f" # stops the world (func_set_qualname)
return type.mro(cls)
stop, errors = threading.Event(), []
def guarded(fn):
def run():
try: fn()
except BaseException as e: errors.append(e); raise
return run
def setter():
while not stop.is_set():
Meta("K", (), {})
def pinner(): # type_set_bases(): pinned TYPE_LOCK, then stop-the-world
class A: pass
class B: pass
class X(A): pass
while not stop.is_set():
X.__bases__ = (B,); X.__bases__ = (A,)
def contender(): # plain stop-the-world; keeps stw->mutex contended
def g(): pass
while not stop.is_set():
g.__qualname__ = "g"
ts = [threading.Thread(target=guarded(fn)) for fn in (setter, pinner, contender) for _ in range(2)]
for t in ts: t.start()
time.sleep(5); stop.set()
for t in ts: t.join()
if errors: sys.exit(f"FAIL: {errors[0]!r}")
print("done")repro_realistic.py (ABC subclasses + functools.wraps + GC) and repro_dunder_pinner.py hang on main and 3.15 only: 3.14 (from 3.14.4, which backported type_lock_prevent_release()) pins only the __bases__/MRO path.
Stock main 8c056c3 (free-threaded debug, no patch), repro.py mro bug, captured with macOS sample (trimmed):
setter type_ready_publish typeobject.c:9513 -> mro_internal typeobject.c:3641 -> [Python mro()]
-> func_set_qualname funcobject.c:755 -> stop_the_world pystate.c:2475 -> _PyMutex_LockTimed
-> _PyThreadState_Attach pystate.c:2305 -> _PyCriticalSection_Resume critical_section.c:152
-> _PyMutex_LockTimed
pinner type_set_bases typeobject.c:2002 -> stop_the_world pystate.c:2475 -> _PyMutex_LockTimed
These stacks are consistent with the lock-cycle diagnosis below; lock ownership is inferred from the code paths, not read from the samples.
fix-{main,3.15,3.14}.diff change Python/pystate.c so that stop_the_world() never waits for a critical-section mutex while owning the stop-the-world locks: if the critical section cannot be resumed without blocking, it drops stw->mutex and the rwmutex, waits, and retries. Regression-tested candidate fix. Broader attachment, finalization, progress, and interaction concerns remain for specialist review:
Performance impact has not been measured. No failure was observed on the patched build tested (main 8c056c3 debug, exactly fix-main.diff): each configuration that hung on stock main debug ran 0/5 or 0/3 hung, and both regression tests passed 3/3.
Validation on fresh builds and environmentFresh out-of-tree free-threaded builds of main 8c056c3 (stock, and with exactly the attached fix-main.diff, sha256 844eee86…) and 3.15 13b1659 (stock), run one at a time on 2026-09-25. A run counts as hung only when the process stopped using CPU and a native sample showed stacks consistent with the lock cycle above.
| main debug | stock | patched |
|---|---|---|
| repro.py mro bug / strkey bug | 5/5 / 5/5 hung | 0/5 / 0/5 hung |
| repro.py mro gc, repro_realistic.py gc, repro_dunder_pinner.py mro bug | 3/3 hung each | 0/3 hung each |
| ctl modes | 0/4 | 0/4 |
| regression tests | 3/3 hung (standalone) | 3/3 pass (in-tree) |
mro bug also hung on stock main release (5/5) and stock 3.15 debug (3/3). On main a000129 (after PR #152826, debug), repro.py mro bug hung 5/5 stock and completed 5/5 with fix-main.diff; a native sample of one stock hang had the same shape as above. test_free_threading, test_types and test_descr pass when patched. Not built: patched main release, patched 3.15 and 3.14 (fix-3.15.diff, fix-3.14.diff).
python -VV of the fresh stock builds:
Python 3.16.0a0 free-threading build (heads/val/main-base-dirty:8c056c3, Sep 25 2026, 08:58:37) [Clang 21.0.0 (clang-2100.3.34.2)] Python 3.15.0rc2+dev free-threading build (heads/val/315-base-dirty:13b1659, Sep 25 2026, 09:07:27) [Clang 21.0.0 (clang-2100.3.34.2)]
Earlier stock builds (used for the older results below):
Python 3.16.0a0 free-threading build (heads/main-dirty:6757482, Sep 22 2026, 20:29:11) [Clang 21.0.0 (clang-2100.3.34.2)] Python 3.15.0rc2+dev free-threading build (heads/3.15-dirty:2dafe2a, Sep 22 2026, 21:35:07) [Clang 21.0.0 (clang-2100.3.34.2)] Python 3.14.7+ free-threading build (heads/3.14-dirty:1d8c9f8, Sep 22 2026, 20:25:42) [Clang 21.0.0 (clang-2100.3.34.2)]
The -dirty suffix comes from CPython's Makefile running git describe --dirty from the out-of-tree build directory, where git sees every tracked file as missing. All of these builds were made out-of-tree, and their source trees show no tracked changes; the fresh trees were verified clean after each build.
Older results, code locations, patch details and open questionsAttached files. Each reproducer prints sys._is_gil_enabled() and exits with status 2 unless it is False. A worker exception exits with status 3.
Older stock-build results. These are stock builds (no patch) of main @ 6757482, 3.15 @ 2dafe2a and 3.14 @ 1d8c9f8, run on 2026-09-25 with the reproducers as they were before the GIL check and the exception propagation were added (the stress logic is the same). CPU-time inactivity was used only to screen for a hang. A native sample was taken of the first hang in each cell; those samples had the same shape as the stacks above (with stop_the_world at pystate.c:2454 on 6757482).
| Build | repro.py mro bug | repro.py strkey bug | repro_realistic.py gc |
|---|---|---|---|
| main FT debug | 2/2 hung | 2/2 hung | 1/1 hung |
| main FT release | 2/2 hung | 2/2 hung | 2/2 hung |
| 3.15 FT debug | 2/2 hung | 2/2 hung | 2/2 hung |
| 3.14 FT debug | 2/2 hung | 2/2 hung | 0/2 (expected: not pinned on 3.14) |
Control: repro.py mro ctl completed on main release. The standalone regression tests hung on stock main release. In the earlier investigation (2026-09-23, same stock commits), the gc modes of repro.py hung on all three branches, and repro_dunder_pinner.py hung on main and 3.15.
Earlier investigation (2026-09-23; stock main 6757482 / 3.15 2dafe2a / 3.14 1d8c9f8; earlier revision of the patch). Most of these patched builds were made before the attached diffs were finalized, so the exact source they were built from may differ.
Code locations.
| Step | main @ 6757482 | 3.15 @ 2dafe2a | 3.14 @ 1d8c9f8 |
|---|---|---|---|
| mro_invoke() calls Python mro() under unpinned TYPE_LOCK | typeobject.c:3578 | :3643 | :3502 |
| type_setattro() calls a str-subclass __eq__ under TYPE_LOCK | :6715 | :6872 | :6317 |
| stop_the_world() blocks on stw->mutex | pystate.c:2454 | :2423 | :2387 |
| Attach resumes the critical section | pystate.c:2305 → critical_section.c:152 | :2274 | :2242 |
| Pinned TYPE_LOCK then stop-the-world | typeobject.c 672, 1712, 1908, 1974, 3903, 6401 | 686, 1782, 1978, 2044, 3968, 6558 | set_tp_mro 647 only |
The unpinned sites are unchanged at the current tips (checked 2026-09-25). stop_the_world() was reworked by PR #152826, but the hang remains on main after it. On 3.14, v3.14.3 has no type_lock_prevent_release() and v3.14.4 does (the backport of gh-145685). This is the same hand-off-after-detach hazard described in gh-144513.
How the patch works. A new stw_lock() replaces the direct PyMutex_Lock(&stw->mutex). If the thread has a resumable critical section and the fast path fails, it:
A mutex pinned by type_lock_prevent_release() is not stored in the critical section, so pinned holders take the unchanged path. The rwmutex is released too, because holding it while waiting for TYPE_LOCK could still deadlock against a pending global stop-the-world such as os.fork().
Alternatives considered:
As noted above, the try-resume helpers could instead live in critical_section.c, for example as _PyCriticalSection_TryResume(), so that pystate.c does not depend on the critical section's private representation.
Open questions and gaps.
An audit of stop_the_world() callers for locks that are held across the wait.
3.14, 3.15, CPython main branch
macOS
repro.py.txt
repro_dunder_pinner.py.txt
repro_realistic.py.txt
test_type_stw_deadlock.py.txt
fix-main.diff.txt
fix-3.15.diff.txt
fix-3.14.diff.txt
| Back | FazBrowse Home | New Git URL |
Activity
picnixz commented on Sep 26, 2026
I personally don't find the reproducers actually realistic.
Unfortunately, we need this.
cc @ZeroIntensity
scasella commented on Sep 26, 2026
Thanks, both fair points. I've followed up on each. All runs below are on free-threaded builds of main @ 8c056c3, 3.15 @ 13b1659 and 3.14 @ 1d8c9f8, on macOS arm64.
Realism. I agree the str-subclass key path is contrived: setattr normalizes the name to an exact str, so it's only reachable through the class namespace. Two other routes need less:
No metaclass at all, through the class's dict mutex. repr(K.__dict__) holds the dict's critical section while calling the values' __repr__. If one of those triggers an automatic GC while another thread uses unittest.mock.patch.object(K, "__lt__", ...), the setter's apply_type_slot_updates() pins TYPE_LOCK and the dict mutex and waits for the stop-the-world, which closes the same cycle.
The unpinned side here is dict_repr, not code in typeobject.c. Pinning more call sites can't close this: any critical section on a type's __dict__ that ends up running Python can play that role. That's why the patch handles it in stop_the_world().
A Python mro() that does nothing unusual. Python-level mro() overrides do ship: PyGObject's GObjectMeta defines one (gi/types.py), so it runs for every GObject class, and pydantic's ModelMetaclass had one in 2.10.0–2.10.4.
Both are still stress tests, and I don't have evidence of this hitting a real application.
Performance. I ran pyperf microbenchmarks of the stop-the-world paths the patch touches: gc.collect(), __qualname__ and __bases__ assignment, ABC subclass creation, and a stop-the-world inside mro(). They ran on release builds of main @ 8c056c3 with and without the patch, same flags, on an untuned M4 Pro.
pyperformance is running on both builds. I'll post the comparison, and note any benchmarks that don't run on 3.16t, when it finishes. The raw pyperf JSON is available if useful.
Attachments:
repro_dict_repr.py.txt
repro_c3_mro.py.txt
ZeroIntensity commented on Sep 27, 2026
Looking at fix-main.diff.txt, the proposed fix seems very invasive. We should fix the lock ordering issues in typeobject.c, not try to retrofit the critical section API to work with it.
I think @colesbury or @nascheme should take a look at this.
scasella commented on Sep 27, 2026
One observation that may help: in every hang I've captured, the pinned side is in typeobject.c (type_set_bases, type_set_abstractmethods, apply_type_slot_updates), waiting for the stop-the-world while holding TYPE_LOCK pinned (plus the type's dict mutex for slot updates). The unpinned side can be outside it (dict_repr in repro_dict_repr.py). So a fix on the pinned side, not waiting for the stop-the-world while holding pinned mutexes, should cover both repros, whereas pinning more of the unpinned sites wouldn't.
Happy to run the repros against any candidate patch on main and 3.15.
For completeness, pyperformance for the current diff (92 of 97 benchmarks; dask, genshi, fastapi and both sqlalchemy benchmarks don't run on 3.16t): geometric mean 1.03x faster with the patch. I read that as run-to-run drift rather than an effect of the diff, since the speedups cluster early in the run order and the slowdowns late. There's no sign of a regression; the largest slowdown is tornado_http at 1.06x (±3 ms).
nascheme commented on Oct 3, 2026
I came up with an alternative fix, less invasive and it seems safe to me.
I considered options along those lines but did not find a feasible one. Type structures are read very often and modified rarely, so reads need to be cheap, and the cost of synchronization should fall on writers. A mutex on the read side is not an option; it scales too badly.
Atomics could work in principle. Relaxed loads are essentially free on x86-64 and ARM64, so the issue is not load cost but the extent of the changes. Every place that reads type members with a plain load (e.g. Py_TYPE(op)->tp_flags or a slot pointer) would need to use an atomic load instead. That affects the C API: inline header functions like PyType_HasFeature() and direct tp_* access in extensions and generated code (Cython). Already-compiled extensions would keep their plain loads, which would then formally race with the writers. Atomics also give no consistency across fields: a slot update currently changes several slots across a type and its subclasses as one step, and readers could then see a partial update.
Merge remote-tracking branch 'origin/main' into pythongh-158195-unpin…
Merge remote-tracking branch 'origin/main' into pythongh-158195-unpin…
pythongh-158195: Do not hold the type lock during stop-the-world
Merge remote-tracking branch 'origin/main' into pythongh-158195-unpin…