资讯详情

资讯详情

实例详解Python的进程,线程和协程

前言「进程、线程、协程」这三个词经常被放在一起讲但很多文章只讲怎么用不讲它们为什么长成现在这样。这篇不走「API 大全」路线而是拆开三者的机制进程的内存隔离、线程的共享内存与 GIL、协程在单线程里的协作式切换。先把一个常见误解说清楚它们不是「一代比一代先进」的替代关系。进程最重、隔离最彻底线程共享内存但有 GIL 限制协程最轻但只在 I/O 密集场景有意义。选哪个取决于你的任务卡在哪里。本文只讨论 CPython。文中「开销」「隔离」这些说法指的是机制层面的成因不会给出任何具体倍速数字——那需要读者在自己的机器上测量而结果随任务、版本、硬件差异极大写死一个数字没有意义。一、进程内存隔离与它的代价进程是操作系统分配资源的基本单位。每个进程有独立的虚拟地址空间一个进程里的全局变量、堆上的对象另一个进程默认看不到。这个隔离带来两个后果好处一个进程崩溃不会直接拖垮另一个数据不会被另一个进程悄悄改掉能真正用上多核——多个进程可以同时在不同 CPU 核心上执行 Python 字节码因为 GIL 是「每个解释器一个」进程之间各管各的。所以 CPU 密集任务要靠multiprocessing。代价进程之间不能直接访问对方的对象必须通过进程间通信IPC传数据而 IPC 通常意味着序列化与反序列化。CPython 的multiprocessing默认用pickle来搬运参数和返回值这就引出了它最容易踩的坑。# 适用于 Python 3.8Windows 下必须放在 if __name__ 保护内调用from multiprocessing import Pooldef square(x):return x * xif __name__ __main__: # Windows 用 spawn 启动子进程这行是必须的with Pool(4) as pool:print(pool.map(square, [1, 2, 3, 4]))这里有两道硬门槛Windows 用 spawn。Linux 上fork可以复制父进程内存Windows 没有fork只能新起一个解释器再重新导入模块、把要调用的函数和参数序列化过去。因此必须有if __name__ __main__:保护否则子进程导入主模块时会递归地再创建子进程直接炸掉。参数和返回值必须可 pickle。lambda、嵌套函数、打开的 socket、数据库连接、文件句柄都不能直接传。想传这些得改设计——比如传一个「可重建的配置」在子进程里自己建连接。「开销从哪来」的答案就在这两点进程创建本身要分配地址空间再加上每次调用都要带着数据走一趟 pickle。所以任务粒度太细时多进程反而更慢——序列化的成本盖过了并行的收益。二、线程共享内存与 GIL线程是 CPU 调度的基本单位同一进程内的多个线程共享同一份内存全局变量、堆对象、打开的文件都对彼此可见。这让线程间通信几乎零成本——不需要序列化直接读写同一个对象就行。但「共享」也正是危险的来源。多个线程同时改同一个变量中间可能被打断结果就错了。这就是竞态条件race condition。再说 GIL全局解释器锁。它是CPython 的实现细节不是 Python 语言的规定。官方词汇表的定义是GIL 是 CPython 用来保证同一时刻只有一个线程执行 Python 字节码的机制它简化了实现让dict等内置类型对并发访问隐式安全。由此得到两条必须记准的结论CPU 密集型任务多线程基本没有加速效果——因为任意时刻只有一个线程在跑字节码其他线程在排队等 GIL。想用多核并行要用multiprocessing或ProcessPoolExecutor。I/O 密集型任务多线程有效——线程在等待网络、磁盘时会释放 GIL别的线程就能趁这段时间跑起来。# 适用于 Python 3.8import threadingcounter 0lock threading.Lock()def worker():global counterfor _ in range(100000):with lock: # 没有这把锁 不是原子操作结果会偏小counter 1注意counter 1看着像一步实际包含「读—加—写」三步线程可能在中间被切换所以必须加锁。关于「Python 有没有去掉 GIL」CPython 3.13 起提供了实验性的自由线程free-threadedno-GIL构建需要专门安装、不是默认构建。而且去掉 GIL 并不等于自动变快——单线程性能可能变慢还需要代码自己处理好并发安全。所以不要说「Python 已经没有 GIL 了」主流发行版默认构建至今仍然有 GIL。三、协程单线程内的协作式切换协程coroutine跑在一个线程里由事件循环调度。它的切换是协作式的协程必须主动在await处让出控制权事件循环才可能去跑别的协程。没人能强行打断一个协程除非它自己 await 了。这带来两个直接后果它为什么轻没有线程创建/销毁的成本没有操作系统上下文切换的成本一个进程里可以同时「挂起」成千上万个协程——因为它们本质上只是内存里的对象不像线程那样每个都要有独立的栈和内核调度实体。它的边界在哪单个协程一旦执行了一段不含await的耗时计算整个事件循环就停住了其他协程全部等它。所以协程只对 I/O 密集有意义CPU 密集放在协程里只会把循环堵死。# 适用于 Python 3.8import asyncioasync def fetch(name, delay):print(f{name} 开始)await asyncio.sleep(delay) # 在这里让位别的协程可以跑print(f{name} 结束)return nameasync def main():# 三个协程几乎同时开始总耗时约等于最长的那个 delay而不是三者相加await asyncio.gather(fetch(A, 1),fetch(B, 1),fetch(C, 1),)asyncio.run(main())四、三者机制对照维度进程线程协程调度者操作系统操作系统事件循环用户态内存各自独立共享共享同一线程切换方式抢占式抢占式协作式靠 await切换开销大中小并行能力真并行可用多核受 GIL 限制无单线程数据传递需 IPC / 序列化直接读写共享对象直接读写无锁主要开销来源创建 pickle 搬运上下文切换 锁争用单个协程阻塞会让位失败适用场景CPU 密集I/O 密集高并发 I/O一句话概括三者的「开销从哪来」进程的开销主要在数据的序列化与复制以及独立的地址空间线程的开销主要在操作系统上下文切换和为了共享内存不得不加的锁协程的开销主要是内存里多存一个协程对象代价小但要求你严格避免阻塞调用。常见坑点以为多线程能让 CPU 密集任务变快❌ThreadPoolExecutor跑大量纯计算还期待用满多核✅ CPU 密集改用multiprocessing/ProcessPoolExecutorWindows 上多进程忘了if __name__ __main__:❌ 直接在主模块顶层写Pool(...)子进程导入时无限递归创建✅ 把所有多进程启动代码放进if __name__ __main__:保护块给子进程传了不可 pickle 的对象❌ 把 lambda、数据库连接、打开的文件当参数传给Pool.apply_async✅ 传可重建的配置在子进程内部创建连接在协程里跑同步阻塞代码❌async def f(): requests.get(url)✅ 用异步 HTTP 客户端或await asyncio.to_thread(...)3.9线程里无锁修改共享变量❌ 两个线程都对全局counter 1✅ 用threading.Lock保护临界区把「协程比线程快」当普遍规律❌ 用协程去跑 CPU 密集计算✅ 认清协程只在 I/O 等待时才有优势认为自由线程构建已经默认生效❌ 说「Python 3.13 已经没有 GIL 了」✅ 自由线程是 3.13 起的实验性非默认构建默认构建仍有 GIL总结你的瓶颈是该用别用CPU 计算多进程 / 进程池多线程、协程网络、磁盘 I/O多线程或协程串行同步调用超高并发连接协程一连接一线程选型的第一步永远是先判断任务卡在哪卡在 CPU就上进程卡在等待才轮到线程或协程。把 GIL 的作用记准、把进程的 spawn 与 pickle 约束记准剩下的就是看场景做取舍。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →