幕 III · 三种活法CH 13进度 13/25

Python:asyncio —— 一个事件循环,和它的染色问题

asyncio 名声不太好。「难用」「生态分裂」「一不小心就卡死」—— 这些抱怨全是真的,但它们的成因不是 asyncio 设计得差,而是它被 GIL 逼到了一个死角:它必须是单线程的(第 11 章那个 N:1),于是它继承了事件循环的全部脆弱性。理解 asyncio 的关键,是理解那个循环本身 —— 它只有二十几行的核心逻辑。这一章会把它单步执行给你看,然后让你亲眼看到一行 time.sleep 是怎么把整个服务搞挂的

事件循环coroutine 对象TaskGroup染色to_thread

事件循环:把它拆开,只有三样东西

剥掉所有的糖,asyncio 的事件循环就是一个 while 循环 + 两个队列

def run_forever(self):                 # 这就是 asyncio 的心脏(极简版)
    while True:
        # ① 有没有到期的定时器? 把它们的回调塞进 ready
        now = time.monotonic()
        while self._timers and self._timers[0].when <= now:
            self._ready.append(heapq.heappop(self._timers).callback)

        # ② 等 IO —— 这就是第 5 章那个 epoll_wait!
        #    如果 ready 队列不空,timeout=0(不等,马上回来干活)
        #    如果 ready 队列空了,就睡到最早的定时器到期为止(0 CPU)
        timeout = 0 if self._ready else self._time_to_next_timer()
        events = self._selector.select(timeout)      # ← select/epoll/kqueue
        for fd, callback in events:
            self._ready.append(callback)             # IO 好了 → 塞进 ready

        # ③ 把 ready 队列里的回调全跑一遍
        for callback in self._ready.pop_all():
            callback()                               # ← 这里绝对不能阻塞!!!
◆ 定论 · 全部的秘密就在第 ③ 步

callback() 是在这个线程上、同步执行的。

它跑多久,循环就停多久。它阻塞了,循环就停摆了。而循环停摆 = 所有的连接、所有的定时器、所有的请求,一起停摆。

这就是第 5 章那条铁律,在 Python 里的具体形态。

下面这个 demo 是一个真的(极小的)事件循环。先按「单步」走一遍正常情况,看 ready 队列和定时器堆怎么动、循环什么时候去睡。然后切到第二个 tab —— 有人偷偷用了 time.sleep

协程对象:它其实是个生成器

Python 的协程不是魔法,它是生成器(generator)的一个变种。这个血缘关系解释了它的一切行为:

async def foo():
    print("A")
    await asyncio.sleep(1)
    print("B")

c = foo()          # ← 注意!这里什么也没执行!
print(type(c))     #   ← 你只是造了一个对象

# 协程对象是「惰性」的 —— 你必须把它交给事件循环,它才会跑:
asyncio.run(foo())              # 或者
task = asyncio.create_task(foo())   # 或者
await foo()
⚠ 陷阱 · 最常见的 asyncio 新手 bug:忘了 await
async def main():
    save_to_db(data)        # ❌ 忘了 await!
    return "ok"             # 函数返回了,而 save_to_db 一行都没执行过

# Python 会好心地警告你:
#   RuntimeWarning: coroutine 'save_to_db' was never awaited
#
# 但它只是个 warning。在生产日志里,它会淹没在一万行 INFO 里。
# 而你的数据,静静地没有被保存。

为什么会这样?因为 save_to_db(data) 只是造了一个 coroutine 对象,然后你把它扔了。

防御:

  • 开发时把这个 warning 变成 error:python -W error::RuntimeWarning
  • PYTHONASYNCIODEBUG=1 跑测试 —— 它还会告诉你哪些回调跑得太慢(阻塞了循环!)
  • 用 mypy / pyright —— 它们能查出「一个 coroutine 对象被当成值用了」

那个会毁掉一切的东西:阻塞调用

上面的 demo 已经演过了。这里列一份通缉名单

❌ 在 async def 里绝对不能用✅ 换成
time.sleep(1)await asyncio.sleep(1)
requests.get(url)await httpx.AsyncClient().get(url) / aiohttp
psycopg2 / pymysql(同步 DB 驱动)asyncpg / aiomysql / SQLAlchemy 2.0 的 async 引擎
open(f).read()(大文件)aiofiles,或者 await asyncio.to_thread(...)
redis.Redis()(同步版)redis.asyncio.Redis()
threading.Lockasyncio.Lock
queue.Queueasyncio.Queue
任何 CPU 密集的循环await asyncio.to_thread(fn)(如果它会放 GIL,如 numpy)
ProcessPoolExecutor(如果是纯 Python 计算)
subprocess.run()await asyncio.create_subprocess_exec(...)
◆ 定论 · 逃生舱:to_thread

