42. 导入锁

多线程代码中的每个模块导入锁、可重入导入检测和死锁场景。

42. 导入锁

导入锁是防止不安全并发导入的同步机制。在 CPython 中,导入不仅仅是名称查找。他们可能会创建模块对象、改变sys.modules,执行任意Python代码,初始化扩展模块,更新包属性,编译源文件,读取字节码缓存,以及运行包初始化代码。

如果没有锁定,两个线程可以同时导入同一模块并观察到不一致的模块状态。

导入锁的存在是因为导入是执行,而执行会改变共享的运行时状态。

42.1 为什么导入需要锁定

考虑这个模块:```python id="ez8v8a"

cache.py

print("initializing cache")

items = {}

def get(key): return items[key] 现在考虑两个线程同时执行此操作:python id="2pklaf" import cache 如果没有同步,两个线程可能:text id="oy5qmp" create a module object insert or overwrite sys.modules["cache"] execute cache.py initialize items twice observe a partially initialized module bind different module objects


## 42.2 导入改变全局运行时状态

导入涉及共享状态。

重要的共享结构包括:```text id="yxk2bl"
sys.modules
sys.meta_path
sys.path
sys.path_hooks
sys.path_importer_cache
parent package attributes
module dictionaries
bytecode cache files
extension module state
```最重要的是`sys.modules`。```python id="ul20gj"
import sys

print(type(sys.modules))

sys.modules是一个普通的字典,但它是模块标识的核心。如果并发导入损坏了模块插入或替换,则运行时可以观察到重复的模块或不完整的模块。

42.3 简单心智模型

带锁定的简化导入如下所示:```python id="d8ycx5" def import_module(name): lock = get_import_lock_for(name)

with lock:
    if name in sys.modules:
        return sys.modules[name]

    spec = find_spec(name)
    module = module_from_spec(spec)
    sys.modules[name] = module

    try:
        spec.loader.exec_module(module)
    except Exception:
        del sys.modules[name]
        raise

    return module

真正的实现案例较多,但中心思想是:text id="dsb489" for a given module name, only one thread should execute that module's initialization at a time


现代 CPython 在导入机制中使用特定于模块的导入锁。目标是避免不必要地序列化所有导入,同时仍然防止两个线程同时初始化同一模块。

从概念上讲:```text id="r8nrgm"
import a        locks module name "a"
import b        locks module name "b"
import a again  waits for "a" if another thread is initializing it
```这比单个全局导入锁提供了更多的并发性,同时保证了每个模块身份的安全。

锁键是完全限定的模块名称:```text id="ev5p7h"
json
json.decoder
email.message
package.submodule
```每个名称都有自己的导入同步边界。

## 42.5 导入锁与 GIL

全局解释器锁和导入锁解决不同的问题。

|机制|目的|
|---|---|
|吉尔 |保护解释器执行和许多内部对象操作 |
|进口锁|防止不安全的并发模块初始化 |

GIL 并没有消除导入锁定的需要。

模块导入可以执行文件 I/O、执行任意 Python 代码、释放本机代码中的 GIL 或等待其他操作。当第一个导入仍在进行时,另一个线程可以尝试相同的导入。

导入锁保护更高级别的不变量:```text id="u3ib58"
one module name should not be initialized concurrently by multiple threads
```## 42.6 导入是可重入的

进口可以引发更多进口。

例子:```python id="4hqnm0"
# app.py
import config
import server
# server.py
import logging
import socket
```输入`app`进口`server`,并导入`server`导入其他模块。

导入锁定机制必须支持嵌套导入。

从概念上讲:```text id="k2va9e"
import app
    lock app
    execute app.py
        import server
            lock server
            execute server.py
                import socket
                    lock socket
                    execute socket.py
```这是正常的。

当第一个模块仍在初始化时,必须允许正在导入一个模块的线程导入另一个模块。

## 42.7 同一模块的递归导入

同一模块的递归导入可以通过循环导入进行。```python id="z153co"
# a.py
import b
x = 1
# b.py
import a
y = 2
```什么时候`a`进口`b`, 和`b`进口`a`,第二次导入`a`等待本身不应该陷入僵局。

这是导入锁定必须仔细跟踪所有权和递归的原因之一。

导入系统通过返回部分初始化的模块来处理这个问题`sys.modules`在适当的时候。

这可以防止无限递归和自死锁,但它会暴露部分初始化的状态。

## 42.8 部分初始化的模块

在导入过程中,CPython 将模块放入`sys.modules`在执行其代码之前。

这支持循环导入。

为了:```python id="4jdauu"
# a.py
import b
value = 1
```和:```python id="ayh1bo"
# b.py
import a
print(a.value)
```该模块`a`存在于`sys.modules`前`value`被分配。所以`b`可以导入`a`, 但`a.value`可能还不存在。

导入锁可防止两个并发初始化。它不会使不完整的模块变得完整。

不同的问题:

|问题 |导入锁有帮助吗? |
|---|---|
|两个线程初始化同一个模块|是的 |
|循环导入观察不完整模块|没有 |
|模块阴影 |没有 |
|坏的`sys.path`订单|没有 |
|顶级副作用|只防止并发重复 |

锁提供了线程安全性。它不修复依赖结构。

## 42.9 导入锁获取指令

导入形成依赖链。一个模块在导入另一个模块时可以持有自己的导入锁。

例子:```text id="m3czjg"
Thread 1:
    lock a
    import b
    wait for lock b

Thread 2:
    lock b
    import a
    wait for lock a
```这是一个经典的死锁形状。

CPython 的导入机制包括围绕模块锁的死锁检测。当它检测到循环等待时,它可以避免永远挂起,而是处理部分初始化的模块路径。

重要的设计点是导入锁必须处理可重入和循环依赖图。

## 42.10 线程导入示例

此示例启动多个导入同一模块的线程。```python id="2bqtk9"
# slowmod.py
import time

print("slowmod start")
time.sleep(2)
value = 42
print("slowmod end")
# main.py
import threading

def worker():
    import slowmod
    print(slowmod.value)

threads = [threading.Thread(target=worker) for _ in range(5)]

for t in threads:
    t.start()

for t in threads:
    t.join()
```预期行为:```text id="mq93uj"
slowmod start
slowmod end
42
42
42
42
42
```模块主体执行一次。其他线程等待导入完成,然后使用缓存的模块。

## 42.11 同时导入不同的模块

每个模块的锁允许在依赖关系允许的情况下同时导入不同的模块。

主题 1:```python id="jg44xg"
import alpha
```话题2:```python id="vyvgaf"
import beta
```如果`alpha`和`beta`是独立的,它们的导入不需要阻塞在同一个模块锁上。

然而,他们可能仍然会争论:```text id="4h21u8"
the GIL
file-system operations
path importer caches
extension module initialization
shared package parents
custom finder state
```导入并发是可能的,但导入仍然是一个复杂的全局操作。

## 42.12 父包和子模块锁

对于:```python id="bbvi06"
import package.submodule
```导入系统必须加载`package`第一的。

从概念上讲:```text id="9mmvrw"
lock package
initialize package
lock package.submodule
initialize package.submodule
bind package.submodule attribute
```如果多个线程导入同一包的不同子项:```python id="rp9s87"
import package.alpha
import package.beta
```他们可以共享父包初始化步骤。

一旦父级初始化,子级就可以由它们自己的模块锁来处理,具体取决于包路径和查找器行为。

## 42.13 全球进口锁定`_imp`CPython 通过以下方式公开低级导入锁函数`_imp`模块。```python id="8j3c5t"
import _imp

