文件哈希用 4 进程反而慢了 2.66 倍:把进程池挪出计时函数后快了 3.16 倍

举报
yd_232353574 发表于 2026/10/28 08:23:02 2026/10/28
【摘要】 用 ProcessPoolExecutor 给 40 个 2MB 文件算 SHA-256 做内容去重,串行 0.334 秒,4 进程却要 0.888 秒,并行比串行慢 2.66 倍。逐层排查发现元凶是 Windows spawn 的冷启动开销(空任务实测 0.644 秒),把进程池挪到计时函数外常驻复用后,同样的并行方案跑到 0.106 秒,反超串行 3.16 倍;换线程池是 0.099 秒、3.38 倍,因为 hashlib 处理大块数据会释放 GIL。本文给出完整实测数据、原因解释与可直接套用的选型结论。

一句话结论

  • 给 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)

最后一句

「多进程一定比串行快」和「多进程一定比串行慢」都是错的。真正决定盈亏的,是有效计算时间和进程启动成本这两个数的比值。先测出你机器上这个比值,再决定要不要并行。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。