Python 并发编程:IO 等待用线程/asyncio,CPU 计算才用进程
一句话记住:IO 等待用线程(或 asyncio),CPU 计算才用进程。
这是 Python 并发编程里非常重要的一条经验。
很多人刚开始接触 Python 并发时,会陷入一个误区:
"并发性能不好,是不是直接多开几个进程就行?"
其实不是。
到底应该使用 线程、asyncio 还是多进程,关键不在于代码看起来复杂不复杂,而在于:
你的程序大部分时间是在"等待",还是在"计算"?
一、先搞懂 IO 和 CPU
一个程序运行的时候,大致会遇到两类工作。
1. IO 密集型
IO 就是 Input / Output,也就是程序需要和外部世界交互。
例如:
- HTTP 请求
- 数据库查询
- Redis 操作
- 读取文件
- 写入文件
- 调用第三方 API
- 网络 Socket 通信
例如:
python
response = requests.get("https://example.com")
发送请求以后,CPU 并不会一直忙着计算。
程序真正做的事情更像:
text
发送请求
↓
等待网络
↓
等待服务器处理
↓
等待数据返回
↓
继续执行
大量时间都浪费在"等"。
这种情况就是:
IO 密集型任务
二、CPU 密集型又是什么?
CPU 密集型任务则相反。
程序的大部分时间都在进行计算。
例如:
python
def calculate():
total = 0
for i in range(100_000_000):
total += i * i
return total
这个程序没有等待数据库,也没有等待网络。
CPU 会一直执行:
text
计算
计算
计算
计算
计算
...
这种就是:
CPU 密集型任务
典型场景包括:
- 大量数学计算
- 图片处理
- 视频编码
- 数据压缩
- 加密计算
- 大规模数据计算
- 机器学习中的部分 CPU 计算
三、为什么 IO 等待适合线程?
先看一个简单例子:
python
import requests
def get_data(url):
return requests.get(url)
假设请求一个接口需要 1 秒。
如果顺序执行:
python
get_data(url1)
get_data(url2)
get_data(url3)
那么大约需要:
text
1s + 1s + 1s = 3s
但这些请求之间实际上没有太强的依赖关系。
我们可以让多个线程同时发请求:
python
from concurrent.futures import ThreadPoolExecutor
import requests
urls = [
"https://example.com/1",
"https://example.com/2",
"https://example.com/3",
]
def get_data(url):
return requests.get(url).text
with ThreadPoolExecutor(max_workers=3) as executor:
results = list(executor.map(get_data, urls))
执行过程变成:
text
线程1 ── 请求 ───────── 等待 ───── 返回
线程2 ── 请求 ───────── 等待 ───── 返回
线程3 ── 请求 ───────── 等待 ───── 返回
三个请求可以同时进行。
因此:
text
IO 密集型
↓
大量时间在等待
↓
线程可以在等待的时候干其他事情
↓
提高整体吞吐量
这就是线程非常适合 IO 密集型任务的原因。
四、那为什么 CPU 计算不推荐线程?
这就涉及 Python 中非常经典的:
GIL(Global Interpreter Lock,全局解释器锁)
在传统 CPython 实现中,同一进程内的多个 Python 线程不能真正同时执行 Python 字节码。
例如:
python
def cpu_task():
total = 0
for i in range(100_000_000):
total += i
return total
如果使用多个线程:
text
线程1:CPU计算 █████████
线程2:CPU计算 █████████
线程3:CPU计算 █████████
并不会简单地变成:
text
CPU核心1:线程1
CPU核心2:线程2
CPU核心3:线程3
多个线程会受到 GIL 的限制。
所以:
线程非常适合等待,但不适合用来解决纯 Python CPU 密集型计算的并行问题。
五、CPU 密集型任务应该使用进程
进程和线程最大的区别之一:
text
线程:
同一个进程里的多个线程
共享进程资源
进程:
每个进程拥有独立的 Python 解释器
因此可以利用多个 CPU 核心。
例如:
python
from concurrent.futures import ProcessPoolExecutor
def calculate(n):
total = 0
for i in range(n):
total += i * i
return total
with ProcessPoolExecutor(max_workers=4) as executor:
results = list(
executor.map(calculate, [10_000_000] * 4)
)
可以理解成:
text
CPU Core 1 ── Process 1
CPU Core 2 ── Process 2
CPU Core 3 ── Process 3
CPU Core 4 ── Process 4
这样才能真正利用多核 CPU 的计算能力。
六、asyncio 又是什么?
很多 Python 开发者看到这里会问:
"那 asyncio 是不是比线程更快?"
不能这么简单理解。
asyncio 的核心思想不是:
创建更多线程。
而是:
一个线程管理大量 IO 任务,在任务等待 IO 时切换到其他任务。
例如:
python
import asyncio
async def task(name):
print(f"{name} 开始")
await asyncio.sleep(1)
print(f"{name} 完成")
async def main():
await asyncio.gather(
task("任务1"),
task("任务2"),
task("任务3"),
)
asyncio.run(main())
执行过程大概是:
text
任务1:开始 → 等待 IO
任务2:开始 → 等待 IO
任务3:开始 → 等待 IO
等待结束
任务1:继续
任务2:继续
任务3:继续
所以 asyncio 的核心是:
text
IO 等待
↓
await
↓
把执行权交给其他任务
↓
IO 完成
↓
继续执行
七、线程和 asyncio 怎么选?
两者都适合 IO 密集型任务,但使用场景有所不同。
可以简单理解:
| 场景 | 推荐 |
|---|---|
| 少量 IO 并发 | 线程 |
| 大量网络 IO | asyncio |
| 已经是异步项目 | asyncio |
| 第三方库只有同步接口 | 线程 |
| CPU 密集计算 | 多进程 |
| 简单脚本 | 线程通常更方便 |
| FastAPI 异步接口 | asyncio |
| FastAPI 调用同步阻塞库 | 线程池 |
| 纯 Python 大量计算 | 多进程 |
八、FastAPI 中尤其容易踩这个坑
例如:
python
from fastapi import FastAPI
import time
app = FastAPI()
@app.get("/test")
async def test():
time.sleep(5)
return {"message": "ok"}
很多人看到:
python
async def
就认为:
"这是异步代码。"
其实不是。
time.sleep() 是同步阻塞操作。
所以:
python
async def
↓
time.sleep(5)
↓
阻塞事件循环
这就会产生问题。
正确的异步方式应该是:
python
import asyncio
@app.get("/test")
async def test():
await asyncio.sleep(5)
return {"message": "ok"}
这样等待期间,事件循环可以去处理其他请求。
九、FastAPI 中同步代码怎么办?
现实项目中不可能所有库都是异步的。
例如你使用一个同步数据库驱动:
python
def query_database():
return db.query(...)
或者:
python
requests.get(url)
这种情况下,如果直接放进异步函数:
python
async def test():
requests.get(url)
就可能阻塞事件循环。
可以考虑把同步阻塞操作放到线程池。
例如:
python
from fastapi.concurrency import run_in_threadpool
@app.get("/test")
async def test():
result = await run_in_threadpool(query_database)
return result
理解成:
text
FastAPI
│
├── asyncio 事件循环
│
└── 阻塞任务 → 线程池
这样可以避免同步 IO 阻塞整个事件循环。
十、一个非常重要的判断方法
以后看到一个任务,不要第一反应:
"用线程还是进程?"
应该先问:
这个任务到底在干什么?
例如:
场景 1:请求 100 个 HTTP 接口
text
主要时间:
等待网络
结论:
IO 密集型
推荐:
asyncio / 线程
场景 2:查询数据库
text
主要时间:
等待数据库返回
结论:
IO 密集型
推荐:
asyncio / 线程
场景 3:读取大量文件
text
主要时间:
等待磁盘 IO
结论:
IO 密集型
推荐:
根据场景使用异步 IO / 线程
场景 4:计算 10 亿次循环
text
主要时间:
CPU 运算
结论:
CPU 密集型
推荐:
多进程
场景 5:图片压缩
如果使用的库主要在 C/C++ 层执行计算,并且能够释放 GIL,那么线程也可能有效。
所以不要简单地认为:
"计算 = 一定不能用线程。"
更准确的说法是:
纯 Python CPU 密集型计算通常应该优先考虑多进程;如果底层库释放 GIL,线程也可能实现并行。
这是实际工程中非常重要的区别。
十一、可以记住这张图
text
Python 并发任务
│
▼
主要是在等待吗?
/ \
是 否
│ │
▼ ▼
IO型 CPU型
│ │
┌──────┴──────┐ │
│ │ │
▼ ▼ ▼
asyncio 线程 多进程
│ │ │
│ │ │
异步库 同步库 多核 CPU
进一步考虑:
text
IO 密集型
│
├── 异步库 → asyncio
│
└── 同步阻塞库 → 线程池
CPU 密集型
│
├── 纯 Python 计算 → 多进程
│
└── C/C++ 扩展且释放 GIL → 线程也可能有效
十二、为什么不能所有东西都用多进程?
因为进程不是免费的。
创建进程、进程间通信、数据序列化、内存占用,都有成本。
例如:
python
ProcessPoolExecutor()
如果任务本身只有几毫秒:
text
创建/调度进程的成本
>
真正计算的成本
那么使用多进程反而可能更慢。
线程同样有成本。
asyncio 也不是万能的。
所以并发模型应该根据任务特点选择,而不是盲目追求"异步"。
十三、最终形成一个工程上的判断口诀
可以把整篇文章浓缩成:
先判断任务是在"等"还是在"算"。
如果是在等:
text
IO 等待
↓
asyncio / 线程
如果是在算:
text
CPU 计算
↓
多进程
如果是:
text
同步阻塞库 + 异步程序
那么:
text
asyncio
↓
线程池
如果是:
text
纯 Python CPU 密集计算
那么:
text
多进程
十四、最后总结
Python 并发并不是简单的:
asyncio > 线程 > 进程
也不存在一个"性能最强"的并发模型。
正确的思路应该是:
text
任务类型
│
┌─────────┴─────────┐
│ │
IO 密集型 CPU 密集型
│ │
┌────┴────┐ │
│ │ │
异步库 同步库 │
│ │ │
asyncio 线程池 │
│
▼
多进程
因此最值得记住的不是复杂的 API,而是这一句话:
IO 等待用线程(或 asyncio),CPU 计算才用进程。
掌握这个判断逻辑之后,面对 FastAPI、数据库、Redis、HTTP 请求、消息队列、文件 IO、数据处理等实际场景,就能快速判断应该采用哪一种并发模型。