有些阻塞你躲不掉(一个只有同步版的第三方 SDK、一段祖传的 CPU 代码)。官方的逃生舱是把它扔进线程池:

import asyncio

async def handler(req):
    # ✅ 把阻塞调用丢进线程池,事件循环继续转
    result = await asyncio.to_thread(legacy_blocking_sdk.query, req.id)
    #        ^^^^^^^^^^^^^^^^^^^^^^ Python 3.9+
    return result

# 它做的事:
#   1. 把 legacy_blocking_sdk.query 提交给一个 ThreadPoolExecutor
#   2. 当前协程挂起(线程还给循环)
#   3. 线程池里的那个线程去阻塞(它阻塞就阻塞吧,反正 GIL 会被 IO 释放)
#   4. 干完了,唤醒协程

注意它的代价:默认线程池只有 min(32, CPU数+4) 个线程。如果你把所有阻塞调用都往里扔,它会成为新的瓶颈(然后你就得回到第 7 章去算线程池该开多大)。

而且:如果那段代码是纯 Python 的 CPU 计算,to_thread 救不了你 —— GIL 会挡住它。那种情况要用 ProcessPoolExecutor

怎么并发:三个 API,和它们的取舍

gather —— 老 API,有个坑

results = await asyncio.gather(
    fetch(url1),
    fetch(url2),
    fetch(url3),
)   # 三个请求并发,全部完成后返回

# ⚠️ 坑:如果 url2 抛异常了呢?
#   默认行为:gather 立刻把异常抛给你 ——
#   但 url1 和 url3 的 task 还在后台跑!没人取消它们,没人等它们。
#   这就是「孤儿 task」。

# 半个补丁:
results = await asyncio.gather(*tasks, return_exceptions=True)
# 现在异常变成了返回值里的一项,不会中断其他 task。
# 但你得自己逐个检查每个 result 是不是 Exception。

TaskGroup(3.11+)—— 用这个

async def main():
    async with asyncio.TaskGroup() as tg:      # Python 3.11+
        t1 = tg.create_task(fetch(url1))
        t2 = tg.create_task(fetch(url2))
        t3 = tg.create_task(fetch(url3))
    # ↑ 退出 with 块时,保证:所有 task 要么全部完成,要么全部被取消。
    #   没有孤儿。

    print(t1.result(), t2.result(), t3.result())

# 如果 fetch(url2) 抛了异常:
#   1. TaskGroup 立刻取消 t1 和 t3
#   2. 等它们真的取消完
#   3. 把所有异常打包成 ExceptionGroup 抛出来
# 配套的 except* 语法(也是 3.11+):
try:
    async with asyncio.TaskGroup() as tg:
        ...
except* ConnectionError as eg:      # 注意那个星号
    for e in eg.exceptions:
        log.warning("网络挂了: %s", e)
except* ValueError as eg:
    for e in eg.exceptions:
        log.error("数据错了: %s", e)
# 因为可能有多个 task 同时失败,异常是「一组」而不是「一个」。
◆ 定论 · TaskGroup 是 Python 并发史上最重要的一次改进

它把 结构化并发(第 20 章)带进了 Python 标准库。

核心承诺:async with 块退出时,里面启动的所有 task 都已经结束了 —— 要么完成,要么被取消。

这意味着:没有孤儿 task、没有泄漏、没有「函数返回了但活儿还在后台跑」。

这个想法来自 Trio(Nathaniel J. Smith 的第三方异步库)的 nursery 概念。Trio 用了好几年证明它是对的,然后 CPython 把它抄进了标准库。这是开源生态最好的样子。

如果你在 3.11+,请把所有 gather 换成 TaskGroup

create_task 裸用 —— 危险

# ❌ 这是 Python 版的 `go f()` —— 一个没人管的孤儿
asyncio.create_task(background_job())
# 函数返回了。这个 task 还在跑。谁等它?谁取消它?谁看它的异常?

# ⚠️ 而且还有一个真实的坑:
#   事件循环只持有 task 的「弱引用」!
#   如果你不保存返回值,task 可能被 GC 掉,任务凭空消失。
#   (官方文档明确警告了这一点。)

# 如果非要用,至少:
_background_tasks = set()

def spawn(coro):
    t = asyncio.create_task(coro)
    _background_tasks.add(t)                    # 保持强引用
    t.add_done_callback(_background_tasks.discard)
    t.add_done_callback(_log_exception)         # 否则异常会被静默吞掉!
    return t
