加了线程反而更慢,这是 Python 并发最常见的第一次翻车。原因不在于线程"没用",而在于线程解决的是等待,不是计算:当任务是 CPU 密集时,多个线程抢同一把 GIL,上下文切换成本加上调度开销,吞吐不升反降。
选型的正确顺序是先给任务分类,再选模型,最后才谈池大小和参数。跳过分类直接抄配置,等于用别人的分母算自己的题。
1. GIL 限制的到底是什么
GIL 保证同一时刻只有一个线程执行 Python 字节码。它限制的不是"所有操作",而是"Python 层面的计算":
| 任务形态 | GIL 是否阻塞并发 | 说明 |
|---|---|---|
| 纯 Python 计算(循环、解析、字符串处理) | 是 | 多线程只增加切换成本 |
阻塞式 IO(socket、文件、time.sleep) |
否 | 等待期间释放 GIL |
| C 扩展计算(NumPy、加密、压缩) | 通常否 | 扩展在执行期主动释放 GIL |
await 等待 |
否 | 事件循环切换任务,不占 GIL |
| 阻塞调用混进协程 | 是 | 整个事件循环被卡住 |
最后一行是实践中最贵的一条:协程里调一个同步阻塞函数,卡的不是那一个任务,而是该事件循环上的全部任务。
2. 三种模型的适用面
| 模型 | 适用 | 主要代价 | 失效信号 |
|---|---|---|---|
| 线程 | IO 阻塞、已有同步库、并发量中等 | GIL 下 CPU 无收益,栈内存与切换成本 | 加线程吞吐不变、CPU 打满 |
| 进程 | CPU 密集、可切分的数据 | 启动开销、序列化、内存按进程复制 | 启动慢、IPC 成为瓶颈 |
| 协程 | 高并发 IO、大量短连接 | 传染性、库生态、调试难 | 一处同步调用拖垮全部任务 |
现实中几乎没有纯一种模型的服务:一个 Web 服务往往用协程处理请求,用线程池跑同步依赖,用进程池跑重计算。混合是常态,不是妥协——关键是每条路径的边界要显式,别让"顺手调一下"把阻塞调用带进事件循环。
3. 协程的传染性:一处 async,全链路 async
协程不是"给函数加个 async 就能变快",它要求调用链上的每一层都能让出控制权:
async def handler(req):
user = await repo.get_user(req.user_id) # 全链路 async
docs = await retriever.search(req.query)
return await llm.complete(build_prompt(user, docs))
链路上只要有一层是同步阻塞的(老 SDK、requests、pymysql 默认驱动),两种选择:换异步实现,或显式把它推进线程池。不要在协程里直接调用,也不要自己造事件循环去跑它。
async def call_legacy(payload):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(LEGACY_POOL, legacy_client.call, payload)
run_in_executor 的边界要说清:它把阻塞转移到线程池,线程池满了就变成排队;它不会取消已经在跑的阻塞调用;默认 executor 与业务线程池混用会互相挤占。给阻塞依赖配独立的有界池,是唯一稳的做法。
生态兼容的判断方法很土但可靠:看依赖是否提供 async def 入口,没有就得走 executor。号称"支持异步"但内部 time.sleep 的库,与同步库等价。
4. asyncio 的三个高频误用
| 误用 | 表现 | 修正 |
|---|---|---|
| 协程里调阻塞函数 | 单请求慢,全部请求一起慢 | 换异步库或 run_in_executor |
忘记 await |
协程对象未执行,静默无输出 | 开 debug 模式,检查 RuntimeWarning |
无界 create_task |
任务数暴涨,内存与 FD 耗尽 | 信号量限流 + 保存引用 |
# 反例:忘记 await,异常也不会抛到这里
result = fetch(url) # 返回 coroutine,未执行
# 正例
result = await fetch(url)
无界任务的坑最隐蔽:asyncio.create_task 不保存返回值,任务被 GC 前没人发现异常。要么保存引用集合,要么用 TaskGroup(按 Python 版本核对可用性)统一等待与取消。
5. 池容量:不是越大越好
| 池 | 主要成本 | 定容依据 |
|---|---|---|
| 线程池 | 栈内存、切换、GIL 争用 | 并发阻塞数 ≈ 目标 QPS × 平均等待时长 |
| 进程池 | 每进程一份解释器与内存 | CPU 核数附近,按任务内存放大 |
| 协程 | 单任务内存小,但 fd/连接有限 | 受下游连接数与内存约束 |
线程池的经验起点是"核数 × (1 + 等待/计算比)"的量级,再按实测排队时间调整;进程池起点是核数,但每个进程都有一份数据副本,任务吃内存时进程数要先服从内存。
from concurrent.futures import ThreadPoolExecutor
IO_POOL = ThreadPoolExecutor(max_workers="[N_IO]", thread_name_prefix="io")
CPU_POOL = ThreadPoolExecutor(max_workers="[N_CPU]", thread_name_prefix="cpu")
两个池分开是纪律,不是洁癖:CPU 任务混进 IO 池,会让 IO 请求排在计算后面,表现为"偶发超时"。
6. 进程模型:启动开销与数据传递
进程池的隐性成本有两块,常常被忽略:
- 启动:Linux 默认
fork,启动快但继承父进程状态(含已打开的连接、线程锁);Windows 与 macOS 默认spawn,要重新导入模块,启动明显更慢,且必须在if __name__ == "__main__"下创建。 - 传递:参数与返回值都要序列化(pickle),大对象走队列时,成本可能超过计算本身。
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers="[N]") as pool:
results = list(pool.map(score_chunk, chunks))
跨平台一致性要求高时,显式指定 multiprocessing.get_context("spawn"),别依赖默认值——同一个脚本在 Linux 能跑、在 macOS 挂住,多数是启动方式差异导致的。
共享大数组时用共享内存(multiprocessing.shared_memory)而不是队列;但要注意生命周期与释放,共享内存泄漏不会随进程退出自动回收。
7. 异步框架里做 CPU 密集的后果
FastAPI 一类框架把同步 def 端点丢进线程池,async def 端点直接在事件循环上跑。于是两种写法有完全不同的失败模式:
| 写法 | 执行位置 | CPU 密集时的后果 |
|---|---|---|
async def + 内部重计算 |
事件循环 | 全部并发请求一起变慢(队头阻塞) |
def + 重计算 |
线程池 | 占满池,其他同步端点排队 |
async def + run_in_executor(CPU_POOL) |
线程/进程池 | 可控,但要限流 |
结论:CPU 密集端点不要写成 async def 直接算;要么保持 def,要么显式推给独立进程池。判据是单次计算耗时是否超过几十毫秒——超过就该离开事件循环。
8. 错误处理与超时:三种模型的差别
| 模型 | 超时手段 | 能否强制停止 | 异常传播 |
|---|---|---|---|
| 线程 | 等待端超时 / 协作式检查 | 不能 | 通过 Future 取回 |
| 进程 | 等待端超时 + terminate | 能(杀进程) | 序列化后回传 |
| 协程 | asyncio.wait_for / timeout |
能(协作取消) | 沿 await 链传播 |
线程这条最值得强调:Python 没有安全的线程强杀,超时只能"不再等",线程仍在跑并可能继续写共享状态。进程超时可以直接杀,代价是丢失现场;协程取消要求阻塞点都能响应 CancelledError,在 finally 里做清理,不要吞掉取消异常。
9. 可观测与调试
| 问题 | 手段 |
|---|---|
| 线程卡在哪 | py-spy dump --pid [PID](不需要改代码,不需要重启) |
| CPU 热点 | py-spy top / 采样 profiler |
| 协程任务泄漏 | 开 PYTHONASYNCIODEBUG=1,看 "Task was destroyed but it is pending" |
| 事件循环卡顿 | 周期测量 loop 延迟,超过阈值告警 |
| 进程池排队 | 队列长度 + 任务耗时分布 |
$ PYTHONASYNCIODEBUG=1 python -X dev app.py
$ py-spy dump --pid 5123 --locals
协程的栈比线程更难读:一条 await 链在 dump 里是分散的协程帧,不是连续调用栈。所以协程服务的可观测性必须补两样——任务计数(创建/完成/存活)与 loop 延迟,否则"变慢"只能靠猜。
10. 排错:五个"加了并发更慢"的现象
| 现象 | 原因 | 处理 |
|---|---|---|
| 加线程吞吐不变、CPU 满 | CPU 密集 + GIL | 改进程池或 C 扩展 |
| 协程服务偶发整体卡顿 | 某处同步阻塞调用 | 定位后换异步库或进 executor |
| 进程池启动慢、任务小 | 序列化与 spawn 开销占比过高 | 增大任务粒度,或改回线程 |
| 超时后资源仍被占用 | 线程无法强杀 | 用进程隔离,或让任务可协作退出 |
| 内存随并发线性上涨 | 无界任务/连接 | 信号量限流 + 池定容 |
11. 边界:这些情况别硬上协程
| 情况 | 判断 | 做法 |
|---|---|---|
| IO 少且轻 | 协程收益接近零 | 同步顺序执行 |
| 任务量小(每秒个位数) | 复杂度不划算 | 线程池足够 |
| 依赖库全是同步 | 全链路要裹 executor | 保持同步,或用线程池 |
| 团队不熟 async | 调试成本高于性能收益 | 先用线程模型 |
| 需要严格顺序与事务边界 | 协程交错会打乱时序 | 同步流程更可控 |
反过来,如果并发连接数上千、单请求大部分时间在等下游,协程是唯一能压低内存与线程数的方案——这时"复杂度"是必要成本,不是可选项。
一句话判据:先问任务是等还是算,再问并发量有多大,最后才问用哪个库。 顺序反了,就会得到"加了并发更慢"的结论。
12. 自测
- GIL 限制的是"所有操作"还是"字节码执行"?举一个不受限的例子。
- 协程里调用同步阻塞函数,为什么影响面大于一个线程阻塞?
- 线程池容量公式里的"等待/计算比"从哪来?
- macOS 与 Linux 的默认进程启动方式差异,会让什么写法失效?
- 线程超时为什么只能"不再等",而不能真正停止任务?
- 协程服务的可观测性必须补哪两类指标?