Skip to content

Commit 7159b15

Browse files
authored
gh-159963: Don't hold parent package import locks while a submodule executes (GH-159965)
Since gh-83065 (3.15.0), the first import of pkg.mod kept pkg's module lock until pkg/mod.py had finished executing. Threads that needed only the finished pkg stayed blocked for that long, and concurrent first imports could raise _DeadlockError or a spurious "partially initialized module" ImportError, or hang, with no import cycle involved. Parent packages are now imported, or waited for if another thread is initializing them, before the submodule's module lock is taken, so only that lock is held while the submodule executes. The lock ordering deadlock fixed by gh-83065 stays fixed. importlib.import_module() now returns the partially initialized module in a circular import split across threads, as the import statement already did, instead of raising _DeadlockError. That was long-standing behavior rather than part of the 3.15.0 regression.
1 parent 081f6fb commit 7159b15

4 files changed

Lines changed: 672 additions & 85 deletions

File tree

‎Lib/importlib/_bootstrap.py‎

Lines changed: 84 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -418,64 +418,6 @@ def __exit__(self, *args, **kwargs):
418418
self._lock.release()
419419

420420

421-
def _get_module_chain(name):
422-
"""Return the chain of dotted-name prefixes from root to leaf.
423-
424-
For example: 'a.b.c' -> ['a', 'a.b', 'a.b.c']
425-
"""
426-
parts = name.split('.')
427-
return ['.'.join(parts[:i+1]) for i in range(len(parts))]
428-
429-
430-
class _HierarchicalLockManager:
431-
"""Manages acquisition of multiple module locks in hierarchical order.
432-
433-
This prevents deadlocks by ensuring all threads acquire locks in the
434-
same order (parent modules before child modules).
435-
"""
436-
437-
def __init__(self, name):
438-
self._name = name
439-
self._module_chain = _get_module_chain(name)
440-
self._locks = []
441-
442-
def __enter__(self):
443-
try:
444-
for module_name in self._module_chain:
445-
# Only acquire lock if module is not already fully loaded
446-
module = sys.modules.get(module_name)
447-
if (module is None or
448-
getattr(getattr(module, "__spec__", None),
449-
"_initializing", False)):
450-
lock = _get_module_lock(module_name)
451-
try:
452-
lock.acquire()
453-
except _DeadlockError:
454-
if module_name == self._name:
455-
raise
456-
# The parent is being initialised by a thread that
457-
# is (transitively) waiting on a lock we hold.
458-
# Apply the same policy as _lock_unlock_module():
459-
# accept a partially-initialised parent for circular
460-
# imports rather than failing the whole chain.
461-
continue
462-
self._locks.append((module_name, lock))
463-
except:
464-
# __exit__ is not called when __enter__ raises (e.g. _DeadlockError
465-
# on the leaf lock, or KeyboardInterrupt), so release whatever we
466-
# already hold to avoid permanently leaking held module locks.
467-
for module_name, lock in reversed(self._locks):
468-
lock.release()
469-
self._locks.clear()
470-
raise
471-
return self
472-
473-
def __exit__(self, *args, **kwargs):
474-
for module_name, lock in reversed(self._locks):
475-
lock.release()
476-
self._locks.clear()
477-
478-
479421
# The following two functions are for consumption by Python/import.c.
480422

481423
def _get_module_lock(name):
@@ -1256,17 +1198,15 @@ def _sanity_check(name, package, level):
12561198

12571199
def _find_and_load_unlocked(name, import_, *, lazy_submodule=False):
12581200
path = None
1259-
sys.audit(
1260-
"import",
1261-
name,
1262-
path,
1263-
getattr(sys, "path", None),
1264-
getattr(sys, "meta_path", None),
1265-
getattr(sys, "path_hooks", None)
1266-
)
12671201
parent = name.rpartition('.')[0]
12681202
parent_spec = None
12691203
if parent:
1204+
# _resolve_parents() has normally imported the parent already. It can
1205+
# be missing again by now if its import failed in another thread or
1206+
# something removed it from sys.modules; it is then imported afresh,
1207+
# like any other module that is not in sys.modules. That import runs
1208+
# while holding name's lock, the lock order _resolve_parents() exists
1209+
# to avoid; it is tolerated here because the case is so rare.
12701210
if parent not in sys.modules:
12711211
_call_with_frames_removed(import_, parent)
12721212
# Crazy side-effects!
@@ -1324,31 +1264,90 @@ def _find_and_load_unlocked(name, import_, *, lazy_submodule=False):
13241264
_NEEDS_LOADING = object()
13251265

13261266

