「Python 进阶之路」系列 Day18
写在前面
Day16、Day17 反复验证了一个结论:CPU 密集型任务用多线程没有加速效果,因为 GIL 让同一时刻只有一个线程能执行 Python 字节码。那如果真的需要用 Python 做并行计算,出路在哪?答案是multiprocessing——今天这篇讲清楚它凭什么能绕开 GIL,以及绕开之后要付出什么新的代价。
一、是什么:进程与线程的核心区别
线程共享同一个进程的内存空间(Day17 讲的锁、竞态条件都是因为这个共享),而多个进程各自拥有独立的内存空间,互不干扰。multiprocessing模块提供了和threading接口非常相似的Process类,但创建出来的是真正独立的操作系统进程,不是线程。
二、为什么能绕开 GIL:每个进程有自己独立的解释器
GIL 是单个 CPython 解释器实例内部的限制——每个进程都会启动一份独立的解释器实例,也就有一份自己独立的 GIL,进程之间的 GIL 互不影响。这就是多进程能绕开 GIL 限制、真正利用多核 CPU 并行计算的根本原因。
代价也很明确:进程间不共享内存,数据没法像多线程那样直接读写同一个变量,需要通过专门的进程间通信(IPC)机制传递;而且创建/切换进程的开销比线程大得多,因为要复制一份独立的运行环境。
三、怎么用
1. 实测:多进程真的能获得并行加速
importtimeimportmultiprocessingasmpdefcpu_task(n):count=0for_inrange(n):count+=1returncountif__name__=="__main__":N=15_000_000start=time.perf_counter()cpu_task(N);cpu_task(N)t_single=time.perf_counter()-start start=time.perf_counter()p1=mp.Process(target=cpu_task,args=(N,))p2=mp.Process(target=cpu_task,args=(N,))p1.start();p2.start()p1.join();p2.join()t_multi=time.perf_counter()-startprint(t_single,t_multi,t_multi/t_single)实测结果:单进程顺序执行两个任务耗时0.538s,两个进程并发执行耗时0.349s,比值0.65。对比 Day16 里同样的 CPU 密集型任务用多线程测出的比值(0.87~0.98,几乎没有加速),多进程的 0.65 明显好得多——虽然没有达到理论上限(两个核心完全并行应该接近 0.5),因为进程创建本身有一定开销,但确实拿到了真实的并行计算收益。
2. 进程间不共享内存
x=1defmodify_var(_):globalx x=999print(f"子进程里 x 被改成了:{x}")if__name__=="__main__":print("主进程修改前 x =",x)p=mp.Process(target=modify_var,args=(None,))p.start()p.join()print("主进程里 x 还是:",x)实测输出:子进程里确实把x改成了999,但主进程里的x完全没有受到影响,还是原来的1。这是因为子进程拿到的是主进程内存的一份独立拷贝,子进程对这份拷贝的任何修改都不会反映回主进程——这一点和多线程完全不同,多线程共享同一份内存,一个线程改了变量,其他线程立刻能看到。
3. 进程间通信:Queue
既然不能直接共享变量,进程之间要传递数据需要用专门的 IPC 机制,multiprocessing.Queue是最常用的一种:
defproducer(q):foriinrange(5):q.put(i*i)if__name__=="__main__":q=mp.Queue()p=mp.Process(target=producer,args=(q,))p.start()p.join()results=[]whilenotq.empty():results.append(q.get())print(results)# [0, 1, 4, 9, 16]multiprocessing.Queue和queue.Queue(Day17 提到的线程安全队列)接口相似,但底层实现完全不同——它能跨进程边界传递数据,本质是通过操作系统提供的管道/共享内存机制配合序列化(pickle)实现的。
4. Pool 进程池与共享数据 Value
批量处理任务时,用进程池比手动管理一堆Process对象更方便:
defcpu_square(n):returnn*nif__name__=="__main__":withmp.Pool(processes=4)aspool:results=pool.map(cpu_square,range(10))print(results)# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]如果确实需要在进程间共享一份简单的数据(不是完整的对象),可以用multiprocessing.Value(或Array),它底层用共享内存实现,同样需要配合锁保证操作安全:
defincrement(val,times):for_inrange(times):withval.get_lock():# 依然需要加锁,多进程访问共享数据同样有竞态问题val.value+=1if__name__=="__main__":shared_val=mp.Value('i',0)procs=[mp.Process(target=increment,args=(shared_val,10000))for_inrange(4)]forpinprocs:p.start()forpinprocs:p.join()print(shared_val.value)# 40000,和期望值一致(因为加了锁)5. 容易踩的坑:ifname== “main” 保护
multiprocessing代码里几乎总要写if __name__ == "__main__":把创建进程的逻辑包起来,不写会直接报错:
# 没有保护的脚本importmultiprocessingasmpdefworker():print("子进程在运行")p=mp.Process(target=worker)p.start()p.join()实际运行会直接报错:
RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase. ... if __name__ == '__main__': freeze_support() ...原因:macOS/Windows 默认用spawn方式创建子进程——子进程会重新启动一个全新的 Python 解释器,并重新import一遍主模块的代码来"恢复"运行环境。如果创建进程的代码没有被if __name__ == "__main__":保护起来,子进程重新执行这个模块时会再次跑到p.start()这一行,尝试再创建一个孙子进程——理论上会无限递归下去,Python 检测到这种"还没完成初始化就试图开子进程"的情况,直接主动抛出RuntimeError来阻止这个问题,而不是真的死循环创建进程。
四、面试追问
Q1:multiprocessing怎么绕开 GIL?
GIL 是单个 CPython 解释器实例内部的限制,每个进程都会启动一份独立的解释器实例,也就有一份自己独立的 GIL,进程之间的 GIL 互不影响。因此多个进程可以真正同时执行 Python 字节码,利用多核 CPU 做并行计算,而不受单个 GIL 的串行化限制。
Q2:进程和线程的核心区别是什么?
线程共享同一个进程的内存空间,Day17 讲的锁、竞态条件都源于这种共享;多个进程各自拥有独立的内存空间,互不干扰,一个进程修改的数据不会影响到另一个进程,数据需要通过专门的进程间通信机制传递。
Q3:多进程之间怎么共享数据?
常见方式有:multiprocessing.Queue,一个跨进程的队列,适合传递一系列数据(生产者-消费者模式);multiprocessing.Value/Array,基于共享内存实现的简单数据共享,操作时同样需要配合锁来保证多进程访问时不出现竞态问题。
Q4:为什么multiprocessing代码里要用if __name__ == "__main__":保护?
macOS/Windows 默认用spawn方式创建子进程,子进程会重新启动一个全新的解释器并重新import主模块来恢复运行环境。如果创建进程的代码没有这层保护,子进程重新执行模块时会再次触发创建进程的逻辑,导致递归创建进程,Python 会检测到这种情况并直接抛出RuntimeError来阻止它,而不是真的无限递归下去。
Q5:什么场景选多进程,什么场景选多线程?
CPU 密集型的纯 Python 计算任务应该选多进程,能绕开 GIL、真正利用多核 CPU;I/O 密集型任务(网络请求、文件读写、数据库查询)更适合选多线程,因为创建/切换线程的开销比进程小得多,而且 I/O 等待期间会释放 GIL(Day16 讲过),没必要为 I/O 密集型任务承担多进程带来的额外开销和进程间通信的复杂度。
下一篇预告
Day19 讲 asyncio 协程入门——event loop、async/await到底是怎么运作的,这是应对大量 I/O 密集型任务时,比多线程更轻量的另一种并发方案。