← 返回题目列表

异步程序卡住了怎么排查?asyncio 的 debug 模式能做什么?

中等 第 17 / 27 题 更新于 2026/08/01
asyncio调试事件循环py-spy

简化版

排查异步程序的第一步永远是打开 debug 模式asyncio.run(main(), debug=True) 或设环境变量 PYTHONASYNCIODEBUG=1。它会自动帮你发现四类问题:① 慢回调——任何在事件循环里执行超过 100ms 的回调都会打印 Executing <Task ...> took 0.512 seconds这是定位「阻塞事件循环」最快的手段(阈值可以用 loop.slow_callback_duration = 0.05 调低);② 没有被 await 的协程coroutine 'xxx' was never awaited);③ 未被取走的任务异常Task exception was never retrieved);④ 销毁时仍在 pending 的任务Task was destroyed but it is pending!)。第二个必备手段是「事件循环滞后探针」:一个后台协程循环 await asyncio.sleep(0.1) 并测量实际耗时,多出来的部分就是事件循环被阻塞的时长——这是可以常驻生产环境的健康指标第三个是 asyncio.all_tasks():打印当前所有任务及其栈(task.print_stack()),能直接看出「谁在等什么」;任务数持续增长则是任务泄漏的信号。线上无法改代码时用 py-spy dump --pid,零侵入地打印所有线程的调用栈。最容易混淆的一点:异步程序「卡住」通常不是死锁,而是「事件循环被某个同步阻塞调用占住了」——因为事件循环是单线程的,一个 time.sleep(5)、一次 requests.get()、一段 numpy 大矩阵运算,都会让所有协程停摆。核心记忆:先开 debug 模式看慢回调;常驻滞后探针;卡住先怀疑同步阻塞调用而不是死锁。

详细版

排查手段速查

手段用途能否用于生产
debug=True / PYTHONASYNCIODEBUG=1慢回调、未 await、任务异常⚠️ 有性能开销
事件循环滞后探针量化「循环被阻塞了多久」推荐常驻
asyncio.all_tasks() + print_stack()看「谁在等什么」
py-spy dump --pid零侵入看调用栈线上首选
loop.set_exception_handler兜底捕获未处理异常
faulthandler卡死时 dump 栈
aiomonitor交互式检查运行中的循环⚠️ 需要开端口
import asyncio, logging, time, sys

# ① ★开 debug 模式(第一步永远是它)★
asyncio.run(main(), debug=True)
# 或:PYTHONASYNCIODEBUG=1 python app.py
# 或:python -X dev app.py    (★开发模式,包含 asyncio debug★)

# 调低慢回调阈值(默认 0.1 秒)
loop = asyncio.get_running_loop()
loop.slow_callback_duration = 0.05        # ★50ms 就告警★
loop.set_debug(True)

# ② ★事件循环滞后探针(★生产常驻★)★
async def lag_monitor(interval=0.1, threshold=0.05):
    while True:
        t0 = time.perf_counter()
        await asyncio.sleep(interval)
        lag = time.perf_counter() - t0 - interval
        if lag > threshold:
            logging.warning("事件循环滞后 %.3f 秒", lag)   # ★被阻塞了这么久★
        metrics.gauge("event_loop.lag", lag)

# ③ ★看当前所有任务在干什么★
def dump_tasks():
    tasks = asyncio.all_tasks()
    logging.info("当前任务数: %d", len(tasks))
    for t in tasks:
        logging.info("  %s state=%s", t.get_name(), t._state)
        t.print_stack(limit=5)             # ★打印它当前挂在哪一行★
        # 或 stack = t.get_stack(); coro = t.get_coro()

# ④ 用信号触发 dump(★线上排查神器★)
import signal, faulthandler
faulthandler.register(signal.SIGUSR1)      # ★kill -USR1 <pid> 打印所有线程栈★
signal.signal(signal.SIGUSR2, lambda *_: dump_tasks())

# ⑤ ★兜底捕获未处理异常★
def handler(loop, context):
    logging.error("asyncio 未处理异常: %s", context["message"],
                  exc_info=context.get("exception"))
loop.set_exception_handler(handler)

# ⑥ 检测阻塞调用(★开发期用★)
# pip install blockbuster / 或自己用 debug 模式的慢回调告警
# 也可以在关键位置手动埋点:
async def timed(coro, name, threshold=0.1):
    t0 = time.perf_counter()
    try:
        return await coro
    finally:
        d = time.perf_counter() - t0
        if d > threshold:
            logging.warning("%s 耗时 %.3fs", name, d)

# ⑦ 常见 warning 的含义
# RuntimeWarning: coroutine 'foo' was never awaited
#   → ★调用了 async 函数但没 await★(忘了 await 或忘了 create_task)
# Task exception was never retrieved
#   → ★任务抛异常但没人取★(加 done_callback)
# Task was destroyed but it is pending!
#   → ★事件循环关闭时还有任务在跑★(退出前没 cancel/await)
# RuntimeError: Event loop is closed
#   → ★在 loop 关闭后还在用它★(常见于 __del__、atexit、后台线程)

