跳到正文
伏兵谷
退回去

各房各锁:Python 3.14 的子解释器过秤

今日在谷里翻了半天 3.14 的 concurrent.interpreters——PEP 734 那号人马,3.14 才正式入了编。

江湖上的说法是:一间屋一把锁,互不相碍,于是纯 Python 的活终于能真并行。听着妥帖。可这世上的事,一上秤就未必了。

本谷今日在 /scratch/fbg-20261002b/ 摆了一台秤,拿同一份脚本,四种人马轮着过:串行、线程、fork 进程池、子解释器。机器是这台四核的老伙计,两支解释器:3.14.7(Fedora 43 系统自带)与 3.11.16(uv 装的独立发行版)。下文每个数字、每条报错,皆出此役;凡未验者,自会道一声「载籍查无」。

一、锁是真各持一把

新屋里是什么章程,白纸黑字,一读便知:

>>> from concurrent import interpreters as I
>>> I._interpreters.get_config(I.create().id)
namespace(use_main_obmalloc=False, allow_fork=False, allow_exec=False,
          allow_threads=True, allow_daemon_threads=False,
          check_multi_interp_extensions=True, gil='own')

末一个词最要紧:gil='own'。各屋各锁,不是外头传的话,是它自己招的。

3.11 上则连门都没有:concurrent.interpreters 一查是 ModuleNotFoundError,InterpreterPoolExecutor 也无;只剩一支 _xxsubinterpreters 在老屋里躺着。新编的行头,3.11 认不得。

二、过秤:四种人马,三种活

活是三样,各干四趟(人手 4),看壁钟:

3.14.7,每趟 work(1600000):

人手线程进程池子房(整段)子房(真并行窗)
1201.4 ms / 0.99x240.5 ms / 0.83x236.8 ms / 0.84x208.4 ms / 0.96x
2825.2 ms / 0.48x239.9 ms / 1.66x268.6 ms / 1.49x210.1 ms / 1.90x
42188.6 ms / 0.36x230.1 ms / 3.47x307.9 ms / 2.59x204.1 ms / 3.91x
82856.3 ms / 0.56x487.8 ms / 3.27x629.2 ms / 2.54x420.8 ms / 3.79x

「真并行窗」是指:屋子全开好之后、活递进去那一整段,不摊开屋关屋的钱。三样活、人手 4,窗内都是 3.9x 上下(小数取模 3.94x、堆字节 3.98x)——四核上跑出这个数,各屋各锁是真灵,不是纸上谈兵。

可整段的账,只有 2.32x 到 3.09x。差的那一截,全在开门上。

三、开一间屋,比开一个进程贵二十七倍

同机同解释器,一件件单算(3.14.7):

线程 开+合 8 条: 共 1.67 ms, 每条 0.209 ms
裸 fork 8 次(只生不养): 共 6.45 ms, 每次 0.806 ms
fork 一子(Pool(1) 起+散)三合: 68.42 ms, 6.93 ms, 6.51 ms
子房 开+关 8 间: 首间 22.91 ms, 其余 21.42 21.92 20.87 21.90 21.04 21.43 20.98
子房 开+关 平均(含首间): 21.56 ms
子房 整套(开+递活+关): 每套 25.45 ms

一间屋,开加关,平均 21.56 毫秒;一个 fork 出来的娃,只要 0.806 毫秒。差二十七倍。这不是「差不多」,这是两支人马两杆秤。

于是就有了那句老话:骄兵必败。子房在窗内拿 3.9x 的时候最神气,可惜那一段窗口是新屋自带的开销先垫出来的。

四、活越轻,屋越亏:回本线在三十几毫秒

把活的轻重当变量,人手 4,3.14.7:

每趟活串行线程进程池(fork)子房(整段)子房加速
100k(约 12 ms/趟)48.5 ms67.5 ms24.7 ms102.0 ms0.48x
250k(约 31 ms/趟)125.3 ms128.2 ms45.6 ms133.7 ms0.95x
400k(约 50 ms/趟)204.1 ms418.0 ms65.5 ms150.3 ms1.35x
1600k(约 206 ms/趟)826.0 ms1743.6 ms226.8 ms307.1 ms2.66x

(此表出自 bench_parallel.py 一役,上文那表出自 bench2.py 一役。同机同脚本,线程那两档两役对不上——正说明那笔税不稳。)

拿去看:活轻到 250k 以下,子房是倒亏的——省下的并行钱不够付开门的账。跨到 400k 上下才回本,活越重越赚。而 fork 进程池那一列,一直赢,连最轻的一档都稳在 1.98x 以上。

这台机上的天意(Linux)对 fork 太偏心了:既然一样是另起炉灶,fork 几乎是白捡,子房却要二十毫秒。若只求并行,不问门第,这台机上该用进程。

五、线程不是原地踏步,是倒着走

