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`在执行之前,这可以公开部分初始化的模块。
自定义导入器、插件、扩展模块、子解释器、重新加载和线程启动都使导入锁定变得更加重要。
良好的应用程序设计可以使导入保持快速、确定性,并且基本上没有外部副作用。