2769 字
约 9 分钟
16
S10-A04 · Python 并发模型决策:线程、进程与协程

加了线程反而更慢,这是 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. 自测

  1. GIL 限制的是"所有操作"还是"字节码执行"?举一个不受限的例子。
  2. 协程里调用同步阻塞函数,为什么影响面大于一个线程阻塞?
  3. 线程池容量公式里的"等待/计算比"从哪来?
  4. macOS 与 Linux 的默认进程启动方式差异,会让什么写法失效?
  5. 线程超时为什么只能"不再等",而不能真正停止任务?
  6. 协程服务的可观测性必须补哪两类指标?
S10-A04 · Python 并发模型决策:线程、进程与协程
https://www.clxhxhhr.top/posts/6143/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。