选不选 asyncio,在 Python 里常常被当成风格问题,其实是容量结构问题:你打算用多少个连接换多少个核。线程模型受限于线程栈与 GIL 的切换开销,协程模型受限于事件循环的单线程纪律,多进程模型受限于内存与序列化成本。三条路的瓶颈位置完全不同,选错的代价不是"慢一点",而是"并发上来之后所有请求一起卡死"。
1. 三类并发模型在 CPython 下的实际行为
| 模型 | 并行粒度 | GIL 的影响 | 主要成本 | 适合的负载 |
|---|---|---|---|---|
| 线程 | OS 线程 | CPU 密集时无法并行 | 栈内存、切换、锁竞争 | 阻塞 IO、少量并发 |
| 协程 | 单线程内切换 | 单线程,天然不并行 | 事件循环纪律、库生态 | 高并发 IO、长连接 |
| 多进程 | 独立解释器 | 各自持有 GIL,真并行 | 内存翻倍、IPC 序列化 | CPU 密集、批处理 |
GIL 的准确表述:同进程内同一时刻只有一个线程执行字节码,但 IO 会释放它,所以线程模型对 IO 密集有效。协程的收益不来自"绕过 GIL",而是单线程承载更多等待中的任务——省的是栈与切换,不是 CPU 时间。
2. asyncio 何时真有收益
| 判据 | 有收益 | 无收益 |
|---|---|---|
| IO 等待占比 | 单请求大部分时间在等网络/磁盘 | 计算占主导 |
| 并发连接数 | 数百到数万,长连接 | 个位数并发 |
| 依赖生态 | 主要依赖有异步实现 | 只有同步 SDK |
一个粗略的容量判断:如果单请求平均阻塞等待远超计算时间,协程能把"线程数上限"这个约束换成"文件描述符与内存上限",容量提升是数量级的;反之,协程只增加了代码复杂度。
CPU 密集场景 asyncio 没有收益:把 numpy 或图片处理塞进协程,整个循环被占住,并发度退化到 1。
3. 同步阻塞库的三种处理
| 方案 | 复杂度 | 隔离性 | 适用 |
|---|---|---|---|
run_in_executor / to_thread |
低 | 中,占用线程池 | 少量调用、无异步替代 |
| 换异步库 | 中 | 好 | 生态成熟(HTTP、DB、Redis) |
| 拆成独立服务 | 高 | 最好 | 重阻塞、需独立扩缩容 |
import asyncio, functools
async def call_blocking(fn, *args, timeout=5):
loop = asyncio.get_running_loop()
# 线程池大小 = 默认 executor 上限,队列满时会排队而不是报错
return await asyncio.wait_for(
loop.run_in_executor(None, functools.partial(fn, *args)), timeout
)
隐患在于它把阻塞挪进线程池,没有消除阻塞:线程池打满后协程就排在这条路径上,表面异步、实际吞吐由线程池决定。所以只是过渡,高频路径必须换异步库或拆服务。
4. 事件循环的纪律
| 违规写法 | 后果 | 正确做法 |
|---|---|---|
| 协程里跑重计算 | 循环停摆,全部连接超时 | 丢进进程池或拆服务 |
time.sleep() |
阻塞循环,等同全局暂停 | await asyncio.sleep() |
| 同步文件 IO | 同上,大文件尤甚 | to_thread 或异步库 |
| 同步锁跨 await | 死锁或长期占用 | asyncio.Lock |
协程里 requests.get |
阻塞到响应返回 | httpx.AsyncClient |
time.sleep 与 asyncio.sleep 的区别不是风格:前者让出的是线程,后者让出的是循环。在一个承载上千连接的循环里,一次 time.sleep(1) 就是一次 1 秒的全站停顿。
# 循环延迟自检:健康值通常在毫秒级,持续抬升说明有人在阻塞循环
import asyncio, time
async def lag_monitor(interval=0.5):
while True:
t0 = time.perf_counter()
await asyncio.sleep(interval)
lag = time.perf_counter() - t0 - interval
LOOP_LAG.observe(lag) # 暴露为指标,不要只打日志
5. 任务生命周期:引用、异常、取消
| 问题 | 表现 | 处理 |
|---|---|---|
| 未持有 Task 引用 | 任务被 GC 回收,静默消失 | 存进集合并加 done callback |
| 异常被吞 | 日志里什么都没有 | task.add_done_callback 里取 exception() |
| 取消不传播 | 子任务在父任务取消后继续跑 | TaskGroup 或手动 cancel 全部子任务 |
| 无边界创建任务 | 请求量涨,任务数无上限 | 信号量或队列约束并发 |
import asyncio
async def main():
async with asyncio.TaskGroup() as tg: # 3.11+,结构化并发
tg.create_task(worker("a"))
tg.create_task(worker("b"))
# 任一子任务异常 → 其余被取消,异常聚合抛出
TaskGroup 的价值是把并发变成有作用域的块:块结束即全部结束,异常不会被吞。裸 create_task 则要自己管三件事:保引用、收异常、关闭时取消。
6. 超时与重试
async def fetch_with_budget(fn, total=8.0, attempts=3):
deadline = asyncio.get_running_loop().time() + total # 总预算,不是单次
for i in range(attempts):
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
raise TimeoutError("budget exhausted")
try:
async with asyncio.timeout(remaining):
return await fn()
except (TimeoutError, TransientError):
await asyncio.sleep(min(2 ** i * 0.1, remaining / 2))
raise TransientError("retries exhausted")
三点:预算放外层(单次超时 × 重试次数才是真实上限);退避有上限;按错误类型重试,4xx 重试只是加倍花钱。
重试放大最容易被忽略:一层请求带三层重试(网关、服务、端),最坏是 3×3×3 倍调用。上游必须能设总预算,否则"高可用"变成"高负载"。
7. 后台任务:进程内队列 vs 外部队列
| 维度 | 进程内(asyncio.Queue / BackgroundTasks) |
外部队列(Redis、RabbitMQ、Kafka) |
|---|---|---|
| 重启丢任务 | 丢 | 不丢(有 ack) |
| 多实例一致性 | 各进程各一份 | 天然共享 |
| 运维复杂度 | 无 | 需部署与监控 |
| 延迟 | 极低 | 毫秒级 |
| 适用 | 可丢的副作用(日志、埋点) | 不可丢的业务动作 |
判据只有一句:任务丢了会不会有人来问。会,就必须外置;不会,进程内队列足够,且省掉一整套运维。
# 进程内队列必须显式处理背压,否则内存无上限增长
async def worker(q: asyncio.Queue, stop: asyncio.Event):
while not stop.is_set():
item = await q.get()
try:
await handle(item)
except Exception:
log.exception("job failed") # 不要吞,否则任务静默丢失
finally:
q.task_done()
隐蔽问题是优雅关闭:收到 SIGTERM 直接退出,队列里的任务全丢。顺序应是停止接收 → 带超时排空 → 退出。
8. 多进程部署
| 部署形态 | 进程模型 | 注意 |
|---|---|---|
uvicorn 单进程 |
1 循环 | 只适合开发或极低流量 |
uvicorn --workers N |
多进程,各自循环 | 共享状态全部失效 |
gunicorn -k uvicorn.workers.UvicornWorker |
主进程 + worker | 超时与重启策略由 gunicorn 管 |
| 容器多副本 | 每副本多 worker | 两者相乘,注意连接数与内存 |
worker 数的经验起点是 CPU 核数(IO 密集可略高),但真正的约束往往是内存:每个 worker 都是完整解释器,加载模型或大依赖后单进程占用可能上 GB。
第一类事故是共享状态失效:进程内字典、计数器、限流器在 N 个 worker 下变成 N 份副本,拿它做限流,阈值被放大 N 倍。
9. 数据一致性:上下文变量与共享状态
| 状态类型 | 正确载体 | 错误做法 |
|---|---|---|
| 请求级上下文(trace_id、用户) | contextvars.ContextVar |
全局变量、线程局部 |
| 跨请求计数/限流 | Redis 等外部存储 | 进程内字典 |
| 连接池 | 每进程一个 | 跨进程共享对象 |
| 配置 | 启动时加载 | 运行时改全局 |
ContextVar 的传播是按任务复制:create_task 继承创建时的快照,之后父任务的修改不回流。这是隔离也是陷阱——任务启动后再设的 trace_id,子任务看不到。
import contextvars
trace_id = contextvars.ContextVar("trace_id", default=None)
async def handle(req):
trace_id.set(req.headers.get("x-trace-id"))
await asyncio.create_task(child()) # 子任务继承此刻的上下文快照
10. 可观测:协程泄漏与循环延迟
| 指标 | 采集方式 | 异常信号 |
|---|---|---|
| 活跃任务数 | len(asyncio.all_tasks()) |
单调上升 → 泄漏 |
| 循环延迟 | 定时自检(见 §4) | p99 抬升 → 有阻塞调用 |
| 队列深度 | 队列 qsize() |
持续增长 → 消费不足 |
泄漏最典型的成因是没有超时的 await:永不返回的调用让任务永久挂起,慢慢耗尽内存与句柄。检测办法是压测后 dump all_tasks() 的栈,看谁停在同一个 await 上。
11. 排错:异步系统变慢或卡死的六种情况
| 现象 | 根因 | 处理 |
|---|---|---|
| 并发上去后全部超时 | 循环里有阻塞调用 | 查循环延迟指标,定位阻塞点 |
| 吞吐上不去但 CPU 空闲 | 线程池打满 | 换异步库或调大 executor |
| 任务随机消失 | 未持有 Task 引用 | 保引用 + done callback |
| 关闭时丢任务 | 未做优雅关闭 | 排空队列再退出 |
| 限流不生效 | 多 worker 各持一份计数 | 计数外置到 Redis |
| 取消后资源未释放 | 未处理 CancelledError | finally 里做清理 |
12. 边界:什么时候不要 asyncio
| 情境 | 为什么 | 替代 |
|---|---|---|
| 一次性脚本 | 无并发需求 | 同步顺序执行 |
| 低流量小服务 | 复杂度成本高于容量收益 | 同步框架 + 多 worker |
| CPU 密集 | 协程不提供并行 | multiprocessing / 进程池 |
| 已有同步技术栈 | 改造面覆盖全部依赖 | 先横向扩副本,再局部异步 |
| 依赖只有同步 SDK | 处处 run_in_executor |
拆服务或换技术栈 |
最后一条最值得强调:异步不是免费的,它把复杂度从"线程安全"转到"循环纪律",而纪律的破坏是全局的——一个阻塞调用拖慢整个进程的所有请求,线程模型里一个慢线程只影响自己。这个"故障半径"才是选型该权衡的东西。
13. 自测
- 协程的收益来自哪里?为什么它不能绕过 GIL?
- 什么信号说明你的负载适合 asyncio,而不适合多进程?
run_in_executor为什么只是过渡手段?- 裸
create_task必须自己管哪三件事? - 多 worker 部署后限流阈值为什么会被放大?
- 线程模型与协程模型的"故障半径"差在哪里?