1267+
# Return whether module (a sys.modules value) is still executing.
1268+
def _is_initializing(module):
1269+
return getattr(getattr(module, '__spec__', None), '_initializing', False)
1270+
1271+
1272+
# Import, or wait for, the parent packages of name.
1273+
#
1274+
# _find_and_load() calls this before taking name's module lock, so that no
1275+
# thread holds a submodule's lock while waiting for its parent. This avoids
1276+
# the deadlocks and import errors of gh-83065 and gh-159963.
1277+
def _resolve_parents(name, import_):
1278+
# Wait for each ancestor package that another thread is still
1279+
# initializing, outermost first. Importing beneath a package while its
1280+
# __init__ is part way through its own imports could give one of the two
1281+
# threads a partially initialized module. Missing ancestors are not
1282+
# imported: only the immediate parent has to exist.
1283+
parts = name.split('.')
1284+
for i in range(1, len(parts) - 1):
1285+
ancestor = '.'.join(parts[:i])
1286+
if _is_initializing(sys.modules.get(ancestor)):
1287+
_lock_unlock_module(ancestor)
1288+
# Import the immediate parent, or wait for it likewise.
1289+
parent = name.rpartition('.')[0]
1290+
if parent:
1291+
module = sys.modules.get(parent, _NEEDS_LOADING)
1292+
if module is _NEEDS_LOADING or _is_initializing(module):
1293+
_call_with_frames_removed(import_, parent)
1294+
1295+
13271296
def _find_and_load(name, import_, *, lazy_submodule=False):
13281297
"""Find and load the module."""
13291298

13301299
# Optimization: we avoid unneeded module locking if the module
13311300
# already exists in sys.modules and is fully initialized.
13321301
module = sys.modules.get(name, _NEEDS_LOADING)
1333-
if (module is _NEEDS_LOADING or
1334-
getattr(getattr(module, "__spec__", None), "_initializing", False)):
1335-
1336-
if '.' in name:
1337-
lock_manager = _HierarchicalLockManager(name)
1338-
else:
1339-
lock_manager = _ModuleLockManager(name)
1340-
1341-
with lock_manager:
1342-
module = sys.modules.get(name, _NEEDS_LOADING)
1343-
if module is _NEEDS_LOADING:
1344-
return _find_and_load_unlocked(
1345-
name, import_, lazy_submodule=lazy_submodule)
1346-
1347-
# Optimization: only call _bootstrap._lock_unlock_module() if
1348-
# module.__spec__._initializing is True.
1349-
# NOTE: because of this, initializing must be set *before*
1350-
# putting the new module in sys.modules.
1351-
_lock_unlock_module(name)
1302+
if module is _NEEDS_LOADING or _is_initializing(module):
1303+
if module is not _NEEDS_LOADING:
1304+
# Wait for the thread executing the module.
1305+
# NOTE: this relies on __spec__._initializing being set *before*
1306+
# the module is put in sys.modules.
1307+
lock = _get_module_lock(name)
1308+
try:
1309+
lock.acquire()
1310+
except _DeadlockError:
1311+
# A circular import split across threads: accept the
1312+
# partially initialized module.
1313+
pass
1314+
else:
1315+
# The module is gone if the thread loading it failed. Look
1316+
# while still holding the lock, so that another thread cannot
1317+
# have begun loading it again.
1318+
try:
1319+
module = sys.modules.get(name, _NEEDS_LOADING)
1320+
finally:
1321+
lock.release()
1322+
1323+
if module is _NEEDS_LOADING:
1324+
sys.audit(
1325+
"import",
1326+
name,
1327+
None,
1328+
getattr(sys, "path", None),
1329+
getattr(sys, "meta_path", None),
1330+
getattr(sys, "path_hooks", None)
1331+
)
1332+
_resolve_parents(name, import_)
1333+
lock = _get_module_lock(name)
1334+
try:
1335+
lock.acquire()
1336+
except _DeadlockError:
1337+
# A circular import split across threads. As in
1338+
# _lock_unlock_module(), accept a partially initialized
1339+
# module if there is one.
1340+
module = sys.modules.get(name, _NEEDS_LOADING)
1341+
if module is _NEEDS_LOADING:
1342+
raise
1343+
else:
1344+
try:
1345+
module = sys.modules.get(name, _NEEDS_LOADING)
1346+
if module is _NEEDS_LOADING:
1347+
return _find_and_load_unlocked(
1348+
name, import_, lazy_submodule=lazy_submodule)
1349+
finally:
1350+
lock.release()
13521351
else:
13531352
# Verify the module is still in sys.modules. Another thread may have
13541353
# removed it (due to import failure) between our sys.modules.get()

‎Lib/test/test_importlib/import_/test_packages.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from test.test_importlib import util
22
import sys
3+
import types
34
import unittest
45
from test.support import import_helper
56

@@ -100,6 +101,21 @@ def module_injection():
100101
finally:
101102
import_helper.unload(subname)
102103

104+
def test_missing_grandparent_not_imported(self):
105+
# Only the immediate parent has to be present. With a hand-made
106+
# 'pkg.sub' package in sys.modules and no 'pkg' at all, importing
107+
# pkg.sub.mod must neither fail nor try to import 'pkg'.
108+
parent = types.ModuleType('pkg.sub')
109+
parent.__path__ = []
110+
with util.mock_spec('pkg.sub.mod') as mock:
111+
with util.import_state(meta_path=[mock]):
112+
with util.uncache('pkg', 'pkg.sub', 'pkg.sub.mod'):
113+
sys.modules['pkg.sub'] = parent
114+
module = self.__import__('pkg.sub.mod', fromlist=['*'])
115+
self.assertEqual(module.__name__, 'pkg.sub.mod')
116+
self.assertIs(parent.mod, module)
117+
self.assertNotIn('pkg', sys.modules)
118+
103119

104120
(Frozen_ParentTests,
105121
Source_ParentTests

0 commit comments

Comments
 (0)