print(_imp.lock_held())
```这`_imp`模块包括以下功能:```text id="iqvyee"
acquire_lock
release_lock
lock_held
```这些是低级实现接口。普通 Python 代码不应使用它们进行应用程序同步。

它们的存在是为了进口机械和兼容性。

不正确地使用它们可能会使过程陷入僵局或干扰导入系统。

## 42.14 导入锁和自定义导入器

自定义查找器和加载器必须假设它们可以在导入同步下调用。

发现者应该尽可能避免缓慢、阻塞或可重入的行为。

装载机的`exec_module`方法执行模块代码。它可能会导入其他模块。

装载机形状示例:```python id="viqfze"
class Loader:
    def create_module(self, spec):
        return None

    def exec_module(self, module):
        module.value = 42
```如果`exec_module`导入其他模块,它参与相同的锁定图。

定制进口商应避免在不一致的订单中获取不相关的锁。否则,他们可能会在 CPython 自己的导入锁逻辑之外造成死锁。

## 42.15 导入锁定和`sys.modules`该锁保护模块初始化周围的关键部分。

重要操作包括:```text id="hu7f15"
checking sys.modules
creating the module
inserting the module
executing module code
removing module on failure
returning the initialized module
```最敏感的时刻是执行前的插入。```text id="fzh60s"
sys.modules[name] = module
exec_module(module)
```此顺序支持循环导入,但这意味着其他代码可以在执行完成之前观察该模块。

该锁确保导入相同名称的其他线程等待完成,而不是再次执行它。

## 42.16 导入失败及锁释放

如果导入失败,必须释放锁。

例子:```python id="3sf7se"
# broken.py
raise RuntimeError("boom")
try:
    import broken
except RuntimeError:
    pass
```失败后,另一个线程不应该永远被阻塞。

进口系统必须:```text id="rhkz4v"
release the module lock
clean up sys.modules when appropriate
propagate the exception
allow future import attempts
```稍后导入可以重试:```python id="36ur7b"
import broken
```并将再次执行该模块,除非特殊机器故意留下失败的模块对象。

## 42.17 本机扩展模块

扩展模块使导入锁定变得复杂。

扩展模块可以:```text id="bc8bib"
run native initialization code
allocate process-global state
register types
import other modules
release the GIL
use static C variables
interact with subinterpreters
```导入锁可以防止同一扩展模块名称的并发初始化,但扩展作者仍然需要仔细编写初始化代码。

现代多阶段初始化有助于扩展模块存储每个模块对象的状态,而不仅仅是在 C 全局变量中。

## 42.18 子解释器

子解释器增加了另一个维度。

每个解释器对于许多模块都有自己的模块字典状态,但本机扩展模块可能仍然具有进程全局 C 状态,除非另有设计。

导入锁定必须相对于解释器状态和扩展状态来考虑。

对于扩展作者来说,这意味着:```text id="l90pr8"
avoid mutable process-global module state
prefer per-module state
support multi-phase initialization
consider subinterpreter isolation
```导入锁可以防止并发导入竞争,但它不会自动使扩展模块状态在解释器之间隔离。

## 42.19 导入锁定并重新加载`importlib.reload(module)`重新执行模块代码。```python id="q3ox9f"
import importlib
import config

importlib.reload(config)
```重新加载必须与导入状态协调,因为它会改变现有的模块字典。

在重新加载期间,其他代码可能仍保留对以下内容的引用:```text id="8a8ikh"
the module object
old functions
old classes
old constants
old imported names
```导入锁可以防止模块名称同时发生冲突的重新加载/导入操作,但重新加载在语义上仍然很棘手。

重新加载不会更新所有外部引用。

## 42.20 导入锁和模块状态可见性

导入锁控制导入操作。它不会使模块全局变量具有事务性。

如果模块代码在导入期间改变全局状态:```python id="uwbmge"
registry = []

registry.append("phase 1")
registry.append("phase 2")
ready = True
```涉及循环导入的代码可能会遵守:```text id="spya66"
registry exists
registry contains only phase 1
ready does not exist yet
```导入锁可以防止并发重复执行,但部分初始化的模块通过循环导入仍然可见。

## 42.21 避免导入时间竞争

应用程序代码应尽量减少导入时的突变。

更喜欢这个:```python id="1kciks"
# service.py

class Service:
    ...

def create_service(config):
    return Service(config)
