Python从入门到实战(十六):多进程编程

目录

[一、 为什么需要并发](#一、 为什么需要并发)

[1. 顺序执行](#1. 顺序执行)

[2. 并发与并行](#2. 并发与并行)

[3. 同步与异步](#3. 同步与异步)

[4. 为什么需要多进程](#4. 为什么需要多进程)

二、进程基础

[1. 什么是进程](#1. 什么是进程)

[2. 主进程与子进程](#2. 主进程与子进程)

[3. Process 类](#3. Process 类)

[4. 创建与启动进程](#4. 创建与启动进程)

[5. 为什么必须写 if name == 'main'](#5. 为什么必须写 if name == 'main')

三、进程控制

[1. 进程生命周期 API](#1. 进程生命周期 API)

[1.1 进程启动:start()](#1.1 进程启动:start())

[1.2 进程等待与同步:join(timeout=None)](#1.2 进程等待与同步:join(timeout=None))

[1.3 进程状态查询:is_alive()](#1.3 进程状态查询:is_alive())

[1.4 守护进程:daemon 属性](#1.4 守护进程:daemon 属性)

[1.5 终止:terminate() 与 kill()](#1.5 终止:terminate() 与 kill())

[1.6 退出码:exitcode 属性](#1.6 退出码:exitcode 属性)

[2. 生命周期控制示例](#2. 生命周期控制示例)

四、进程同步

[1. 为什么需要锁](#1. 为什么需要锁)

[2. Lock](#2. Lock)

[3. 获取锁与释放锁](#3. 获取锁与释放锁)

[4. 标准规范](#4. 标准规范)

[5. 注意事项](#5. 注意事项)

五、进程通信

[1. 为什么不能共享变量](#1. 为什么不能共享变量)

[2. Queue](#2. Queue)

[3. Queue 使用案例](#3. Queue 使用案例)

[4. Pipe](#4. Pipe)

[5. Pipe 使用案例](#5. Pipe 使用案例)

[六、自定义 Process](#六、自定义 Process)

[1. 继承 Process 类](#1. 继承 Process 类)

[2. start() 与 run()](#2. start() 与 run())

[3. 使用案例](#3. 使用案例)

七、进程池

[1. 为什么需要进程池](#1. 为什么需要进程池)

[2. Pool 类](#2. Pool 类)

[3. 进程池常见 API](#3. 进程池常见 API)

[4. 进程池生命周期](#4. 进程池生命周期)

[5. 使用案例](#5. 使用案例)

总结


一、 为什么需要并发

在前面的章节中,我们编写的所有程序基本都是按单线程、顺序执行的方式运行的。在早期单核 CPU 时代,这种线性思维符合计算机执行指令的逻辑

然而,现代计算机早已普及多核 CPU 架构。如果继续采用单线程顺序执行,程序在面对计算密集型或高延迟 I/O 任务时,就无法有效利用硬件资源。为了打破这一瓶颈,我们需要引入多任务并发与并行编程

在深入 Python 的 multiprocessing 模块之前,我们必须先厘清并发编程中最基础的几个核心概念:顺序执行、并发、并行、同步与异步


1. 顺序执行

顺序执行是最直观的程序运行方式。系统按照代码的先后顺序,将任务逐个放入 CPU 中执行。前一个任务未完成前,后一个任务必须处于等待状态

  • 特点:简单直观,逻辑可预测

  • 痛点:若其中某个任务涉及高延迟操作,CPU 就会进入空闲等待状态,导致后续任务被阻塞,整体吞吐量低下


2. 并发与并行

很多人常将"并发"与"并行"混为一谈,但它们在计算机底层机制上有本质的区别:

并发

并发是指系统具备处理多个任务的能力,但并不意味着这些任务在同一时刻被执行

单核 CPU 或 CPU 核心数少于任务数的情况下,操作系统通过时间片轮转上下文切换机制,在极短的时间内频繁在多个任务之间交替切换。由于切换速度极快(通常为毫秒级),在宏观视角上给用户造成了 "多个任务在同时运行" 的错觉

并行

并行是指系统具备同时执行多个任务的能力

这必须依赖多核 CPU。每个 CPU 核心在同一物理时刻各自独立运行一个任务,任务之间在时间轴上是真正的重叠运行,互不抢占 CPU 资源

对比:

并发是关于 "结构" 的:强调的是程序的设计结构能够处理多个事情

并行是关于 "执行" 的:强调的是硬件在同一时刻真正做多个事情


3. 同步与异步

这两个概念关注的是消息通信与控制权回收的机制:

同步

调用方发起一个函数或任务请求后,必须停下手头的所有工作,等待该请求返回结果。在结果返回之前,调用方处于阻塞状态,无法继续执行后续代码

异步

调用方发起一个请求后,无需等待结果返回,即可立刻继续向下执行其他任务 。被调用的任务会在后台独立运行,当其处理完毕后,通过回调函数事件通知信号通知调用方


4. 为什么需要多进程

在许多编程语言(如 Java、C++)中,提高并发/并行效率的首选方案是多线程

但在 Python(具体指最常用的 CPython 解释器)中,由于存在全局解释器锁(GIL) ,同一时刻只允许一个线程执行 Python 字节码。这就导致 Python 的多线程在 CPU 密集型任务中无法利用多核 CPU 的并行能力

因此,在 Python 中要实现真正的多核物理并行 ,最主流且官方推荐的解决方案就是使用多进程------通过创建独立的 Python 进程,让每个进程拥有自己独立的解释器与内存空间,从而完美绕过 GIL 的限制

二、进程基础

在理解了并发与并行的区别后,本章将正式切入 Python 的多进程核心模块------multiprocessing。从进程的概念出发,剖析主进程与子进程的衍生关系,并掌握 Process 类的基本使用规范


1. 什么是进程

在操作系统中,进程是计算机进行资源分配和调度的基本单位

当运行一个 Python 脚本时,操作系统会为该程序开辟一块独立的内存空间,并分配唯一的进程标识符

进程特征:

  • 独立的内存空间 :每个进程拥有自己独立的虚拟内存地址空间,包含堆、栈、全局变量与代码段。不同进程之间无法直接共享内存数据

  • 独立的系统资源:每个进程拥有独占的文件描述符表、CPU 寄存器状态以及上下文信息

  • 开销相对较大:相比于线程,创建、销毁或切换进程需要操作系统重新分配内存与上下文,因此开销较高


2. 主进程与子进程

在多进程编程中,进程之间存在明确的父子层级关系:

  • 主进程:启动 Python 脚本时创建的进程。主进程负责协调控制流、衍生子进程并回收资源

  • 子进程:由主进程通过 multiprocessing.Process 显式派生出的新进程。子进程会继承父进程的部分环境,但会在自己独立的空间中运行指定的任务代码

  • PID 与 PPID

    • 使用 os.getpid() 可以获取当前进程的 PID

    • 使用 os.getppid() 可以获取父进程的 PID


3. Process 类

Python 提供了标准库 multiprocessing 中的 Process 类,用于创建和管理子进程

构造函数签名:

python 复制代码
from multiprocessing import Process

Process(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)

参数解析:
target:Callable ------ 子进程启动后将要执行的函数/可调用对象
args:tuple      ------ 传递给 target 函数的位置参数元组
kwargs:dict     ------ 传递给 target 函数的关键字参数字典
name:str        ------ 子进程的自定义名称
daemon:bool     ------ 设置是否为守护进程

4. 创建与启动进程

通过一段代码演示如何使用 Process 创建并运行两个子进程:

python 复制代码
import os
import time
from multiprocessing import Process

def task(task_name: str, duration: int):
    """子进程执行的目标任务"""
    print(f"[子进程 {name}] 开始运行 | PID: {os.getpid()} | 父进程 PPID: {os.getppid()}")
    time.sleep(duration)
    print(f"[子进程 {name}] 任务完成 | 耗时: {duration}s")


if __name__ == '__main__':
    print(f"[主进程] | PID: {os.getpid()}")
    
    # 1. 实例化 Process 对象(此时仅分配了对象,未真正创建进程)
    p1 = Process(target=task, args=("A", 2), name="Worker-A")
    p2 = Process(target=task, kwargs={"task_name": "B", "duration": 1}, name="Worker-B")
    
    # 2. 调用 start():向操作系统发起创建进程请求,异步启动子进程
    p1.start()
    p2.start()

输出结果:


5. 为什么必须写 if name == 'main'

在 Python 多进程编程中,如果不写 if name == 'main',程序在 Windows 或 macOS系统下会抛出 RuntimeError,导致无限递归创建子进程直至系统崩溃

原因:

  • Windows / macOS (spawn 模式) :创建子进程时,操作系统并不会完全复制内存,而是启动一个新的 Python 解释器进程,然后以导入的方式重新加载主模块

  • 如果不加入口保护:当子进程重新导入当前脚本文件时,顶级作用域中的 p1 = Process(...) 和 p1.start() 代码会被重新执行一遍,子进程又尝试创建孙子进程,造成无限递归循环

  • 加上入口保护:在导入模块时 name 的值为模块名而非 'main',因此子进程导入时不会重复触发进程创建逻辑

复制代码

三、进程控制

实例化一个 Process 对象仅仅是在内存中创建了一个 Python 对象,并不意味着已经开始运行。要精细化掌控进程的整个运行轨迹,我们需要掌握其生命周期控制 API


1. 进程生命周期 API

1.1 进程启动:start()

  • 机制:向操作系统内核发起创建进程的系统调用。操作系统完成 PCB(进程控制块)分配、内存空间映射后,隐式触发并执行 run() 方法

  • 特性非阻塞。start() 执行后会立即返回,主进程继续向下执行,不会等待子进程运行完毕

1.2 进程等待与同步:join(timeout=None)

  • 机制:阻塞当前调用方,直至被调用的子进程执行完毕并退出,或者达到了指定的 timeout 超时秒数

  • 意义 :用于实现主子进程的时间同步,确保主进程获取到子进程运行后的数据或完成信号后再继续往下走

1.3 进程状态查询:is_alive()

  • 机制:返回一个布尔值,用于实时探测子进程当前的运行状态

  • 返回值

    • True:子进程已启动且尚未终止

    • False:子进程尚未调用 start(),或已经正常/异常终止

1.4 守护进程:daemon 属性

  • 机制 :布尔值属性,必须在调用 start() 之前设置

  • 行为特征

    • False:主进程代码执行完毕后,会静默等待所有非守护子进程运行结束才会真正退出

    • True :子进程变为后台服务。一旦主进程的代码执行完毕,所有守护子进程无论是否运行完,都会被操作系统瞬间强制终止

  • 守护进程内部不能再创建新的子进程

1.5 终止:terminate() 与 kill()

  • 机制

    • terminate() :向子进程发送 SIGTERM 信号,请求操作系统强制终止该进程。不会执行 finally 代码块

    • kill():向子进程发送 SIGKILL 信号,直接抹杀进程

  • 注意 :如果子进程在终止时正持有锁资源或在 Queue 中写入数据,强制终止可能导致锁无法释放进而引发死锁,或造成管道数据损坏。应谨慎使用

1.6 退出码:exitcode 属性

  • 机制:用于获取进程终止时的退出状态代码

  • 数值含义

    • None:进程尚未终止(仍在运行)

    • 0:进程正常执行完毕退出

    • > 0:进程在运行中抛出未捕获的异常并退出(代码通常为 1)

    • < 0:进程被信号强制终止。例如,若被 terminate() 杀死,exitcode 通常为 -15(即 -SIGTERM)


2. 生命周期控制示例

下面的代码展示了如何结合使用 join()、daemon、terminate() 和 exitcode 对多个子进程实施生命周期管理:

python 复制代码
import time
import os
from multiprocessing import Process

def normal_worker(duration: int):
    """常规工作进程"""
    print(f"[Worker] PID {os.getpid()} 开始工作...")
    time.sleep(duration)
    print(f"[Worker] PID {os.getpid()} 正常工作结束。")

def daemon_worker():
    """后台监控进程(守护进程)"""
    while True:
        print(f"[Daemon] | PID: {os.getpid()}...")
        time.sleep(0.5)

if __name__ == '__main__':
    print("=== 主进程启动 ===")
    
    # 1. 创建普通子进程
    p_worker = Process(target=normal_worker, args=(2,), name="WorkerProcess")
    
    # 2. 创建守护子进程
    p_daemon = Process(target=daemon_worker, name="DaemonProcess")
    p_daemon.daemon = True  # 必须在 start() 前设置

    # 启动进程
    p_worker.start()
    p_daemon.start()

    print(f"p_worker 是否存活? {p_worker.is_alive()}")
    print(f"p_daemon 是否存活? {p_daemon.is_alive()}")

    # 3. 演示 join() 超时控制与 terminate()
    # 假设我们给 p_worker 最多 1 秒的执行时间,超时则强制终止
    p_worker.join(timeout=1.0)
    
    if p_worker.is_alive():
        print("p_worker 执行超时,强行终止")
        p_worker.terminate()
        p_worker.join()  # 等待 OS 回收该进程,刷新 exitcode

    # 4. 打印退出状态码
    print(f"p_worker exitcode: {p_worker.exitcode}")
    print(f"p_worker 是否存活? {p_worker.is_alive()}")

    print("主进程退出")

输出结果:

最佳实践:

  • **组合使用 terminate() 和 join():**调用 terminate() 后,操作系统需要微小的响应时间来注销进程。紧接着调用 join() 可以确保主进程在继续向下推进前,该子进程占用的系统句柄与 exitcode 状态已被彻底清理回收

  • 孤儿与僵尸进程:在类 Unix 系统中,若子进程退出而父进程未收集其状态,子进程将短暂变为僵尸进程。善用 join() 是良好的资源回收习惯

四、进程同步

虽然操作系统为每个进程分配了独立的虚拟内存空间,进程间无法直接篡改彼此的变量,但在实际工程中,多个进程常常需要共享某些外部物理资源------例如同一份磁盘文件、数据库连接等

当多个进程同时试图读写同一个共享资源时,就会触发竞态条件 ,引发数据损坏、输出乱码或逻辑交错。为了保证资源的互斥访问 ,我们需要引入进程锁


1. 为什么需要锁

假设我们有两个子进程同时向同一个文件写入数据,或者同时向控制台打印字符。由于操作系统调度进程的随机性,如果不对访问进行限制,输出流可能会产生重叠和交叉交错:

python 复制代码
未加锁(乱序抢占):
[进程A]: 正在写入[进程B]: 正在写入数据B...数据A...

加锁后(排队互斥):
[进程A]: 正在写入数据A...
[进程B]: 正在写入数据B...

关键概念:临界区

在代码中,访问或修改共享资源的那段代码区域 被称为临界区 。进程锁的核心作用,就是保证在任意时刻,最多只有一个进程能够进入临界区执行代码


2. Lock

Python 的 multiprocessing 模块提供了 Lock(互斥锁)类。它在底层依赖操作系统的信号量机制实现

互斥锁拥有两种状态:

  • Unlocked:任何进程都可以请求并获取该锁

  • Locked:锁已被某个进程持有,其他试图获取该锁的进程将被阻塞挂起,进入等待队列


3. 获取锁与释放锁

获取锁:acquire(block=True, timeout=None)

  • 参数说明

    • block=True:阻塞式获取。如果锁已被占用,当前进程暂停执行,直到锁被释放

    • block=False :非阻塞式获取。无论是否拿到锁,方法会立刻返回。成功拿到锁返回 True,锁被占用则返回 False

    • timeout:设置最长等待时间(秒)。超过设定时间仍未拿到锁则返回 False

释放锁:release()

  • 机制:将锁的状态重置为 Unlocked,并唤醒等待队列中的下一个进程。

  • 约束:只有持有该锁的进程才能调用 release(),否则会抛出 ValueError


4. 标准规范

如果采用手动调用 acquire() 和 release() 的方式,一旦临界区内部的代码在执行过程中抛出未捕获的异常,就会导致 release() 被跳过

这会让锁永远处于 Locked 状态,引发严重的死锁,导致后续所有等待该锁的进程被永久卡死

python 复制代码
# 危险做法:缺少异常防护,极易引发死锁
lock.acquire()
try:
    unsafe_operation()  # 若此处抛出异常,release 永远不会执行
finally:
    lock.release()

最佳实践:

与文件操作类似,Lock 对象实现了上下文管理器协议。使用 with lock: 结构,解释器会在进入代码块时自动调用 acquire(),并在离开代码块时百分之百保证调用 release():

python 复制代码
# 标准做法:安全、简洁、防死锁
with lock:
    # 临界区代码
    unsafe_operation()

5. 注意事项

  • 锁会降低并发性能 :锁的本质是将并行执行强制转化为串行执行。因此,临界区内的代码应当尽可能精简,只包含真正需要互斥的资源操作,切忌将耗时的计算或网络请求放在锁内部

  • Lock 对象必须通过参数传递 :在 Windows 和 macOS 系统下,主进程创建的 Lock 对象必须通过 args 显示传递给子进程,不能依赖全局变量,否则子进程接收到的会是重新初始化过的独立锁,导致锁机制失效

  • 嵌套锁引发死锁:同一个进程试图对同一个非可重入锁连续调用两次 acquire(),会导致该进程自己卡死自己。若需要递归加锁,应使用 multiprocessing.RLock(可重入锁)

五、进程通信

在上一章中,我们讨论了如何使用进程锁控制共享资源的访问。然而,在大多数并发编程场景中,进程之间不仅需要竞争资源,更需要协同工作与传递数据

由于操作系统为每个进程分配了独立的内存地址空间,这导致了一个关键现象:进程间无法直接共享普通变量


1. 为什么不能共享变量

在单进程或多线程程序中,全局变量位于同一个内存空间内,所有线程均可自由修改。但对于多进程而言,子进程在创建时,操作系统会为其分配独立的虚拟内存,或者在写时拷贝机制下为其生成一份独立的内存副本

全局变量在子进程中的独立性

python 复制代码
import os
import time
from multiprocessing import Process

# 定义一个全局列表
global_data = []

def modify_data():
    global global_data 
    global_data.append("Result")
    print(f"子进程 [PID: {os.getpid()}] 内部的列表内容: {global_data}")

if __name__ == '__main__':
    p = Process(target=modify_data)
    p.start()
    p.join()

    # 在主进程中检查全局变量
    print(f"主进程 [PID: {os.getpid()}] 内部的列表内容: {global_data}")

输出结果:

从结果可以看出,子进程对 global_data 的修改只发生在它自己的内存副本中。子进程执行完毕退出后,该内存空间被销毁,主进程中的全局变量未受任何影响

为了在独立内存的进程间安全、高效地传输数据,我们需要使用 Python 提供的进程间通信机制


2. Queue

在单进程中,我们常使用 queue.Queue;但在多进程环境下,必须使用 multiprocessing.Queue

底层原理

multiprocessing.Queue 是线程和进程安全的。其底层依赖操作系统原生的管道 信号量/锁实现。 当一个进程向队列 put() 数据时,Python 会先将数据 序列化为字节流,通过管道传输给队列;当另一个进程调用 get() 时,再将字节流反序列化还原为 Python 对象

常用 API

方法签名 作用描述 阻塞控制
put(obj, block=True, timeout=None) 写入数据 若队列已满,block=True 会阻塞等待;block=False 或超时会抛出 queue.Full 异常
get(block=True, timeout=None) 读取并弹出数据 若队列为空,block=True 会阻塞等待;block=False 或超时会抛出 queue.Empty 异常
empty() 判空 返回 bool
full() 判满 返回 bool
qsize() 获取元素数量 返回 int

3. Queue 使用案例

在分布式系统中,生产者-消费者模型是最基础解耦设计。通过向队列写入哨兵值,可以优雅地通知消费者进程安全退出:

python 复制代码
from multiprocessing import Process, Queue
import time

def producer(queue):
    """生产者进程:向队列中放入数据"""
    for item in ["商品A", "商品B", "商品C"]:
        queue.put(item)
        print(f"生产者生产了:{item}")
        time.sleep(1)

    # 发送结束标记
    queue.put(None)

def consumer(queue):
    """消费者进程:从队列中取出数据"""
    while True:
        item = queue.get()

        if item is None:
            break

        print(f"消费者取出了:{item}")


if __name__ == "__main__":
    queue = Queue()

    producer_process = Process(
        target=producer,
        args=(queue,)
    )

    consumer_process = Process(
        target=consumer,
        args=(queue,)
    )

    producer_process.start()
    consumer_process.start()

    producer_process.join()
    consumer_process.join()

    print("生产和消费任务全部完成")

输出结果:


4. Pipe

除了 Queue 之外,Python 还提供了更原生的点对点通信工具------Pipe (管道)

Pipe 与 Queue 对比

  • Queue:允许多个生产者和多个消费者同时读写,内部自带锁保护,功能全面,但由于有额外的缓冲区管理与同步开销,性能略低于 Pipe

  • Pipe :专门用于两个进程之间进行一对一的高性能通信。架构轻量,吞吐量和传输速率显著高于 Queue

函数签名与双工模式

python 复制代码
conn1, conn2 = Pipe(duplex=True)
  • **duplex=True(双向全双工):**返回两个端点连接对象 conn1 和 conn2。两端均可同时调用 send() 和 recv()

  • **duplex=False(单向半双工):**conn1 只能用于接收数据(recv()),conn2 只能用于发送数据(send())

端点连接对象常用 API

  • **send(obj):**向管道写入一个可序列化的 Python 对象

  • recv(): 从管道读取一个对象。若管道为空,该方法会一直阻塞等待;如果对端连接已被关闭,会抛出 EOFError

  • **close():**关闭当前管道端点


5. Pipe 使用案例

下面的代码演示了 Pipe 建立双向通信渠道,实现 "主进程下发指令 -> 子进程计算 -> 返回结果"

python 复制代码
from multiprocessing import Process, Pipe


def worker(conn):
    # 接收主进程发送的数据
    number = conn.recv()

    # 计算结果并发送回主进程
    conn.send(number ** 2)

    conn.close()


if __name__ == "__main__":
    # 创建管道的两个端点
    parent_conn, child_conn = Pipe()

    # 将一个端点交给子进程
    process = Process(
        target=worker,
        args=(child_conn,)
    )

    process.start()

    # 主进程发送数据
    parent_conn.send(12)

    # 主进程接收结果
    result = parent_conn.recv()
    print(f"计算结果:{result}")

    process.join()
    parent_conn.close()

输出结果:

对比选择:

  1. 一对一高性能连接 :优先选择 Pipe,避免队列机制带来的额外性能开销。

  2. 若涉及多生产者、多消费者或任务分发池 :优先选择 Queue,依靠其自带的锁与同步机制保障多端并发安全

  3. 注意 :由于 Queue 和 Pipe 底层依赖 pickle 模块进行序列化,切勿接收并处理来自不可信网络端的裸数据,防止反序列化漏洞

六、自定义 Process

在前面的章节中,我们主要通过向 Process(target=func) 传递函数指针的方式来创建子进程。这种函数式写法在编写简单脚本或临时任务时非常高效

然而,在面对复杂项目时,将所有业务逻辑挤在一个独立函数里会导致代码耦合度高、难以复用。此时,采用面向对象方式继承 Process 类是更优雅的选择


1. 继承 Process 类

通过自定义类继承 Process,可以将进程相关的状态数据与执行逻辑封装在一个对象中。实现自定义进程类需要遵循以下原则:

  1. 继承基类:显式继承 multiprocessing.Process

  2. 重写 init :在自定义类的 init 方法中,必须首先显式调用 super().init(),以初始化基类

  3. 重写 run() 方法: 将子进程实际要执行的业务代码写入 run() 方法中。切勿在外部手动调用 run(),而应调用 start()


2. start() 与 run()

常见的一个错误是混淆 start() 与 run() 的触发时机:

  • 调用 p.start(): 向操作系统申请创建新的子进程,并在新进程的独立地址空间内异步触发 run() 方法。这才是真正的多进程并行

  • 调用 p.run() :仅相当于在当前主进程 中同步执行了一个普通类方法,不会创建任何新的子进程,程序依然是单线程顺序执行


3. 使用案例

下面的示例展示了如何实现一个自定义的 LogCleaner 类,用于从共享队列中获取日志,进行格式化清洗,并将清洗后的合格数据传递至下一个管道:

python 复制代码
from multiprocessing import Process, Queue


class LogCleaner(Process):
    def __init__(self, task_queue, result_queue):
        super().__init__()
        self.task_queue = task_queue
        self.result_queue = result_queue

    def run(self):
        while True:
            log = self.task_queue.get()

            # 收到结束标记,退出子进程
            if log is None:
                break

            cleaned_log = log.strip().upper()
            self.result_queue.put(cleaned_log)

if __name__ == "__main__":
    task_queue = Queue()
    result_queue = Queue()

    logs = [
        "  error: connection timeout  ",
        "info: user logged in",
        "  warning: high memory usage  "
    ]

    # 创建两个工作进程
    workers = [
        LogCleaner(task_queue, result_queue)
        for _ in range(2)
    ]

    for worker in workers:
        worker.start()

    # 添加任务
    for log in logs:
        task_queue.put(log)

    # 每个子进程都需要一个结束标记
    for _ in workers:
        task_queue.put(None)

    for worker in workers:
        worker.join()

    # 结果数量与原始任务数量相同
    for _ in logs:
        print(result_queue.get())

输出结果:

七、进程池

在前面的章节中,我们都是手动创建 Process 对象来执行多任务。如果需要处理大量短生命周期的任务,为每个任务都手动实例化、启动并销毁,会给操作系统带来极大的负担

为高效管理大规模并发任务,通常采用进程池机制


1. 为什么需要进程池

手动创建进程的痛点:

  • 资源开销巨大:频发地向操作系统申请创建与销毁进程(包括 PCB 分配、物理内存映射、虚拟地址空间初始化),吞吐效率低下

  • 宕机风险:如果任务数量无节制增长,同时创建 10000 个子进程会瞬间耗尽系统的 CPU 和 RAM 资源,触发 OOM 导致操作系统崩溃

进程池的核心思想:资源复用与总量控制

进程池在程序启动时预先创建好固定数量的子进程并维持其常驻内存。当有新任务提交时,进程池从池中拣选空闲的 Worker 去执行;任务执行完毕后,子进程不会被销毁,而是继续回到池中等待下一个任务


2. Pool 类

Python 标准库提供了 multiprocessing.Pool 类来快速构建进程池

python 复制代码
from multiprocessing import Pool

Pool(processes=None, initializer=None, initargs=(), maxtasksperchild=None)

- processes:指定进程池中允许同时运行的最大 Worker 进程数量
- initializer:每个 Worker 进程启动时要优先执行的初始化函数
- maxtasksperchild:指定每个 Worker 进程在处理多少个任务后自动退出并重启一个新的 Worker

3. 进程池常见 API

Pool 提供了多种将任务提交给进程池执行的方法,核心可以归纳为同步与异步单任务与批量任务两个维度

apply():

将任务提交给进程池,并阻塞主进程直到该任务被分配的 Worker 执行完毕并返回结果

python 复制代码
pool.apply(func, args=(), kwds={}) -> Any

缺点 :由于主进程会被完全阻塞,apply() 无法实现多任务并行 ,只会在池中依次串行执行任务。因此极少使用

apply_async():

异步提交任务,方法会立刻返回一个 AsyncResult 对象,主进程不会被阻塞,可以继续向池中下发后续任务

python 复制代码
pool.apply_async(
    func, 
    args=(), 
    kwds={}, 
    callback=None, 
    error_callback=None
) -> AsyncResult

- callback:指定任务成功完成后调用的回调函数,接收 func 的返回值作为入参
- error_callback:指定任务抛出未捕获异常时的回调函数,接收异常对象作为入参

AsyncResult 常用 API:

  • get(timeout=None):阻塞获取任务返回值

  • wait(timeout=None):阻塞等待任务执行完毕,不获取返回值

  • ready():返回 bool,判断任务是否已执行完毕

  • successful():返回 bool,判断任务是否未触发异常成功结束

map() 与 starmap():

功能类似于 Python 内置的 map() 函数。它会将 iterable 中的每一个元素作为参数分发给 Worker 进程,阻塞等待所有元素计算完毕,并以列表形式返回全量结果

python 复制代码
pool.map(func, iterable, chunksize=None) -> list
pool.starmap(func, iterable, chunksize=None) -> list

map() 与 starmap() 的区别

  • map() :适用于目标函数仅接收 1 个入参的场景

  • starmap() :适用于目标函数接收多个入参的场景。它会自动将 iterable 中的元组元素进行解包传参


4. 进程池生命周期

对进程池进行显式销毁与资源回收时,必须严格遵守调用顺序规范

  • **pool.close():**关闭进程池的入口,阻止后续继续提交新任务。如果不先调用 close(),直接调用 join() 会抛出 ValueError

  • **pool.terminate():**强行打断并终止池内的所有 Worker 正在执行的任务,立即关闭进程池

  • **pool.join():**阻塞等待进程池中已经提交的所有任务全部执行完毕


5. 使用案例

下面的代码展示了如何利用 Pool.starmap 进行批量矩阵数值计算,以及利用 Pool.apply_async 实现带有异步回调的任务调度:

python 复制代码
import time
from multiprocessing import Pool

def power(x, y):
    return x ** y

def task(name):
    time.sleep(1)
    return f"{name} 执行完成"

def show_result(result):
    print("回调结果:", result)

if __name__ == "__main__":
    with Pool(4) as pool:
        # 批量执行多参数任务
        data = [
            (2, 10),
            (3, 5),
            (4, 4)
        ]

        results = pool.starmap(power, data)
        print("批量计算结果:", results)

        # 异步提交任务
        for name in ["任务A", "任务B", "任务C"]:
            pool.apply_async(
                task,
                args=(name,),
                callback=show_result
            )

        # 等待异步任务完成
        pool.close()
        pool.join()

    print("所有任务执行完成")

输出结果:

总结

本章围绕 Python 多进程编程展开,首先介绍了并发、并行、同步和异步等基本概念,帮助我们建立了并发编程的整体认识。随后学习了 Process 类的使用方法、进程的创建与控制、进程锁以及 join()、terminate()、守护进程等常用机制,并理解了进程之间相互独立、不能直接共享变量的特点。最后,我们学习了 Queue 和 Pipe 两种进程通信方式、继承 Process 类创建进程的方法以及进程池的使用,使我们能够更加高效地管理和利用多个进程完成复杂任务。

下一篇文章我们将学习 Python 多线程编程,深入了解线程的创建与管理、线程池、全局解释器锁以及多线程与多进程的适用场景,进一步掌握 Python 并发编程的核心知识

相关推荐
wdfk_prog2 小时前
嵌入式面试真题第 15 题:不可恢复异常后的通用崩溃快照、调用栈保存与离线分析架构
linux·开发语言·面试·架构
_Jimmy_3 小时前
FastAPI + SQLAlchemy全局事务管理
python·fastapi
晴空了无痕3 小时前
从 Go 基础到 K8s:一条可落地的 Go 服务端成长路线
开发语言·后端·golang·kubernetes
用户8356290780513 小时前
如何使用 Python 在 Excel 中添加、编辑和删除超链接
后端·python
神明不懂浪漫3 小时前
【第五章】Java中的继承与多态
java·开发语言
神州世通4 小时前
解密企业通信安全防线:Avaya Aura 三层安全架构深度解析
开发语言·php
AC赳赳老秦4 小时前
企业工商公开信息采集分析:OpenClaw 批量查询企业工商信息,生成企业画像报告
大数据·开发语言·python·自动化·php·deepseek·openclaw
qq_2518364575 小时前
基于java Web 动漫视频网站毕业论文
java·开发语言·前端
野生风长5 小时前
C++入门基础:从命名空间到引用与指针的全面解析
开发语言·c++