
「Python 进阶之路」系列 Day18写在前面Day16、Day17 反复验证了一个结论CPU 密集型任务用多线程没有加速效果因为 GIL 让同一时刻只有一个线程能执行 Python 字节码。那如果真的需要用 Python 做并行计算出路在哪答案是multiprocessing——今天这篇讲清楚它凭什么能绕开 GIL以及绕开之后要付出什么新的代价。一、是什么进程与线程的核心区别线程模型单个进程共享的内存空间线程1线程2进程模型进程1独立内存空间进程2独立内存空间线程共享同一个进程的内存空间Day17 讲的锁、竞态条件都是因为这个共享而多个进程各自拥有独立的内存空间互不干扰。multiprocessing模块提供了和threading接口非常相似的Process类但创建出来的是真正独立的操作系统进程不是线程。二、为什么能绕开 GIL每个进程有自己独立的解释器GIL 是单个 CPython 解释器实例内部的限制——每个进程都会启动一份独立的解释器实例也就有一份自己独立的 GIL进程之间的 GIL 互不影响。这就是多进程能绕开 GIL 限制、真正利用多核 CPU 并行计算的根本原因。代价也很明确进程间不共享内存数据没法像多线程那样直接读写同一个变量需要通过专门的进程间通信IPC机制传递而且创建/切换进程的开销比线程大得多因为要复制一份独立的运行环境。三、怎么用1. 实测多进程真的能获得并行加速importtimeimportmultiprocessingasmpdefcpu_task(n):count0for_inrange(n):count1returncountif__name____main__:N15_000_000starttime.perf_counter()cpu_task(N);cpu_task(N)t_singletime.perf_counter()-start starttime.perf_counter()p1mp.Process(targetcpu_task,args(N,))p2mp.Process(targetcpu_task,args(N,))p1.start();p2.start()p1.join();p2.join()t_multitime.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. 进程间不共享内存x1defmodify_var(_):globalx x999print(f子进程里 x 被改成了:{x})if__name____main__:print(主进程修改前 x ,x)pmp.Process(targetmodify_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__:qmp.Queue()pmp.Process(targetproducer,args(q,))p.start()p.join()results[]whilenotq.empty():results.append(q.get())print(results)# [0, 1, 4, 9, 16]multiprocessing.Queue和queue.QueueDay17 提到的线程安全队列接口相似但底层实现完全不同——它能跨进程边界传递数据本质是通过操作系统提供的管道/共享内存机制配合序列化pickle实现的。4. Pool 进程池与共享数据 Value批量处理任务时用进程池比手动管理一堆Process对象更方便defcpu_square(n):returnn*nif__name____main__:withmp.Pool(processes4)aspool:resultspool.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.value1if__name____main__:shared_valmp.Value(i,0)procs[mp.Process(targetincrement,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(子进程在运行)pmp.Process(targetworker)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来阻止这个问题而不是真的死循环创建进程。四、面试追问Q1multiprocessing怎么绕开 GILGIL 是单个 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、真正利用多核 CPUI/O 密集型任务网络请求、文件读写、数据库查询更适合选多线程因为创建/切换线程的开销比进程小得多而且 I/O 等待期间会释放 GILDay16 讲过没必要为 I/O 密集型任务承担多进程带来的额外开销和进程间通信的复杂度。下一篇预告Day19 讲 asyncio 协程入门——event loop、async/await到底是怎么运作的这是应对大量 I/O 密集型任务时比多线程更轻量的另一种并发方案。