⚠️ 三个必须建立的认知:① 异步程序「卡住」几乎从来不是死锁,而是「事件循环被同步代码占住了」。事件循环是单线程的——一个 time.sleep(5)、一次 requests.get()、一段 json.loads(100MB)、一次密集的正则匹配,都会让所有协程一起停摆:其他请求不处理、超时不触发、心跳发不出去(连接被对端断开)。所以看到「服务偶尔无响应」「P99 出现尖刺」,第一怀疑对象是某处混进了同步阻塞调用,而 debug 模式的慢回调告警正是为此设计的。② debug=True 有性能开销(它会记录协程创建时的 traceback、检查每个回调的耗时),不建议长期开在生产核心路径上;生产环境应该用「事件循环滞后探针」这种低成本的替代方案,加上 py-spy 做临时诊断。③ asyncio.all_tasks() 的数量是最重要的健康指标之一——它持续增长意味着任务泄漏(创建了任务却从没结束,比如 while True 的 worker 没被 cancel、或者等待一个永远不会完成的 Future),最终会耗尽内存;而它长期贴着某个上限通常说明下游变慢、任务积压。

完整版教学

一、debug 模式做了什么

开启方式(三选一):
  asyncio.run(main(), debug=True)
  PYTHONASYNCIODEBUG=1 python app.py
  python -X dev app.py            # ★开发模式:包含 asyncio debug + 更多警告★
  # 运行中开关:loop.set_debug(True)

★ 它会做四件事:

  ① ★检测慢回调★(最有价值)
     任何在事件循环里执行超过 loop.slow_callback_duration(★默认 0.1 秒★)
     的回调/任务步骤,都会打印:
       Executing <Task pending name='Task-3' coro=<handler() at app.py:42>
                 wait_for=<Future pending>> took 0.512 seconds
     → ★直接告诉你:哪个协程、哪个文件哪一行、卡了多久★
     → 这是定位"阻塞事件循环"最快的手段
     调低阈值抓更小的阻塞:loop.slow_callback_duration = 0.02

  ② ★记录协程/Future 的创建位置★
     非 debug 模式下,"coroutine was never awaited" 警告只告诉你协程名字;
     debug 模式会附上★创建它的完整 traceback★ → 知道是哪行代码创建的

  ③ ★更严格的检查★
     - 非线程安全地调用 loop 的方法 → 报错
     - Future/Task 在错误的 loop 上被 await → 报错
     - 资源没关闭(transport、生成器)→ 警告

  ④ ★销毁时仍 pending 的任务会警告★
     Task was destroyed but it is pending!

★ 代价(★为什么不能一直开★):
  - 每个协程创建时都要抓 traceback → ★明显的性能开销★
  - 每个回调都要计时
  - 更多的检查
  → 实测在高并发下吞吐可能下降 10%~30%
  → ★开发和测试环境常开、生产环境按需临时开★

★ 一个典型的排查过程:
  现象:接口偶尔超时,P99 有尖刺
  ① 本地/预发开 debug 模式压测
  ② 日志里看到:Executing <Task ... coro=<get_user() at api.py:88>> took 0.8 seconds
  ③ 打开 api.py:88 → 发现是 `img = Image.open(path)`(★同步 IO + CPU★)
  ④ 改成 await asyncio.to_thread(Image.open, path)
  ⑤ 重新压测确认慢回调告警消失

debug 模式是排查异步问题的第一步,开启方式有三种(asyncio.run(debug=True)PYTHONASYNCIODEBUG=1python -X dev)。它做四件事,其中最有价值的是检测慢回调:任何在事件循环里执行超过 slow_callback_duration默认 0.1 秒)的回调都会打印 Executing <Task ... coro=<handler() at app.py:42>> took 0.512 seconds——直接告诉你哪个协程、哪个文件的哪一行、卡了多久,这是定位「阻塞事件循环」最快的手段。其次它会记录协程创建时的完整 traceback(非 debug 模式下 never awaited 警告只有协程名字,debug 模式能告诉你是哪行代码创建的)、做更严格的检查(跨线程调用、跨 loop await)、以及警告销毁时仍 pending 的任务。代价是明显的性能开销(每个协程创建都要抓 traceback、每个回调都要计时,高并发下吞吐可能降 10%~30%),所以开发测试常开、生产按需临时开

二、事件循环滞后:生产环境的核心指标

