Python 并发编程:线程、进程与协程对比
Python 并发编程入门:线程、进程与协程全面对比
1. 为什么你需要关注并发编程?
想象一个简单的场景:你需要下载100张图片,如果一张张按顺序来,后面的图片只能干等前面的下载完成。又或者你的Web服务器在等待数据库查询时,CPU处于闲置状态,无法处理新的用户请求。并发编程就是为了解决这类“等待”造成的资源浪费,让程序在同样的时间内完成更多工作。
Python 提供了三种主流的并发编程方式:
- 多线程(Threading):在同一个进程内创建多个执行流,共享内存空间。
- 多进程(Multiprocessing):开启多个独立的Python解释器进程,每个进程拥有独立的内存空间。
- 协程(Coroutine / asyncio):在单线程内通过协作式切换实现并发,由程序代码显式控制任务切换。
本文将用同一套对比框架,带你理解它们的原理、适用场景与代码实现,并给出清晰的选择决策图。
2. 预备知识:并发、并行与 GIL
在深入具体技术之前,我们需要厘清几个关键概念:
- 并发(Concurrency):多个任务在同一时间段内交替执行。即使只有一个CPU核心,也能通过快速切换任务实现“同时推进”的错觉。
- 并行(Parallelism):多个任务在同一时刻真正同时执行,必须依赖多核CPU。
- GIL(全局解释器锁):CPython 解释器中的一把大锁,它保证同一时刻只有一个线程可以执行 Python 字节码。这意味着多线程在CPU密集型任务中无法实现真正的并行,只能实现并发;但IO密集型任务由于会释放GIL,多线程依然能带来性能提升。
graph LR
A[并发编程模型] --> B[多线程]
A --> C[多进程]
A --> D[协程]
B --> E{受GIL限制}
C --> F{绕过GIL}
D --> E{受GIL限制但高效}
E --> G[适合IO密集型]
F --> H[适合CPU密集型]
G --> I[资源开销小]
H --> J[资源开销大]
3. 多线程(Threading)
3.1 工作原理
使用 threading 模块创建多个线程,它们共享同一个进程的内存(全局变量、堆等)。操作系统调度线程,线程切换由内核控制,属于抢占式多任务。当线程执行IO操作(如网络请求、文件读写)时会释放GIL,允许其他线程运行。
3.2 代码示例
import threading
import time
def download(url):
print(f"开始下载 {url}")
time.sleep(2) # 模拟IO操作
print(f"完成下载 {url}")
urls = ["file1", "file2", "file3"]
threads = []
for u in urls:
t = threading.Thread(target=download, args=(u,))
t.start()
threads.append(t)
for t in threads:
t.join() # 等待所有线程结束
print("所有下载任务完成")
执行耗时约2秒,而不是6秒。
3.3 适用场景
- IO密集型任务:网络爬虫、Web服务、文件读写、数据库查询等。
- 需要避免阻塞主线程,保持用户界面响应(如桌面应用)。
3.4 主要优点
- 创建和切换开销比进程小。
- 共享内存,线程间通信简单(直接读取全局变量或通过
queue.Queue)。 - 代码结构对初学者比较直观。
3.5 主要缺点
- 受GIL限制,CPU密集型任务表现糟糕。
- 共享数据需加锁(
Lock、RLock),容易导致死锁、数据竞争等bug。 - 线程数量过多会导致频繁上下文切换,反而降低性能。
4. 多进程(Multiprocessing)
4.1 工作原理
multiprocessing 模块会为每个任务启动一个独立的Python解释器进程,每个进程拥有自己的GIL和内存空间。进程间不共享内存,操作系统调度进程,可实现真正的并行计算。
4.2 代码示例
import multiprocessing
import time
def compute(n):
print(f"进程 {multiprocessing.current_process().name} 开始计算")
total = 0
for i in range(10**7):
total += i
print(f"进程 {multiprocessing.current_process().name} 结束")
return total
if __name__ == '__main__':
with multiprocessing.Pool(processes=4) as pool:
results = pool.map(compute, range(4))
print("计算结果:", results)
多核CPU上,4个计算任务可以同时执行,时间近乎线性降低。
4.3 适用场景
- CPU密集型任务:科学计算、图像/视频处理、机器学习推理、大文件加解密等。
- 需要绕过GIL,充分利用多核性能的场景。
4.4 主要优点
- 真正的并行计算,充分利用多核CPU。
- 每个进程独立崩溃不会影响主程序(鲁棒性高)。
- 天然的内存隔离,避免很多与共享内存相关的问题。
4.5 主要缺点
- 进程创建和销毁的开销远大于线程。
- 内存占用大,每个子进程需要复制一份Python解释器状态。
- 进程间通信(
Pipe、Queue、共享内存)复杂且序列化开销大。 - 代码稍显臃肿,且必须放在
if __name__ == '__main__':保护下。
5. 协程(Coroutine / asyncio)
5.1 工作原理
协程是一种用户态的并发模型。它运行在单线程内,通过 async/await 语法显式声明挂起点。当一个协程遇到 await 时,它会暂时让出控制权,让事件循环(event loop)调度其他协程执行。这被称为协作式多任务。由于切换由程序自身控制,几乎无内核切换开销。
Python 3.4+ 引入 asyncio,3.5+ 支持 async/await 语法,已成为标准库。
5.2 代码示例
import asyncio
async def download(name):
print(f"开始下载 {name}")
await asyncio.sleep(2) # 模拟IO操作,使用asyncio.sleep
print(f"完成下载 {name}")
async def main():
tasks = [download(f"file{i}") for i in range(3)]
await asyncio.gather(*tasks)
asyncio.run(main())
同样耗时约2秒,但整个程序只用一个线程。
5.3 适用场景
- 高并发IO密集型任务:异步Web框架(FastAPI、aiohttp)、网络爬虫、实时聊天、数据库异步驱动。
- 需要成千上万个并发连接且希望占用极少资源的场景。
5.4 主要优点
- 超轻量级,可轻松创建数万个协程。
- 切换零成本,无上下文切换开销。
- 代码结构清晰,通过
await直观表达异步逻辑,避免回调地狱。 - 内存占用极低。
5.5 主要缺点
- CPU密集型任务会阻塞整个事件循环,导致所有协程停滞。
- 需要所有网络/IO库支持异步版本(例如使用
aiohttp替代requests)。 - 调试相对复杂,栈追踪不直观。
- 概念较多(事件循环、future、task),学习曲线略陡。
6. 终极对比:何时该用哪种?
| 特性 | 多线程 (Threading) | 多进程 (Multiprocessing) | 协程 (asyncio) |
|---|---|---|---|
| 核心模块 | threading |
multiprocessing |
asyncio |
| 任务切换者 | 操作系统(抢占式) | 操作系统(抢占式) | 程序自身(协作式) |
| 并行能力 | 受GIL限制,单核并发 | 真正的多核并行 | 单线程内并发 |
| 内存占用 | 中等 (MB级) | 高 (每个进程独立内存) | 极低 (KB级) |
| 创建/切换开销 | 小 | 大 | 极小 |
| 数据共享与通信 | 简单(共享内存) | 复杂(序列化、管道、队列) | 无需特殊机制(单线程) |
| 适用任务类型 | IO密集型 | CPU密集型 | 超高并发IO密集型 |
| 典型并发数量级 | 几十到几百 | 几个到几十(受CPU核心限制) | 数千至上万 |
| 代码风格 | 传统函数式 | 类似线程,需保护入口点 | async/await 异步风格 |
决策流程图
面临多个任务需要并发执行?
│
├── 任务是CPU密集型? → 使用 多进程
│
├── 任务是IO密集型,且并发量不大(<100)? → 使用 多线程
│
└── 任务是IO密集型,且需要超高并发(1000+)或很少的内存? → 使用 协程 (asyncio)
7. 实战案例:IO密集 vs CPU密集场景对比
我们通过两个典型任务来直观感受三者的效率差异。
案例A:模拟IO密集型(网络请求)
任务:发起10次“网络请求”,每次等待0.5秒。
测试代码框架类似,记录总耗时。
大致结果(典型相对耗时):
- 串行执行:约5秒
- 多线程(10线程):约0.5秒
- 协程(asyncio):约0.5秒
- 多进程:约0.5秒,但进程创建开销更大,可能略慢
结论:IO密集型任务中,多线程和协程都能极大地提升性能,且损耗很低。多进程虽然也行,但重武器没必要。
案例B:模拟CPU密集型(计算斐波那契数)
任务:重复计算40次斐波那契(递归,可加大计算量)。
在多核CPU上,纯Python耗时如下:
- 串行计算:假设10秒
- 多线程:约10秒(甚至稍慢,因为GIL竞争)
- 协程:10秒(单线程无帮助)
- 多进程(4进程):约2.6秒
结论:CPU密集型必须用多进程,多线程和协程无能为力。
8. 避免常见坑点
- 多线程的锁:访问共享数据必须使用
threading.Lock,否则会出现竞态条件。推荐使用queue.Queue做线程间任务分发。 - 多进程的序列化:传递给子进程的参数必须可序列化(pickle)。lambda、闭包等可能失败。
- 协程的阻塞:绝不能在协程中调用同步阻塞函数(如
time.sleep()、requests.get()),必须使用异步版本的库,否则整个事件循环会被卡死。 - 混用:可以在一个项目中组合使用多进程 + 协程(例如每个进程内运行一个事件循环),但不要轻易混合多线程和协程,除非你清楚了解
asyncio的线程安全性。 - 守护线程:用
setDaemon(True)可以让线程在主线程结束时自动退出,避免程序无法终止。
9. 进阶建议与学习路径
- 从
concurrent.futures开始:它提供了高层的ThreadPoolExecutor和ProcessPoolExecutor,用统一的接口管理线程池和进程池,是新手快速上手的最佳选择。 - 深入
asyncio:理解事件循环、Future、Task 和await的实质(yield from的语法糖),建议阅读官方文档和经典教程。 - 掌握异步生态:学会使用
aiohttp(HTTP客户端/服务器)、asyncpg(PostgreSQL异步驱动)、aioredis等,才能真正发挥协程的威力。 - 性能分析:使用
cProfile、py-spy等工具确认瓶颈究竟是CPU还是IO,再针对性选择并发模型。 - 考虑替代方案:如果CPU密集型任务非常繁重,Python原生的多进程可能也不够高效,可以结合C扩展(如Cython、C模块),或将任务分发到分布式任务队列(Celery)。
10. 总结
Python的并发编程世界清晰且实用:IO密集用协程,CPU密集用多进程,简单的IO任务多线程够用。切记不要拿着一把锤子看什么都是钉子。理解底层GIL机制和三大模型的本质差异,你将能设计出高效、简洁、稳健的并发程序。现在,打开编辑器,选择你手头的一个IO任务,用 asyncio 重写它,亲身体验并发的魔力吧。