Skip to content

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()

python
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 类:创建子进程的标准姿势

python
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 基本用法

python
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() 的顺序

python
p.close()    # 先关门:不再接受新任务
p.join()     # 再等待:等里面的人干完活

顺序不能反——先 join 会永远等下去(因为池子还开着,随时可能有新任务)。

池子大小的限制

python
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 启动的是外部命令/程序——比如 nslookupping、其他 Python 脚本、甚至另一个可执行文件。

4.2 最简单的调用:subprocess.call()

python
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()

python
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)

相当于在命令行执行:

bash
nslookup
set q=mx
python.org
exit

生活化理解call() 像打电话说一句话就挂;Popen 像保持通话,可以一来一回地对话。


五、进程间通信:Queue

5.1 为什么进程间要通信?

进程是独立内存空间——你的数据我看不到,我的数据你拿不到。但很多时候需要协作:一个进程生产数据,另一个进程消费数据。

Python 提供了 QueuePipe 等方式,最常用的是 Queue

5.2 生产者-消费者模型

python
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__'

python
# ❌ 危险!Windows 下会无限创建进程
from multiprocessing import Process

def worker():
    print('working')

p = Process(target=worker)
p.start()

Windows 下 multiprocessing 会重新导入模块,没有 if __name__ == '__main__': 保护就会递归崩溃。

6.2 误区二:以为进程间共享变量

python
from multiprocessing import Process

n = 0

def worker():
    global n
    n = 100

p = Process(target=worker)
p.start()
p.join()
print(n)   # 0!不是 100

进程有独立内存空间,子进程改的是自己的副本,父进程看不到。

解决:用 QueuePipeValueArray 等进程间通信工具。

6.3 误区三:Pool 的 close() 和 join() 顺序搞反

python
# ❌ 错误:先 join 会永远等待
p.join()
p.close()

# ✅ 正确:先关门,再等待
p.close()
p.join()

6.4 误区四:子进程里做耗时初始化,拖慢整体速度

python
# ❌ 每个子进程都重新加载大模型
def worker(x):
    model = load_huge_model()   # 每次都要 10 秒
    return model.predict(x)

优化:用 Poolinitializer 参数,让每个子进程启动时只加载一次。


七、实战练习

练习 1:并行计算平方

Pool 并行计算 0~9 的平方,收集结果并打印。

参考答案
python
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 取出并"下载"(打印模拟)。

参考答案
python
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()

八、小结

  1. fork() 是 Unix/Linux 的进程复制机制,一次调用两次返回,Windows 不支持;
  2. multiprocessing.Process 是跨平台方案,start() 启动、join() 等待;
  3. Pool 批量管理进程,close()join(),默认大小为 CPU 核数;
  4. subprocess 启动外部程序,call() 简单调用,Popen + communicate() 交互式输入输出;
  5. Queue 进程间通信,生产者-消费者模型的经典工具;
  6. 避坑要点:Windows 必须加 __main__ 保护、进程间不共享变量、Pool 先 close 后 join。

多进程是 Python 突破 GIL 限制、利用多核 CPU 的关键武器。下一篇我们将学习多线程,对比两者的适用场景。