★ 原理:睡多久 vs 实际睡了多久
  async def lag_monitor(interval=0.1):
      while True:
          t0 = time.perf_counter()
          await asyncio.sleep(interval)          # ★我要求睡 100ms★
          lag = time.perf_counter() - t0 - interval
          # ★lag 就是"事件循环没能及时唤醒我"的时长 = 它被别的事占住了★
          if lag > 0.05:
              logging.warning("事件循环滞后 %.3fs", lag)

  为什么准确:
    asyncio.sleep(0.1) 到期后,事件循环要在下一轮调度里唤醒这个协程;
    如果循环正忙着执行别的(同步阻塞代码、大量回调),唤醒就会被推迟
    → ★推迟的时长 = 事件循环的"卡顿时长"★

★ 数值怎么看:
  < 5ms      健康(正常调度抖动)
  5~50ms     有压力(回调多或有中等阻塞)
  ★> 100ms★ 有明确的阻塞点 → 该查了
  ★> 1s★    严重(超时判断已经失真、心跳可能已经断了)

★ 为什么它比 debug 模式更适合生产:
  ✓ 开销极小(一个协程 + 一次时间读取)
  ✓ ★可以常驻★,接入监控系统做告警
  ✓ 给出的是"整体健康度"而不是逐个回调的细节
  ✗ 只告诉你"卡了",★不告诉你卡在哪★ → 配合 py-spy 定位

★ 配套的三个指标(一起看才有用):
  ① ★事件循环滞后★                 → 有没有被阻塞
  ② ★asyncio.all_tasks() 的数量★   → 任务是否泄漏/积压
  ③ ★在途请求数 / 队列长度★        → 下游是否变慢

  async def metrics_loop():
      while True:
          metrics.gauge("loop.lag", measure_lag())
          metrics.gauge("loop.tasks", len(asyncio.all_tasks()))
          await asyncio.sleep(10)

★ 进阶:用 loop.call_later 测更精确的调度延迟
  def probe():
      actual = time.perf_counter()
      lag = actual - scheduled
      ...
      loop.call_later(0.1, probe)
  → 比 sleep 版本少一层协程调度开销

事件循环滞后探针是生产环境最实用的健康指标:原理是「我要求睡 100ms,实际睡了多久」——多出来的部分就是事件循环被别的事占住的时长。数值的判读标准:小于 5ms 健康、5~50ms 有压力、超过 100ms 说明有明确的阻塞点、超过 1 秒则超时判断已经失真、心跳可能已经断了。它比 debug 模式更适合生产的原因是开销极小(一个协程 + 一次时间读取)、可以常驻并接入告警;代价是只告诉你「卡了」而不告诉你「卡在哪」,需要配合 py-spy 定位。实践中要和另外两个指标一起看:asyncio.all_tasks() 的数量(判断任务泄漏或积压)和在途请求数/队列长度(判断下游是否变慢)——三个指标组合起来才能区分「被阻塞」「任务泄漏」「下游变慢」这三种不同的故障。

三、症状 → 原因对照表

★ 症状一:程序卡住不动,CPU 占用为 0★
  可能原因:
    ① ★等待一个永远不会完成的 await★(Future 没人 set_result、
       队列没人 put、锁没人释放)
    ② 死锁:两个协程互相等对方持有的 asyncio.Lock
    ③ 等待一个已经被取消/丢弃的任务
  排查:
    ✓ ★py-spy dump --pid★ 看主线程栈(一眼看出卡在哪个 await)
    ✓ asyncio.all_tasks() + task.print_stack()
    ✓ 检查所有 await 是否都有超时

★ 症状二:程序卡住,CPU 占用 100%★
  → ★不是异步问题,是同步的 CPU 密集代码占住了事件循环★
  常见:大 JSON 解析、正则灾难性回溯、密集循环、图像处理
  排查:py-spy top --pid(★看哪个函数在烧 CPU★)
  修法:asyncio.to_thread(IO/释放 GIL 的计算)或 ProcessPoolExecutor(纯 CPU)

★ 症状三:偶发的延迟尖刺(P99 很差但 P50 正常)★
  → ★典型的"混进了同步阻塞调用"★
  常见元凶:requests、time.sleep、同步 DB 驱动、文件读写、
            logging 写慢盘、DNS 解析、import
  排查:★debug 模式的慢回调告警★ + 滞后探针
  ★ 注意:单次阻塞 100ms,会让当时在飞的所有请求都多等 100ms

★ 症状四:任务好像没执行★
  ① ★忘了 await★ → RuntimeWarning: coroutine was never awaited
  ② ★create_task 没保存引用★ → 任务可能被 GC 掉
  ③ 任务抛异常但异常被吞(★没人 await★)
  ④ 任务在 loop 关闭前没跑完
  排查:debug 模式 + set_exception_handler + all_tasks 计数

★ 症状五:内存持续增长★
  ① ★任务泄漏★:all_tasks() 数量单调上升
     常见:while True 的 worker 没 cancel、每个请求 create_task 但没结束条件
  ② 无界队列积压
  ③ 未关闭的连接/响应体(aiohttp 的 response 没 release)
  排查:定期打印 len(asyncio.all_tasks())、tracemalloc