```对此:```python id="5wtu0a"
# service.py

config = load_config()
service = Service(config)
service.start()
```第二种形式在导入时执行工作。它更难测试,更难重新加载,并且对导入顺序更敏感。

导入时的工作通常应限于定义和廉价常量。

## 42.22 安全顶级代码

安全的顶层模块代码通常包括:```text id="xbk1bu"
imports
constants
class definitions
function definitions
small table definitions
cheap feature detection
type aliases
```风险较高的顶级代码包括:```text id="ippw6v"
network calls
database connections
thread startup
event loop startup
large file reads
global registration with side effects
process-wide configuration
monkey patching
```导入锁会序列化初始化,但昂贵或副作用大的导入仍然会损害启动和并发性。

## 42.23 导入锁定和死导入

“死导入”是等待由于依赖循环或外部锁而无法完成导入的模块的导入。

例子:```python id="jt6y2f"
# a.py
import threading
import b

lock = threading.Lock()
# b.py
import a
```简单的循环导入通常通过部分模块可见性来处理。但是,如果模块代码在导入期间获取外部锁、启动线程或等待事件,则更容易产生死锁。

避免在导入时等待线程或外部锁。

## 42.24 导入期间的自定义锁定

这是有风险的:```python id="626g6o"
# registry.py
import threading

lock = threading.Lock()

with lock:
    import plugin
```如果`plugin`进口`registry`并尝试获取相同的锁,程序可能会死锁。

更好的设计:```python id="9ez7pd"
# registry.py
import threading

lock = threading.Lock()
handlers = {}

def load_plugin(name):
    import importlib
    module = importlib.import_module(name)
    return module

def register(name, handler):
    with lock:
        handlers[name] = handler
```尽可能将应用程序锁保留在导入依赖周期之外。

## 42.25 导入锁和插件系统

插件系统通常动态导入模块。```python id="iixpre"
import importlib

def load_plugins(names):
    for name in names:
        importlib.import_module(name)
```如果插件在导入时注册自己,则加载会按模块名称序列化,但仍会改变共享注册表。

更安全的插件设计将导入与注册分开:```python id="d13tr6"
def load_plugin(name):
    module = importlib.import_module(name)
    return module.setup
```然后在受控阶段调用setup:```python id="p8oelv"
for setup in setups:
    setup(registry)
```这使得初始化顺序变得明确。

## 42.26 导入锁定和启动性能

导入锁会影响线程程序的启动性能。

如果许多线程延迟启动并导入依赖项,则多个线程可能会阻塞相同的导入。

一个常见的改进是在单线程启动期间导入主要依赖项:```python id="e6dmwr"
def main():
    import logging
    import database
    import server

    server.run()
```这会在工作线程开始之前预先加载初始化。

对于服务器程序,通常的模式是:```text id="iq8ig9"
configure process
import dependencies
initialize application
start worker threads or event loop
```避免让工作线程独立发现和导入大型依赖图。

## 42.27 诊断导入锁定问题

导入锁定或导入周期问题的症状包括:```text id="m5z0iy"
program hangs during import
thread dump shows imports in multiple threads
partially initialized module errors
module attributes missing only during startup
plugin loading deadlocks
reload behaves inconsistently
```有用的调试工具:```python id="1mccj7"
import sys

print(sys.modules.get("module_name"))
import _imp

print(_imp.lock_held())
```对于挂起,请使用线程转储。```python id="csvfsw"
import faulthandler

faulthandler.dump_traceback()
```或者从命令行启用故障处理程序:```bash id="7wjpm2"
python -X faulthandler app.py
```## 42.28 导入时序和锁争用

可以通过以下方式检查导入时间:```bash id="gr9wta"
python -X importtime -c "import your_package"
```该报告报告导入期间所花费的时间。

它不直接显示锁争用,但它有助于识别可能成为争用点的缓慢导入。

缓慢的导入通常在发生时值得关注:```text id="7c79oz"
inside worker startup
inside request paths
inside plugin discovery
inside command-line entry points
inside test collection
```## 42.29 导入锁和异步代码

异步代码不会使导入异步。

里面的 import 语句`async def`当协程的该部分执行时,仍然同步运行。```python id="fg7lak"
async def handler():
    import heavy_module
    return heavy_module.run()
