bigywater.tech
Python · 92

子解释器:多个 Python 解释器如何隔离

想让 Python 代码真正并行,除了多进程,3.14 又多了一条路:同一个进程里跑多个相互隔离的解释器,每个有自己的 GIL。这篇用标准库的 concurrent.interpreters 和 InterpreterPoolExecutor 实际跑一遍:隔离到什么程度、能传什么、快多少、启动贵不贵。

2.86x
4 个子解释器并行做 CPU 密集任务的加速比(线程池只有 1.01x)
8.7 ms
创建并调用、关闭一个子解释器的耗时(spawn 进程约 48 ms)
NotShareableError
Lock、打开的文件、主脚本里定义的类,都传不进子解释器

验证一:子解释器之间相互隔离

实测 · 本机执行输出
  main = Interpreter(0), interpreters.create() 用时 8.39 ms → Interpreter(1)
  list_all(): [Interpreter(0), Interpreter(1)]
  子解释器里定义的 Y, 主解释器能看到吗? False
  子解释器给 sys 加的属性, 主解释器的 sys 有吗? False
  子解释器看得到主解释器的全局变量 X 吗? False
  sys.modules 是各自独立的: 主解释器 len = 135 | 子解释器 len = 52

子解释器里定义的变量、给 sys 加的属性,主解释器都看不到,反过来主解释器的全局变量它也看不到;sys.modules 是各自独立的一份(主 135 个模块,子解释器刚创建只有 52 个)。它们在同一个进程、同一个地址空间里,但语言层面互不可见——比线程隔离得多,又比进程轻得多。exec 出错时抛 ExecutionFailed,而且出错之后那个子解释器仍然能继续用。

验证二:能传什么、不能传什么

Interpreter.call(func, arg) 的参数与返回值
实测 · 本机执行输出
  it.call(echo, int 42          ) → 成功, 返回 42  (is_shareable=True)
  it.call(echo, str             ) → 成功, 返回 'hello'  (is_shareable=True)
  it.call(echo, bytes           ) → 成功, 返回 b'ab'  (is_shareable=True)
  it.call(echo, tuple (1, 'a')  ) → 成功, 返回 (1, 'a')  (is_shareable=True)
  it.call(echo, None            ) → 成功, 返回 None  (is_shareable=True)
  it.call(echo, list [1, 2]     ) → 成功, 返回 [1, 2]  (is_shareable=False)
  it.call(echo, dict            ) → 成功, 返回 {'a': 1}  (is_shareable=False)
  it.call(echo, object()        ) → 成功, 返回 <object object at 0x109554940>  (is_shareable=False)
  it.call(echo, lambda          ) → 成功, 返回 <function main.<locals>.<lambda> at 0x109a29850>  (is_shareable=False)
  it.call(echo, 主脚本里定义的类 Foo()  ) → NotShareableError: (<__main__.main.<locals>.Foo object at 0x109a341a0>,) does not support
  it.call(echo, 模块里定义的类 Bar()   ) → 成功, 返回 <w92mod.Bar object at 0x1099534d0>  (is_shareable=False)
  it.call(echo, threading.Lock()) → NotShareableError: (<unlocked _thread.lock object at 0x109a2e490>,) does not support cros
  it.call(echo, 打开的文件           ) → NotShareableError: (<_io.TextIOWrapper name='/dev/null' mode='r' encoding='UTF-8'>,) does

基本类型(int、str、bytes、tuple、None)是「可共享」的;list、dict 这类虽然 is_shareable 是 False,通过 call 也能传过去(按值复制)。真正传不了的是:线程锁、打开的文件——它们绑定着解释器/系统资源。有意思的是类的实例:定义在可导入模块里的 Bar() 能传过去,而定义在主脚本里的 Foo() 传不过去——因为对方解释器要重新构造它,而它的 __main__ 里根本没有这个类。这和第 89 篇里 pickle 的问题是同一件事。

验证三:函数要能被对方找到

实测 · 本机执行输出
  it.call(在 main() 里定义的 local_fn) → 成功 1
  it.call(w92mod.noop)(定义在可导入模块里) → 1
  子解释器 exec('print(work)') → ExecutionFailed: name 'work' is not defined

子解释器的 __main__ 是一个空的命名空间——主脚本里定义的 work,在子解释器里 exec("print(work)") 是 NameError 级别的失败。让子解释器执行你的函数,函数要放在它能 import 的模块里(我这里放进 w92mod.py)。我一开始把 work 定义在主脚本里再交给 InterpreterPoolExecutor,就是因为这个原因失败的。另外 ProcessPoolExecutor(macOS 默认 spawn)会重新导入主脚本,所以入口必须有 if __name__ == "__main__":——我第一次没写,结果整个脚本被重复执行了一遍。

验证四:CPU 密集任务的并行效果

实测 · 本机执行输出
  串行做 4 份: 397.1 ms
  ThreadPoolExecutor       4 个 worker 做 4 份:   394.4 ms  → 相对串行加速 1.01x
  InterpreterPoolExecutor  4 个 worker 做 4 份:   138.9 ms  → 相对串行加速 2.86x
  ProcessPoolExecutor      4 个 worker 做 4 份:   179.1 ms  → 相对串行加速 2.22x

串行做 4 份要 397 ms。线程池 394 ms,几乎没有加速——它们共享一个 GIL。子解释器池 139 ms,加速 2.86x:每个子解释器有自己独立的 GIL,所以可以同时在多个核上跑纯 Python 字节码,而且不需要 3.14t 那样的自由线程构建。进程池 179 ms(2.22x),这次比子解释器慢(spawn 一个进程的成本见验证五)。加速比不是线性的,这是本机 4 个 worker、这个特定任务下的结果。

验证五:启动成本

实测 · 本机执行输出
  create+call+close 一个子解释器         8.71 ms
  创建并等待一个线程                        0.04 ms
  spawn 一个进程(macOS 默认)            48.00 ms

线程创建约 0.04 ms,子解释器约 8.7 ms,spawn 一个进程约 48 ms。子解释器介于两者之间:比进程便宜得多,又比线程贵两百倍左右。所以它适合「少量、长期存在的 worker」,而不是「每个任务临时创建一个」。

分步演示:子解释器的隔离、传参和并行

数据取自上面的真实实验
concurrent.interpreters
当前主题 / 数据

    本文用到的工具与边界

    • 本机 macOS 27 / arm64,Python 3.14.7(Homebrew),标准库 concurrent.interpreters、concurrent.futures.InterpreterPoolExecutor;每项取 3 次最小值
    • 没有验证:C 扩展模块在子解释器里的兼容性、子解释器之间共享内存(memoryview 等)、在自由线程构建(3.14t)里的表现、Windows / Linux 上的数字。函数对象被传入子解释器时的具体机制,我只观察到「可以传、返回的是另一个对象」,没有深究原因
    京ICP备2026067502号-1京公网安备11010502063487号