Skip to content

Commit a33b831

Browse files
committed
gh-158195: Do not hold the type lock during stop-the-world
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.
1 parent 80040b6 commit a33b831

5 files changed

Lines changed: 562 additions & 187 deletions

File tree

‎Include/internal/pycore_interp_structs.h‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,12 @@ struct types_state {
588588
where all those lower numbers are used for core static types. */
589589
unsigned int next_version_tag;
590590

591+
#ifdef Py_GIL_DISABLED
592+
/* Incremented on changes that can invalidate a type update that is
593+
prepared under the type lock and published with the world stopped. */
594+
uint64_t publish_gen;
595+
#endif
596+
591597
/* Every static builtin type is initialized for each interpreter
592598
during its own initialization, including for the main interpreter
593599
during global runtime initialization. This is done by calling

‎Lib/test/test_descr.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6176,6 +6176,8 @@ class C(A):
61766176
self.assertEqual(C.__bases__, (B2,))
61776177
self.assertEqual(B2.__subclasses__(), [C])
61786178
self.assertEqual(B1.__subclasses__(), [])
6179+
# C must have been removed from its original base, A.
6180+
self.assertEqual(A.__subclasses__(), [B2])
61796181

61806182
self.assertEqual(B1.__bases__, (C,))
61816183
self.assertEqual(C.__subclasses__(), [B1])

‎Lib/test/test_free_threading/test_type.py‎

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import gc
12
import threading
23
import unittest
34

@@ -19,6 +20,27 @@
1920
class A:
2021
attr = 1
2122

23+
def world_stopper_threads(done):
24+
"""Threads that stop the world until 'done' is set.
25+
26+
The stopper keeps the stop-the-world mutex contended, so that threads
27+
updating types block on it and release the type lock. The collector
28+
makes the stop-the-world pauses long, so that other threads have time
29+
to update types while the lock is released.
30+
"""
31+
def stopper():
32+
def g():
33+
pass
34+
while not done.is_set():
35+
g.__qualname__ = "g" # stops the world
36+
37+
def collector():
38+
while not done.is_set():
39+
gc.collect()
40+
41+
return [Thread(target=stopper), Thread(target=collector)]
42+
43+
2244
@threading_helper.requires_working_threading()
2345
class TestType(TestCase):
2446
def test_attr_cache(self):
@@ -401,6 +423,216 @@ class B(A):
401423
with threading_helper.start_threads(threads):
402424
pass
403425

426+
def check_stop_the_world_under_type_lock(self, setter):
427+
# gh-158195: Python code that runs with the type lock held and stops
428+
# the world could deadlock with a thread that held the type lock
429+
# pinned while it waited for the stop-the-world mutex (__bases__
430+
# assignment). The first thread released the type lock while it was
431+
# blocked on the stop-the-world mutex, was handed the mutex, and then
432+
# waited for the type lock while owning the mutex.
433+
done = threading.Event()
434+
435+
def run_setter():
436+
barrier.wait()
437+
setter()
438+
439+
def basesetter():
440+
class A:
441+
pass
442+
class B:
443+
pass
444+
class C(A):
445+
pass
446+
barrier.wait()
447+
while not done.is_set():
448+
C.__bases__ = (B,)
449+
C.__bases__ = (A,)
450+
451+
def stopper():
452+
def g():
453+
pass
454+
barrier.wait()
455+
while not done.is_set():
456+
g.__qualname__ = "g" # stops the world
457+
458+
# The stopper thread keeps the stop-the-world mutex contended, which
459+
# makes the setters block on it.
460+
setters = [Thread(target=run_setter) for _ in range(2)]
461+
others = [Thread(target=basesetter), Thread(target=stopper)]
462+
barrier = threading.Barrier(len(setters) + len(others))
463+
with threading_helper.start_threads(setters + others):
464+
for t in setters:
465+
t.join()
466+
done.set()
467+
468+
def test_stop_the_world_in_mro_deadlock(self):
469+
# type_ready() calls a metaclass mro() with the type lock held.
470+
N = 50
471+
def f():
472+
pass
473+
474+
class Meta(type):
475+
def mro(cls):
476+
f.__qualname__ = "f" # stops the world
477+
return type.mro(cls)
478+
479+
def setter():
480+
for _ in range(N):
481+
Meta("K", (), {})
482+
483+
self.check_stop_the_world_under_type_lock(setter)
484+
485+
def test_stop_the_world_in_setattr_key_eq_deadlock(self):
486+
# type_setattro() compares the attribute name with a str subclass key
487+
# of the type's __dict__ (calling its __eq__) with the type lock held.
488+
N = 50
489+
def f():
490+
pass
491+
492+
class S(str):
493+
__hash__ = str.__hash__
494+
def __eq__(self, other):
495+
f.__qualname__ = "f" # stops the world
496+
return str.__eq__(self, other)
497+
498+
C = type("C", (), {S("x"): 0})
499+
500+
def setter():
501+
for i in range(N):
502+
C.x = i
503+
504+
self.check_stop_the_world_under_type_lock(setter)
505+
506+
def test_concurrent_slot_update_consistency(self):
507+
# Several threads assign and delete the same special method of a
508+
# class with subclasses while other threads stop the world. The
509+
# type lock is released while a thread waits for the stop-the-world,
510+
# so the slot updates it prepared can go stale and must be redone.
511+
# At the end, the type slots must agree with the class dicts.
512+
N = 300
513+
done = threading.Event()
514+
515+
class Base:
516+
pass
517+
518+
subclasses = [type(f"Sub{i}", (Base,), {}) for i in range(5)]
519+
funcs = [lambda self, i=i: f"repr{i}" for i in range(4)]
520+
521+
def setter(i):
522+
barrier.wait()
523+
for _ in range(N):
524+
Base.__repr__ = funcs[i]
525+
try:
526+
del Base.__repr__
527+
except AttributeError:
528+
pass
529+
530+
setters = [Thread(target=setter, args=(i,)) for i in range(len(funcs))]
531+
others = world_stopper_threads(done)
532+
barrier = threading.Barrier(len(setters))
533+
with threading_helper.start_threads(setters + others):
534+
for t in setters:
535+
t.join()
536+
done.set()
537+
538+
for cls in [Base] + subclasses:
539+
if "__repr__" in Base.__dict__:
540+
expected = Base.__dict__["__repr__"](cls())
541+
self.assertEqual(repr(cls()), expected)
542+
else:
543+
# object.__repr__ is used
544+
self.assertStartsWith(repr(cls()), "<")
545+
546+
def test_slot_update_subclass_freed(self):
547+
# Subclasses are created and freed while another thread updates a
548+
# slot that is inherited by them. The slot update queue must keep
549+
# the subclasses alive until the update is applied, since the type
550+
# lock is released while waiting for the stop-the-world.
551+
N = 300
552+
done = threading.Event()
553+
554+
class Base:
555+
pass
556+
557+
def setter():
558+
func = lambda self: "x"
559+
barrier.wait()
560+
for _ in range(N):
561+
Base.__repr__ = func
562+
try:
563+
del Base.__repr__
564+
except AttributeError:
565+
pass
566+
567+
def subclasser():
568+
barrier.wait()
569+
while not done.is_set():
570+
for _ in range(20):
571+
type("Sub", (Base,), {})
572+
573+
setters = [Thread(target=setter) for _ in range(2)]
574+
others = ([Thread(target=subclasser), Thread(target=subclasser)]
575+
+ world_stopper_threads(done))
576+
barrier = threading.Barrier(len(setters))
577+
with threading_helper.start_threads(setters + others):
578+
for t in setters:
579+
t.join()
580+
done.set()
581+
582+
def test_concurrent_bases_assignment(self):
583+
# Several threads reassign __bases__ of the same class, and of its
584+
# base, while others stop the world. The validated bases and the
585+
# computed MRO can go stale while the type lock is released and
586+
# must be recomputed. At the end, the MRO must agree with __bases__.
587+
N = 100
588+
done = threading.Event()
589+
590+
class A:
591+
def meth(self):
592+
return "A"
593+
def __repr__(self):
594+
return "A"
595+
class B:
596+
def meth(self):
597+
return "B"
598+
def __repr__(self):
599+
return "B"
600+
class C(A):
601+
pass
602+
class D(C):
603+
pass
604+
605+
def setter():
606+
barrier.wait()
607+
for _ in range(N):
608+
C.__bases__ = (B,)
609+
C.__bases__ = (A,)
610+
611+
def subsetter():
612+
barrier.wait()
613+
for _ in range(N):
614+
D.__bases__ = (C, B)
615+
D.__bases__ = (C,)
616+
617+
setters = [Thread(target=setter), Thread(target=setter),
618+
Thread(target=subsetter)]
619+
others = world_stopper_threads(done)
620+
barrier = threading.Barrier(len(setters))
621+
with threading_helper.start_threads(setters + others):
622+
for t in setters:
623+
t.join()
624+
done.set()
625+
626+
self.assertEqual(C.__bases__, (A,))
627+
self.assertEqual(C.__mro__, (C, A, object))
628+
self.assertEqual(D.__bases__, (C,))
629+
self.assertEqual(D.__mro__, (D, C, A, object))
630+
self.assertEqual(D().meth(), "A")
631+
self.assertEqual(repr(C()), "A")
632+
self.assertEqual(repr(D()), "A")
633+
self.assertIn(C, A.__subclasses__())
634+
self.assertNotIn(C, B.__subclasses__())
635+
404636

405637
if __name__ == "__main__":
406638
unittest.main()
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Fix a deadlock in the free-threaded build between a thread that stops the
2+
world while running Python code under the type lock (for example a custom
3+
metaclass ``mro()`` or the ``__eq__`` of a :class:`str` subclass used as a
4+
class ``__dict__`` key) and a thread that assigns to a special method or to
5+
``__bases__`` of a class. The type lock is no longer held while waiting
6+
for a stop-the-world; the prepared slot, MRO and bases updates are
7+
validated with the world stopped and recomputed if they went stale.

0 commit comments

Comments
 (0)