Python 中的 multiprocessing 用于 CPU 密集型任务
1. 概述
当 Python 代码需要大量计算时,比如处理图像、计算校验和或数值模拟,你会发现使用线程几乎不会更快。原因是 GIL(全局解释器锁)。本文简要解释这个问题。随后指导你使用 multiprocessing 模块和 ProcessPoolExecutor 将工作分散到多个进程。读完后,你将知道如何并行化 CPU 密集型任务、为其编写测试并避免常见错误。
所有示例使用 Python 3.12+ 并且只需要标准库,无需额外安装。
2. 为什么线程不能帮助
2.1. 一个 CPU 密集型任务
我们使用一个用纯 Python 编写的计数质数的函数。这是典型的 CPU 密集任务:
# cpu_task.py
import math
def count_primes(start: int, end: int) -> int:
count = 0
for n in range(max(start, 2), end):
if all(n % d for d in range(2, math.isqrt(n) + 1)):
count += 1
return count
该函数遍历 [start, end) 中的每个数字,并尝试除以从 2 到其平方根的数字。该函数不读取文件,不调用网络。它只进行计算。
2.2. 测量时间:顺序和线程
# bench_threads.py
import time
from concurrent.futures import ThreadPoolExecutor
from cpu_task import count_primes
RANGES = [(i, i + 125_000) for i in range(0, 1_000_000, 125_000)]
def run_sequential() -> int:
return sum(count_primes(a, b) for a, b in RANGES)
def run_threads() -> int:
starts, ends = zip(*RANGES)
with ThreadPoolExecutor(max_workers=8) as executor:
return sum(executor.map(count_primes, starts, ends))
if __name__ == "__main__":
for fn in (run_sequential, run_threads):
t0 = time.perf_counter()
result = fn()
print(f"{fn.__name__}: {result} primes, {time.perf_counter() - t0:.2f}s")
RANGES 将区间 [0, 1_000_000) 分成 8 部分。run_threads 将每个部分分配给一个线程。8 核机器上的结果(你的机器上的数据会有所不同):
run_sequential: 78498 primes, 6.41s
run_threads: 78498 primes, 6.58s
线程版本甚至更慢一点。在标准 CPython 中,任何时刻只有一个线程能执行 Python 字节码,因为它必须持有 GIL。八个线程只是轮流使用一个核心,还增加了线程切换的开销。
注意: 从 Python 3.13 开始,有一个无 GIL 的构建版本(
python3.13t)。这个版本仍处于实验阶段,许多 C 扩展库还不支持它。对于大多数现有项目,使用进程仍然是最可靠的方法。
3. multiprocessing 基础
每个进程都有自己的解释器和自己的 GIL。因此,N 个进程可以在 N 个核心上真正并行运行。
3.1. 手动创建 Process
# basic_process.py
from multiprocessing import Process, Queue
from cpu_task import count_primes
def worker(start: int, end: int, results: Queue) -> None:
results.put(count_primes(start, end))
if __name__ == "__main__":
results = Queue()
ranges = [(0, 500_000), (500_000, 1_000_000)]
processes = [Process(target=worker, args=(a, b, results)) for a, b in ranges]
for p in processes:
p.start()
total = sum(results.get() for _ in processes)
for p in processes:
p.join()
print("Total primes:", total)
解释各部分:
Process(target=..., args=...)描述一个子进程。它不会运行直到你调用start()。- 子进程不与父进程共享内存。要发送结果回来,你必须使用通信通道。这里是
Queue。 - 程序在
join()之前调用results.get()。如果先join()而队列中还有未读取的大数据,程序可能会发生死锁。
Total primes: 78498
这种方法灵活但冗长。在实践中你很少自己管理 Process。
3.2. 使用 Pool
Pool 创建一个预先存在的 worker 组,并自动将工作分配给它们:
# pool_example.py
from multiprocessing import Pool
from cpu_task import count_primes
RANGES = [(i, i + 125_000) for i in range(0, 1_000_000, 125_000)]
if __name__ == "__main__":
with Pool(processes=8) as pool:
counts = pool.starmap(count_primes, RANGES)
print(counts)
print("Total:", sum(counts))
starmap 将 RANGES 中的每个元组拆分为 start, end 参数。结果按输入的确切顺序返回:
[10746, 9895, 9622, 9480, 9637, 9554, 9454, 9610]
Total: 78498
如果不传递 processes,Pool 将使用 os.cpu_count() 个 worker。with 块在结束时会清理这些 worker。
4. ProcessPoolExecutor:更现代的 API
concurrent.futures.ProcessPoolExecutor 与 ThreadPoolExecutor 有相同的接口。因此你几乎可以通过仅改变类名来从线程切换到进程:
# parallel.py
import os
from concurrent.futures import ProcessPoolExecutor
from cpu_task import count_primes
def split_range(start: int, end: int, parts: int) -> list[tuple[int, int]]:
step = (end - start + parts - 1) // parts
return [(s, min(s + step, end)) for s in range(start, end, step)]
def parallel_count_primes(start: int, end: int, workers: int | None = None) -> int:
workers = workers or os.cpu_count() or 1
chunks = split_range(start, end, workers)
starts, ends = zip(*chunks)
with ProcessPoolExecutor(max_workers=workers) as executor:
return sum(executor.map(count_primes, starts, ends))
if __name__ == "__main__":
import time
t0 = time.perf_counter()
print(parallel_count_primes(0, 1_000_000, workers=8))
print(f"{time.perf_counter() - t0:.2f}s")
78498
1.27s
时间从大约 6.4 秒降低到 1.3 秒。速度不能精确提高 8 倍有两个原因。首先,各个段的工作量不均匀:较大数字的段检查需要更长时间。其次,启动进程和传输数据也需要时间。
如果你想在每个结果完成时立即处理它,请使用 submit 结合 as_completed:
from concurrent.futures import ProcessPoolExecutor, as_completed
from cpu_task import count_primes
if __name__ == "__main__":
with ProcessPoolExecutor() as executor:
futures = {executor.submit(count_primes, a, a + 250_000): a
for a in range(0, 1_000_000, 250_000)}
for future in as_completed(futures):
print(f"开始位置 {futures[future]} 的段:{future.result()} 个质数")
如果 worker 抛出异常,future.result() 会在父进程中重新抛出该异常。因此你可以像平常一样用 try/except 捕捉错误。
5. 许多小任务:chunksize 和 imap_unordered
每次向 worker 发送一个任务时,Python 必须 pickle 参数、通过管道发送、然后 unpickle 结果。如果你有 100,000 个很小的任务,这个开销可能大于计算本身。你可以通过用 chunksize 将任务分组成批处理来解决这个问题:
# chunks.py
from multiprocessing import Pool
def is_prime(n: int) -> bool:
if n < 2:
return False
return all(n % d for d in range(2, int(n ** 0.5) + 1))
if __name__ == "__main__":
numbers = range(1_000_000)
with Pool() as pool:
total = sum(pool.imap_unordered(is_prime, numbers, chunksize=10_000))
print(total)
chunksize=10_000使得每次发送包含 10,000 个数字而不是 1 个。进程间的通信次数大大减少。imap_unordered以惰性方式返回结果且不保持顺序。当你只需要聚合时(例如求和),且想节省内存时,这种方式很合适。ProcessPoolExecutor.map也接受chunksize参数。其默认值为 1,所以当有许多小任务时,你需要调整它。
6. 为每个 worker 初始化资源一次
假设每个任务需要一个耗时的查找表来创建。如果在每次函数调用时都重新创建表会很浪费。initializer 参数允许在 worker 启动时恰好运行一次函数:
# initializer_example.py
from concurrent.futures import ProcessPoolExecutor
_price_table: dict[str, float] = {}
def load_prices(currency: str) -> None:
global _price_table
rate = 25_000 if currency == "VND" else 1
_price_table = {f"SKU-{i}": i * 1.5 * rate for i in range(100_000)}
def order_total(skus: list[str]) -> float:
return sum(_price_table[s] for s in skus)
if __name__ == "__main__":
orders = [["SKU-1", "SKU-2"], ["SKU-10", "SKU-20", "SKU-30"]]
with ProcessPoolExecutor(max_workers=2, initializer=load_prices,
initargs=("VND",)) as executor:
print(list(executor.map(order_total, orders)))
[112500.0, 2250000.0]
变量 _price_table 是在每个 worker 中的全局变量。父进程看不到这些更改。这是一种安全的方式,使每个 worker 都有自己的资源,而不必随每个任务发送查找表。
7. 为并行代码编写测试
你可以像测试普通代码一样用 pytest 测试使用进程的代码。条件是 worker 函数必须在模块级别定义:
# test_parallel.py
from cpu_task import count_primes
from parallel import parallel_count_primes, split_range
def test_count_primes_known_value():
assert count_primes(0, 100) == 25
def test_split_range_covers_whole_interval():
chunks = split_range(0, 10, 3)
assert chunks == [(0, 4), (4, 8), (8, 10)]
def test_parallel_matches_sequential():
expected = count_primes(0, 50_000)
assert parallel_count_primes(0, 50_000, workers=4) == expected
pip install pytest
pytest -q test_parallel.py
... [100%]
3 passed in 0.62s
最重要的测试是 test_parallel_matches_sequential。它确保分段不会遗漏或重复计算任何元素。这是自己编写 split_range 时常见的错误。
8. 常见错误和何时不应该使用
忘记 if __name__ == "__main__":。 使用 spawn 启动方法(在 Windows 和 macOS 上的默认方法)或 forkserver(从 Python 3.14 开始在 Linux 上的默认方法),子进程会重新导入主模块。如果创建池的代码在模块级别,每个子进程都会创建一个新池。然后你会遇到 RuntimeError 或进程无限创建。
传递 lambda 或嵌套函数。 发送给 worker 的所有东西都必须是可 pickle 的:
from concurrent.futures import ProcessPoolExecutor
if __name__ == "__main__":
with ProcessPoolExecutor() as executor:
print(list(executor.map(lambda x: x * x, range(5))))
_pickle.PicklingError: Can't pickle <function <lambda> at 0x...>: attribute lookup <lambda> on __main__ failed
你应该在模块级别用 def 定义函数。如果需要固定某些参数,使用 functools.partial。
传递过大的数据。 一个包含数百万元素的列表传递给每个任务会被多次 pickle。复制时间可能超过计算部分。有几种解决方法:只发送索引或文件路径、使用 initializer,或将 multiprocessing.shared_memory 用于数组类型的数据。
依赖 fork 的行为。 在旧版 Linux 上能工作的代码,因为 fork(子进程继承全局变量),在 macOS、Windows 或 Python 3.14 上可能会出错。为了获得一致的行为,应明确选择启动方法:
import multiprocessing as mp
if __name__ == "__main__":
mp.set_start_method("spawn")
注意: 不应该为 I/O 密集型任务(如 HTTP 调用或数据库查询)使用 multiprocessing。在等待 I/O 时,线程仍会释放 GIL,因此
ThreadPoolExecutor或asyncio要轻量得多。当每个任务只需要几微秒而没有分组,或当计算已经在释放 GIL 的 C 库中时(NumPy、大数据哈希函数等),也不应该使用 multiprocessing。在这些情况下,线程或向量化通常就足够了。
创建过多 worker。 设置 worker 数量大于 CPU 核心数不会使 CPU 密集型代码更快。它只会浪费 RAM,因为每个进程都是一个完整的解释器。从 os.cpu_count() 开始是合理的。
9. 结论
GIL 使得线程无法加速 CPU 密集型的纯 Python 代码。每个进程都有自己的 GIL,所以将工作分散到多个进程是充分利用所有 CPU 核心的简单方法。在大多数情况下,ProcessPoolExecutor 是最简洁的选择。当你需要 imap_unordered 或 starmap 时,Pool 很有用。而手动 Process 仅适用于特殊情况。
使用 multiprocessing 时,你需要记住以下几点:
- 始终有
if __name__ == "__main__":。 - 仅传递可 pickle 的函数和数据。
- 当有许多小任务时增加
chunksize。 - 为重型资源使用
initializer。 - 编写一个测试来比较并行结果与顺序结果,以确保分段正确。