★ 症状六:退出时报一堆警告★
  Task was destroyed but it is pending!
  → ★事件循环关闭时还有任务在跑★
  ✓ 优雅关闭:cancel 所有任务 → gather(..., return_exceptions=True) → 再关闭
    tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
    for t in tasks: t.cancel()
    await asyncio.gather(*tasks, return_exceptions=True)

★ 症状七:RuntimeError: Event loop is closed★
  → 在 loop 关闭之后还在用它
  常见:__del__ 里调 loop、atexit、后台线程里 call_soon、
        ★aiohttp session 没 close 就退出★
  ✓ 用 async with 管理生命周期;退出前显式 close

这张对照表是排查的核心。「卡住 + CPU 为 0」 是在等一个永远不会完成的 await(Future 没人 set_result、队列没人 put、锁没人释放)或死锁——用 py-spy dump 一眼看出卡在哪。「卡住 + CPU 100%」 根本不是异步问题,而是同步的 CPU 密集代码占住了事件循环(大 JSON 解析、正则灾难性回溯、图像处理)——用 py-spy top 看哪个函数在烧 CPU。「偶发延迟尖刺(P99 差但 P50 正常)」是典型的「混进了同步阻塞调用」requeststime.sleep、同步 DB 驱动、写慢盘的日志)——单次阻塞 100ms 会让当时在飞的所有请求都多等 100ms「任务好像没执行」要查四点:忘了 await、create_task 没保存引用被 GC、异常被吞、loop 关闭前没跑完。「内存持续增长」先看 all_tasks() 数量是否单调上升(任务泄漏)。「退出时一堆 Task was destroyed but it is pending 说明关闭前没有 cancel 并等待剩余任务。

四、看清运行时状态

★ asyncio.all_tasks():当前所有未完成的任务
  tasks = asyncio.all_tasks()          # ★只包含当前 loop 的、未完成的★
  asyncio.current_task()               # 当前正在执行的任务

  for t in tasks:
      print(t.get_name())              # ★3.8+ 可以在 create_task(name=) 时命名★
      print(t.get_coro())              # 底层协程对象
      print(t._state)                  # PENDING / FINISHED / CANCELLED
      t.print_stack(limit=10)          # ★打印它当前挂起在哪★
      # stack = t.get_stack()          # 拿到帧列表自己处理

  ★ print_stack 的输出:
    Stack for <Task pending name='fetch-3' coro=<fetch() ...>>:
      File "app.py", line 42, in fetch
        async with session.get(url) as resp:     ← ★它正挂在这一行★

★ 给任务命名(★排查时极其有用★):
  asyncio.create_task(work(), name=f"handle-{request_id}")
  → dump 时能直接看出是哪个请求的任务
  ★ TaskGroup.create_task 也支持 name

★ 用信号触发 dump(★线上不改代码也能用★):
  import signal, faulthandler
  faulthandler.register(signal.SIGUSR1)         # kill -USR1 → 所有线程栈
  def dump_async(*_):
      for t in asyncio.all_tasks(loop):
          t.print_stack()
  loop.add_signal_handler(signal.SIGUSR2, dump_async)   # kill -USR2 → 任务栈

★ py-spy(★线上首选,零侵入★):
  py-spy dump --pid 1234            # ★打印所有线程当前栈(不用改代码、不用重启)★
  py-spy top --pid 1234             # 实时看哪个函数占 CPU
  py-spy record -o out.svg --pid 1234   # 火焰图
  ★ 对异步程序:dump 出来的是"事件循环线程正在执行的那个协程"
    → 如果它卡在某个同步函数里 → ★就是它阻塞了循环★

★ aiomonitor(交互式):
  pip install aiomonitor
  with aiomonitor.start_monitor(loop):
      loop.run_forever()
  # 另一个终端:nc localhost 50101
  # 命令:ps(列任务)、where <taskid>(看栈)、cancel <taskid>
  ★ 需要开监听端口,生产环境要注意安全

★ 组合排查流程(★推荐顺序★):
  ① 看监控:★事件循环滞后★ + 任务数 + 在途请求
  ② 滞后高 → py-spy top(是不是 CPU 密集)/ py-spy dump(卡在哪)
  ③ 任务数涨 → dump 任务列表,看是哪类任务在堆积
  ④ 本地复现 → ★debug=True★ 拿到精确的慢回调位置
  ⑤ 修完再压测验证(滞后指标回到正常)

