Skip to content

Commit 78d4373

Browse files
gpsheadclaude
andcommitted
gh-83065: Fix import deadlock by implementing hierarchical module locking
Fix a deadlock in the import system that occurs when concurrent threads import modules at different levels of the same package hierarchy while `__init__.py` files have circular imports. The deadlock scenario (correctly analyzed by @emmatyping): - Thread 1 imports `package.subpackage`, which imports `package.subpackage.module` in its `__init__.py` - Thread 2 imports `package.subpackage.module`, which needs to ensure `package.subpackage` is loaded first - Each thread holds a lock the other needs, causing deadlock The fix introduces _HierarchicalLockManager that acquires all necessary module locks in a consistent order (parent before child) for nested modules. This ensures all threads acquire locks in the same order, preventing circular wait conditions. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent d7e12a3 commit 78d4373

File tree

2 files changed

+130
-1
lines changed

2 files changed

+130
-1
lines changed

Lib/importlib/_bootstrap.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,45 @@ def __exit__(self, *args, **kwargs):
424424
self._lock.release()
425425

426426

427+
class _HierarchicalLockManager:
428+
"""Manages acquisition of multiple module locks in hierarchical order.
429+
430+
This prevents deadlocks by ensuring all threads acquire locks in the
431+
same order (parent modules before child modules).
432+
"""
433+
434+
def __init__(self, name):
435+
self._name = name
436+
self._module_chain = self._get_module_chain(name)
437+
self._locks = []
438+
439+
def _get_module_chain(self, name):
440+
"""Get all modules in the hierarchy from root to leaf.
441+
442+
For example: 'a.b.c' -> ['a', 'a.b', 'a.b.c']
443+
"""
444+
parts = name.split('.')
445+
return ['.'.join(parts[:i+1]) for i in range(len(parts))]
446+
447+
def __enter__(self):
448+
# Acquire locks for all modules in hierarchy order (parent to child)
449+
for module_name in self._module_chain:
450+
# Only acquire lock if module is not already fully loaded
451+
module = sys.modules.get(module_name)
452+
if (module is None or
453+
getattr(getattr(module, "__spec__", None), "_initializing", False)):
454+
lock = _get_module_lock(module_name)
455+
lock.acquire()
456+
self._locks.append((module_name, lock))
457+
return self
458+
459+
def __exit__(self, *args, **kwargs):
460+
# Release locks in reverse order (child to parent)
461+
for module_name, lock in reversed(self._locks):
462+
lock.release()
463+
self._locks.clear()
464+
465+
427466
# The following two functions are for consumption by Python/import.c.
428467

429468
def _get_module_lock(name):
@@ -1365,7 +1404,14 @@ def _find_and_load(name, import_):
13651404
module = sys.modules.get(name, _NEEDS_LOADING)
13661405
if (module is _NEEDS_LOADING or
13671406
getattr(getattr(module, "__spec__", None), "_initializing", False)):
1368-
with _ModuleLockManager(name):
1407+
1408+
# Use hierarchical locking for nested modules to prevent deadlocks
1409+
if '.' in name:
1410+
lock_manager = _HierarchicalLockManager(name)
1411+
else:
1412+
lock_manager = _ModuleLockManager(name)
1413+
1414+
with lock_manager:
13691415
module = sys.modules.get(name, _NEEDS_LOADING)
13701416
if module is _NEEDS_LOADING:
13711417
return _find_and_load_unlocked(name, import_)

Lib/test/test_importlib/test_threaded_import.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,89 @@ def test_multiprocessing_pool_circular_import(self, size):
259259
'partial', 'pool_in_threads.py')
260260
script_helper.assert_python_ok(fn)
261261

262+
def test_hierarchical_import_deadlock(self):
263+
# Regression test for bpo-38884 / gh-83065
264+
# Tests that concurrent imports at different hierarchy levels
265+
# don't deadlock when parent imports child in __init__.py
266+
267+
# Create package structure:
268+
# package/__init__.py: from package import subpackage
269+
# package/subpackage/__init__.py: from package.subpackage.module import *
270+
# package/subpackage/module.py: class SomeClass: pass
271+
272+
pkg_dir = os.path.join(TESTFN, 'hier_deadlock_pkg')
273+
os.makedirs(pkg_dir)
274+
self.addCleanup(shutil.rmtree, TESTFN)
275+
276+
subpkg_dir = os.path.join(pkg_dir, 'subpackage')
277+
os.makedirs(subpkg_dir)
278+
279+
# Create package files
280+
with open(os.path.join(pkg_dir, "__init__.py"), "w") as f:
281+
f.write("from hier_deadlock_pkg import subpackage\n")
282+
283+
with open(os.path.join(subpkg_dir, "__init__.py"), "w") as f:
284+
f.write("from hier_deadlock_pkg.subpackage.module import *\n")
285+
286+
with open(os.path.join(subpkg_dir, "module.py"), "w") as f:
287+
f.write("class SomeClass:\n pass\n")
288+
289+
sys.path.insert(0, TESTFN)
290+
self.addCleanup(sys.path.remove, TESTFN)
291+
self.addCleanup(forget, 'hier_deadlock_pkg')
292+
self.addCleanup(forget, 'hier_deadlock_pkg.subpackage')
293+
self.addCleanup(forget, 'hier_deadlock_pkg.subpackage.module')
294+
295+
importlib.invalidate_caches()
296+
297+
errors = []
298+
results = []
299+
300+
def t1():
301+
try:
302+
import hier_deadlock_pkg.subpackage
303+
results.append('t1_success')
304+
except Exception as e:
305+
errors.append(('t1', e))
306+
307+
def t2():
308+
try:
309+
import hier_deadlock_pkg.subpackage.module
310+
results.append('t2_success')
311+
except Exception as e:
312+
errors.append(('t2', e))
313+
314+
# Run multiple times to increase chance of hitting race condition
315+
for i in range(10):
316+
# Clear modules between runs
317+
for mod in ['hier_deadlock_pkg', 'hier_deadlock_pkg.subpackage',
318+
'hier_deadlock_pkg.subpackage.module']:
319+
sys.modules.pop(mod, None)
320+
321+
errors.clear()
322+
results.clear()
323+
324+
thread1 = threading.Thread(target=t1)
325+
thread2 = threading.Thread(target=t2)
326+
327+
thread1.start()
328+
thread2.start()
329+
330+
thread1.join(timeout=5)
331+
thread2.join(timeout=5)
332+
333+
# Check that both threads completed successfully
334+
if thread1.is_alive() or thread2.is_alive():
335+
self.fail(f"Threads deadlocked on iteration {i}")
336+
337+
# No deadlock errors should occur
338+
for thread_name, error in errors:
339+
if isinstance(error, ImportError) and "deadlock" in str(error):
340+
self.fail(f"Deadlock detected in {thread_name} on iteration {i}: {error}")
341+
342+
# Both imports should succeed
343+
self.assertEqual(len(results), 2, f"Not all imports succeeded on iteration {i}")
344+
262345

263346
def setUpModule():
264347
thread_info = threading_helper.threading_setup()

0 commit comments

Comments
 (0)