三、多进程基础
并发编程里,多进程是个绕不开的话题。说白了,很多刚入行的朋友会把进程和线程搞混,以为是一回事。嗯,我刚开始接触并发时也踩过这个坑。今天我们就把它聊透。
进程与线程的差异
先问一个问题:进程和线程,到底是什么关系?
我个人习惯这样理解:进程是资源分配的最小单位,线程是CPU调度的最小单位。你想象一下,进程就像一间独立的工厂车间,有自己的设备、原材料、工人;线程就是车间里的工人,共享车间里的所有资源。一个车间可以有多个工人同时干活,这就是多线程。但每个车间之间是隔离的,工人不能随便跑到隔壁车间拿东西。
| 维度 | 进程 | 线程 |
|---|---|---|
| 资源开销 | 大(独立内存空间) | 小(共享内存) |
| 通信方式 | IPC(管道、队列、共享内存) | 直接读写共享变量 |
| 创建速度 | 慢 | 快 |
| 稳定性 | 一个进程崩溃不影响其他 | 一个线程崩溃可能拖垮整个进程 |
| CPU利用 | 可充分利用多核 | 受GIL限制(Python中) |
核心要点:多进程更适合CPU密集型任务,多线程更适合I/O密集型任务。我在项目中遇到过的情况是:做CPU绑定的计算时用多进程,速度直接翻倍;但换到网络请求这种场景,多线程反而更省资源。
fork与exec系统调用
Linux里创建进程的经典方式是什么?fork() 和 exec()。
fork() 的作用,你想想看,就是“分身”。当前进程调用 fork(),内核会复制一份几乎一模一样的进程出来。复制内容包括:进程控制块、内存页表、文件描述符表、信号处理函数……嗯,几乎是全部的上下文。
区别在哪里?fork() 返回值不同。父进程拿到的是子进程的PID,子进程拿到的是0。
避坑提醒:我曾经在写监控程序时,忘检查 fork() 后的返回值,结果父进程和子进程都在做同样的日志收集工作,线上瞬间产生大量重复数据。
而 exec() 家族的作用是“换芯”。execlp、execvp 这些函数会用新程序覆盖当前进程的代码段、数据段和堆栈。进程PID不变,但跑的东西完全变了。
举个我常用的组合拳:先 fork() 创建一个子进程,然后在子进程里调用 exec() 启动新程序。这样父进程依然活着,子进程变成另一个独立的程序。
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
char *args[] = {"/bin/ls", "-l", NULL};
execvp(args[0], args);
// 如果exec成功,这里永远不会被执行
perror("exec failed");
return 1;
} else if (pid > 0) {
// 父进程,等待子进程完成
wait(NULL);
printf("子进程已结束\n");
} else {
perror("fork failed");
return 1;
}
return 0;
}
注意:fork() 在父进程调用一次,返回两次。忘记处理返回值,会导致逻辑混乱。另外,现代Linux使用写时复制(Copy-on-Write)技术,fork() 时不会真的拷贝整个内存空间,只有写入时才复制。所以,别再担心 fork() 太慢了。
Python multiprocessing深度实践
Python 的多进程,我习惯用 multiprocessing 模块。相比 threading,它是真正的并行——能绕开GIL的限制。每个进程绑一个Python解释器,各自独立调度的。
核心组件一览
- Process:创建单个进程,类似
threading.Thread - Pool:进程池,适合固定数量的Worker并行执行
- Queue:进程安全队列,用于IPC
- Pipe:进程间管道,双工通信
- Lock / Semaphore / Event:同步原语,跟线程版一致
- Manager:共享状态代理,支持跨进程的列表、字典等
一个典型的Pool使用例子
from multiprocessing import Pool
import time
def cpu_intensive(n):
"""模拟CPU密集型计算"""
return sum(i * i for i in range(n))
if __name__ == '__main__':
numbers = [10_000_000] * 4
# 逐次计算
start = time.time()
results = [cpu_intensive(n) for n in numbers]
print(f"单进程耗时: {time.time() - start:.2f}s")
# 用4个进程并行
start = time.time()
with Pool(processes=4) as pool:
results = pool.map(cpu_intensive, numbers)
print(f"多进程耗时: {time.time() - start:.2f}s")
我在项目中实测过,Pool 的 map 比单进程快3.8倍左右。当然,前提是任务真的CPU密集,没有I/O等待。
进程间通信:Queue的安全之道
多进程不能直接用全局变量。你修改一个全局列表,子进程看到的是另一个副本。怎么办呢?
from multiprocessing import Process, Queue
def worker(q, name):
q.put(f"{name}:任务完成")
if __name__ == '__main__':
q = Queue()
procs = []
for i in range(3):
p = Process(target=worker, args=(q, f"Worker-{i}"))
procs.append(p)
p.start()
for p in procs:
p.join()
while not q.empty():
print(q.get())
Queue内部做了加锁和序列化,保证数据一致。个人建议:能用Queue就别用共享内存,调试和维护成本低很多。
实用技巧:如果进程间需要传递大量数据,试试 multiprocessing.Array 或 multiprocessing.Value。它们基于共享内存,性能优异,但要注意同步——加个Lock是标配。
进程池的异常处理
我踩过最深的坑是:Pool里某个Worker抛异常,map() 不会马上报错,而是默默地吞噬掉异常。你得等到所有任务完成,或者设置 maxtasksperchild 来保证每个进程干净退出。
with Pool(processes=4, maxtasksperchild=1) as pool:
# 每个进程只处理1个任务,防止异常累积
try:
results = pool.map(my_func, tasks)
except Exception as e:
print(f"Pool出错: {e}")
坑注意:fork() 后不要在父进程和子进程同时操作相同数据库连接或文件句柄——它们共享文件描述符表,容易导致数据错乱。我的习惯是:先 fork(),再在子进程里重新建立连接。
最后,送一张我总结的多进程核心知识结构图,帮你一眼看透这章讲了什么。
嗯,多进程这块内容到这就差不多了。基本概念打扎实,接下来的并发模式才能讲得动。