看清运行时状态有三层工具。asyncio.all_tasks() 列出当前所有未完成的任务,配合 task.print_stack() 能直接打印出「它正挂起在哪一行」——给任务命名(create_task(coro, name=...))后排查会容易得多用信号触发 dump 让你在线上不改代码就能取到现场:faulthandler.register(signal.SIGUSR1) 打印所有线程栈,loop.add_signal_handler(SIGUSR2, dump_async) 打印任务栈。py-spy 是线上首选(零侵入、不用重启):py-spy dump --pid 看当前栈、py-spy top --pid 看谁在烧 CPU、py-spy record 出火焰图——对异步程序来说,dump 出来的就是「事件循环线程正在执行的那个协程」,如果它卡在某个同步函数里,那就是罪魁祸首。推荐的排查顺序是:看监控(滞后 + 任务数)→ 滞后高就 py-spy → 任务数涨就 dump 任务列表 → 本地用 debug=True 拿到精确位置 → 修完压测验证

五、常见警告的含义与修法

★ ① RuntimeWarning: coroutine 'foo' was never awaited
  含义:调用了 async 函数得到协程对象,但★从来没有 await 或 create_task★
  常见原因:
    foo()                          # ✗ 忘了 await
    asyncio.gather(foo, bar)       # ✗ 传的是函数不是调用结果
    tasks.append(foo())            # 后面忘了 await tasks
    在同步函数里调用了 async 函数
  ✓ debug 模式会附上★协程创建位置的 traceback★ → 一眼定位

★ ② Task exception was never retrieved
  含义:任务抛了异常,但★没人 await/result()/exception() 取走★
  → Task 被 GC 时打印(★时机不确定★)
  ✓ 修法:给后台任务加 done_callback 记录异常(见异常处理专题)

★ ③ Task was destroyed but it is pending!
  含义:★事件循环关闭时,还有任务处于 pending★
  常见:asyncio.run 结束了但后台任务还在跑;忘了 cancel worker
  ✓ 优雅关闭模板:
    async def shutdown():
        tasks = [t for t in asyncio.all_tasks()
                 if t is not asyncio.current_task()]
        for t in tasks:
            t.cancel()
        await asyncio.gather(*tasks, return_exceptions=True)
  ★ asyncio.run() 本身会做这件事(3.8+ 会 cancel 剩余任务),
    但★不会等你的清理逻辑做完★ → 复杂场景要自己管

★ ④ RuntimeError: Event loop is closed
  含义:loop 关闭后还在用它
  常见:
    - ★aiohttp.ClientSession 没 close★(析构时想关连接,loop 已经没了)
    - __del__ 里调用异步 API
    - 后台线程里 call_soon_threadsafe
    - 用了 asyncio.run 多次,前一个 loop 的对象被后面用了
  ✓ 用 async with 管理会话/连接的生命周期
  ✓ ★asyncio 的对象不要跨 loop 使用★

★ ⑤ RuntimeError: This event loop is already running
  含义:在已经运行的 loop 里又调 run_until_complete / asyncio.run
  常见:Jupyter、某些框架内部、同步函数里想"顺手跑个协程"
  ✓ 已经在异步上下文里 → 直接 await
  ✓ Jupyter → 用 await(它本身就在 loop 里)或 nest_asyncio(★不推荐★)

★ ⑥ RuntimeError: no running event loop
  含义:在没有运行的 loop 时调用了需要 loop 的 API(如 create_task)
  ✓ 确保在协程内部调用;同步线程里要用 run_coroutine_threadsafe

★ ⑦ Executing <Task ...> took X seconds
  → ★debug 模式的慢回调告警★,最有价值的一条
  → 直接定位阻塞点

★ ⑧ aiohttp 的 "Unclosed client session" / "Unclosed connector"
  → session 没关 → 连接泄漏
  ✓ async with aiohttp.ClientSession() as s: ...

八类常见警告要能一眼认出。coroutine was never awaited 是「调用了 async 函数但没 await」(debug 模式会附上创建位置的 traceback,一眼定位)。Task exception was never retrieved 是「任务抛异常没人取」。Task was destroyed but it is pending! 是「事件循环关闭时还有任务在跑」——需要在退出前 cancel 所有任务并 gather(return_exceptions=True) 等它们收尾。Event loop is closed 最常见的原因是 aiohttp.ClientSession 没有 close(析构时想关连接但 loop 已经没了),解法是用 async with 管理生命周期、asyncio 的对象不要跨 loop 使用This event loop is already running 出现在「已经在 loop 里又调 asyncio.run」的场景(Jupyter 里最常见,正确做法是直接 await)。no running event loop 则是在同步线程里调用了 create_task(应该用 run_coroutine_threadsafe)。

六、预防:让问题更容易被发现

★ 开发期:
  □ ★本地和 CI 一律开 debug 模式★(python -X dev 或 PYTHONASYNCIODEBUG=1)
  □ ★把警告变成错误★:python -W error::RuntimeWarning
    → "coroutine was never awaited" 直接失败,不会漏掉
  □ pytest 里配 filterwarnings = error
  □ 用类型检查(mypy)发现"忘了 await"(★返回 Coroutine 却当值用★)
  □ lint:ruff 的 ASYNC 规则(检测异步函数里的阻塞调用)

★ 代码习惯:
  □ ★所有 create_task 都通过统一的 spawn() 工具★(引用 + 命名 + 异常回调)
  □ ★所有 await 都有超时★(asyncio.timeout / wait_for)
  □ ★禁止在协程里调用同步 IO★(评审时重点看:requests、time.sleep、
    open().read()、同步 DB 驱动)
  □ CPU 密集 → to_thread / ProcessPoolExecutor
  □ 用 async with 管理所有资源(session、连接、锁)

★ 生产环境:
  □ ★事件循环滞后探针★(常驻 + 告警)
  □ ★asyncio.all_tasks() 数量★打点(泄漏检测)
  □ ★loop.set_exception_handler★ 上报 Sentry
  □ 注册 ★faulthandler + 信号 dump★(出事时能远程取现场)
  □ 部署 py-spy 到镜像里(★出事时能立刻 dump★)
  □ 日志里带 ★request_id(contextvars)★,能串起一次请求的全部日志

★ 一个可复用的诊断端点(内网访问):
  @app.get("/_debug/tasks")
  async def debug_tasks():
      out = []
      for t in asyncio.all_tasks():
          out.append({
              "name": t.get_name(),
              "done": t.done(),
              "stack": [str(f) for f in t.get_stack(limit=3)],
          })
      return {"count": len(out), "tasks": out, "lag": current_lag}
  ★ 必须做鉴权 + 只对内网开放

★ 排查心法(★一句话★):
  ★"异步程序的问题 90% 是'某处阻塞了事件循环'或'某个任务没有所有者'——
    前者用慢回调告警和 py-spy 定位,后者用 all_tasks 计数和
    done_callback 暴露。"★

预防比排查更重要。开发期:本地和 CI 一律开 debug 模式(python -X dev)、把警告变成错误-W error::RuntimeWarningcoroutine was never awaited 直接失败)、用 mypy 发现「忘了 await」、用 ruff 的 ASYNC 规则检测协程里的阻塞调用。代码习惯:所有 create_task 走统一的 spawn() 工具(引用 + 命名 + 异常回调)、所有 await 都有超时禁止在协程里调用同步 IO(评审时重点看 requeststime.sleep、同步 DB 驱动)。生产环境:常驻滞后探针任务数打点set_exception_handler 上报 Sentry、注册 faulthandler + 信号 dump镜像里装好 py-spy。还可以做一个鉴权保护的内网诊断端点直接返回任务列表和滞后值。心法是:异步程序的问题 90% 是「某处阻塞了事件循环」或「某个任务没有所有者」——前者用慢回调告警和 py-spy 定位,后者用 all_tasks 计数和 done_callback 暴露

记忆钩子:「排查异步问题的★第一步永远是开 debug 模式★:asyncio.run(main(), debug=True) / PYTHONASYNCIODEBUG=1 / python -X dev。它最有价值的能力是★慢回调告警★——任何在事件循环里执行超过 slow_callback_duration(★默认 0.1 秒★)的回调都会打印『Executing <Task … coro=<handler() at app.py:42>> took 0.512 seconds』,★直接给出协程名、文件行号和耗时★;此外还会附上协程创建位置的 traceback、做跨线程/跨 loop 检查、警告销毁时仍 pending 的任务。代价是★明显的性能开销★(每个协程创建都抓 traceback),所以★开发常开、生产按需★。生产环境常驻的是★事件循环滞后探针★:循环 await asyncio.sleep(0.1) 并测实际耗时,★多出来的部分就是循环被阻塞的时长★(<5ms 健康、>100ms 该查了、>1s 说明超时判断已失真);配套还要打点 ★asyncio.all_tasks() 的数量★(★持续增长=任务泄漏★)和在途请求数。★最重要的认知:异步程序『卡住』几乎从不是死锁,而是事件循环被同步阻塞调用占住了★——单线程的循环里一个 time.sleep/requests.get/大 JSON 解析就会让所有协程停摆、超时不触发、心跳断连;所以『P99 尖刺但 P50 正常』第一怀疑同步调用混入。症状对照:★卡住+CPU 为 0★=等一个永不完成的 await 或死锁;★卡住+CPU 100%★=同步 CPU 密集代码;★内存涨★先看 all_tasks 是否单调上升。工具链:★py-spy dump/top —pid 是线上首选(零侵入、不用重启)★,faulthandler + 信号可以远程触发 dump,task.print_stack() 看『挂在哪一行』(记得 create_task(name=) 命名)。常见警告速记:coroutine was never awaited(忘了 await)、Task exception was never retrieved(异常没人取)、★Task was destroyed but it is pending(关闭前没 cancel)★、Event loop is closed(多半是 aiohttp session 没 close)。预防:★CI 里 -W error::RuntimeWarning 把警告变成错误★、所有 await 加超时、所有 create_task 走统一 spawn 工具。」

