Python 多进程:让程序拥有"分身术"
引言:餐厅后厨的启示
一家餐厅只有一个厨师,客人点菜要排队——点完一道等一道,效率极低。
老板想了个办法:多雇几个厨师,同时开工。每个厨师独立做自己的菜,互不干扰,上菜速度翻倍。
Python 的**多进程(Multiprocessing)**就是给程序"雇多个厨师"——一个进程忙不过来,就复制几个"分身",同时处理多个任务。
这一篇,我们从操作系统的 fork() 讲起,到跨平台的 multiprocessing 模块,再到进程池、外部进程调用和进程间通信,完整掌握 Python 多进程编程。
一、fork():操作系统的"影分身之术"
1.1 fork() 是什么?
Unix/Linux 系统提供了一个神奇的系统调用 fork():
- 普通函数调用一次,返回一次;
fork()调用一次,返回两次!
为什么?因为操作系统把当前进程(父进程)完整复制了一份(子进程),然后分别在两个进程里返回。
1.2 fork() 的返回值规则
| 进程 | 返回值 | 含义 |
|---|---|---|
| 子进程 | 0 | "我是刚被复制出来的" |
| 父进程 | 子进程的 PID | "我创建了一个子进程,ID 是 xxx" |
一个父进程可以 fork 出很多子进程,所以父进程要记下每个子进程的 ID;子进程只需要调用 getppid() 就能拿到父进程 ID。
1.3 Python 中使用 fork()
import os
print('Process (%s) start...' % os.getpid())
# 只在 Unix/Linux/macOS 有效:
pid = os.fork()
if pid == 0:
print('I am child process (%s) and my parent is %s.' % (os.getpid(), os.getppid()))
else:
print('I (%s) just created a child process (%s).' % (os.getpid(), pid))运行结果:
Process (876) start...
I (876) just created a child process (877).
I am child process (877) and my parent is 876.生活化理解:fork() 像影分身之术——本体(父进程)喊一声"变",就分出一个和自己一模一样的分身(子进程)。分身说"我是 0 号分身",本体说"我变出了 877 号分身"。
1.4 fork() 的实际应用
经典场景:Apache 服务器
- 父进程监听 80 端口;
- 每来一个 HTTP 请求,
fork()一个子进程去处理; - 子进程处理完就退出,父进程继续监听。
注意:Windows 没有
fork()!上面的代码在 Windows 上无法运行。Mac 基于 Unix 内核,可以正常运行。
二、multiprocessing:跨平台的多进程方案
2.1 为什么需要 multiprocessing?
Windows 没有 fork(),难道 Windows 用户就不能写多进程?
Python 是跨平台的,所以提供了 multiprocessing 模块——在 Unix/Linux 下封装 fork(),在 Windows 下模拟出类似效果,一套代码到处运行。
2.2 Process 类:创建子进程的标准姿势
from multiprocessing import Process
import os
def run_proc(name):
print('Run child process %s (%s)...' % (name, os.getpid()))
if __name__ == '__main__':
print('Parent process %s.' % os.getpid())
p = Process(target=run_proc, args=('test',))
print('Child process will start.')
p.start()
p.join()
print('Child process end.')运行结果:
Parent process 928.
Child process will start.
Run child process test (929)...
Child process end.2.3 关键步骤拆解
| 步骤 | 代码 | 说明 |
|---|---|---|
| 1. 创建进程对象 | p = Process(target=函数, args=(参数,)) | 告诉它"要干什么" |
| 2. 启动进程 | p.start() | 真正开始运行 |
| 3. 等待结束 | p.join() | 主进程等子进程干完活 |
join() 的作用:进程间同步——主进程"卡住",等子进程结束后再继续。
生活化理解:Process 像雇临时工——你先登记(Process(...)),然后喊开工(start()),最后等他把活干完再结账(join())。
2.4 为什么要把代码包在 if __name__ == '__main__': 里?
Windows 没有 fork(),multiprocessing 在 Windows 下是重新导入主模块来模拟子进程。如果不加 if __name__ == '__main__':,子进程会无限递归创建新进程,直到系统崩溃。
铁律:Windows 下使用 multiprocessing,必须加 if __name__ == '__main__': 保护。
三、Pool:进程池,批量管理子进程
3.1 为什么要用进程池?
要启动 100 个子进程,难道写 100 个 Process + start + join?
进程池(Pool)帮你批量管理:指定池子大小,任务丢进去,自动调度。
3.2 基本用法
from multiprocessing import Pool
import os, time, random
def long_time_task(name):
print('Run task %s (%s)...' % (name, os.getpid()))
start = time.time()
time.sleep(random.random() * 3)
end = time.time()
print('Task %s runs %0.2f seconds.' % (name, (end - start)))
if __name__ == '__main__':
print('Parent process %s.' % os.getpid())
p = Pool(4) # 池子最多同时跑 4 个进程
for i in range(5):
p.apply_async(long_time_task, args=(i,))
print('Waiting for all subprocesses done...')
p.close() # 关闭池子,不再接受新任务
p.join() # 等所有子进程结束
print('All subprocesses done.')3.3 关键细节
close() 和 join() 的顺序
p.close() # 先关门:不再接受新任务
p.join() # 再等待:等里面的人干完活顺序不能反——先 join 会永远等下去(因为池子还开着,随时可能有新任务)。
池子大小的限制
p = Pool(4) # 最多同时 4 个进程5 个任务丢进去,前 4 个立刻执行,第 5 个排队等前面某个完成后才执行。
Pool 的默认大小是 CPU 核数。8 核 CPU 默认就是 8,要看到排队效果得提交 9 个以上任务。
生活化理解:Pool 像餐厅只有 4 口锅——5 道菜同时点,前 4 道同时下锅,第 5 道等某口锅空出来才能做。
四、subprocess:启动外部程序
4.1 什么时候用 subprocess?
multiprocessing 启动的是Python 函数;subprocess 启动的是外部命令/程序——比如 nslookup、ping、其他 Python 脚本、甚至另一个可执行文件。
4.2 最简单的调用:subprocess.call()
import subprocess
print('$ nslookup www.python.org')
r = subprocess.call(['nslookup', 'www.python.org'])
print('Exit code:', r)效果等同于在命令行直接输入 nslookup www.python.org。
4.3 需要输入输出的场景:Popen + communicate()
import subprocess
print('$ nslookup')
p = subprocess.Popen(
['nslookup'],
stdin=subprocess.PIPE, # 我们要给它输入
stdout=subprocess.PIPE, # 我们要读它的输出
stderr=subprocess.PIPE
)
# 模拟手动输入三行命令
output, err = p.communicate(b'set q=mx\npython.org\nexit\n')
print(output.decode('utf-8'))
print('Exit code:', p.returncode)相当于在命令行执行:
nslookup
set q=mx
python.org
exit生活化理解:call() 像打电话说一句话就挂;Popen 像保持通话,可以一来一回地对话。
五、进程间通信:Queue
5.1 为什么进程间要通信?
进程是独立内存空间——你的数据我看不到,我的数据你拿不到。但很多时候需要协作:一个进程生产数据,另一个进程消费数据。
Python 提供了 Queue、Pipe 等方式,最常用的是 Queue。
5.2 生产者-消费者模型
from multiprocessing import Process, Queue
import os, time, random
def write(q):
print('Process to write: %s' % os.getpid())
for value in ['A', 'B', 'C']:
print('Put %s to queue...' % value)
q.put(value)
time.sleep(random.random())
def read(q):
print('Process to read: %s' % os.getpid())
while True:
value = q.get(True) # 阻塞等待,直到有数据
print('Get %s from queue.' % value)
if __name__ == '__main__':
q = Queue()
pw = Process(target=write, args=(q,))
pr = Process(target=read, args=(q,))
pw.start()
pr.start()
pw.join() # 等写进程结束
pr.terminate() # 读进程是死循环,强制终止运行结果:
Process to write: 50563
Put A to queue...
Process to read: 50564
Get A from queue.
Put B to queue...
Get B from queue.
Put C to queue...
Get C from queue.5.3 Queue 的特点
- 线程/进程安全:多个进程同时读写不会乱;
- 阻塞式:
get()没数据时会"卡住"等数据来; - FIFO:先进先出,像排队打饭。
生活化理解:Queue 像传菜窗口——厨师(写进程)把菜放窗口,服务员(读进程)从窗口取菜。窗口有缓冲,厨师不用等服务员来了才放,服务员没菜时就在窗口等着。
六、常见误区与避坑指南
6.1 误区一:Windows 下忘记 if __name__ == '__main__'
# ❌ 危险!Windows 下会无限创建进程
from multiprocessing import Process
def worker():
print('working')
p = Process(target=worker)
p.start()Windows 下 multiprocessing 会重新导入模块,没有 if __name__ == '__main__': 保护就会递归崩溃。
6.2 误区二:以为进程间共享变量
from multiprocessing import Process
n = 0
def worker():
global n
n = 100
p = Process(target=worker)
p.start()
p.join()
print(n) # 0!不是 100进程有独立内存空间,子进程改的是自己的副本,父进程看不到。
解决:用 Queue、Pipe、Value、Array 等进程间通信工具。
6.3 误区三:Pool 的 close() 和 join() 顺序搞反
# ❌ 错误:先 join 会永远等待
p.join()
p.close()
# ✅ 正确:先关门,再等待
p.close()
p.join()6.4 误区四:子进程里做耗时初始化,拖慢整体速度
# ❌ 每个子进程都重新加载大模型
def worker(x):
model = load_huge_model() # 每次都要 10 秒
return model.predict(x)优化:用 Pool 的 initializer 参数,让每个子进程启动时只加载一次。
七、实战练习
练习 1:并行计算平方
用 Pool 并行计算 0~9 的平方,收集结果并打印。
参考答案
from multiprocessing import Pool
def square(x):
return x * x
if __name__ == '__main__':
with Pool(4) as p:
results = p.map(square, range(10))
print(results) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]p.map() 自动分配任务并收集结果,比 apply_async 更简洁。
练习 2:生产者-消费者下载器
一个进程不断生成 URL 放入 Queue,另一个进程从 Queue 取出并"下载"(打印模拟)。
参考答案
from multiprocessing import Process, Queue
import time
def producer(q):
for i in range(5):
url = f'http://example.com/page{i}'
print(f'生产:{url}')
q.put(url)
time.sleep(0.5)
def consumer(q):
while True:
url = q.get()
print(f'下载:{url}')
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()
p2.terminate()八、小结
fork()是 Unix/Linux 的进程复制机制,一次调用两次返回,Windows 不支持;multiprocessing.Process是跨平台方案,start()启动、join()等待;Pool批量管理进程,close()后join(),默认大小为 CPU 核数;subprocess启动外部程序,call()简单调用,Popen+communicate()交互式输入输出;Queue进程间通信,生产者-消费者模型的经典工具;- 避坑要点:Windows 必须加
__main__保护、进程间不共享变量、Pool 先 close 后 join。
多进程是 Python 突破 GIL 限制、利用多核 CPU 的关键武器。下一篇我们将学习多线程,对比两者的适用场景。