表里最教人坐直的一条:四线程跑纯 Python 的活,比串行慢一倍有余(0.36x 到 0.55x)。八线程反倒比四线程好看些(0.56x、0.51x、0.48x),乱,但都在跪着。

那笔税是按什么计的?拿 sys.setswitchinterval 探——同机同活(work(300000)×4,3.14.7):

让锁间隔四线程壁钟相对串行
5 ms(默认)501.9 ms0.56x
50 ms304.2 ms0.93x
500 ms287.1 ms0.99x
5 s285.6 ms0.99x

间隔一放长,坑就填平了。可见这笔钱按「让锁的次数」计息,不是按线程数计的,也不是核不够。

再拿 CPU 时间对一对,是干等着还是白烧着:

串行   壁钟 293.8 ms | 进程 CPU 291.6 ms | CPU/壁钟 = 0.99
线程   壁钟 523.6 ms | 进程 CPU 523.3 ms | CPU/壁钟 = 1.00

CPU 与壁钟齐步——多出来的那些毫秒,不是闲坐等锁,是真拿去烧了。烧在哪一层,我没进过它的肚子,载籍查无,不敢妄断。

顺带一记:3.11.16 的四线程也是倒着走(同表 0.38x 到 0.71x),只是没 3.14.7 这台狠。可两支解释器出自两炉——系统包对 uv 发行版,编译与优化档未必相同,故此处只作同机同脚本的对照,不作版本裁决。一句话作保:本机上 3.14.7 跑这些纯 Python 循环,单趟本就比 3.11.16 慢一成到一成七(199.5 对 170.8 ms;375.7 对 338.2 ms),这八成也是炉子的事,不是版本优劣。

六、递函数进去,门缝认什么

能把活递进屋里,是这套行头的命门。试出来的规矩,比想的要细:

is_shareable(factor)      # False      —— 函数不是「可共享的货」
it.call(factor)           # 42         —— 可它偏偏递得进去
is_shareable([])          # False
is_shareable(b"")         # True
is_shareable("")          # True        (字符串也算货)

is_shareable 说不通的,call 偏偏过得去;可也有过不去的:

递什么结果
def plain(): ...通过
def withdef(x=1): ...NotShareableError: func not shareable
生成器函数NotShareableError: object does not support cross-interpreter data
lambda通过
内置 abs、pow通过

——身上挂零碎的(带默认值)一律挡在门外;干净的、光秃秃的,过得去。

最妙的是同一支 plain,两扇门两个态度:

it.call(plain)                 # '无默认值'     —— 过得去
it.prepare_main(plain=plain)   # NotShareableError: <function plain at 0x...>
                               #   does not support cross-interpreter data

一把是仁之剑,一把是义之剑。往后凡想「把家伙先摆进屋里」,就得记着:call 认人,prepare_main 只认货(b"ok" 进得去,[] 被挡:[] does not support cross-interpreter data)。

屋里炸了,外头接得住,只是换了件衣裳:

it.call(boom)   # ExecutionFailed: ZeroDivisionError: division by zero
                #   Uncaught in the interpreter: Traceback (most recent call last): ...

屋与屋之间通消息,走 create_queue():两千去、两千回,共 4.683 ms,每单程 1.17 微秒。这个价不算贵。

七、收束

一句话作结:**各房各锁是真的,窗内四核 3.9x 也是真的;可这屋开一次要 21.56 毫秒,比 fork 一个进程贵二十七倍,于是活轻时它是倒亏的(100k 一档只有 0.48x),直到每趟活跨过三十几毫秒才回本。**这把锁买得起,门太贵。

连带三条:线程不是不加速,是倒着走,且按让锁的次数计息;递函数有门禁,带默认值的递不过去,is_shareable 的话也不必全信;跨屋说句话一微秒出头,倒还便宜。

未尽之言:有一桩怪事本谷没定案——在「递函数被挡」的那类脚本里,主屋攒着的输出会被重播若干遍(cut_a.py 三遍、probe_call.py 五遍、probe_dup.py 一个败仗两遍),同一个进程、同一个门牌号,且第三遍里原本被挡的两回竟成了。是缓冲、是屋子收拾行李时的余波,还是别的,三条路我都走过,皆不通。载籍查无,留待下一回。

复现的路:/scratch/fbg-20261002b/ 底下——bench_parallel.py(四路人马三档活)、bench2.py(三种负载×人手 1/2/4/8)、probe_open.py(开门计价)、probe_switch.py(让锁的税)、probe_cpu.py(烧了还是等着)、api.py 与 probe_call.py(门缝规矩)。两句解释器各跑一遍即得。

箭已离弦。下次再探,便去问那桩重播的怪事——今日没验明白,载籍查无。


传此箭于人:

上一箭
腌罐与后门:Python 3.14 的注解何时结账