七、常见误区与追问

  • 误区:异步程序卡住不动,肯定是发生死锁了。 绝大多数情况不是死锁,而是「事件循环被同步阻塞调用占住了」。事件循环是单线程的——协程之间靠 await 主动让出控制权,一旦某段代码进入了不会让出的同步调用(time.sleep(5)requests.get()、同步数据库驱动、json.loads(100MB)、灾难性正则回溯、大矩阵运算),整个循环就停在那里:其他协程不被调度、超时不触发、心跳发不出去(连接被对端断开)、健康检查失败。判断方法很简单:看 CPU —— CPU 接近 0 才可能是「等一个永远不会完成的 await」或真正的死锁;CPU 打满则一定是同步 CPU 密集代码。用 py-spy top --pid 一眼就能区分。

  • 误区:debug=True 开销很小,生产环境也可以一直开着。 它做的事不便宜:为每个协程和 Future 记录创建时的 traceback(涉及栈帧遍历和字符串构造)、为每个回调计时、以及一系列额外的一致性检查。在高并发场景下实测吞吐可能下降 10%~30%,而且内存也会因为保存 traceback 而增加。正确做法是开发和测试环境常开(配合 python -X dev 还能拿到更多警告)、生产环境用低成本的替代方案:常驻「事件循环滞后探针」做健康度监控,临时诊断时用 py-spy(零侵入、不用重启),确实需要精确定位时在预发环境复现并开启 debug 模式

  • 误区:asyncio.all_tasks() 返回的是所有创建过的任务。只返回「当前事件循环中尚未完成」的任务——已经完成、取消或抛异常结束的任务不在其中;而且它只看当前 loop(在多 loop 或多线程场景下,别的 loop 的任务看不到)。正因为如此,它才成为一个有意义的健康指标:这个数字持续单调增长就意味着任务泄漏(创建了任务却永远不结束,比如 while True 的 worker 没被 cancel、或者在等一个永远不会 set_result 的 Future),最终会耗尽内存;而长期贴着某个较高的值通常说明下游变慢、任务积压。要注意在 asyncio.run() 之外调用它会抛 RuntimeError: no running event loop,需要显式传 loop 参数或在协程内调用。

  • 误区:看到 coroutine 'foo' was never awaited 警告,加个 # noqa 忽略就行。 这个警告意味着你写的那段异步逻辑根本没有执行——协程对象被创建出来后就被丢弃了,函数体里的代码一行都没跑。常见原因有四类:忘了 awaitfoo() 而不是 await foo())、gather 传了函数而不是调用结果gather(foo, bar) 应为 gather(foo(), bar()))、在同步函数里调用了 async 函数、以及创建了协程列表但忘了 await。它的危害是静默的功能缺失:数据没写、通知没发、缓存没刷新,而程序一切正常。正确做法是在 CI 里用 python -W error::RuntimeWarning 把它变成错误(pytest 里配 filterwarnings = error),并开启 debug 模式获取协程创建位置的完整 traceback来精确定位。

  • 误区:程序退出时打印 Task was destroyed but it is pending! 只是个无害的提示。 它说明事件循环关闭时还有任务处于运行中——那些任务被强制销毁,它们的清理逻辑(finally 块、async with__aexit__)没有机会执行:连接没关、事务没提交或回滚、缓冲区没 flush、分布式锁没释放、正在写的文件停在半截。在需要数据一致性的场景这是真实的数据风险。正确的优雅关闭是:cancel() 所有非当前任务 → await asyncio.gather(*tasks, return_exceptions=True) 等它们真正收尾 → 再关闭 loop。要注意 asyncio.run() 虽然会在退出时 cancel 剩余任务,但它不保证给你的清理逻辑足够的时间——复杂服务应该自己实现关闭流程(配合信号处理器),并给收尾操作设置合理的超时。

  • 追问:怎么定位是哪一行代码阻塞了事件循环? 三条路径按顺序试。① debug 模式的慢回调告警(最精确)asyncio.run(main(), debug=True),日志会直接给出 Executing <Task ... coro=<handler() at app.py:42>> took 0.512 seconds——协程名、文件、行号、耗时全都有;把 loop.slow_callback_duration 调低到 0.02 还能抓到更小的阻塞。py-spy top --pid(线上零侵入):实时看哪个函数占 CPU,如果榜首是 json.loadsre.searchImage.open 这类同步函数,基本就锁定了。py-spy dump --pid:打印事件循环线程当前的完整调用栈——如果栈顶是一个同步函数而不是 epoll_wait/select,那就是它在阻塞。补充手段是自己在可疑区域埋点计时(t0 = perf_counter() … 超过阈值就 warning),以及用 ruff 的 ASYNC 规则在静态层面扫出协程里的阻塞调用(requeststime.sleepopen)。

  • 追问:py-spy 对异步程序的输出该怎么读? 关键是理解异步程序通常只有一个「事件循环线程」在跑所有协程,所以 py-spy dump 打出来的主线程栈就是「此刻正在执行的那个协程」的栈。读法分三种情况:① 栈顶是 epoll_wait/select/kqueue —— 事件循环正在等 IO,这是健康状态(说明没有协程在占用 CPU);② 栈顶是某个同步函数json.loadsssl.do_handshakeImage.open、你自己的计算函数)—— 它正在阻塞事件循环,就是元凶③ 栈里能看到 run_until_complete_run_once → 你的协程函数 —— 中间那一层层就是当前协程的调用链。另外 py-spy dump 会显示所有线程,如果你用了 to_thread 或线程池,能看到工作线程各自在做什么。局限是:它只能看到「此刻」的状态,对于「偶发卡顿」需要多次采样或用 py-spy record 出火焰图;而且它看不到「有多少个协程在等待」——那要靠 asyncio.all_tasks()

  • 追问:生产环境不能开 debug 模式,出了问题怎么留下现场? 靠「预埋 + 按需触发」这套组合,四件事在上线前就要做好。① 常驻低成本指标:事件循环滞后探针、asyncio.all_tasks() 数量、在途请求数——这三个指标打到监控系统并配告警,出问题时能立刻判断是「被阻塞」「任务泄漏」还是「下游变慢」② 信号触发的 dumpfaulthandler.register(signal.SIGUSR1)kill -USR1 <pid> 打印所有线程栈,再用 loop.add_signal_handler(SIGUSR2, dump_tasks) 打印所有任务的挂起位置——不用重启、不用改代码就能取到现场③ 镜像里预装 py-spy:出事时 py-spy dump --pid 1 零侵入抓栈(容器里记得给 SYS_PTRACE 权限或用 --pid--nonblocking)。④ 兜底的 loop.set_exception_handler 把所有未处理的异步异常上报到 Sentry。此外还可以做一个受鉴权保护、只对内网开放的诊断端点(返回任务列表、栈摘要、当前滞后值),但要注意它本身也在事件循环里执行,dump 大量任务栈可能造成新的卡顿——限制返回数量并加频率限制。

