| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
... because parents' module locks stay held while a submodule's body runs
(bear with me this is verbose, yes mostly model written, i tried to make sure the important info was up top - and i have a proposed fix coming) -gps
Since gh-83065 (GH-137196, in 3.15.0b1 and later), _find_and_load("a.leaf") also takes the module lock of a if a isn't fully imported yet, and releases it together with the lock of a.leaf, after the body of a/leaf.py has run. So a thread whose first import of a.leaf also imports a keeps the lock of a long after a is fully initialised, for as long as the body of a.leaf runs, including everything that body imports. A second thread that found a missing or still initializing waits for that lock... Oops! 😬
This causes three failures that 3.14 and earlier don't have. None needs an import cycle, and in one thread the same imports work in either order:
import os, shutil, sys, tempfile, threading, time
root = tempfile.mkdtemp()
sys.path.insert(0, root)
for path, text in {
"a/__init__.py": "import time; time.sleep(0.2)", # any package whose __init__ takes a while
"a/leaf.py": "from b.leaf import X",
"b/__init__.py": "import a",
"b/leaf.py": "X = 1",
}.items():
os.makedirs(os.path.dirname(os.path.join(root, path)), exist_ok=True)
with open(os.path.join(root, path), "w") as f:
f.write(text + "\n")
def thread_1():
import a.leaf
def thread_2():
time.sleep(0.1) # start while a/__init__.py is running
import b.leaf
threads = [threading.Thread(target=thread_1), threading.Thread(target=thread_2)]
for t in threads: t.start()
for t in threads: t.join()
shutil.rmtree(root)
print("a.leaf imported:", "a.leaf" in sys.modules, "- b.leaf imported:", "b.leaf" in sys.modules)
sys.exit("a.leaf" not in sys.modules or "b.leaf" not in sys.modules)The dependencies are a.leaf -> b.leaf -> b -> a, and a imports nothing, so there is no cycle. On 3.15.0:
Exception in thread Thread-1 (thread_1):
Traceback (most recent call last):
File "threading.py", line 1218, in _bootstrap_inner
self._context.run(self.run)
~~~~~~~~~~~~~~~~~^^^^^^^^^^
File "threading.py", line 1160, in run
self._target(*self._args, **self._kwargs)
~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "repro_mini.py", line 16, in thread_1
import a.leaf
File "a/leaf.py", line 1, in <module>
from b.leaf import X
File "<frozen importlib._bootstrap>", line 1347, in _find_and_load
File "<frozen importlib._bootstrap>", line 458, in __enter__
File "<frozen importlib._bootstrap>", line 346, in acquire
_frozen_importlib._DeadlockError: deadlock detected by _ModuleLock('b.leaf') at 140633457717456
a.leaf imported: False - b.leaf imported: True
The cause shows without threads. With an empty w/__init__.py and this w/main.py:
import importlib._bootstrap as b
ref = b._module_locks.get("w"); lock = ref() if ref else None
print("lock of w held while w.main's body runs:", bool(lock and lock.locked()))python -c "import w.main" prints True on 3.15.0 and False on 3.14.7.
Line numbers are those of Lib/importlib/_bootstrap.py at tag v3.15.0.
Every acquire() and release() of the module locks by the two threads of reproducer 1 on 3.15.0, in order (the figure is how many times the thread holds the lock afterwards):
thread 1 wants a thread 1 has (1) a thread 1 wants a.leaf thread 1 has (1) a.leaf thread 1 wants a thread 1 has (2) a thread 2 wants b thread 2 has (1) b thread 2 wants b.leaf thread 2 has (1) b.leaf thread 2 wants b thread 2 has (2) b thread 2 wants a thread 1 gives back (1) a thread 1 wants b thread 1 DEADLOCK b thread 1 wants b.leaf thread 1 DEADLOCK b.leaf thread 1 gives back (0) a.leaf thread 1 gives back (0) a thread 2 has (1) a thread 2 gives back (0) a thread 2 gives back (1) b thread 2 gives back (0) b.leaf thread 2 gives back (0) b
On 3.14.x the line after thread 2 wants a is thread 1 gives back (0) a. Thread 2 goes on and finishes b and b.leaf; thread 1 waits for b.leaf if it isn't finished yet.
How the three failures arise:
"Parent before child" orders the locks along one dotted name, but gives no order between two chains, (a, a.leaf) and (b, b.leaf). Since a chain stays held while module bodies run, a thread can ask for a second chain while holding the first. Before 3.15, a thread inside the body of a.leaf held only the locks of a.leaf and of the modules further out on its own import stack, all really unfinished, so it could only block a thread that needed one of those. Now it also blocks every thread that needs only the finished a and happened to look before a was complete.
This trade-off (ancestors' locks held through the leaf's body) isn't discussed in the class docstring (437-441), the comment at 462-466, the two new tests, the NEWS entry (3.15.0 beta 1: "so the two threads serialise instead of raising _DeadlockError") or What's New, so it looks unintended. test_cross_package_circular_import (Lib/test/test_importlib/test_threaded_import.py, 410-414) says even of two packages that do import each other that concurrent imports of their submodules "must not raise _DeadlockError". CPython's own tests don't catch these failures: python -m test -j2 test_importlib test_import test_threading test_runpy test_pkgutil test_zipimport test_lazy_import passes on the 3.15.0 build.
Reproducer 1: both errors, without sleepsStandard library only. It writes three or four small files to a temporary folder and first checks that both orders work in one thread. Exit code 1: an import failed; 0: none did.
"""Two threads make first imports in two packages. No import cycle, and either
order works in one thread, yet thread 1 fails on 3.15 (passes on 3.11 to 3.14).
python repro_error.py -> _DeadlockError
python repro_error.py partial -> ImportError: cannot import name 'X' from
partially initialized module 'b'
"""
import importlib._bootstrap as bootstrap
import os, shutil, sys, tempfile, threading, time, traceback, types
CASES = {
# Needs: a.leaf -> b.leaf -> b -> a. "a" imports nothing, so there is no cycle.
"deadlock": ("import a.leaf", "import b.leaf", {
"a/__init__.py": "import sync; sync.slow_init()",
"a/leaf.py": "from b.leaf import X",
"b/__init__.py": "import a",
"b/leaf.py": "X = 1"}),
# Needs: a.leaf -> b -> a. No cycle either.
"partial": ("import a.leaf", "import b", {
"a/__init__.py": "import sync; sync.slow_init()",
"a/leaf.py": "from b import X",
"b.py": "import a; X = 1"}),
}
stmt1, stmt2, files = CASES[sys.argv[1] if len(sys.argv) > 1 else "deadlock"]
sys.dont_write_bytecode = True
root = tempfile.mkdtemp()
sys.path.insert(0, root)
for path, text in files.items():
os.makedirs(os.path.dirname(os.path.join(root, path)), exist_ok=True)
with open(os.path.join(root, path), "w") as f:
f.write(text + "\n")
# "sync" is not a file: it lets a/__init__.py call back into this script.
sync = sys.modules["sync"] = types.ModuleType("sync")
# Control: one thread, both orders.
sync.slow_init = lambda: None
for order in ((stmt1, stmt2), (stmt2, stmt1)):
for stmt in order:
exec(stmt)
for name in [n for n in sys.modules if n.split(".")[0] in ("a", "b")]:
del sys.modules[name]
print("one thread, both orders: ok")
# Two threads. a/__init__.py stands for any package whose __init__ takes a while:
# it returns once thread 2 has got as far as waiting for the module lock of "a".
# No sleep decides the order; the private _blocking_on is only read, to see that moment.
in_a = threading.Event()
failed = []
def slow_init():
in_a.set()
while "_ModuleLock('a')" not in repr(bootstrap._blocking_on.get(t2.ident)):
time.sleep(0.001)
sync.slow_init = slow_init
def run(stmt, start_after=None):
if start_after:
start_after.wait()
try:
exec(stmt)
except BaseException:
failed.append(stmt)
print(f"{threading.current_thread().name}, {stmt!r}:")
traceback.print_exc(file=sys.stdout)
t1 = threading.Thread(target=run, args=(stmt1,), name="thread 1", daemon=True)
t2 = threading.Thread(target=run, args=(stmt2, in_a), name="thread 2", daemon=True)
t2.start(); t1.start()
t1.join(30); t2.join(30)
if t1.is_alive() or t2.is_alive():
failed.append("still running after 30 s")
shutil.rmtree(root, ignore_errors=True)
print("two threads:", f"FAILED {failed}" if failed else "ok", "on", sys.version.split()[0])
sys.exit(1 if failed else 0)On 3.15.0 (on 3.14.7 both print two threads: ok):
$ python repro_error.py
one thread, both orders: ok
thread 1, 'import a.leaf':
Traceback (most recent call last):
File "repro_error.py", line 62, in run
exec(stmt)
~~~~^^^^^^
File "<string>", line 1, in <module>
File "a/leaf.py", line 1, in <module>
from b.leaf import X
File "<frozen importlib._bootstrap>", line 1347, in _find_and_load
File "<frozen importlib._bootstrap>", line 458, in __enter__
File "<frozen importlib._bootstrap>", line 346, in acquire
_frozen_importlib._DeadlockError: deadlock detected by _ModuleLock('b.leaf') at 139688739099088
two threads: FAILED ['import a.leaf'] on 3.15.0
$ python repro_error.py partial
one thread, both orders: ok
thread 1, 'import a.leaf':
Traceback (most recent call last):
File "repro_error.py", line 62, in run
exec(stmt)
~~~~^^^^^^
File "<string>", line 1, in <module>
File "a/leaf.py", line 1, in <module>
from b import X
ImportError: cannot import name 'X' from partially initialized module 'b' (most likely due to a circular import) (b.py)
two threads: FAILED ['import a.leaf'] on 3.15.0
The second message blames a circular import that doesn't exist.
Reproducer 2: the wait and the hang"""Thread 1 makes the first import of w.main. Thread 2 imports w.other, and starts
while w/__init__.py is still running. On 3.15 thread 2 then has to wait until the
BODY of w.main has ended, though "w" was complete long before. Here that body waits
for thread 2, so both would hang forever; no _DeadlockError is raised. The wait is
cut off after 5 s so that the script ends. Passes on 3.11 to 3.14.
"""
import importlib._bootstrap as bootstrap
import os, shutil, sys, tempfile, threading, time, traceback, types
files = {
"w/__init__.py": "import sync; sync.slow_init()",
"w/main.py": "import sync; sync.wait_for_thread_2()",
"w/other.py": "",
}
sys.dont_write_bytecode = True
root = tempfile.mkdtemp()
sys.path.insert(0, root)
os.mkdir(os.path.join(root, "w"))
for path, text in files.items():
with open(os.path.join(root, path), "w") as f:
f.write(text + "\n")
in_w = threading.Event()
thread_2_done = threading.Event()
hung = []
def slow_init():
# Stands for any __init__ that takes a while: returns once thread 2 waits for
# the module lock of "w" (or is done: before gh-130094 it doesn't wait at all).
in_w.set()
while not (thread_2_done.is_set() or
"_ModuleLock('w')" in repr(bootstrap._blocking_on.get(t2.ident))):
time.sleep(0.001)
def wait_for_thread_2():
w = sys.modules["w"]
print("w.main's body runs; w.__spec__._initializing =", w.__spec__._initializing)
if not thread_2_done.wait(5):
hung.append(True)
print("after 5 s thread 2 is still here:")
traceback.print_stack(sys._current_frames()[t2.ident], limit=4, file=sys.stdout)
# "sync" is not a file: it lets the modules call back into this script.
sync = sys.modules["sync"] = types.ModuleType("sync")
sync.slow_init, sync.wait_for_thread_2 = slow_init, wait_for_thread_2
def thread_1():
import w.main
def thread_2():
in_w.wait()
t0 = time.monotonic()
import w.other
thread_2_done.set()
print(f"thread 2: 'import w.other' took {time.monotonic() - t0:.1f} s")
t1 = threading.Thread(target=thread_1, daemon=True)
t2 = threading.Thread(target=thread_2, daemon=True)
t2.start(); t1.start()
t1.join(30); t2.join(30)
shutil.rmtree(root, ignore_errors=True)
print("HANG (cut off by the script)" if hung else "ok", "on", sys.version.split()[0])
sys.exit(1 if hung else 0)On 3.15.0:
$ python repro_hang.py
w.main's body runs; w.__spec__._initializing = False
after 5 s thread 2 is still here:
File "repro_hang.py", line 53, in thread_2
import w.other
File "<frozen importlib._bootstrap>", line 1347, in _find_and_load
File "<frozen importlib._bootstrap>", line 458, in __enter__
File "<frozen importlib._bootstrap>", line 365, in acquire
thread 2: 'import w.other' took 5.1 s
HANG (cut off by the script) on 3.15.0
On 3.14.7 (the first two lines can come in either order):
w.main's body runs; w.__spec__._initializing = False thread 2: 'import w.other' took 0.0 s ok on 3.14.7
The same happens if thread 2 says import w or from w.other import X (40 of 40 runs on 3.15.0, 0 of 40 on 3.14.7).
The plain form of this failure is the wait: 5 s here against 0.0 s, for a module whose parent is complete and which has nothing to do with w.main. The hang is the extreme case. A module body that waits for another thread is unusual, but per-module locks were introduced for just that: What's New in 3.3 names "deadlocks when importing a module would trigger code execution in a different thread as a side-effect" as the annoyance they remove, and test_side_effect_import in test_threaded_import.py imports a module whose body starts a thread that imports, and joins it. A realistic shape: app/__init__.py starts a background thread that imports app.something on first use, and the main thread's first import is import app.main, whose body takes a long time. python -m app.main is not affected, because runpy imports the parent by itself first.
CPython main branch, 3.15
Linux
| Back | FazBrowse Home | New Git URL |
Activity