在我们编写任何一行并发代码之前,我们必须首先回到一切计算行为的源头,去理解一个程序是如何被执行的,以及“时间”在计算机的世界里究竟意味着什么。如果不建立这些最底层的、物理学般的直觉,那么并发(Concurrency)与并行(Parallelism)将永远是两个模糊不清的抽象概念。本章,我们将剥去所有编程语言的外壳,直面计算机体系结构的核心——中央处理器(CPU),并从它的工作方式中,推导出所有并发编程范式赖以建立的基石。
想象一下,一个现代CPU的核心,是一位一丝不苟、速度快得惊人的办公室文员。这位文员面前只有一张办公桌,他唯一会做的事情,就是从一个名为“内存”的文件柜里,取出标记为“下一条指令”的文件夹,严格按照文件夹里的指示去执行一个操作,然后重复这个过程。这个过程被称为**取指-译码-执行(Fetch-Decode-Execute)**循环。
对于这名单核CPU文员来说,他的世界是完全**串行(Serial)**的。在任何一个精确的时间点,他只能且必须在做一件事情。一个Python程序,无论写得多复杂,在被解释器翻译后,最终都会变成这样一长串等待被CPU文员依次处理的、线性的机器指令。
让我们通过一个简单的Python脚本来具象化这个过程。
import time # 导入time模块,我们将用它来模拟耗时操作
def load_data_from_disk(): # 定义一个函数,用于模拟从磁盘加载数据
print("CPU开始执行 '加载数据' 指令...") # 打印信息,表示CPU正在处理这个函数的指令
# 假设从磁盘读取数据是一个耗时的I/O操作,需要2秒钟
time.sleep(2) # CPU向磁盘控制器发出指令后会等待,这里用sleep模拟这个等待时间
print("'加载数据' 指令执行完毕。") # 打印完成信息
return {
"data": "一些沉重的数据"} # 函数返回一个包含数据的字典
def process_data(data): # 定义一个函数,用于模拟数据处理
print("CPU开始执行 '处理数据' 指令...") # 打印开始处理的信息
# 假设数据处理是一个需要消耗CPU计算资源的操作,需要1秒钟
time.sleep(1) # 这里用sleep模拟纯粹的计算耗时
processed_result = len(data.get("data", "")) # 对数据进行一个简单的计算
print("'处理数据' 指令执行完毕。") # 打印处理完成的信息
return processed_result # 返回处理结果
def save_report_to_database(result): # 定义一个函数,用于模拟将报告保存到数据库
print("CPU开始执行 '保存报告' 指令...") # 打印开始保存的信息
# 假设写入数据库是另一个耗时的I/O操作,需要1.5秒钟
time.sleep(1.5) # 模拟写入数据库的等待时间
print(f"'保存报告' 指令执行完毕,已保存结果: {
result}") # 打印保存完成的信息
# --- 程序的主执行流程 ---
print("程序开始,CPU将按顺序执行指令。")
start_time = time.time() # 记录程序开始的墙上时钟时间
# CPU首先处理 load_data_from_disk 函数的指令集
loaded_data = load_data_from_disk()
# 在上一组指令完全结束后,CPU接着处理 process_data 函数的指令集
processing_result = process_data(loaded_data)
# 在上一组指令完全结束后,CPU最后处理 save_report_to_database 函数的指令集
save_report_to_database(processing_result)
end_time = time.time() # 记录程序结束的墙上时钟时间
total_duration = end_time - start_time # 计算总耗时
print(f"\n所有指令已按顺序执行完毕。")
print(f"程序总运行时长: {
total_duration:.2f} 秒。") # 打印总耗时
当我们运行这个脚本时,输出的顺序和耗时将是完全确定和可预测的。总耗时约等于 2 + 1 + 1.5 = 4.5
秒。CPU就像那位文员,严格地、一步一步地完成了我们交给它的三个任务文件夹。这就是串行执行的本质:任务一个接一个地完成,总耗时等于所有任务耗时的总和。
在上面的例子中,我们用time.sleep()
模糊地模拟了两种不同的“耗时”。但在真实世界中,任务的耗时有着截然不同的性质。正确地识别一个任务的“天性”,是选择正确并发策略的绝对前提。
CPU密集型(CPU-Bound)
一个任务是CPU密集型的,意味着它的大部分时间都花在了纯粹的、繁忙的数学和逻辑运算上。这个任务始终在让CPU“马不停蹄”地工作,程序的执行速度只受限于CPU的计算能力和时钟频率。
让我们创建一个纯粹的CPU密集型任务示例。
import time # 导入time模块
def cpu_intensive_task(iterations): # 定义一个CPU密集型任务
"""
这个函数通过执行大量无意义的数学运算来消耗CPU时间。
"""
print(f"CPU密集型任务开始,需要执行 {
iterations} 次计算。")
result = 0 # 初始化一个结果变量
# range()创建了一个需要迭代的对象
for i in range(iterations):
# 这里的加法和乘法是纯粹的CPU操作
result += (i * i) % 1234 + (i // (i+1))
print(f"CPU密集型任务结束。")
return result # 返回最终计算结果
# --- 执行一个CPU密集型任务 ---
# 设置一个非常大的迭代次数来使其耗时明显
ITERATION_COUNT = 30_000_000
start_cpu_time = time.time() # 记录开始时间
cpu_intensive_task(ITERATION_COUNT) # 调用并执行任务
end_cpu_time = time.time() # 记录结束时间
print(f"执行 {
ITERATION_COUNT} 次计算,耗时: {
end_cpu_time - start_cpu_time:.4f} 秒。")
在这个例子中,程序的瓶颈是for
循环内部的计算。增加ITERATION_COUNT
会直接导致执行时间的线性增长。如果我们有一颗更快的CPU,执行时间就会缩短。
I/O密集型(I/O-Bound)
一个任务是I/O密集型的,意味着它的大部分时间都花在了等待外部设备或资源上。I/O(Input/Output,输入/输出)的范围很广,包括:
在执行I/O密集型任务时,CPU其实是非常清闲的。它向外部设备(如网卡、硬盘控制器)发出一个指令(比如“请从这个网址下载数据”)后,就进入了漫长的等待期。在这个等待期内,CPU完全可以去做别的事情。
让我们创建一个纯粹的I/O密集型任务示例。
import time # 导入time模块
import requests # 导入requests库,用于发起网络请求。需要 pip install requests
def io_intensive_task(url): # 定义一个I/O密集型任务
"""
这个函数通过请求一个真实的网站来模拟网络I/O。
"""
print(f"I/O密集型任务开始,准备请求URL: {
url}")
try:
# requests.get(url) 是一个阻塞操作。
# 程序会在这里暂停,等待DNS查询、TCP连接建立、HTTP请求发送、
# 远程服务器处理、HTTP响应返回等一系列网络事件。
# 在此期间,我们的CPU几乎是100%空闲的。
response = requests.get(url, timeout=10) # 设置10秒超时
print(f"I/O密集型任务结束,收到响应,状态码: {
response.status_code}")
return response.text # 返回响应的文本内容
except requests.exceptions.RequestException as e: # 捕获可能发生的网络异常
print(f"I/O密集型任务失败,错误: {
e}")
return None # 失败时返回None
# --- 执行一个I/O密集型任务 ---
# 使用一个已知存在的、响应稳定的网站
TARGET_URL = "https://www.python.org"
start_io_time = time.time() # 记录开始时间
io_intensive_task(TARGET_URL) # 调用并执行任务
end_io_time = time.time() # 记录结束时间
print(f"请求URL '{
TARGET_URL}',耗时: {
end_io_time - start_io_time:.4f} 秒。")
在这个例子中,程序的瓶颈完全不在于我们的CPU,而在于网络另一端的服务器的响应速度以及我们和它之间的网络延迟。即使我们换一台快10倍的CPU,这个任务的执行时间也几乎不会有任何变化。
识别任务的天性,是通往并发编程的第一把钥匙。因为,我们选择threading
, asyncio
还是multiprocessing
,几乎完全取决于我们要处理的任务,是CPU密集型,还是I/O密集型。
现在,我们终于可以精确地定义这两个在并发编程中被频繁提及,却又极易混淆的核心概念了。
并发(Concurrency)
定义:并发指的是一种逻辑上的结构,它允许一个程序拥有多个独立的、可以向前推进的执行流。这些执行流在时间上可以重叠(overlap)。并发的核心在于**处理(Dealing With)**多件事情。
想象一位技艺高超的厨师,他只有一个炉灶(单核CPU),但他需要同时准备三道菜:一道炖汤(需要长时间炖煮)、一道炒菜(需要快速翻炒)、一道沙拉(需要切配)。
这位厨师在任何一个瞬间,仍然只在做一件事情(切菜、或者翻炒、或者看火),但他通过在不同任务之间巧妙地切换,以及利用一个任务的等待时间去执行另一个任务,在宏观上给人一种“同时在做三道菜”的感觉。
这就是并发。它的关键在于任务的切换和调度,目的是最大化资源的利用率,尤其是让CPU在I/O等待期间不空闲。在单核CPU上,我们只能实现并发,而无法实现并行。threading
和asyncio
是Python中实现并发的主要工具。
并行(Parallelism)
定义:并行指的是一种物理上的实现,它允许一个程序在同一时刻,真正地、同时地执行多个计算。并行的核心在于**完成(Doing)**多件事情。
现在,我们给这位厨师升级了厨房。我们为他配备了三位助手,以及三个独立的、全功能的炉灶(多核CPU)。现在,准备三道菜的过程变成了:
这就是并行。它的前提是必须拥有多个计算单元(如多核CPU、多台机器)。它的目标是通过将计算任务分解,来缩短完成整个工作的总时长。multiprocessing
是Python中实现并行的主要工具。
一个常见的误解是“并行一定比并发快”。这是不正确的。
asyncio
)在单核上,其效率可能远超一个使用多进程的并行程序。因为后者的进程创建和通信开销,可能远远大于其并行带来的收益。结论:并发是一种程序结构,并行是一种执行方式。并发可以在单核上实现,而并行必须在多核上。我们可以编写一个并发的程序,让它在一个多核系统上并行地执行。我们的终极目标,是根据任务的天性,选择正确的并发模型,来最大化程序的执行效率。
在并发的世界里,任务之间不可避免地需要进行交互或调用。一个函数调用另一个函数,这种交互的方式,可以分为两种截然不同的哲学:同步与异步。
同步(Synchronous)
同步交互,是一种阻塞式(Blocking)的调用模型。当主调用者发起一个调用时,它会停下自己所有的事情,原地等待,直到被调用者完成任务并返回结果。在被调用者返回之前,主调用者处于完全的“冰冻”状态。
Python中绝大多数的函数调用都是同步的。
import time
def synchronous_sub_task(): # 定义一个同步的子任务
"""一个需要执行3秒的子任务"""
print(" 子任务: 开始执行,需要3秒。")
time.sleep(3) # 模拟耗时工作
print(" 子任务: 执行完毕,准备返回结果。")
return "子任务的结果" # 返回结果
def main_synchronous_caller(): # 定义一个同步的主调用者
print("主调用者: 准备调用子任务...")
# 在下面这一行,主调用者的执行流将完全暂停
# 它会一直等待,直到synchronous_sub_task函数执行完毕并返回
result = synchronous_sub_task()
# 只有在子任务返回后,下面的代码才会被执行
print(f"主调用者: 终于收到了子任务的结果: '{
result}'")
print("主调用者: 任务完成。")
# --- 执行同步调用 ---
main_synchronous_caller()
输出清晰地展示了这种阻塞关系。主调用者在调用子任务后,必须经历一个3秒钟的“空窗期”,什么也做不了。
异步(Asynchronous)
异步交互,是一种非阻塞式(Non-blocking)的调用模型。当主调用者发起一个调用时,它不会等待,而是会立即得到一个“承诺”(Promise)或者“凭证”(Future/Task),然后继续执行自己后续的工作。这个“凭证”代表了未来某个时间点子任务会完成并提供的结果。主调用者可以在未来的任何时候,去检查这个“凭证”的状态,或者注册一个回调函数(Callback),让子任务在完成时主动通知自己。
异步编程的核心,在于调用与结果的分离。它打破了同步模型中严格的“请求-等待-响应”的线性链条。
让我们用一个概念性的回调模式来演示异步交互。
import time
import threading
# 我们将使用一个后台线程来模拟异步执行子任务的环境
# 这不是真正的asyncio,但能很好地解释异步回调的概念
class AsyncExecutor: # 定义一个异步执行器
def __init__(self):
self._thread = None # 用于执行后台任务的线程
def execute(self, task_function, on_complete_callback): # 定义一个执行方法
"""
接收一个任务函数和一个回调函数。
它会在后台线程中执行任务函数,任务完成后调用回调函数。
"""
# 定义一个线程的工作目标函数
def _target():
print(" (后台线程): 开始执行子任务...")
result = task_function() # 执行耗时的同步任务
print(" (后台线程): 子任务执行完毕,准备调用回调函数...")
on_complete_callback(result) # 当任务完成时,调用主调用者提供的回调函数
self._thread = threading.Thread(target=_target) # 创建后台线程
self._thread.start() # 启动线程,任务开始在后台异步执行
def async_sub_task(): # 子任务本身仍然是一个同步函数
"""一个需要执行3秒的子任务"""
time.sleep(3) # 模拟耗时工作
return "异步子任务的结果"
# --- 异步主调用者的逻辑 ---
class MainAsynchronousCaller:
def __init__(self):
self.final_result = None # 用于存放最终结果
def on_sub_task_complete(self, result): # 定义回调函数
"""这个函数将由AsyncExecutor在子任务完成后调用"""
print(f"主调用者(回调): 收到通知!子任务的结果是: '{
result}'")
self.final_result = result # 保存结果
def run(self): # 运行主逻辑
executor = AsyncExecutor() # 创建一个执行器
print("主调用者: 准备异步调用子任务...")
# 发起异步调用,并注册回调函数
# 这个调用会立即返回,不会阻塞
executor.execute(async_sub_task, self.on_sub_task_complete)
# 在子任务在后台执行的同时,主调用者可以做其他事情
print("主调用者: 调用已发出,我先去忙别的了...")
time.sleep(1)
print("主调用_者: (1秒后) 我还在忙...")
time.sleep(1)
print("主调用者: (2秒后) 我还在忙...")
# 等待后台任务完成(在真实应用中,这里可能是等待一个事件或检查状态)
while self.final_result is None:
time.sleep(0.5)
print("主调用者: 检测到结果已收到,任务完成。")
# --- 执行异步调用 ---
caller = MainAsynchronousCaller()
caller.run()
在这个异步模型中,主调用者在发出调用后,并没有被阻塞,而是继续执行自己的工作。它将“处理结果”的逻辑(on_sub_task_complete
)委托了出去,实现了“只管发起,不问过程”的非阻塞交互。
在理解了计算的基本形态之后,我们现在正式踏入并发编程的第一个宏伟领域:进程(Process)。进程是现代操作系统中资源分配和调度的基本单位,它提供了一个强大的、隔离的执行环境。要真正驾驭Python的multiprocessing
模块,我们必须首先理解操作系统是如何看待和管理进程的。这不仅仅是调用一个库函数那么简单,这是一次与操作系统核心功能的直接对话。
当我们运行一个程序(例如,双击一个.exe
文件或在终端输入python my_script.py
),操作系统并不会直接“运行代码”。相反,它会创建一个进程的实例。你可以将一个进程想象成一个为特定程序运行而专门准备的、设施齐全且完全隔离的“办公室隔间”。
这个“隔间”包含了程序运行所需要的一切资源,并且这些资源是它独占的。操作系统是这座“办公大楼”的物业管理员,它确保每个隔间(进程)都不能窥探或干扰其他隔间。
一个进程具体包含以下核心组件:
独立的内存地址空间(Address Space):这是进程最重要的特性。操作系统通过一种名为虚拟内存的技术,让每个进程都“误以为”自己独占了整个计算机的内存。例如,在64位系统上,每个进程都认为自己拥有从0x0000000000000000
到0xFFFFFFFFFFFFFFFF
的庞大内存空间。这块空间是私有的。进程A无法直接读取或写入属于进程B的内存地址。这种严格的隔离是现代操作系统稳定性的基石,它意味着一个进程的崩溃(例如,发生了内存访问错误)通常不会影响到其他正在运行的进程或操作系统本身。
进程控制块(Process Control Block, PCB):如果说进程是办公室隔间,那么PCB就是这个隔间的“档案袋”,由操作系统内核紧紧保管。这个数据结构记录了关于进程的一切信息,是操作系统对进程进行管理和调度的唯一依据。它通常包含:
系统资源句柄:每个进程都维护着一张自己打开的资源列表,例如文件描述符(在Unix-like系统中)或句柄(在Windows中)。当进程A打开一个文件时,它会得到一个文件描述符fd=5
;当进程B也打开同一个文件时,它可能会得到一个完全不同的文件描述符fd=8
。它们都指向同一个底层文件,但进程本身是通过自己的私有句柄来操作的。
理解了这一点,我们就明白为什么说进程是资源分配的单位。创建一个进程,意味着操作系统需要分配一整套全新的、隔离的资源,这是一项相对“昂贵”的操作。
fork()
的魔法与Windows的现实既然进程是一个如此复杂的结构,那么操作系统是如何创建它的呢?这里,不同的操作系统家族给出了不同的答案,而Python的multiprocessing
模块则巧妙地将这些差异隐藏了起来。
Unix/Linux/macOS的方式:fork()
系统调用
在类Unix世界中,创建新进程的方式充满了哲学意味:克隆。fork()
是一个核心的系统调用,当一个进程(父进程)调用fork()
时,操作系统内核会做以下事情:
fork()
调用本身非常奇特:它被调用一次,却在两个进程中返回。
fork()
返回新创建的子进程的PID。fork()
返回 0。程序员可以通过检查fork()
的返回值来区分自己当前是在父进程还是子进程中,从而执行不同的代码路径。
让我们用Python的os
模块来直接体验一下这个底层的过程(注意:这段代码只能在Unix-like系统上运行)。
import os
import time
print(f"程序开始,当前进程PID: {
os.getpid()}")
# fork() 调用只能在Unix-like系统(Linux, macOS)上使用
# 在Windows上执行会引发AttributeError
if not hasattr(os, 'fork'):
print("当前系统不支持 os.fork()。")
else:
# --- fork() 发生点 ---
pid = os.fork() # 从这一刻起,代码被两个进程同时执行
# --- 分叉路口 ---
if pid > 0:
# ---- 父进程的执行路径 ----
# pid变量的值是子进程的PID
print(f"[父进程] PID: {
os.getpid()}, 我创建了一个子进程,它的PID是: {
pid}")
# 父进程可以继续做自己的事
time.sleep(2)
# wait() 会阻塞父进程,直到子进程结束,这样可以避免子进程成为“僵尸进程”
child_pid, status = os.wait()
print(f"[父进程] 子进程 {
child_pid} 已结束,状态码: {
status}")
print("[父进程] 我也即将结束。")
elif pid == 0:
# ---- 子进程的执行路径 ----
# pid变量的值是0
print(f" [子进程] PID: {
os.getpid()}, 我的父进程PID是: {
os.getppid()}")
# 子进程可以执行与父进程完全不同的任务
time.sleep(1) # 模拟子进程在工作
print(f" [子进程] 我的工作完成了,我准备退出。")
os._exit(0) # 子进程结束自己的执行
else:
# ---- 异常情况 ----
# 如果pid < 0,表示创建进程失败
print("fork() 调用失败!")
性能优化:写时复制(Copy-on-Write, COW)
你可能会想,如果父进程占用了4GB内存,调用fork()
岂不是要立刻复制4GB的数据,那也太慢了。实际上,现代操作系统使用了一种名为**写时复制(Copy-on-Write)**的绝妙优化。
当fork()
被调用时,内核并不会真的去复制父进程的物理内存页。相反,它只是将子进程的虚拟内存地址,也指向父进程的那些物理内存页,并将这些内存页标记为“只读”。父子进程共享着同一份物理内存。只有当其中一个进程(无论是父是子)尝试写入某个内存页时,CPU会触发一个页错误异常。此时,内核才会介入,真正地复制那一个被写入的内存页,为写入方创建一个私有的副本,并恢复其写入操作。
COW机制使得fork()
的创建成本极低,因为绝大多数情况下,子进程创建后会立刻执行一个全新的程序(通过exec
系列函数),父进程的数据根本没必要复制。
Windows的方式:CreateProcess()
Windows没有fork()
的概念。它的进程创建模型是生成(Spawn)。当你需要在Windows上创建新进程时,你使用的是CreateProcess()
这样的API。这个API的工作方式更符合直觉:
.exe
)加载到这个新的地址空间中。这就像是物业管理员直接给你分配一个全新的、空空如也的隔间,然后帮你把办公用品(程序)搬进去。
Python的multiprocessing
抽象层
由于底层操作系统的巨大差异,如果直接使用os.fork()
或Windows API来编写跨平台的并发程序,将是一场噩梦。multiprocessing
模块的伟大之处就在于,它为我们提供了一个统一的高层接口。我们可以指定进程的“启动方法”:
fork
:Unix默认。使用os.fork()
。子进程继承父进程的一切。优点是快,缺点是可能与某些第三方库不兼容(特别是那些自己管理线程的库),可能导致奇怪的死锁。spawn
:Windows默认,macOS和Python 3.8+也推荐使用。使用“生成”模式。每次都会启动一个全新的Python解释器进程。优点是干净、稳定,避免了fork
带来的继承问题。缺点是比fork
慢,因为需要从头加载程序和库。forkserver
:Unix可用。程序启动时,会先创建一个“服务器”进程。之后每次需要新进程时,父进程会请求服务器进程来fork
一个子进程。这结合了fork
的速度和spawn
的干净性,是一种折衷方案。我们可以通过multiprocessing.set_start_method('spawn')
来在程序开始时指定一种方法。理解这些底层机制,能帮助我们在遇到奇怪的多进程问题时,有一个清晰的诊断方向。
multiprocessing.Process
:开启并行之旅的第一步multiprocessing.Process
类是我们在Python中创建和管理进程的入口。它将底层的fork
/spawn
等复杂性完美地封装起来,让我们能用一种面向对象的方式来思考并行任务。
最直接的用法是创建一个Process
对象,并为其指定一个target
函数,这个函数就是我们希望新进程去执行的任务。
假设我们有一个主程序,需要对一个大文件进行耗时的分析(例如,统计单词数量),为了不阻塞主程序的其他工作(比如响应用户界面),我们可以将这个分析任务交给一个子进程来完成。
import multiprocessing
import time
import os
def analyze_file(filepath): # 定义一个函数,这将是子进程要执行的任务
"""
一个耗时的文件分析函数,它会读取文件并统计行数和单词数。
"""
print(f" [子进程-PID:{
os.getpid()}] 开始分析文件: {
filepath}")
try:
with open(filepath, 'r', encoding='utf-8') as f: # 以只读模式打开文件
# 读取所有行到内存中
lines = f.readlines()
line_count = len(lines) # 计算行数
word_count = sum(len(line.split()) for line in lines) # 计算总单词数
time.sleep(3) # 模拟这是一个非常耗时的分析过程
print(f" [子进程-PID:{
os.getpid()}] 分析完成。")
print(f" [子进程-PID:{
os.getpid()}] > 文件: {
os.path.basename(filepath)}")
print(f" [子进程-PID:{
os.getpid()}] > 行数: {
line_count}")
print(f" [子进程-PID:{
os.getpid()}] > 单词数: {
word_count}")
except FileNotFoundError: # 异常处理,如果文件不存在
print(f" [子进程-PID:{
os.getpid()}] 错误: 文件未找到 {
filepath}")
if __name__ == '__main__': # 这是一个极其重要的守护代码块
# 在使用 multiprocessing 的程序中,主模块的代码必须被 if __name__ == '__main__': 保护起来。
# 尤其是在使用 'spawn' 或 'forkserver' 启动方法时,子进程会重新导入主脚本。
# 如果没有这个保护,子进程会再次执行创建新进程的代码,导致无限递归地创建进程直到系统崩溃。
print(f"[父进程-PID:{
os.getpid()}] 程序启动。")
# 创建一个虚拟的大文件用于演示
dummy_filepath = "large_document.txt"
with open(dummy_filepath, "w", encoding="utf-8") as f:
f.write("Python multiprocessing provides a powerful way to leverage multiple cores.\n")
f.write("Each process has its own memory space, which ensures isolation.\n" * 100)
f.write("This example demonstrates the basic creation of a worker process.\n")
# 创建一个Process对象
# target=analyze_file 指定了子进程要运行的函数
# args=(dummy_filepath,) 是一个元组,包含了要传递给target函数的位置参数。注意,即使只有一个参数,也必须是元组(后面加个逗号)。
process = multiprocessing.Process(target=analyze_file, args=(dummy_filepath,))
print(f"[父进程-PID:{
os.getpid()}] 即将启动子进程来分析文件...")
# p.start() 是一个非阻塞方法。它会初始化子进程并让其开始执行,然后父进程的控制流会立即返回,继续执行下面的代码。
process.start()
# 父进程在启动子进程后,可以继续做其他事情
print(f"[父进程-PID:{
os.getpid()}] 子进程已启动,我先处理别的任务...")
# ... 这里可以执行其他代码 ...
time.sleep(0.5)
print(f"[父进程-PID:{
os.getpid()}] 正在处理其他任务中...")
# p.join() 是一个阻塞方法。它会告诉父进程:“停在这里,一直等到名为'process'的那个子进程执行完毕再继续往下走”。
# 调用 .join() 是一个好习惯,它可以确保主程序在所有子任务完成前不会提前退出。
print(f"[父进程-PID:{
os.getpid()}] 等待子进程分析结束...")
process.join()
print(f"[父进程-PID:{
os.getpid()}] 子进程已结束,主程序即将退出。")
# 清理创建的虚拟文件
os.remove(dummy_filepath)
仔细观察输出,你会发现父进程在打印“子进程已启动”后,并没有等待3秒,而是立刻打印了“我先处理别的任务”。这证明了start()
的非阻塞性。父进程和子进程在一段时间内是并行执行的。直到父进程遇到join()
,它才停下来,等待子进程的工作画上句号。
进程的隔离性是一把双刃剑。它保证了安全和稳定,但也让进程间的数据共享变得困难。当我们通过args
或kwargs
向子进程传递参数时,到底发生了什么?
答案是序列化(Serialization),在Python中通常指**pickle
**。
当调用process.start()
时,multiprocessing
模块会将args
和kwargs
元组中的所有对象,使用pickle
模块转换成字节流。然后,这个字节流被传递给新的子进程。子进程在开始执行target
函数之前,会先将这个字节流反序列化(unpickle),重建这些对象。
这意味着,子进程得到的是父进程中对象的一个完整的数据副本,而不是对象的引用。它们是两个完全独立的对象,只是在创建副本的那个瞬间,它们的值是相同的。
让我们用一个实验来无可辩驳地证明这一点。
import multiprocessing
import os
import time
def modify_data(data_container): # 子进程的目标函数
"""
尝试修改接收到的数据容器
"""
print(f" [子进程-PID:{
os.getpid()}] 已接收到数据: {
data_container}")
print(f" [子进程-PID:{
os.getpid()}] 数据对象的内存地址是: {
id(data_container)}")
# 我们将在子进程中修改这个数据容器
data_container['id'] = os.getpid() # 修改字典中的值
data_container['items'].append('child_added') # 修改列表中的内容
data_container['is_modified'] = True # 添加新的键值对
print(f" [子进程-PID:{
os.getpid()}] 我已将数据修改为: {
data_container}")
time.sleep(2) # 保持运行一段时间,以便观察
print(f" [子进程-PID:{
os.getpid()}] 我即将退出。")
if __name__ == '__main__':
print(f"[父进程-PID:{
os.getpid()}] 程序启动。")
# 在父进程中创建一个复杂的可变数据结构
parent_data = {
'id': os.getpid(),
'items': ['parent_item_1', 'parent_item_2'],
'is_modified': False
}
print(f"[父进程-PID:{
os.getpid()}] 原始数据为: {
parent_data}")
print(f"[父进程-PID:{
os.getpid()}] 原始数据对象的内存地址是: {
id(parent_data)}")
# 创建并启动子进程,将这个数据结构作为参数传递过去
# multiprocessing 模块会将 parent_data "pickle" 成字节流
# 然后在新的子进程中 "unpickle" 成一个新的字典对象
process = multiprocessing.Process(target=modify_data, args=(parent_data,))
process.start()
# 等待子进程执行完毕
process.join()
print(f"[父进程-PID:{
os.getpid()}] 子进程已结束。")
# 在父进程中检查原始的数据结构
print(f"[父进程-PID:{
os.getpid()}] 现在父进程中的数据是: {
parent_data}")
print(f"[父进程-PID:{
os.getpid()}] 它的内存地址仍然是: {
id(parent_data)}")
# 最终的输出将清晰地显示,父进程中的 parent_data 根本没有被子进程的任何操作所影响。
运行这段代码,你会看到,尽管子进程在内部对data_container
进行了各种修改,但当子进程结束后,父进程中的parent_data
变量没有任何变化。两个进程打印出的对象内存地址(id()
)也完全不同。这强有力地证明了进程间的内存隔离。
这个特性意味着,你不能通过简单地传递一个可变对象(如列表或字典)来让子进程将结果“放回”父进程。要实现进程间的通信和数据共享,我们必须使用multiprocessing
模块提供的专门工具,例如Queue
, Pipe
, Value
, Array
等。这正是我们下一节将要深入探讨的核心内容。
Process
类除了将函数作为target
传递,multiprocessing
还提供了另一种更具结构化和面向对象风格的方式来定义进程任务:继承multiprocessing.Process
类。
这种方式非常适合于封装更复杂的进程逻辑,特别是当进程需要维护自身的状态时。我们只需创建一个自己的类,让它继承自Process
,并重写两个关键方法:
__init__(self, ...)
: 我们的类的构造函数。我们可以在这里接收自定义的参数,并调用父类(multiprocessing.Process
)的构造函数。run(self)
: 这是Process.start()
方法内部会自动调用的方法。我们把进程需要执行的核心逻辑全部写在这里。run
方法执行完毕,子进程也就结束了。让我们将前面的文件分析器例子,改写成一个自定义进程类。
import multiprocessing
import time
import os
class FileAnalyzerProcess(multiprocessing.Process): # 定义一个类,继承自 multiprocessing.Process
"""
一个用于分析文件的自定义进程类。
它封装了所有与文件分析相关的逻辑和数据。
"""
def __init__(self, filepath, report_id): # 重写构造函数 __init__
# 必须显式调用父类的构造函数
super().__init__()
# 定义我们这个类自己的属性,用于存储状态
self.filepath = filepath # 要分析的文件路径
self.report_id = report_id # 一个自定义的报告ID
self.line_count = 0 # 用于存储分析结果
self.word_count = 0 # 用于存储分析结果
self.analysis_complete = False # 标记分析是否完成
def run(self): # 重写 run 方法,这是进程启动后要执行的核心逻辑
"""
当调用 .start() 时,这个方法会在新的子进程中被自动执行。
"""
print(f" [分析器进程-{
self.pid}] 报告ID {
self.report_id}: 开始分析 {
self.filepath}")
try:
with open(self.filepath, 'r', encoding='utf-8') as f:
lines = f.readlines()
# 将计算结果保存在类的实例属性中
self.line_count = len(lines)
self.word_count = sum(len(line.split()) for line in lines)
self.analysis_complete = True
time.sleep(2) # 模拟耗时
# 注意:这里打印的结果是子进程内部的状态
print(f" [分析器进程-{
self.pid}] 报告ID {
self.report_id}: 分析完成。")
print(f" [分析器进程-{
self.pid}] > 结果: {
self.line_count} 行, {
self.word_count} 词。")
except FileNotFoundError:
print(f" [分析器进程-{
self.pid}] 报告ID {
self.report_id}: 错误,文件未找到。")
# run方法结束,子进程的生命周期也就结束了。
if __name__ == '__main__':
print(f"[主进程-{
os.getpid()}] 程序启动。")
# 准备测试文件
dummy_filepath = "report_data.txt"
with open(dummy_filepath, "w", encoding="utf-8") as f:
f.write("Object-oriented approach to multiprocessing.\n")
f.write("Inheriting from Process class provides better structure for complex tasks.")
# 实例化我们自定义的进程类
analyzer = FileAnalyzerProcess(filepath=dummy_filepath, report_id="A-001")
print(f"[主进程-{
os.getpid()}] 准备启动文件分析器进程...")
analyzer.start() # 启动进程,这将自动调用 analyzer 对象的 run() 方法
print(f"[主进程-{
os.getpid()}] 分析器已启动,等待其完成...")
analyzer.join() # 等待子进程结束
print(f"[主进程-{
os.getpid()}] 分析器进程已结束。")
# 重要警示:你无法在父进程中直接访问子进程修改后的属性!
# analyzer 对象的属性是在父进程中初始化的。
# 子进程在自己的内存空间中拥有这个对象的一个副本。
# 子进程对 self.word_count 的修改,发生在那个副本上,父进程中的原始对象毫不知情。
print(f"[主进程-{
os.getpid()}] 尝试从父进程访问分析结果:")
print(f"[主进程-{
os.getpid()}] > 分析完成标志: {
analyzer.analysis_complete}") # 将会打印 False
print(f"[主进程-{
os.getpid()}] > 单词数: {
analyzer.word_count}") # 将会打印 0
os.remove(dummy_filepath)
这个例子再次强调了进程隔离性的后果。尽管面向对象的方式让代码结构更清晰,但父进程依然无法直接获取子进程的计算结果(即analyzer
对象在子进程中被修改后的状态)。analyzer
在父进程和子进程中是两个独立的实例。
正如前文所示,进程的核心特性是其独立的内存地址空间。这种隔离性虽然带来了稳定性和安全性,却也构建了一道难以逾越的“数据壁垒”。当多个进程需要协同完成一个复杂任务时,它们之间不可避免地需要交换信息、传递数据或同步状态。例如,一个进程负责从网络下载数据,另一个进程负责处理数据,第三个进程负责将处理结果写入数据库。它们之间若无有效的沟通机制,便无法形成一个流畅的工作流。
**进程间通信(Inter-Process Communication, IPC)**就是为了解决这个根本问题而生的。IPC是操作系统提供的一系列机制,允许不同的进程在互不干扰的前提下,安全、有序地进行数据交换。multiprocessing
模块为我们抽象了多种底层的IPC机制,使其在Python中变得易于使用。理解这些机制的适用场景、底层原理和使用限制,是构建高效、健壮多进程程序的关键。
multiprocessing.Queue
是进程间通信最常用且最灵活的工具之一。它实现了一个先进先出(FIFO)的数据结构,允许一个或多个进程向队列中放入数据,同时允许一个或多个进程从队列中取出数据。这使得队列非常适合实现**生产者-消费者(Producer-Consumer)**模式,即一部分进程负责生产数据,另一部分进程负责消费数据。
核心机制:管道 + 序列化 + 锁
multiprocessing.Queue
并非直接实现了共享内存。在大多数操作系统上,它的底层实现依赖于以下几个核心组件:
Queue
通常在内部利用了操作系统的管道(或套接字对)。管道是一种单向或双向的字节流通信通道。当一个进程调用queue.put(item)
时,item
会被序列化成字节流,然后写入管道的一端。当另一个进程调用queue.get()
时,它会从管道的另一端读取字节流,然后反序列化回原始的Python对象。Queue
会自动使用Python的pickle
模块(或更高效的dill
等)将要放入队列的对象进行序列化,将其转换为字节串。接收方则进行反序列化,重建对象。这意味着,只有**可序列化(picklable)**的Python对象才能通过Queue
传递。Queue
内部使用了锁(如threading.Lock
或multiprocessing.Lock
)来保护对队列的访问,防止竞态条件。同时,它也可能使用条件变量来实现阻塞行为,例如当队列为空时get()
操作会等待,当队列满时put()
操作会等待。Queue
的基本用法
Queue
提供了几个核心方法:
put(item, block=True, timeout=None)
:将item
放入队列。
block=True
:如果队列已满,则阻塞直到有空位。timeout
:如果阻塞,则最长等待timeout
秒。get(block=True, timeout=None)
:从队列中取出一个item
。
block=True
:如果队列为空,则阻塞直到有数据。timeout
:如果阻塞,则最长等待timeout
秒。qsize()
:返回队列中当前项的数量。注意:此方法在多进程环境中可能不准确,因为它无法原子性地获取队列的实时大小,仅作为近似参考。empty()
:如果队列为空返回True
,否则返回False
。同样,在多进程环境下可能不准确。full()
:如果队列已满返回True
,否则返回False
。同样,在多进程环境下可能不准确。close()
:关闭队列,释放相关资源。在所有消费者都已完成工作后调用。join_thread()
:等待所有通过此队列放入的数据都被取出并处理完毕。通常与TaskQueue
配合使用。实战案例:分布式文件处理管道
假设我们有一个大型日志文件,需要进行多个阶段的处理:
这是一个典型的多阶段、多进程协同工作的场景,Queue
是完美的解决方案。
import multiprocessing
import time
import os
import random
from datetime import datetime
# --- 辅助函数:模拟耗时操作 ---
def simulate_work(min_duration, max_duration):
"""模拟一个耗时操作,持续时间在 min_duration 和 max_duration 之间"""
time.sleep(random.uniform(min_duration, max_duration))
# --- 阶段1:日志读取器进程 (生产者) ---
def log_producer_worker(log_filepath, raw_log_queue, stop_event): # 接收日志文件路径、原始日志队列和停止事件
"""
负责从指定日志文件中读取每一行日志,并放入原始日志队列。
"""
process_name = multiprocessing.current_process().name # 获取当前进程的名称
print(f"[{
process_name}] 启动,准备读取日志文件: {
log_filepath}")
try:
with open(log_filepath, 'r', encoding='utf-8') as f: # 以只读模式打开日志文件
for line_num, line in enumerate(f, 1): # 逐行读取文件,并获取行号
if stop_event.is_set(): # 检查停止事件是否被设置,如果设置了就立即停止
print(f"[{
process_name}] 收到停止信号,停止读取。")
break
log_entry = line.strip() # 移除行末的空白字符
if log_entry: # 确保不是空行
raw_log_queue.put(log_entry) # 将日志条目放入队列
# simulate_work(0.001, 0.005) # 模拟读取和放入队列的微小耗时
if line_num % 100 == 0: # 每读取100行打印一次进度
print(f"[{
process_name}] 已读取并放入 {
line_num} 条日志。")
print(f"[{
process_name}] 所有日志已读取完毕并放入队列。")
except FileNotFoundError: # 捕获文件未找到的异常
print(f"[{
process_name}] 错误: 日志文件 '{
log_filepath}' 未找到。")
finally:
# 当所有数据放入队列后,放入一个特殊的“结束标记”
# 这告诉消费者:没有更多的数据了
raw_log_queue.put(None)
print(f"[{
process_name}] 已放入结束标记。")
# --- 阶段2:日志处理器进程 (消费者/生产者) ---
def log_processor_worker(raw_log_queue, processed_log_queue, worker_id): # 接收原始日志队列、处理后日志队列和工作者ID
"""
负责从原始日志队列中取出日志,进行解析,并将结构化数据放入处理后日志队列。
"""
process_name = f"LogProcessor-{
worker_id}-PID:{
os.getpid()}" # 自定义进程名称,包含ID和PID
print(f"[{
process_name}] 启动,准备处理日志。")
processed_count = 0 # 记录处理的日志数量
while True: # 循环不断地从队列中获取任务
raw_log = raw_log_queue.get() # 从原始日志队列中获取一条日志
if raw_log is None: # 如果收到结束标记 (None)
# 将结束标记再次放入队列,以便通知其他同伴消费者
raw_log_queue.put(None)
print(f"[{
process_name}] 收到结束标记,处理 {
processed_count} 条日志后退出。")
break # 退出循环,结束进程
# --- 模拟日志解析逻辑 ---
# 假设日志格式是 "Timestamp | Level | Message | IPAddress"
parts = raw_log.split(' | ') # 按分隔符拆分日志
timestamp = parts[0] if len(parts) > 0 else "N/A"
level = parts[1] if len(parts) > 1 else "INFO"
message = parts[2] if len(parts) > 2 else raw_log
ip_address = parts[3] if len(parts) > 3 else "0.0.0.0"
# 构造结构化字典
structured_log = {
"timestamp": timestamp,
"level": level,
"message": message,
"ip_address": ip_address,
"processed_by": process_name, # 记录是哪个进程处理的
"processed_at": datetime.now().isoformat() # 记录处理时间
}
simulate_work(0.01, 0.05) # 模拟解析的耗时
processed_log_queue.put(structured_log) # 将处理后的结构化日志放入处理后队列
processed_count += 1
if processed_count % 50 == 0: # 每处理50条打印一次进度
print(f"[{
process_name}] 已处理 {
processed_count} 条日志。")
# --- 阶段3:结果存储器进程 (消费者) ---
def result_sink_worker(processed_log_queue, output_filepath): # 接收处理后日志队列和输出文件路径
"""
负责从处理后日志队列中取出结构化数据,并将其写入模拟的数据库(这里是文件)。
"""
process_name = multiprocessing.current_process().name # 获取当前进程的名称
print(f"[{
process_name}] 启动,准备存储结果到: {
output_filepath}")
stored_count = 0 # 记录存储的日志数量
try:
with open(output_filepath, 'a', encoding='utf-8') as f: # 以追加模式打开输出文件
while True: # 循环不断地从队列中获取结果
structured_log = processed_log_queue.get() # 从处理后日志队列中获取一条结构化日志
if structured_log is None: # 如果收到结束标记
print(f"[{
process_name}] 收到结束标记,存储 {
stored_count} 条日志后退出。")
break # 退出循环,结束进程
# 模拟写入数据库的耗时操作
simulate_work(0.005, 0.02)
# 将结构化日志转换为字符串并写入文件
f.write(f"{
structured_log}\n")
stored_count += 1
if stored_count % 20 == 0: # 每存储20条打印一次进度
print(f"[{
process_name}] 已存储 {
stored_count} 条日志。")
except Exception as e: # 捕获其他可能的异常
print(f"[{
process_name}] 存储过程中发生错误: {
e}")
if __name__ == '__main__':
# 确保在Windows或Python 3.8+的macOS/Linux上使用'spawn'启动方法
# 这样可以避免fork带来的不兼容问题,并保证每个子进程都是干净启动。
# 这是一个最佳实践。
multiprocessing.set_start_method('spawn', force=True)
print(f"[主进程-PID:{
os.getpid()}] 系统启动。")
# --- 1. 创建共享队列 ---
# 创建一个用于存放原始日志的队列
raw_log_queue = multiprocessing.Queue()
# 创建一个用于存放处理后结构化日志的队列
processed_log_queue = multiprocessing.Queue()
# 创建一个事件来通知生产者停止读取(在某些情况下,例如程序需要提前终止)
stop_producer_event = multiprocessing.Event()
# --- 2. 准备模拟日志文件 ---
# 创建一个较大的模拟日志文件
test_log_filepath = "application_logs.log"
num_log_entries = 1000 # 生成1000条日志
print(f"[主进程] 正在生成 {
num_log_entries} 条模拟日志到 '{
test_log_filepath}'...")
with open(test_log_filepath, "w", encoding="utf-8") as f:
for i in range(num_log_entries):
log_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] # 精确到毫秒
log_level = random.choice(["INFO", "WARNING", "ERROR", "DEBUG"])
message_content = f"请求处理完成,用户ID: {
random.randint(1000, 9999)}, 页面: /api/data/{
i}"
ip = f"192.168.1.{
random.randint(1, 254)}"
f.write(f"{
log_time} | {
log_level} | {
message_content} | {
ip}\n")
print(f"[主进程] 模拟日志生成完毕。")
# 清理旧的输出文件
output_results_filepath = "processed_results.txt"
if os.path.exists(output_results_filepath):
os.remove(output_results_filepath)
print(f"[主进程] 已清除旧的输出文件: {
output_results_filepath}")
# --- 3. 启动进程 ---
print(f"[主进程] 启动日志读取器...")
# 启动一个日志生产者进程
producer_process = multiprocessing.Process(
target=log_producer_worker,
args=(test_log_filepath, raw_log_queue, stop_producer_event),
name="LogProducer" # 给进程命名
)
producer_process.start() # 启动生产者进程
num_processors = 3 # 启动3个日志处理器进程
processor_processes = []
print(f"[主进程] 启动 {
num_processors} 个日志处理器...")
for i in range(num_processors):
processor = multiprocessing.Process(
target=log_processor_worker,
args=(raw_log_queue, processed_log_queue, i + 1),
name=f"LogProcessor-{
i+1}" # 给每个处理器进程命名
)
processor_processes.append(processor)
processor.start