☰
详解Python如何使用并发模型编程
2026/10/30 14:20:16 网站建设 项目流程

前言


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.8+
import threading
import time

def task(name):
time.sleep(0.5) # 模拟 I/O 等待
print(f"{name} 完成")

threads = [threading.Thread(target=task, args=(f"t{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内部实现了锁,多个线程同时读写不会破坏内部状态)。


线程模型的短板是"管理":线程数一多,切换开销和共享状态的复杂度都上一个台阶。这时候用线程池更合适——固定几个工作线程,把任务排队。


三、multiprocessing:CPU 密集的正解


进程模型绕开了 GIL,因为每个进程都是独立的解释器。代价是启动开销大,且进程之间通过序列化传数据,所以传给子进程的参数和返回的结果都必须是可序列化(pickle)的。


# 适用于 Python 3.8+(Windows 下必须放在 if __name__ 保护内调用)
from multiprocessing import Pool

def square(x):
return x * x

def 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 before
the current process has finished its bootstrapping phase.
进程数迅速膨胀,最终内存耗尽或被系统终止。

四、asyncio:单线程处理海量连接


asyncio走的是另一条路:单线程 + 事件循环,用async def定义协程,用await在等待处主动让出控制权。它的优势是把"每个连接一个线程"的内存开销降到"每个连接一个协程"。


# 适用于 Python 3.8+
import asyncio

async 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.8+
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed
import time

def io_task(n):
time.sleep(0.2)
return n * 2

def cpu_task(n):
return sum(i * i for i in range(n))

def main():
with ThreadPoolExecutor(max_workers=4) 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_workers=4) 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)返回Future;f.result()取结果,如果任务抛了异常,异常会在这里重新抛出——这是它比裸线程更好用的地方,错误不会被悄悄吞掉。as_completed()按完成顺序产出,map()则按输入顺序返回。


注意上面第二个块用了ProcessPoolExecutor,所以整个main()被放在if __name__ == "__main__":保护之内,同样是 Windows 下 spawn 启动的硬性要求。


常见坑点



  1. 用多线程加速 CPU 密集任务。受 GIL 限制,同一进程同一刻只有一个线程执行字节码,纯计算不会因加线程而变快。


❌ 用ThreadPoolExecutor跑大量数值计算期待提速 ✅ 改用ProcessPoolExecutor或multiprocessing,各进程有独立解释器和独立 GIL。



  1. 误以为"现在跑 Python 已经不带 GIL 了"。自由线程构建在 3.13 起是实验性的、非默认,需要单独的可执行文件。


❌ "新版 Python 里多线程已经能让纯计算任务真正并行提速" ✅ 自由线程是实验特性,且去掉 GIL 不等于自动变快,单线程可能更慢。



  1. Windows 下漏写if __name__ == "__main__":。spawn 启动的子进程重新导入主模块,会再次执行创建进程池的代码,导致无限重启子进程。


❌ 把Pool(...)或ProcessPoolExecutor(...)直接写在模块顶层 ✅ 入口逻辑放进if __name__ == "__main__":保护块。



  1. 在协程里调用阻塞函数。time.sleep()、同步的网络或磁盘调用会把整个事件循环卡住,所有协程一起停。


❌ 在async def里写time.sleep(1)✅ 用await asyncio.sleep(1),或用asyncio.to_thread()把阻塞调用挪出循环。



  1. 传不可序列化的参数给子进程。进程间要 pickle 传递,lambda、打开的文件句柄、数据库连接都传不过去。


❌pool.submit(lambda x: x + 1, 1)✅ 用模块级的具名函数,参数与返回值限制为可序列化的基本类型。



  1. 创建进程池时不限制并发数或忘了关闭。默认工作进程数可能远超机器核数,且不关闭会留下悬挂进程。


❌ProcessPoolExecutor()后不shutdown✅ 用with ProcessPoolExecutor(...) as pool:,让上下文管理器负责收尾。



  1. 以为Future.result()会立刻返回。它是阻塞调用,会一直等到任务结束;在主线程里对第一个Future直接result(),会让并发退化成串行等待。


❌ 按提交顺序逐个result()而不先等其他任务 ✅ 用as_completed()按完成顺序收集,或最后统一取结果。



  1. 在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传回

并行计算多进程不要指望线程



并发模型没有"最好"的,只有"匹配当前任务"的。先判断任务是在等还是在算,再决定用线程、进程还是协程;判断错了,代码写得再漂亮也只是原地打转。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询