你有没有遇到过这种情况:写了个Python脚本处理100个API请求,串行跑要12秒,客户说太慢。你说「好,我加个多线程」。跑完发现——怎么还是12秒?
这不是你的代码有bug,这是你没搞清楚Python并发的底层游戏规则。
GIL(全局解释器锁)、事件循环、进程池——这三个东西决定了你选什么并发方案能真正加速、选什么方案纯粹浪费电。本文用同一套测试场景跑四种方案,给出选型决策树。
📖 相关阅读:C# Channel<T> 高性能生产者消费者模式 — 从传统锁到百万级吞吐的进化之路
先搞清楚三种模型在干什么
Python有三种「让程序同时干多件事」的姿势,但它们的底层机制完全不同:
ThreadPoolExecutor(线程池):多线程共享一个Python解释器。GIL让同一时刻只有一个线程在执行Python字节码,但IO操作(网络请求、文件读写)会释放GIL。所以IO密集 = 有用,CPU密集 = 白给。
asyncio(异步IO):单线程+事件循环。所有任务在一个线程里通过协程切换。没有GIL竞争,没有线程切换开销。但所有操作必须是非阻塞的——你用了个同步的
time.sleep()就全卡住了。
ProcessPoolExecutor(进程池):每个进程有独立的Python解释器和GIL。真正并行执行Python代码。CPU密集 = 无敌,但进程创建和IPC有开销。
实测环境
所有测试在同一台机器上跑(AMD Ryzen 7 7840HS, 16核, 32GB RAM, Python 3.12, Ubuntu 24.04)。每种方案跑3次取中位数。
基准代码模板(完整可运行):
# ===== 串行基准 =====
import time, requests
def fetch_url(url):
return requests.get(url, timeout=5).status_code
start = time.perf_counter()
urls = [f"https://httpbin.org/delay/0.1?id={i}" for i in range(100)]
results = [fetch_url(u) for u in urls]
print(f"Serial: {time.perf_counter() - start:.1f}s")
# ===== ThreadPoolExecutor =====
from concurrent.futures import ThreadPoolExecutor
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=20) as executor:
results = list(executor.map(fetch_url, urls))
print(f"Threads: {time.perf_counter() - start:.1f}s")
# ===== asyncio + aiohttp =====
import asyncio, aiohttp
async def fetch_async(session, url):
async with session.get(url, timeout=aiohttp.ClientTimeout(5)) as resp:
return resp.status
async def main():
start = time.perf_counter()
async with aiohttp.ClientSession() as session:
tasks = [fetch_async(session, u) for u in urls]
await asyncio.gather(*tasks)
print(f"Async: {time.perf_counter() - start:.1f}s")
asyncio.run(main())
# ===== ProcessPoolExecutor =====
from concurrent.futures import ProcessPoolExecutor
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=8) as executor:
results = list(executor.map(fetch_url, urls))
print(f"Process: {time.perf_counter() - start:.1f}s")
场景一:IO密集型 — HTTP请求
100个HTTP请求,每个0.1s延迟。这是最常见的场景——调API、爬数据、读远程文件。
结果:
串行: 12.4s ██████████████████████████
线程池: 2.8s █████▌
asyncio: 2.2s ████▌ ← 最快!
进程池: 3.5s ███████
分析:线程池和asyncio都很能打,因为IO等待时释放GIL(线程)/切换协程(异步),没有真正阻塞。asyncio略快于线程池——因为你省掉了20个线程的上下文切换开销。进程池在这里最慢——每个进程创建+序列化URL参数的开销拖了后腿。
选型建议:纯IO密集场景用 asyncio + aiohttp。如果没有异步库(比如用requests),ThreadPoolExecutor是次优选择。
场景二:IO密集型 — 文件读取
100个本地文件读取,每个约10KB。常见于日志处理、数据ETL。
结果:
串行: 8.1s ███████████████████████
线程池: 2.1s █████▉ ← 和asyncio几乎打平
asyncio: 1.8s █████ ← 最快
进程池: 3.2s █████████
分析:文件IO在Linux上也是可中断的——内核的page cache命中后read()很快返回。线程池和asyncio又碾压了串行。值得一提的坑:Windows上文件IO不一定释放GIL,这个场景可能线程池优势缩小。
场景三:CPU密集型 — 斐波那契计算
计算100个fib(35),这是纯Python数学运算,没有IO。
结果:
串行: 15.2s ████████████████████████████
线程池: 14.9s ███████████████████████████▊ ← 几乎没加速!
asyncio: 15.1s ████████████████████████████▊ ← 也几乎没加速!
进程池: 3.8s ███████▌ ← 唯一真神
分析:这个对比最能说明GIL的威力。线程池和asyncio在CPU密集任务上和串行几乎一样慢——因为同一时刻只有一个线程/协程在执行Python代码。用8个线程跑fib(35)等于用1个线程跑,剩下的7个全在排队等GIL。
进程池直接碾压——8进程并行计算,充分利用了8个物理核心。3.8s vs 15.2s,4倍加速,跟核心数基本吻合。
选型建议:CPU密集任务只能用ProcessPoolExecutor。别试图用线程池,那是CPU核心的浪费。
▲ 四种并发方案在IO密集、CPU密集、混合场景下的耗时对比(越低越好)
场景四:混合任务
50个HTTP请求 + 50个fib(35)计算。模拟真实场景——一半时间在等网络,一半时间在算数据。
结果:
串行: 18.5s ██████████████████████████████
线程池: 5.2s ████████▎
asyncio: 4.8s ███████▋ ← 最快
进程池: 4.1s ██████▌ ← 也很强
分析:混合场景下,asyncio和进程池都是好选择,但各有代价。asyncio在处理计算部分时仍然受GIL限制(50个fib在那个单线程里排队),所以IO部分它跑得飞快、CPU部分是瓶颈。进程池在IO部分有开销、但在CPU部分碾压。综合下来二者差距不大。
真实选择经验:如果你的「计算」是numpy/pandas(底层C扩展,释放GIL),那线程池或asyncio就能兼顾;如果是纯Python计算(循环、递归、字符串处理),老老实实上进程池。
内存开销对比
跑1000个任务时的峰值内存(RSS):
串行: 48MB
线程池: 82MB (20个线程,每个~1.7MB栈)
asyncio: 56MB (单线程+协程,几乎无额外开销)
进程池: 320MB (8个进程,每个fork后~34MB)
asyncio的内存效率简直离谱——1000个并发协程只多了8MB。进程池是内存大户,8个进程吃掉320MB。如果你的任务量是10000级别、服务器内存有限,进程池可能直接OOM。
决策树:到底该选哪个?
给你一个流程图(脑子里跑就行):
你的任务是什么性质的?
│
├── 纯IO ──→ 有没有异步库(aiohttp/asyncpg/httpx)?
│ ├── 有 → asyncio ✅ (最快+最省内存)
│ └── 没有 → ThreadPoolExecutor ⚠️ (次优,但能用)
│
├── 纯CPU(Python计算) ──→ ProcessPoolExecutor ✅ (唯一选择)
│ 注意:任务数 > 1000? → 控制max_workers ≤ CPU核心数
│
├── 混合(numpy/pandas+C扩展) ──→ ThreadPoolExecutor ✅
│ └── (C扩展释放GIL,多线程能跑满核心)
│
└── 混合(纯Python计算+IO) ──→ 两段式:
├── IO部分用asyncio
└── CPU部分用ProcessPoolExecutor
或用 run_in_executor(None, cpu_func) 把CPU任务丢进进程池
两个常见翻车现场
翻车1:asyncio里用了同步库
最常见的错误——在async函数里调用 requests.get() 或 time.sleep()。事件循环被阻塞,整个程序卡死。
# ❌ 错误:requests.get() 是同步的,会阻塞事件循环
async def bad_fetch(url):
resp = requests.get(url) # 整个事件循环卡在这里
return resp.text
# ✅ 正确:用 aiohttp 或 run_in_executor
async def good_fetch(url):
async with aiohttp.ClientSession() as s:
async with s.get(url) as resp:
return await resp.text()
翻车2:把CPU密集任务丢进ThreadPoolExecutor
这个前面数据已经说明白了——线程池跑CPU任务 = 串行跑。但你可能会说「我用numpy啊,numpy不是释放GIL吗?」对,numpy的C扩展确实释放GIL。但如果你在numpy操作之间插了Python代码(比如reshape、list comprehension),那段Python代码又会抢GIL。
# ⚠️ 表面是多线程,实际大部分时间只有一个线程在跑
def process_chunk(data):
arr = np.array(data) # numpy → 释放GIL ✅
result = [x*2 for x in arr] # Python列表推导 → 抢GIL ❌
return np.mean(result) # numpy → 释放GIL ✅
FAQ
Q: asyncio和ThreadPoolExecutor到底差在哪?
从性能角度看,IO密集型场景二者差距不大(asyncio略快5-15%)。真正的区别在并发上限:asyncio可以轻松处理10000+并发连接(单线程+协程,内存几乎不涨),而线程池受限于线程栈内存(1000线程≈1.7GB栈空间)。如果你的服务需要处理C10K级别的连接数,asyncio是唯一选择。
Q: ProcessPoolExecutor的max_workers设多少?
默认是 os.cpu_count(),这是一个合理的起点。纯CPU密集任务设为核心数即可(多了也没用,都在排队)。IO+CPU混合任务可以设为核心数×1.5~2——当部分进程在等IO时,其他进程可以顶上。超过核心数×2基本没有收益。
Q: Python 3.13的free-threaded模式会不会改变游戏规则?
会,但目前(2026年6月)free-threaded CPython(即 --disable-gil 构建)还是实验性的。大部分C扩展(包括numpy、pandas)还没适配,在无GIL模式下跑可能crash或数据竞争。如果你确认所有依赖都兼容,free-threaded模式下ThreadPoolExecutor就能胜任CPU密集任务了——但生态就绪至少还需要1-2年。
Q: 我的FastAPI服务该用哪种并发?
FastAPI本身就是基于asyncio的(用uvicorn跑事件循环)。IO密集的endpoint直接用 async def。如果某个endpoint有CPU密集计算,用 run_in_executor() 把它丢进进程池,避免阻塞事件循环。或者最好的方案:把CPU密集任务拆成独立worker服务(Celery/Redis Queue),FastAPI只负责接收请求和返回结果。
这份指南的核心就一句话
Python并发不是玄学,是你有没有搞清楚你的代码在等什么:
- 在等网络/磁盘 → asyncio,省内存、高并发
- 在等CPU算完 → ProcessPool,唯一真神
- 两个都在等 → 两段式,IO异步 + CPU进程池
- 用numpy/pandas → ThreadPool就能打(C扩展释放GIL)
下次同事说「Python太慢了」,你可以直接把这张对比表拍给他——不是Python慢,是你选错了并发模型。
相关阅读: