三、多进程基础

并发编程里,多进程是个绕不开的话题。说白了,很多刚入行的朋友会把进程和线程搞混,以为是一回事。嗯,我刚开始接触并发时也踩过这个坑。今天我们就把它聊透。

进程与线程的差异

先问一个问题:进程和线程,到底是什么关系?

我个人习惯这样理解:进程是资源分配的最小单位,线程是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(),再在子进程里重新建立连接。

最后,送一张我总结的多进程核心知识结构图,帮你一眼看透这章讲了什么。

多进程核心知识结构 多进程基础 进程 vs 线程 fork() & exec() multiprocessing模块 独立内存 共享内存 写时复制 exec覆盖 Pool Queue 核心:进程隔离 → 安全但开销大;线程共享 → 快速但需同步 Python中多进程绕开GIL,适合CPU密集;多线程适合I/O密集 IPC:Queue / Pipe / Manager / Array / Value fork后用exec启动新程序,是Linux经典进程创建方式

嗯,多进程这块内容到这就差不多了。基本概念打扎实,接下来的并发模式才能讲得动。