🚀 Python 并发实战:从“质数计算”看懂多进程的核心逻辑

在后台开发中,我们常听到“并发”、“并行”、“异步”这些词。很多初学者容易混淆,或者知道概念但不知道什么时候该用怎么用才安全

今天,我们通过一个经典的**“大数质数判断”**案例,拆解 Python multiprocessing 的核心机制,帮你彻底搞懂并发编程的底层逻辑。

01. 什么是并发?为什么需要它?

想象一下,你是一家餐厅的主厨(主进程)。

  • 串行(同步):客人点了一道菜,你做完这道,再做下一道。如果有一道菜需要炖煮 1 小时,其他客人都得等着。
  • 并发(并行):你雇了 5 个帮厨(子进程)。当一道菜需要长时间炖煮时,你把它交给帮厨 A,同时自己继续处理其他订单,或者让帮厨 B、C 同时处理其他菜品。

并发的本质:利用多个执行单元(进程/线程),同时处理多个任务,从而缩短总耗时或提高吞吐量。

⏰ 什么时候必须用并发?

  1. CPU 密集型任务:如视频编码、复杂数学计算、图像渲染、机器学习推理。这类任务会占满 CPU,单核跑太慢,需要多核并行。
  2. IO 密集型任务:如大量网络请求、数据库查询、文件读写。虽然 Python 多线程更适合 IO,但多进程也能通过隔离避免 GIL 锁的影响,提高稳定性。
  3. 隔离性要求高:某个任务可能崩溃或内存泄漏,使用独立进程可以防止主程序挂掉。

💡 本案例场景:判断一个大数是否为质数,属于典型的 CPU 密集型计算,非常适合用多进程来加速。

02. 并发的前提:你需要什么?

并不是所有情况都适合开启并发。在启动多进程前,请确认以下前提:

  1. 硬件资源:你有足够的 CPU 核心吗?如果只有 1 核,多进程反而会因为上下文切换(Context Switch)导致更慢。
  2. 任务独立性:子任务之间是否共享大量状态?如果任务间需要频繁通信或共享内存,多进程的开销(序列化/反序列化)可能会抵消并行带来的收益。
  3. 操作系统支持:Windows 和 Linux/macOS 对进程创建的支持不同(见下文代码分析)。

03. 代码深度解析:一步步看穿多进程

让我们回顾一下你提供的代码,看看它是如何工作的。

🔧 核心组件

1
2
3
4
5
6
7
8
import multiprocessing as mp
import os
import sys
import time
import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')
logger = logging.getLogger(__name__)

1. 任务函数:纯逻辑,无副作用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def is_prime(n):
"""判断一个数是否是质数(耗时计算)"""
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True

def worker(number, result_queue):
"""子进程执行的函数"""
pid = os.getpid()
logger.info(f"[子进程 {pid}] 开始计算: {number}")

try:
time.sleep(1) # 模拟耗时
result = is_prime(number)

# ✅ 关键:通过队列返回结果
result_queue.put({
"number": number,
"is_prime": result,
"pid": pid
})
logger.info(f"[子进程 {pid}] 计算完成: {number} = {result}")

except Exception as e:
logger.error(f"[子进程 {pid}] 出错: {e}")
result_queue.put({"number": number, "error": str(e)})

注意worker 函数必须是可序列化的(即不能包含 lambda 或局部定义的复杂对象),因为它会被打包发送到子进程。

2. 启动方式的选择:跨平台兼容性

1
2
3
4
if sys.platform.startswith("win"):
start_method = "spawn"
else:
start_method = os.environ.get("MP_START_METHOD", "forkserver")

这是很多初学者踩坑的地方!

  • fork (Linux/macOS 默认):直接复制父进程内存,速度极快,但不安全(可能复制锁状态)。
  • spawn (Windows 唯一支持):启动一个全新的 Python 解释器,只导入必要的模块。安全但慢。
  • forkserver (Linux 推荐):先启动一个服务器进程,后续子进程由它 fork 出来。兼顾速度与安全性。

3. 创建上下文与队列

1
2
ctx = mp.get_context(start_method)
result_queue = ctx.Queue()

使用 ctx.Queue() 而不是普通的 queue.Queue,因为后者是线程安全的,而多进程间通信需要基于管道或共享内存的特殊队列。

4. 启动进程与收集结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
processes = []
for num in numbers:
proc = ctx.Process(target=worker, args=(num, result_queue))
proc.start() # 非阻塞,立即返回
processes.append(proc)

# 收集结果
results = []
for _ in range(len(numbers)):
try:
result = result_queue.get(timeout=10)
results.append(result)
except mp.queues.Empty:
logger.error("等待结果超时!")

# 确保资源释放
for proc in processes:
proc.join()

最佳实践:始终调用 join() 等待子进程结束,避免僵尸进程。

04. 并发的优缺点:一把双刃剑

✅ 优点

  1. 性能提升:充分利用多核 CPU,显著缩短 CPU 密集型任务的执行时间。
  2. 隔离性强:一个子进程崩溃不会影响主进程或其他子进程。
  3. 绕过 GIL:Python 的全局解释器锁(GIL)限制多线程并行执行字节码,但多进程每个进程有独立的 GIL,真正实现并行。

❌ 缺点

  1. 资源开销大:每个进程都有独立的内存空间,创建和销毁成本高。
  2. 通信复杂:进程间不能直接共享变量,必须通过 Queue、Pipe、Manager 等机制通信,涉及序列化(Pickle)开销。
  3. 调试困难:多进程日志分散,断点调试不如单进程直观。
  4. 数据一致性:如果需要共享状态,需引入锁或同步机制,容易引发死锁。

05. 总结与建议

场景 推荐方案 原因
CPU 密集计算 Multiprocessing 绕过 GIL,真正并行
IO 密集(网络/DB) Asyncio / Threading 轻量级,切换成本低
简单脚本/小任务 Single Thread 避免过度工程化

🎯 给开发者的建议

  1. 先测量,再优化:不要盲目上并发。先用 time.time()cProfile 确认瓶颈是否在 CPU。
  2. 控制进程数:不要无限制创建进程。通常建议进程数 = CPU 核心数 + 1。可以使用 mp.cpu_count() 获取。
  3. 善用连接池:如果是数据库或 HTTP 请求,考虑使用连接池而非每次新建进程。
  4. 日志清晰:像案例中一样,在日志中加入 pid,方便追踪问题来源。