
前言Python 提供了不止一种并发模型这让初学者很困惑既然有线程为什么还要协程既然有多进程为什么还要线程池答案是它们解决的问题不一样。并发模型的选择取决于任务是在等还是在算以及你要管理的任务量有多大。先立三条本文反复要用的判断。第一GIL全局解释器锁是 CPython 的实现细节它让同一进程内同一时刻只有一个线程执行 Python 字节码所以多线程对 CPU 密集任务基本没有加速对 I/O 密集任务有效等待时会释放 GIL。第二CPU 密集要并行就用multiprocessing或concurrent.futures.ProcessPoolExecutor每个进程有独立的解释器和独立的 GIL。第三现在跑 Python 已经不带 GIL 了这个说法是错的——PEP 703 的自由线程构建从 3.13 起是实验性的、非默认需要单独的可执行文件而且去掉 GIL 不等于自动变快。本文把这四种模型摆在一起对比给出各自的适用边界和能跑的骨架。示例按 Python 3.8 编写。一、四模型总览模型并行还是并发适合任务主要成本threading并发受 GIL 限制I/O 密集线程切换、需自己加锁multiprocessing真并行CPU 密集进程启动开销、数据要可序列化asyncio单线程并发海量 I/O 连接阻塞调用会拖垮整个事件循环concurrent.futures上面两者的统一封装大多数批量任务便利性换一点灵活性有一张速查表你的任务选择抓几百个网页ThreadPoolExecutor压缩几百个文件计算密集ProcessPoolExecutor维持上万个长连接asyncio只是想让一个耗时函数别挡住主流程threading.Thread不确定先量一下时间花在算还是等二、threading把等待重叠起来线程是最直观的模型开一个线程跑一个函数。# 适用于 Python 3.8import threadingimport timedef task(name):time.sleep(0.5) # 模拟 I/O 等待print(f{name} 完成)threads [threading.Thread(targettask, args(ft{i},)) for i in range(3)]for t in threads:t.start()for t in threads:t.join() # 等全部结束三个线程的等待重叠总耗时接近 0.5 秒而非 1.5 秒。共享状态要用Lock保护或者干脆用线程安全的queue.Queue传递数据Queue内部实现了锁多个线程同时读写不会破坏内部状态。线程模型的短板是管理:线程数一多切换开销和共享状态的复杂度都上一个台阶。这时候用线程池更合适——固定几个工作线程把任务排队。三、multiprocessingCPU 密集的正解进程模型绕开了 GIL因为每个进程都是独立的解释器。代价是启动开销大且进程之间通过序列化传数据所以传给子进程的参数和返回的结果都必须是可序列化pickle的。# 适用于 Python 3.8Windows 下必须放在 if __name__ 保护内调用from multiprocessing import Pooldef square(x):return x * xdef main():with Pool(4) as pool:results pool.map(square, range(10))print(list(results))if __name__ __main__:main()这里的if __name__ __main__:不是装饰而是 Windows 上的硬性要求。原因在于 Windows 没有 fork启动子进程用的是 spawn新进程会重新导入主模块。如果创建进程池的代码写在模块顶层子进程导入时又会执行一遍这段代码、又去创建进程池如此递归下去程序会不断重启子进程直到崩溃。把入口逻辑放进这个保护块子进程导入时就不会重复执行。如果漏写保护在 Windows 上典型的表现是反复输出重复的启动信息或直接抛出RuntimeError: An attempt has been made to start a new process beforethe current process has finished its bootstrapping phase.进程数迅速膨胀最终内存耗尽或被系统终止。四、asyncio单线程处理海量连接asyncio走的是另一条路单线程 事件循环用async def定义协程用await在等待处主动让出控制权。它的优势是把每个连接一个线程的内存开销降到每个连接一个协程。# 适用于 Python 3.8import asyncioasync def fetch(name, delay):await asyncio.sleep(delay) # 让出控制权事件循环去跑别的协程return f{name} 完成async def main():results await asyncio.gather(fetch(a, 0.5),fetch(b, 0.3),fetch(c, 0.2),)print(results)asyncio.run(main()) # asyncio.run() 是 3.7 新增的入口函数asyncio.gather()把多个协程并发跑起来并收集结果。两点必须记住一是asyncio.run()需要 Python 3.7 及以上二是协程里绝对不能调用阻塞函数——一句普通的time.sleep(1)而不是await asyncio.sleep(1)会把整个事件循环卡住一秒所有协程一起停顿。CPU 密集的代码同理会把事件循环占满让并发完全失效。阻塞和协程混用时可以用asyncio.to_thread()把阻塞函数丢进线程执行需要 Python 3.9 及以上避免堵住循环。五、concurrent.futures统一的池化接口concurrent.futures把线程池和进程池抽象成同一套接口两者都通过submit提交任务、返回Future用as_completed按完成顺序收集。# 适用于 Python 3.8from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completedimport timedef io_task(n):time.sleep(0.2)return n * 2def cpu_task(n):return sum(i * i for i in range(n))def main():with ThreadPoolExecutor(max_workers4) as pool:futures [pool.submit(io_task, i) for i in range(8)]for f in as_completed(futures):print(I/O 结果:, f.result())with ProcessPoolExecutor(max_workers4) as pool:futures [pool.submit(cpu_task, 100000) for _ in range(4)]print(CPU 结果:, [f.result() for f in as_completed(futures)])if __name__ __main__:main()submit(fn, *args)返回Futuref.result()取结果如果任务抛了异常异常会在这里重新抛出——这是它比裸线程更好用的地方错误不会被悄悄吞掉。as_completed()按完成顺序产出map()则按输入顺序返回。注意上面第二个块用了ProcessPoolExecutor所以整个main()被放在if __name__ __main__:保护之内同样是 Windows 下 spawn 启动的硬性要求。常见坑点用多线程加速 CPU 密集任务。受 GIL 限制同一进程同一刻只有一个线程执行字节码纯计算不会因加线程而变快。❌ 用ThreadPoolExecutor跑大量数值计算期待提速 ✅ 改用ProcessPoolExecutor或multiprocessing各进程有独立解释器和独立 GIL。误以为现在跑 Python 已经不带 GIL 了。自由线程构建在 3.13 起是实验性的、非默认需要单独的可执行文件。❌ 新版 Python 里多线程已经能让纯计算任务真正并行提速 ✅ 自由线程是实验特性且去掉 GIL 不等于自动变快单线程可能更慢。Windows 下漏写if __name__ __main__:。spawn 启动的子进程重新导入主模块会再次执行创建进程池的代码导致无限重启子进程。❌ 把Pool(...)或ProcessPoolExecutor(...)直接写在模块顶层 ✅ 入口逻辑放进if __name__ __main__:保护块。在协程里调用阻塞函数。time.sleep()、同步的网络或磁盘调用会把整个事件循环卡住所有协程一起停。❌ 在async def里写time.sleep(1)✅ 用await asyncio.sleep(1)或用asyncio.to_thread()把阻塞调用挪出循环。传不可序列化的参数给子进程。进程间要 pickle 传递lambda、打开的文件句柄、数据库连接都传不过去。❌pool.submit(lambda x: x 1, 1)✅ 用模块级的具名函数参数与返回值限制为可序列化的基本类型。创建进程池时不限制并发数或忘了关闭。默认工作进程数可能远超机器核数且不关闭会留下悬挂进程。❌ProcessPoolExecutor()后不shutdown✅ 用with ProcessPoolExecutor(...) as pool:让上下文管理器负责收尾。以为Future.result()会立刻返回。它是阻塞调用会一直等到任务结束在主线程里对第一个Future直接result()会让并发退化成串行等待。❌ 按提交顺序逐个result()而不先等其他任务 ✅ 用as_completed()按完成顺序收集或最后统一取结果。在asyncio里用asyncio.get_event_loop()拿循环。3.10 起在没有运行中的事件循环时它会发DeprecationWarning行为也在版本间调整过。❌ 老代码里到处asyncio.get_event_loop().run_until_complete(...)✅ 用asyncio.run(main())作为入口3.7让它在内部管理循环。总结需求模型关键约束I/O 密集、任务量中等threading/ThreadPoolExecutor共享状态加锁用Queue传递数据CPU 密集multiprocessing/ProcessPoolExecutor参数可序列化Windows 需要__main__保护超大量 I/O 连接asyncio循环内不得有阻塞调用asyncio.run()需 3.7批量任务、要统一接口concurrent.futuressubmit/map/as_completed异常经Future传回并行计算多进程不要指望线程并发模型没有最好的只有匹配当前任务的。先判断任务是在等还是在算再决定用线程、进程还是协程判断错了代码写得再漂亮也只是原地打转。