Python:asyncio —— 一个事件循环,和它的染色问题
asyncio 名声不太好。「难用」「生态分裂」「一不小心就卡死」—— 这些抱怨全是真的,但它们的成因不是 asyncio 设计得差,而是它被 GIL 逼到了一个死角:它必须是单线程的(第 11 章那个 N:1),于是它继承了事件循环的全部脆弱性。理解 asyncio 的关键,是理解那个循环本身 —— 它只有二十几行的核心逻辑。这一章会把它单步执行给你看,然后让你亲眼看到一行 time.sleep 是怎么把整个服务搞挂的。
事件循环:把它拆开,只有三样东西
剥掉所有的糖,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()
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.Lock | asyncio.Lock |
queue.Queue | asyncio.Queue |
| 任何 CPU 密集的循环 | await asyncio.to_thread(fn)(如果它会放 GIL,如 numpy)ProcessPoolExecutor(如果是纯 Python 计算) |
subprocess.run() | await asyncio.create_subprocess_exec(...) |
有些阻塞你躲不掉(一个只有同步版的第三方 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 同时失败,异常是「一组」而不是「一个」。
它把 结构化并发(第 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 抛了异常,而没有人 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)
# ❌ 灾难
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
它买到了什么:
- ✅ 高并发 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 —— 那个「看起来像同步、跑起来非阻塞」的魔法,编译器到底做了什么。(剧透:一台状态机。)