「Python 进阶之路」系列 Day21
写在前面
Day16 到 Day20 分别把 GIL、多线程、多进程、asyncio 协程逐个讲透了,模块四的收官篇把这三种并发方案放到同一套 benchmark 下直接对比------不是空谈"该用哪个",而是用实测数据和一张决策图,把选择标准钉死。
一、是什么:三种方案的本质回顾
- threading:真正的操作系统线程,共享同一进程的内存空间,但受 CPython 的 GIL 限制,同一时刻只有一个线程能执行 Python 字节码(Day16、Day17)
- multiprocessing:真正的操作系统进程,每个进程有独立的内存空间和独立的 GIL,能真正利用多核 CPU 并行计算,代价是进程间不共享内存、创建开销更大(Day18)
- asyncio :单线程内的协作式调度,协程在
await点主动让出控制权,靠 event loop 在等待间隙切换任务,本质是并发不是并行(Day19、Day20)
二、为什么要对比:任务类型决定最优选择
选哪个方案,核心看两个维度:任务是 CPU 密集型还是 I/O 密集型 ,以及并发数量级有多大。前四篇分别验证过三者各自的表现,这一篇把它们放在同一套 benchmark 下直接对比,用数据说话。
三、怎么用:三组实测对比
1. CPU 密集型任务对比
用一个判断大数是否为质数的纯计算任务,4 份同样的工作分别用单线程顺序、ThreadPoolExecutor、ProcessPoolExecutor 执行:
python
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True
def cpu_task():
nums = [100_000_000_000_037 + i * 2 for i in range(3)]
return [is_prime(n) for n in nums]
实测结果:
| 方式 | 耗时 | 相对单线程比值 |
|---|---|---|
| 单线程顺序执行 | 0.19s | 1.00 |
| 多线程(ThreadPoolExecutor) | 0.18s | 0.97(几乎没有加速) |
| 多进程(ProcessPoolExecutor) | 0.11s | 0.57(明显加速) |
和 Day16-18 的结论完全一致:CPU 密集型任务,多线程因为 GIL 限制基本没有收益,多进程能拿到真实的并行加速。
2. I/O 密集型任务对比
50 个各耗时 0.1 秒的"I/O 任务",分别用多线程和 asyncio 并发执行:
python
def io_task_sync(seconds):
time.sleep(seconds)
async def io_task_async(seconds):
await asyncio.sleep(seconds)
实测结果:多线程执行 50 个任务耗时 0.11s,asyncio 执行 50 个任务耗时 0.10s------两者在 I/O 密集型场景下的效果基本相当,都能把原本需要 5 秒(50 x 0.1s)的顺序等待压缩到约 0.1 秒。这说明数量不大的时候,选哪个对性能影响不大,真正的差异要看下面的资源开销。
3. 资源开销对比
创建 1000 个线程 vs 1000 个协程,分别用独立进程测量避免互相干扰:
python
# 1000个线程
threads = [threading.Thread(target=worker) for _ in range(1000)]
for t in threads: t.start()
结果:耗时 0.045s,峰值内存约 49.5MB
python
# 1000个协程
tasks = [asyncio.create_task(coro_worker()) for _ in range(1000)]
结果:耗时 0.001s,峰值内存约 21.7MB
创建协程比创建线程快了 45 倍,内存占用也只有线程的不到一半。这就是为什么面对成百上千甚至上万的并发 I/O 任务(比如同时抓取几千个网页),asyncio 是比 threading 更合适的选择------线程数量一旦上千,内存和调度开销会变得很可观,而协程的轻量级特性可以轻松支撑更大的并发规模。
4. 三者可以混用:run_in_executor
asyncio 代码里如果混进了一段无法避免的 CPU 密集型计算,不应该直接在协程里硬跑(会卡住整个 event loop,Day19 讲过这个坑),而是用 loop.run_in_executor() 把它丢进线程池或进程池:
python
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
cpu_future = loop.run_in_executor(pool, cpu_task) # 丢进进程池,不阻塞event loop
await quick_io() # 这个协程能在CPU任务算的同时正常执行,不被卡住
result = await cpu_future
实测:quick_io 正常在 0.1 秒内完成,没有被 cpu_task 卡住,cpu_task 在后台的进程池里独立运行------这是三种方案实际项目里最常见的组合方式:用 asyncio 做整体的 I/O 调度框架,遇到 CPU 密集型的小段计算就临时借助线程池/进程池来处理,不需要在三选一里死磕,可以搭配使用。
5. 一张图说清楚怎么选
四、面试追问
Q1:三种方案各自的核心原理是什么?
threading 是共享内存的操作系统线程,受 GIL 限制,同一时刻只有一个线程能执行 Python 字节码;multiprocessing 是独立内存和独立 GIL 的操作系统进程,能真正利用多核并行;asyncio 是单线程内基于 await 主动让出的协作式调度,本质是并发而不是并行。
Q2:CPU 密集型任务该选哪个,为什么?
选 multiprocessing。实测同样的计算任务,多线程耗时比值 0.97(几乎没有加速),多进程比值 0.57(明显加速),因为多进程能绕开单个 GIL 的限制,让多个进程各自的解释器真正同时执行字节码,利用多核 CPU。
Q3:I/O 密集型任务,线程和协程怎么选?
并发数量不大(几十到几百)时两者性能相当,选哪个都可以;并发数量很大(成百上千甚至更多)时优先选 asyncio------实测创建 1000 个协程比创建 1000 个线程快 45 倍、内存占用不到线程的一半,协程的轻量级特性更适合大规模并发场景。
Q4:三种方案能不能混用?
可以。asyncio 代码里如果有无法避免的 CPU 密集型计算,用 loop.run_in_executor() 把它丢进线程池或进程池处理,这样它不会阻塞 event loop,其他协程可以继续正常执行------这是实际项目中很常见的组合方式,不需要在三者里死磕单选。
Q5:三者的资源开销/可扩展性对比是怎样的?
进程开销最大:独立内存空间,创建成本高,适合数量不多的 CPU 密集型任务;线程开销中等:共享内存,但每个线程仍有独立的调用栈和操作系统调度成本,数量上千会带来明显的内存和调度压力;协程开销最小:创建和切换都只是函数调用级别的成本,可以轻松支撑上万级别的并发数量。
下一篇预告
模块四(GIL 与并发编程)到这里全部完成。Day22 开始进入模块五------内存管理与性能,第一篇讲引用计数与垃圾回收:Python 的内存管理机制到底是怎么工作的。