2798 字
约 9 分钟
14
S10-A03 · Python 异步架构:asyncio、后台任务与进程模型

选不选 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. 自测

  1. 协程的收益来自哪里?为什么它不能绕过 GIL?
  2. 什么信号说明你的负载适合 asyncio,而不适合多进程?
  3. run_in_executor 为什么只是过渡手段?
  4. 裸 create_task 必须自己管哪三件事?
  5. 多 worker 部署后限流阈值为什么会被放大?
  6. 线程模型与协程模型的"故障半径"差在哪里?
S10-A03 · Python 异步架构:asyncio、后台任务与进程模型
https://www.clxhxhhr.top/posts/6083/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。