前言
批量处理 PDF 时(比如一晚上要预处理几百份文档、提取全文送翻译),“开多进程能不能加速"是个高频问题。网上答案两极分化:有人说"PDF 解析是 CPU 密集,多进程必提速”,有人说"Python 多进程开销大,得不偿失"。
我实测了两种典型负载:轻负载(12 个 30 页文档、纯文本提取)和重负载(8 个 40 页密集文档、dict 模式结构化提取),对比串行与 multiprocessing.Pool 2/4/8 进程的耗时。结论先行:轻负载下多进程反而慢 5-8 倍,重负载下 4 进程提速 3 倍——并发不是银弹,门槛比多数人想的要高。
环境:Python 3.13.14,PyMuPDF 1.28.2,Windows 11,CPU 18 核。所有数字为真机 3 次取最优。
Step 1: 生成批量测试文档
import os
import pymupdf
N_DOCS, PAGES = 12, 30
os.makedirs("testdocs/batch", exist_ok=True)
def gen_docs():
for d in range(N_DOCS):
doc = pymupdf.open()
for i in range(PAGES):
page = doc.new_page()
page.insert_text((72, 90), f"Document {d} page {i+1}", fontsize=13)
page.insert_text((72, 120), "Quarterly revenue grew 8.4% YoY. " * 8, fontsize=10.5)
doc.save(f"testdocs/batch/doc_{d:02d}.pdf")
doc.close()
Step 2: 定义提取任务与两种执行模式
提取函数保持最朴素:逐页 get_text() 累加字符数(模拟送翻译前的全文提取)。串行直接循环;并行用 Pool.map,把文档列表切成 N 份分给 N 个进程:
import multiprocessing as mp
def extract_one(path):
"""提取单个 PDF 全文,返回字符数"""
doc = pymupdf.open(path)
n = 0
for page in doc:
n += len(page.get_text())
doc.close()
return n
def worker(paths): # 子进程入口:处理一批文档
return [extract_one(p) for p in paths]
def run_serial(paths):
return [extract_one(p) for p in paths]
def run_pool(paths, n):
chunks = [paths[i::n] for i in range(n)] # 交错分块,负载均匀
with mp.Pool(n) as pool:
return [x for sub in pool.map(worker, chunks) for x in sub]
Windows 下多进程必须放在
if __name__ == "__main__":里跑(spawn 启动方式会重新 import 主模块),否则直接递归爆栈。
Step 3: 轻负载实测——多进程反而慢 5-8 倍
12 个 30 页文档(合计 360 页、45,672 字符)的结果:
| 模式 | 最优耗时 | 加速比 |
|---|---|---|
| 串行 | 0.065 s | 1.00x |
| Pool(2) | 0.362 s | 0.18x |
| Pool(4) | 0.399 s | 0.16x |
| Pool(8) | 0.532 s | 0.12x |
字符总数四组完全一致(45,672),说明并行没有漏页漏文档,纯粹是调度开销的差异。
为什么越并发越慢? 三个因素叠加:
- 进程启动成本:Windows 的 spawn 方式要为每个 worker 重新解释器启动 + import pymupdf,单次约 0.1-0.2 秒,4 个 worker 光启动就吃掉了串行全程的 6-12 倍时间;
- 任务本身太轻:纯文本提取是 C 代码里的 memcpy 级操作,360 页总共才 65 毫秒,根本凑不出值得分摊的计算量;
- 结果回传:每个 chunk 的字符串列表要走 pickle 序列化过管道,轻负载下这笔"运费"比货物还贵。
Step 4: 重负载实测——4 进程提速 3 倍
把负载加重:8 个 40 页文档,每页 45 行密集英文(模拟扫描质量良好的报告类文件),提取改用 get_text("dict") 遍历 span(结构化提取,CPU 消耗是纯文本的数倍):
def extract_one_heavy(path):
doc = pymupdf.open(path)
n = 0
for page in doc:
d = page.get_text("dict") # 结构化提取,CPU 密集
for b in d["blocks"]:
for l in b.get("lines", []):
for s in l["spans"]:
n += len(s["text"])
doc.close()
return n
结果(同样 3 次取最优,字符总数 2,203,200 四组一致):
| 模式 | 最优耗时 | 加速比 |
|---|---|---|
| 串行 | 2.003 s | 1.00x |
| Pool(4) | 0.666 s | 3.01x |
| Pool(8) | 0.756 s | 2.65x |
这次并发的收益兑现了:4 进程 3.01 倍加速,接近理想值 4 的 75%。Pool(8) 反而回落到 2.65x 不是 CPU 不够(18 核绰绰有余),而是分块策略:paths[i::8] 把 8 个文档切成 8 份、每份 1 个,负载没有任何错峰空间,且 8 份 spawn 开销比 4 份翻倍——文档数少于进程数时,加进程纯属给操作系统交税。
顺带算一笔"值不值得"的账:重负载下串行全程 2 秒,即使 3 倍加速也只省 1.3 秒。并行改造本身有开发和维护成本(spawn 兼容、异常处理、结果合并),如果这条流水线一天只跑一次,2 秒的串行根本不需要优化;只有当它每天跑几十次、或者单批文档数上到几百份(耗时按比例放大到分钟级)时,这 3 倍加速才真正落地为收益。性能优化的第一原则永远是先量化、再动手。
Step 5: 补充实测——进程数与文档数的配比比"核数"更重要
重负载数据里 Pool(8) 反而比 Pool(4) 慢,值得单独展开,因为它指向一个比"开几个进程"更本质的问题:怎么切任务。
本文用的是交错分块(paths[i::n]),8 个文档切给 8 个进程就是一人一份,没有丝毫调度弹性——任何一个文档偏慢,对应进程就成为长尾,其余 7 个进程干完活干等它。而 4 进程时每份 2 个文档,快慢文档在份内自然错峰,长尾被平均掉了。
这给出一条比"进程数 = 核数"更实用的配比原则:每个进程至少分到 2-4 个任务。按这个原则反推进程数:
n_workers = max(1, min(cpu_count // 2, len(paths) // 3))
# 8 个文档、18 核的机器:min(9, 2) = 2~4 之间实测选优
另外两个 Windows 特有的注意点:
- spawn 启动开销按进程数线性增长,每个 worker 都要重新 import pymupdf(实测单次 import 约 0.15s)。任务越小,这笔固定税越重——这也是轻负载场景崩盘的主因;
- 父进程别持有已打开的文档句柄再 fork/spawn,Windows 下句柄继承行为不一致,稳妥做法是子进程内自己
open、用完即close(本文的worker函数写法)。
判据:什么时候该上多进程
综合两组数据,给批量预处理一个可直接套用的决策门槛:
- 单文档提取耗时 < 100ms,或文档数 < 2×进程数:串行。轻负载实测已经证明这区间并发是负优化;
- 重提取模式(dict/rawdict)、OCR 预处理、栅格化 等单页 CPU 消耗大的任务:并行收益显著,进程数取
min(cpu_count // 2, 文档数)起步实测调优; - 先测串行基线再决定:像本文 Step 3 那样花 10 秒测一次串行耗时,如果全程不到 1 秒,优化它纯属浪费时间——把精力放到 IO 或网络环节收益更大;
- 进程内批量、跨进程分文档:worker 拿到一批路径循环处理(本文写法),而不是每页一个任务,摊薄通信成本。
另外一个工程视角:如果批量预处理的目的是送在线翻译服务,本地并发省下的秒级耗时,往往小于云端并行翻译省下的分钟级耗时。本地流水线做到"串行够用、无重复解析"即可,把并发优化留给真正 CPU 密集的环节(OCR、栅格化、版面分析)。
总结
| 负载 | 串行 | 最优并行 | 结论 |
|---|---|---|---|
| 轻(12×30页纯文本) | 0.065s | 0.362s(0.18x) | 多进程负优化 |
| 重(8×40页dict提取) | 2.003s | 0.666s(3.01x,4进程) | 值得并行 |
一句话结论:多进程加速的前提是"单个任务的计算量 >> 进程启动与通信成本"。对 PDF 批量提取来说,这个门槛大约在"单文档处理上百毫秒"以上;达不到就老老实实串行,省下的复杂度比省下的时间值钱。
标签:PDF翻译、Python自动化、multiprocessing、性能优化、效率工具
406



被折叠的 条评论
为什么被折叠?