```第一次致电`handler`到达导入可能会在模块加载时阻止事件循环。

对于异步服务器,在启动事件循环之前导入大量依赖项,或者将昂贵的初始化移至显式异步启动挂钩中。

## 42.30 导入锁和多重处理

每个进程都有自己的解释器状态和自己的导入状态。

通过多处理,子进程单独导入模块。

这对于产生新解释器的平台或启动方法更为重要。

例如,通过生成式进程创建,子进程导入主模块。

这就是多处理代码需要:```python id="w0j45j"
if __name__ == "__main__":
    main()
```如果没有防护,子进程可能会在导入期间重新运行顶级代码。

导入锁仅保护一个进程内的导入。它不会跨进程同步导入。

## 42.31 导入锁和字节码缓存写入

当CPython导入源模块时,它可能会读或写`.pyc`文件。

否则,并发导入可能会围绕字节码缓存生成进行竞争。

进口机械对此处理得很仔细。尽管如此,字节码缓存是一种优化,而不是模块标识的来源。

身份的来源是:```text id="ka9zk4"
module name in sys.modules
```字节码缓存仅存储已编译的代码对象,以便以后更快地导入。

## 42.32 导入锁和文件系统更改

导入锁不会使文件系统状态稳定。

如果在导入时创建、删除或替换文件,行为仍然会令人困惑。

示例:```text id="50enmw"
deployment replacing package files during process startup
tests generating modules dynamically
plugin files being written while discovery runs
zip archive changed while importing
```使用稳定的部署方法。避免在正在运行的进程导入可导入代码时改变可导入代码。

## 42.33 导入锁不是应用程序锁

不要使用导入作为同步机制。

这是一个糟糕的模式:```python id="xdy9zr"
def initialize_once():
    import initialize_side_effects
```它依赖导入缓存来运行初始化一次。

首选显式一次性初始化:```python id="q7vhsy"
_lock = threading.Lock()
_initialized = False

def initialize_once():
    global _initialized

    if _initialized:
        return

    with _lock:
        if _initialized:
            return
        do_initialization()
        _initialized = True
```导入缓存适用于模块。应用程序的生命周期应该是明确的。

## 42.34 设计规则:导入应该是无聊的

最安全的进口产品很无聊。

无聊的导入:```text id="tjyc8p"
defines names
sets constants
imports dependencies
does no external work
finishes quickly
```令人惊讶的进口:```text id="8vcz4t"
starts threads
opens sockets
loads large models
registers global plugins
patches builtins
changes logging globally
reads mutable external config
```导入锁定可能会降低令人惊讶的导入的活跃度。它不能让它们变得容易推理。

## 42.35 最小导入锁定模型

紧凑型:```text id="iwck6e"
Thread imports module M.
Import system acquires lock for M.
If M is already initialized, return it.
If M is initializing in another thread, wait.
If this thread is already involved in the cycle, use partial module state.
Create and cache M before execution.
Execute M.
On success, mark M initialized and release lock.
On failure, clean up and release lock.
```该模型解释了为什么导入对于正常使用来说足够线程安全,为什么循环导入仍然可以暴露不完整的模块,以及为什么导入时副作用仍然很危险。

## 42.36 要点

导入锁可防止同一模块名称的并发初始化。

该锁保护模块加载,而不是保护任意模块语义。

GIL 和导入锁解决不同的问题。

导入是可重入的,因为模块可以在执行期间导入其他模块。

循环导入是通过将模块插入到`sys.modules`在执行之前,这可以公开部分初始化的模块。

自定义导入器、插件、扩展模块、子解释器、重新加载和线程启动都使导入锁定变得更加重要。

良好的应用程序设计可以使导入保持快速、确定性,并且基本上没有外部副作用。