文件哈希用 4 进程反而慢了 2.66 倍:把进程池挪出计时函数后快了 3.16 倍
一句话结论
- 给 40 个 2MB 文件算 SHA-256 做内容去重,串行 0.334 秒,4 进程却要 0.888 秒,并行比串行慢 2.66 倍。
- 把 worker 数降到 1,问题一点没变——说明不是并行竞争,而是每次新建进程池的冷启动开销被算进了计时。
- 空任务实测:4 进程启动加销毁要 0.644 秒,比整个串行任务还长。这就是全部原因。
- 把进程池挪到计时函数外面常驻复用,同样的并行方案跑到 0.106 秒,反超串行 3.16 倍。
- 换成线程池是 0.099 秒、3.38 倍——因为
hashlib在处理大块数据时会释放 GIL。
场景:为什么要给文件算哈希
做文件去重时,稳妥做法不是比文件名,也不是比大小,而是逐个算内容哈希,再把哈希值相同的路径归成一组。哈希天然抗改名、抗改时间戳、跨平台一致。
问题在于哈希很吃 IO 和 CPU。一个几 TB 的素材库跑下来可能几小时,串行等不起,于是十有八九会想到多进程。但我实测下来,第一次的结论差点让我走错路。
第一版基准:并行比串行还慢
测试机是 i5-4590,4 核 4 线程无超线程,Python 3.13.12,Windows 10。测试数据是 40 个 2MB 随机文件共 80MB,取三轮中位数。
- 纯串行:0.3490 秒
- 4 进程
ProcessPoolExecutor:0.8022 秒 - 比值 0.44 倍,也就是并行慢了约 1.3 倍
这和常识完全相反。哈希计算是纯 CPU 的活,多核机器上不可能比单核慢,那一定是哪里写错了。
排查一:把 worker 数降到 1,问题还在
如果慢是并行竞争或者数据竞争造成的,那把 worker 降到 1 就应该退回串行水平。实测各档 worker 数:
- workers=1:1.0142 秒(相对串行 0.34 倍)
- workers=2:0.8382 秒(0.42 倍)
- workers=4:0.7783 秒(0.45 倍)
- workers=8:1.5926 秒(0.22 倍)
规律很清楚:worker 越少反而越接近串行,1 个 worker 时甚至比纯串行慢近三倍。单进程不可能有竞争问题,所以问题出在「从单进程变成多进程」这件事本身,跟并行度没关系。
排查二:直接测进程启动成本
既然怀疑是启动开销,那就别猜,直接测。写一个什么都不干的空任务,只把 4 个进程拉起来再销毁:
- 空任务 · 4 进程启动加销毁:0.6438 秒
对上了。串行跑完整个 80MB 才 0.349 秒,而光是把这 4 个进程生出来就要 0.644 秒。第一版的「并行慢」,其实是在 0.334 秒的有效计算之外,又额外付了 0.644 秒的启动税。
真相:Windows 的 spawn 冷启动
Windows 上的 multiprocessing 默认用 spawn 启动子进程,意思是每个子进程都要重新拉起一个完整的 Python 解释器,再重新 import 一遍模块。而 ProcessPoolExecutor 是在 with 语句进入时创建、退出时销毁的——只要池写在循环里或者计时函数里,每次调用都要重新付一遍这 0.644 秒。
Linux 上默认是 fork,子进程直接复制父进程地址空间,启动是毫秒级,所以同样的写法在 Linux 上可能根本看不出问题。这就是同一个 bug 只在 Windows 上暴露的原因。
解法一:进程池常驻复用
把进程池挪到计时函数外面,创建一次、跨轮复用,把启动成本摊掉:
- 常驻进程池 · 4 进程:0.1057 秒,比串行快 3.16 倍
同一个并行方案,同一台机器,只是把池子挪了个位置,0.8877 秒变成 0.1057 秒。
解法二:调 chunksize
ex.map 默认把每个任务单独投递,任务数多时序列化开销明显。设 chunksize=10 把任务打包:
- 常驻进程池 + chunksize=10:0.1032 秒,3.24 倍
比常驻不调 chunksize 只快了一点点(0.1057 → 0.1032),说明在这个规模下 IPC 不是瓶颈,池子常驻才是那笔大钱。
解法三:直接换线程池
hashlib 的 update() 在处理大于 2047 字节的数据块时会释放 GIL,而 hashlib 本身又是 C 实现,不会因为 GIL 释放而变慢。所以对文件哈希这种场景,线程池既没有进程启动税,又实打实能并行:
- 线程池 · 4 线程:0.0987 秒,3.38 倍
比常驻进程池还快一点,而且省掉一整套 IPC 和序列化逻辑。
小文件场景:结论一致,而且差距更大
换成 2000 个 1KB 小文件(同样 2MB 总量),串行因为系统调用次数多而变得很慢:
- 纯串行:3.5918 秒
- 进程池 · 每次新建:1.7810 秒(2.02 倍)
- 线程池 · 每次新建:1.3131 秒(2.74 倍)
- 进程池 · 常驻复用:1.1494 秒(3.12 倍)
即使在新建进程池的最差写法下,小文件场景也能跑赢串行——因为 3.59 秒的串行耗时足够摊薄 0.644 秒的启动税。并行是赚是赔,取决于有效计算时间能不能盖过启动成本。
两个必踩的坑
- Windows 用 spawn,脚本顶层必须写
if __name__ == "__main__":保护。否则子进程重新导入主模块时又会执行一遍建池逻辑,直接无限递归,报RuntimeError。这条在 Linux 上用 fork 时不会暴露。 - 传给
ex.map的函数不能是 lambda。spawn 用 pickle 传参,lambda 会被报AttributeError: Can't get local object '<locals>.<lambda>'。写成模块级函数就没问题。
选型建议
- 文件哈希、读文件、编解码这类任务,优先线程池。
hashlib释放 GIL,线程池实测 3.38 倍,还省掉 spawn 冷启动。 - 确实需要进程池时,一定把它放在循环外面常驻复用。 Windows 上每次新建约 0.644 秒,这是实打实的启动税。
- 有效计算时间太短就别并行。 如果整个任务本身跑不满 1 秒,多进程的启动成本会直接吃掉全部收益。
- worker 数按核数给,别超。 实测 8 worker 比 4 worker 还慢一倍,超配的调度和内存争抢比并行收益更贵。
- 调 chunksize 是第二优先级。 它只优化 IPC,池子没常驻的话,调 chunksize 救不了场。
附:可直接用的内容去重脚本
import hashlib
import os
from concurrent.futures import ThreadPoolExecutor
CHUNK = 1 << 20
def sha256_file(path):
h = hashlib.sha256()
with open(path, "rb") as f:
while True:
b = f.read(CHUNK)
if not b:
break
h.update(b)
return h.hexdigest()
def find_duplicates(root, workers=4):
paths = []
for dirpath, _, names in os.walk(root):
for n in names:
paths.append(os.path.join(dirpath, n))
if not paths:
return {}
digests = {}
# 线程池放在循环外复用;大文件读数据时 hashlib 会释放 GIL
with ThreadPoolExecutor(max_workers=workers) as ex:
for path, digest in zip(paths, ex.map(sha256_file, paths)):
digests.setdefault(digest, []).append(path)
return {d: ps for d, ps in digests.items() if len(ps) > 1}
if __name__ == "__main__":
for digest, paths in find_duplicates(r"D:\data").items():
print(digest[:12], paths)
最后一句
「多进程一定比串行快」和「多进程一定比串行慢」都是错的。真正决定盈亏的,是有效计算时间和进程启动成本这两个数的比值。先测出你机器上这个比值,再决定要不要并行。
- 点赞
- 收藏
- 关注作者
评论(0)