八、加强记忆

排查异步问题的第一步永远是开 debug 模式asyncio.run(main(), debug=True) / PYTHONASYNCIODEBUG=1 / python -X dev。它最有价值的能力是慢回调告警——任何在事件循环里执行超过 slow_callback_duration默认 0.1 秒)的回调都会打印 Executing <Task ... coro=<handler() at app.py:42>> took 0.512 seconds直接给出协程名、文件行号和耗时;此外它还会附上协程创建位置的 traceback、做跨线程/跨 loop 的检查、并警告销毁时仍 pending 的任务。代价是明显的性能开销(每个协程创建都要抓 traceback,高并发下吞吐可能降 10%~30%),所以开发常开、生产按需临时开。生产环境常驻的是事件循环滞后探针:循环 await asyncio.sleep(0.1) 并测量实际耗时,多出来的部分就是循环被阻塞的时长(小于 5ms 健康、超过 100ms 该查了、超过 1 秒说明超时判断已经失真);配套还要打点 asyncio.all_tasks() 的数量持续单调增长 = 任务泄漏)和在途请求数。最重要的认知:异步程序「卡住」几乎从来不是死锁,而是事件循环被同步阻塞调用占住了——单线程的循环里一个 time.sleep、一次 requests.get、一段大 JSON 解析就会让所有协程停摆、超时不触发、心跳断连;所以「P99 尖刺但 P50 正常」要第一时间怀疑同步调用混入。症状对照:卡住 + CPU 为 0 是在等一个永不完成的 await 或死锁;卡住 + CPU 100% 是同步 CPU 密集代码;内存涨先看 all_tasks() 是否单调上升。工具链上,py-spy dump/top --pid 是线上首选(零侵入、不用重启,栈顶是 epoll_wait 说明健康、是同步函数就是元凶),faulthandler + 信号可以远程触发 dump,task.print_stack() 能看出「挂在哪一行」(记得用 create_task(coro, name=...) 命名)。常见警告速记:coroutine was never awaited(忘了 await,功能静默缺失)、Task exception was never retrieved(异常没人取)、Task was destroyed but it is pending(关闭前没 cancel,清理逻辑没执行)、Event loop is closed(多半是 aiohttp session 没 close)。预防上最有效的一条:CI 里用 -W error::RuntimeWarning 把警告变成错误,再加上「所有 await 有超时」和「所有 create_task 走统一的 spawn 工具」。