⚠ 陷阱 · 「Task exception was never retrieved」

如果一个 task 抛了异常,而没有人 await —— 那个异常会被存在 task 对象里,静静地待着。

直到这个 task 被 GC 回收时,asyncio 才会在日志里吐一句:

ERROR:asyncio:Task exception was never retrieved
future: <Task finished coro=<background_job() done> exception=ValueError('boom')>

而这条日志可能在异常发生几分钟后才出现,而且完全没有上下文。

这是 asyncio 最难查的一类 bug。TaskGroup 从根本上消灭了它 —— 因为它保证每个 task 都被等待、每个异常都被收集。

超时和取消

# ✅ 3.11+ 的正确姿势
async with asyncio.timeout(5):        # 5 秒超时
    await fetch(url)
# 超时时:向这个协程注入一个 CancelledError

# 更细的控制
async with asyncio.timeout(5) as cm:
    cm.reschedule(loop.time() + 10)   # 中途改主意,延长到 10 秒
    await fetch(url)

# 老 API(还能用,但不如上面的组合性好)
await asyncio.wait_for(fetch(url), timeout=5)
⚠ 陷阱 · 不要吞掉 CancelledError
# ❌ 灾难
async def worker():
    try:
        await do_work()
    except Exception:          # CancelledError 在 3.8+ 继承自 BaseException,
        log.error("出错了")     # 所以这里【抓不到】它 —— 这是好事!
        # ↑ 3.8 之前它继承 Exception,这里会把取消吞掉,导致取消失效

# ❌ 但这个还是灾难(3.8+ 也一样)
async def worker():
    try:
        await do_work()
    except BaseException:      # ← 抓到了 CancelledError
        log.error("出错了")     # ← 然后【没有重新抛出】
        # 取消信号被你吞了。上层以为取消成功了,而这个 task 还在跑。

# ✅ 正确:清理,然后一定要重新抛出
async def worker():
    try:
        await do_work()
    except asyncio.CancelledError:
        await cleanup()        # 做你的清理
        raise                  # ← 必须!把取消传下去

「取消必须能穿透」是所有协程系统的共同规则。Kotlin 里有一模一样的问题(runCatching 会吞掉 CancellationException)—— 第 22 章会一起算这笔账。

诚实地评价 asyncio

◈ 三语 · asyncio 的处境

它买到了什么:

  • ✅ 高并发 IO(几万连接,单进程)
  • ✅ 代码读起来是顺序的(比回调好一万倍)
  • 单线程 → 不需要锁,没有竞态(这一条被严重低估)
  • ✅ 3.11+ 的 TaskGroup + timeout 是真正现代的 API

它付出了什么:

  • 染色,而且是最重的染色。整个生态被劈成两半,你必须全栈异步
  • 一个阻塞调用毁掉一切。而「哪些库是阻塞的」这件事,全靠你自己记。
  • 用不了多核。(要用就得多进程 —— 于是标准部署是 gunicorn -w 4 -k uvicorn。)
  • CPU 密集完全无解。
  • ❌ 栈追踪、调试体验依然不如同步代码

什么时候用它:你的服务是纯 IO 密集的(API 网关、爬虫、聊天服务、代理),并发量在几千以上,而且你能控制整条依赖链都是异步的。

什么时候不要用它:并发量只有几百(线程池就够了,别自找麻烦);有 CPU 密集的部分;或者你依赖的关键库只有同步版本。

一个真诚的建议:不要为了「显得现代」把一个 200 QPS 的服务改成全 asyncio。你会得到一堆更难查的 bug、一份没人看得懂的栈追踪,和 0% 的性能提升。

✚ 动手

1. PYTHONASYNCIODEBUG=1 跑你的服务。它会打印所有「执行时间过长的回调」—— 那些就是在阻塞你的事件循环的东西。这一步经常能挖出惊喜。

2. 把项目里所有的 asyncio.gather 换成 TaskGroup(如果你在 3.11+)。然后数一数你消灭了多少个潜在的孤儿 task。

3. 搜一遍 create_task(,看有几处没保存返回值。那些 task 随时可能被 GC 掉。

4. 在你的 async 代码里故意插一行 time.sleep(3),然后压测。看着 QPS 崩掉。亲手做一次,你就永远不会犯这个错了。

下一章:换一门语言。Kotlin 的 suspend —— 那个「看起来像同步、跑起来非阻塞」的魔法,编译器到底做了什么。(剧透:一台状态机。)