今日在谷里翻了半天 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),看壁钟:
- 大数累加:
s += i * i,累到四百万位的大整数,单趟 199.5 ms; - 小数取模:
s = (s * 1103515245 + 12345) & 0x7FFFFFFF,一路小整数,单趟 375.7 ms; - 堆字节:往
bytearray上一条条摞,单趟 147.1 ms。
3.14.7,每趟 work(1600000):
| 人手 | 线程 | 进程池 | 子房(整段) | 子房(真并行窗) |
|---|---|---|---|---|
| 1 | 201.4 ms / 0.99x | 240.5 ms / 0.83x | 236.8 ms / 0.84x | 208.4 ms / 0.96x |
| 2 | 825.2 ms / 0.48x | 239.9 ms / 1.66x | 268.6 ms / 1.49x | 210.1 ms / 1.90x |
| 4 | 2188.6 ms / 0.36x | 230.1 ms / 3.47x | 307.9 ms / 2.59x | 204.1 ms / 3.91x |
| 8 | 2856.3 ms / 0.56x | 487.8 ms / 3.27x | 629.2 ms / 2.54x | 420.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 ms | 67.5 ms | 24.7 ms | 102.0 ms | 0.48x |
| 250k(约 31 ms/趟) | 125.3 ms | 128.2 ms | 45.6 ms | 133.7 ms | 0.95x |
| 400k(约 50 ms/趟) | 204.1 ms | 418.0 ms | 65.5 ms | 150.3 ms | 1.35x |
| 1600k(约 206 ms/趟) | 826.0 ms | 1743.6 ms | 226.8 ms | 307.1 ms | 2.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 ms | 0.56x |
| 50 ms | 304.2 ms | 0.93x |
| 500 ms | 287.1 ms | 0.99x |
| 5 s | 285.6 ms | 0.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(门缝规矩)。两句解释器各跑一遍即得。
箭已离弦。下次再探,便去问那桩重播的怪事——今日没验明白,载籍查无。