3. Python多进程实战:multiprocessing模块、进程间通信(Queue/Pipe)、进程池Pool的使用

聊到Python并发,很多人第一反应就是多线程。但说实话,GIL这把锁让多线程在CPU密集型任务上基本成了摆设。我早年做爬虫调度系统时就踩过这个坑——开了16个线程,CPU利用率死活上不去,一看监控,8个核都在排队等GIL释放。

后来我换了个思路:既然线程被GIL卡脖子,那干脆上多进程。每个进程有独立的Python解释器和内存空间,GIL互不干扰。今天我们就来聊聊Python的multiprocessing模块,怎么用它做真正的并行计算。

核心认知: 多进程适合CPU密集型任务(计算、压缩、加密),多线程适合I/O密集型任务(网络请求、文件读写)。选错了模型,硬件资源再足也白搭。

3.1 进程的创建与基础操作

multiprocessing.Process的用法和threading.Thread几乎一样。你只需要定义一个函数,然后把它丢进进程里跑就行。

from multiprocessing import Process
import os

def worker(name):
    print(f"进程 {name} 启动,PID: {os.getpid()}")
    # 模拟计算任务
    result = sum(i * i for i in range(10_000_000))
    print(f"进程 {name} 完成,结果: {result}")

if __name__ == "__main__":
    processes = []
    for i in range(4):
        p = Process(target=worker, args=(f"Worker-{i}",))
        processes.append(p)
        p.start()

    for p in processes:
        p.join()

    print("所有进程执行完毕")

这里有个细节我提醒一下:if __name__ == "__main__": 这行不是摆设。Windows下创建子进程时会重新导入模块,如果没有这个保护,子进程会递归创建进程,直接炸掉。我在给团队做培训时,至少有一半人第一次跑多进程代码都栽在这上面。

方法 说明 注意事项
start() 启动进程,调用run()方法 非阻塞,立即返回
join([timeout]) 等待进程结束 不调用join可能导致僵尸进程
terminate() 强制终止进程 不触发清理逻辑,慎用
is_alive() 检查进程是否存活 常用于监控和重启逻辑

3.2 进程间通信:Queue vs Pipe

多进程最大的痛点就是数据共享。进程之间内存不互通,你不能像线程那样直接读写全局变量。那怎么办?两种官方方案:Queue 和 Pipe。

3.2.1 Queue:生产者-消费者模式的首选

multiprocessing.Queue 是线程安全且进程安全的队列。底层用了管道加锁,支持多个生产者和多个消费者。我个人习惯在大多数场景下优先用Queue,因为它用起来最省心。

from multiprocessing import Process, Queue
import time

def producer(q):
    for i in range(5):
        item = f"数据-{i}"
        q.put(item)
        print(f"生产: {item}")
        time.sleep(0.5)

def consumer(q):
    while True:
        item = q.get()
        if item is None:  # 哨兵值,表示结束
            break
        print(f"消费: {item}")
        time.sleep(1)

if __name__ == "__main__":
    q = Queue()
    p1 = Process(target=producer, args=(q,))
    p2 = Process(target=consumer, args=(q,))

    p1.start()
    p2.start()

    p1.join()
    q.put(None)  # 发送结束信号
    p2.join()
避坑指南: 我曾经在生产环境遇到过Queue死锁的问题。原因是子进程异常退出,但Queue里还有数据没取完,导致父进程的join()永远卡住。解决方案是给join()加超时,或者用multiprocessing.Queue的qsize()做监控。

3.2.2 Pipe:双向通信,但要注意数据完整性

Pipe本质上是一对读写端,适合两个进程之间的点对点通信。它比Queue轻量,但没有内置的锁机制,多个进程同时写Pipe会导致数据错乱。

from multiprocessing import Process, Pipe

def send_msg(conn):
    conn.send("Hello from child")
    conn.close()

if __name__ == "__main__":
    parent_conn, child_conn = Pipe()
    p = Process(target=send_msg, args=(child_conn,))
    p.start()
    msg = parent_conn.recv()
    print(f"收到消息: {msg}")
    p.join()

嗯,这里要注意:Pipe的send()和recv()是阻塞的。如果两端同时发送或接收,可能会死锁。我建议只在两个进程间用Pipe,而且最好明确谁发谁收,别搞成双向同时读写。

特性 Queue Pipe
适用场景 多生产者/多消费者 两个进程点对点
线程安全 是(内置锁) 否(需自行加锁)
性能 中等(锁开销) 高(无锁)
数据容量 有限(可配置) 有限(系统缓冲区)

3.3 进程池Pool:管理海量进程的利器

你想想看,如果任务有几百个,难道要手动创建几百个Process对象吗?显然不现实。进程池就是干这个的——它维护一组工作进程,你把任务丢进去,它自动调度执行。

from multiprocessing import Pool
import time

def cpu_intensive(n):
    """模拟CPU密集型计算"""
    return sum(i * i for i in range(n))

if __name__ == "__main__":
    # 创建进程池,默认使用CPU核心数
    with Pool(processes=4) as pool:
        # map:同步方式,阻塞直到所有任务完成
        results = pool.map(cpu_intensive, [10_000_000] * 8)
        print(f"同步结果: {results}")

        # apply_async:异步方式,立即返回
        async_results = [pool.apply_async(cpu_intensive, args=(10_000_000,)) 
                        for _ in range(8)]
        async_results = [r.get() for r in async_results]
        print(f"异步结果: {async_results}")
性能陷阱: 进程数不是越多越好。我见过有人把进程池设成64,结果CPU上下文切换开销比计算本身还大。经验值是:进程数 = CPU核心数 × (1 + 平均等待时间/计算时间)。纯计算任务,设为核心数就够了。

3.3.1 Pool的三种提交方式

  • apply():同步执行,阻塞等待结果。适合单个任务。
  • apply_async():异步执行,立即返回AsyncResult对象。适合批量提交。
  • map() / map_async():类似内置map,将可迭代对象分发给进程池。适合数据并行。
  • starmap() / starmap_async():类似map,但支持多参数元组。

我个人最常用的是apply_async配合回调函数。为什么呢?因为你可以一边提交任务,一边处理已完成的结果,不用等所有任务跑完。

def callback(result):
    print(f"任务完成,结果: {result}")

with Pool(4) as pool:
    for i in range(10):
        pool.apply_async(cpu_intensive, args=(i * 1_000_000,), callback=callback)
    pool.close()
    pool.join()

3.4 进程间同步:Lock与共享内存

虽然多进程不共享内存,但有些场景下你还是需要同步——比如多个进程同时写同一个文件,或者更新共享计数器。这时候就需要multiprocessing.Lock和Value/Array。

from multiprocessing import Process, Lock, Value

def increment(counter, lock):
    for _ in range(1000):
        with lock:
            counter.value += 1

if __name__ == "__main__":
    counter = Value('i', 0)  # 'i' 表示有符号整数
    lock = Lock()
    processes = [Process(target=increment, args=(counter, lock)) 
                for _ in range(10)]

    for p in processes:
        p.start()
    for p in processes:
        p.join()

    print(f"最终计数: {counter.value}")  # 应该输出 10000
共享内存类型码: 'i' 整数、'd' 双精度浮点、'c' 字符。用Value和Array时一定要选对类型,否则数据会错乱。我曾经因为用了'f'(单精度浮点)存整数,结果精度丢失,排查了半天。

3.5 知识体系总览

下面这张图把本章的核心知识点串起来了。你可以把它当作多进程开发的路线图——从进程创建,到通信,到池化管理,再到同步控制。

Python多进程核心知识体系 multiprocessing Process 创建与管理 start / join / terminate 进程间通信 (IPC) Queue / Pipe 进程池 Pool map / apply_async 同步与共享内存 Lock / Value / Array 适用场景:CPU密集型任务、数据并行处理 避坑要点 1. 必须使用 if __name__ == '__main__' 保护入口 2. 进程数建议 ≤ CPU核心数(纯计算场景) 3. Queue记得用哨兵值(None)通知消费者退出 4. 共享内存类型码必须匹配(i/d/c)

3.6 实战建议与避坑总结

最后,我把这些年用多进程踩过的坑和总结的经验列出来,希望能帮你少走弯路。

  • 进程数不是越多越好:CPU密集型任务,进程数超过核心数反而会降低性能。I/O密集型可以适当多开,但别超过核心数的2倍。
  • Queue的容量要监控:如果生产者速度远大于消费者,Queue会无限膨胀,最终撑爆内存。我习惯在Queue上设置maxsize,或者用qsize()做限流。
  • 避免频繁创建销毁进程:进程创建开销很大(fork或spawn)。能用Pool就别手动创建Process,复用才是王道。
  • 子进程异常要处理:子进程崩溃不会自动通知父进程。建议在子进程里加try/except,把异常通过Queue传回父进程。
  • Windows下注意spawn模式:Linux默认fork,Windows默认spawn。spawn模式下子进程会重新导入模块,全局变量和资源需要重新初始化。
一句话总结: 多进程是Python突破GIL限制、实现真正并行的唯一途径。掌握Process、Queue、Pool这三板斧,你就能应对绝大多数并发计算场景。但记住——工具只是手段,理解场景才是关键。