上周五下午四点五十八分,我们的订单处理服务突然假死了——API 网关还在返回 200,但所有请求都在超时。没有报错日志,CPU 使用率不到 5%,内存正常。同事盯着 Grafana 面板看了十分钟,说了一句让我至今难忘的话:
📌 延伸阅读:Python subprocess 深度实战——Popen 死锁陷阱、asyncio 流式处理、10,000 次基准测试全覆盖
“你用的 asyncio 是 100 个协程,每个协程都在 await,但它们到底在等什么?”
这个问题戳中了一个被大量 Python 开发者忽略的事实:我们知道 asyncio 怎么写,但不知道它是怎么跑的。 我们用 async/await 语法糖写得飞起,但当协程调度出问题时——死锁、饥饿、假死——就只能重启大法伺候。
这篇文章把 asyncio 事件循环拆开来看:await 到底做了什么、Event Loop 怎么调度协程、Task/Future/Coroutine 的区别,以及一套排查协程调度问题的实战方法。读完你会发现:那些”莫名其妙”的 asyncio 问题,其实一点都不玄学。
📎 相关推荐:Python 上下文管理器深度实战:不只是 with open——6 个生产级场景从数据库事务到 ExitStack
🔗 相关新文:Python asyncio.TaskGroup 结构化并发深度实战(2026) — 告别幽灵协程,用 TaskGroup 让异步代码真正可预测。
为什么需要异步?——从一个”串行噩梦”开始
先看一个最朴素的场景:你需要从三个微服务拉数据,每个请求耗时 100ms。
import time
def fetch_user(user_id):
time.sleep(0.1)
return {"id": user_id, "name": f"User {user_id}"}
def fetch_orders(user_id):
time.sleep(0.1)
return [{"order_id": i} for i in range(3)]
def fetch_profile(user_id):
time.sleep(0.1)
return {"avatar": f"avatar_{user_id}.png"}
# 串行调用
t0 = time.time()
user = fetch_user(1)
orders = fetch_orders(1)
profile = fetch_profile(1)
print(f"串行耗时: {time.time() - t0:.2f}s") # ~0.3s
三个请求串行 = 300ms。你可能会说”我能用多线程”——但 100 个请求呢?100 个线程的上下文切换开销和 GIL 竞争会让性能不升反降(这个我们之前在 GIL 深度分析文章 中详细讲过)。
asyncio 的思路完全不同:不是同时做多件事,而是在等待的时候去做另一件事。 这就是协程(coroutine)的核心——协作式多任务,自己决定什么时候让出执行权。
import asyncio
async def fetch_user(user_id):
await asyncio.sleep(0.1)
return {"id": user_id, "name": f"User {user_id}"}
async def main():
t0 = time.time()
user, orders, profile = await asyncio.gather(
fetch_user(1),
fetch_orders(1),
fetch_profile(1)
)
print(f"并发耗时: {time.time() - t0:.2f}s") # ~0.1s
asyncio.run(main())
三个协程并发,总耗时还是 100ms。关键就在于 await asyncio.sleep(0.1) 这行代码——它告诉 Event Loop:”我要等 100ms,这段时间你可以去跑别的协程。”
Event Loop 到底怎么调度协程?
理解 Event Loop 是理解一切 asyncio 问题的基础。我们用一个极简版实现来演示它的核心逻辑:
import time
import selectors
class MiniEventLoop:
"""极简 Event Loop - 演示协程调度的核心原理"""
def __init__(self):
self._ready = [] # 就绪队列:等待执行的协程
self._sleeping = [] # 休眠队列:(到期时间, 协程)
self._selector = selectors.DefaultSelector() # IO 多路复用
def call_soon(self, coro):
"""把一个协程加入就绪队列"""
self._ready.append(coro)
def call_later(self, delay, coro):
"""delay 秒后把协程加入就绪队列"""
self._sleeping.append((time.time() + delay, coro))
def run_forever(self):
"""主循环:不停从就绪队列取协程执行"""
while self._ready or self._sleeping:
# 1. 检查休眠队列,到期的移到就绪队列
now = time.time()
for i, (deadline, coro) in enumerate(self._sleeping):
if deadline <= now:
self._ready.append(coro)
self._sleeping[i] = None
self._sleeping = [s for s in self._sleeping if s is not None]
# 2. 如果没有就绪协程,算一下最近的休眠到期时间,sleep
if not self._ready and self._sleeping:
nearest_deadline = min(d for d, _ in self._sleeping)
time.sleep(max(0, nearest_deadline - time.time()))
continue
# 3. 取就绪队列头部,执行一步
if self._ready:
coro = self._ready.pop(0)
try:
coro.send(None) # 驱动协程执行到下一个 await
self._ready.append(coro) # 重新排队
except StopIteration:
pass # 协程执行完毕
这个不到 40 行的 MiniEventLoop 揭示了 asyncio 调度器最核心的三个步骤:
- 收集就绪任务:从休眠队列中捞出到期的协程,放入就绪队列
- 等待下一个事件:如果没有就绪任务,算一下最近的到期时间,sleep 过去
- 轮转执行:从就绪队列头部取一个协程,执行到它的下一个
await,然后放回队尾
第三点特别关键:asyncio 是单线程的,同一时刻只有一个协程在执行。 它没有"并行",只有"并发"——通过快速切换让多个协程看起来同时在跑。
来看一个具体的调度时序:
async def task_a():
print("A1") # 执行点 1
await asyncio.sleep(0.001)
print("A2") # 执行点 2
await asyncio.sleep(0.001)
print("A3") # 执行点 3
async def task_b():
print("B1")
await asyncio.sleep(0.001)
print("B2")
# 输出: A1 → B1 → A2 → B2 → A3
# 解释:
# A 执行到 A1 后 await sleep → 让出控制权
# B 执行到 B1 后 await sleep → 让出控制权
# 两个 sleep 都到期 → A 排前面先执行 A2 → 再次 await
# B 执行 B2 → 结束
# A 执行 A3 → 结束
在每个 await 点,协程把控制权交还给 Event Loop。Event Loop 检查有没有其他协程可以跑——如果有就切换过去,没有就等 I/O 或定时器就绪。
await 到底做了什么?——拆解 async/await 的语法糖
很多人以为 await 就是"等待结果",但它背后发生了四件事:
# 当你写:
result = await some_coroutine()
# Python 实际上在执行:
# 1. 调用 some_coroutine(),得到一个 coroutine 对象
# 2. 把 coroutine 对象包装成 Task(如果还没有的话)
# 3. 把 Task 提交给 Event Loop
# 4. 当前协程挂起(yield),Event Loop 去执行别的任务
# 5. 当 Task 完成后,Event Loop 把结果送回当前协程
# 6. 当前协程恢复执行,result 拿到值
这里的核心机制是 Python 生成器的 send() 和 yield。async 函数本质上是一个可以暂停和恢复的生成器。每次 await 内部就是一个 yield 点,把控制权交还给 Event Loop。
理解这一点后,你就能解释很多"奇怪的"行为:
# 场景1:await 一个已经完成的 Future —— 立即返回
future = asyncio.Future()
future.set_result(42)
result = await future # 不会暂停,立即返回 42
# 场景2:await 一个普通函数 —— TypeError
async def foo():
return await len("hello") # ❌ len() 不是 awaitable
# 正确写法: return len("hello")
# 场景3:忘记 await —— 协程不执行!
async def fetch():
return "data"
async def main():
fetch() # ⚠️ 创建了一个协程对象,但没有执行!
# 正确: result = await fetch()
# 或者: asyncio.create_task(fetch())
Task vs Future vs Coroutine:三者到底是什么关系?
这是 asyncio 中最容易被混淆的概念。用一句话总结:
Coroutine 是"待执行的代码",Task 是"正在执行中的协程",Future 是"未来会有结果的东西"。
更精确地说:
| 概念 | 本质 | 类比 |
|---|---|---|
| Coroutine | 用 async def 定义的函数的调用结果,是一个可以被 await 的对象 | 一份待办的"菜谱" |
| Task | 包装了 Coroutine 并提交给 Event Loop 调度,继承自 Future | "正在锅里煮的菜" |
| Future | 一个占位符,代表将来某个时刻会产生的结果 | "出餐铃"——响了就有结果 |
看代码更清楚:
async def cook():
await asyncio.sleep(0.1)
return "红烧肉"
# Coroutine:只是菜谱,还没有开始执行
coro = cook()
print(type(coro)) # <class 'coroutine'>
# Task:提交给 Event Loop,开始调度
task = asyncio.create_task(coro)
print(type(task)) # <class '_asyncio.Task'>
print(isinstance(task, asyncio.Future)) # True! Task 是 Future 的子类
# Future:底层的占位符
future = asyncio.Future()
future.set_result("done")
print(await future) # "done"——Future 已有结果,立即返回
关键认知:Task 继承了 Future,所以所有 Task 都是 Future。你可以在一个地方 create_task(),在另一个地方 await task 拿到结果——这就是 asyncio 的"发射后不管"模式。
实战场景:为什么你的 asyncio 代码跑起来还是慢?
理论讲完,来点真的。以下是四个我在生产环境和 Code Review 中反复遇到的 asyncio 性能陷阱。
陷阱 1:在协程中调用同步阻塞函数
# ❌ 这段代码看起来是异步的,实际上阻塞了整个 Event Loop
async def bad_fetch():
import requests # requests 是同步库!
resp = requests.get("https://httpbin.org/delay/2")
return resp.json()
async def main():
tasks = [bad_fetch() for _ in range(10)]
results = await asyncio.gather(*tasks)
# 耗时 ~20 秒(10个请求串行),而不是 2 秒!
# ✅ 正确做法:用异步 HTTP 库
import aiohttp
async def good_fetch(session):
async with session.get("https://httpbin.org/delay/2") as resp:
return await resp.json()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [good_fetch(session) for _ in range(10)]
results = await asyncio.gather(*tasks)
# 耗时 ~2 秒
这一条坑过无数人。在 async 函数里调用同步阻塞代码,等于用一个核弹把 Event Loop 炸瘫痪。 如果你必须调用同步代码(比如读一个大文件、调一个没有异步版本的 SDK),用 loop.run_in_executor() 把它丢到线程池:
async def safe_blocking_call():
loop = asyncio.get_running_loop()
# 把阻塞操作扔到默认的线程池执行
result = await loop.run_in_executor(None, blocking_function)
return result
陷阱 2:await 放在循环体内而不是 gather 外
# ❌ 串行执行——虽然用了 async/await
async def slow():
results = []
for i in range(10):
result = await fetch_item(i) # 每次 await 都等上一个完成
results.append(result)
# ✅ 并发执行——先创建所有 Task,再一起等
async def fast():
tasks = [fetch_item(i) for i in range(10)]
results = await asyncio.gather(*tasks)
这个错误在初学者中极其常见。记住:把 await 放在离数据消费越近越好,离任务创建越远越好。
陷阱 3:TaskGroup 中一个异常吃掉全部结果
Python 3.11 引入的 TaskGroup 比 gather 更安全,但也有新坑:
# ❌ TaskGroup 默认:一个 Task 抛异常,其他全部取消
async def risky():
async with asyncio.TaskGroup() as tg:
tg.create_task(good_task()) # 正常完成
tg.create_task(bad_task()) # 抛异常 → 整个 TaskGroup 取消
tg.create_task(another_task()) # 被取消,结果丢失
# ✅ 解决:在 task 内部捕获异常,不要让异常冒泡到 TaskGroup
async def safe_task_wrapper(coro):
try:
return await coro
except Exception as e:
return {"error": str(e)}
async def robust():
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(safe_task_wrapper(good_task()))
t2 = tg.create_task(safe_task_wrapper(bad_task()))
# 两个 task 都能拿到结果
print(t1.result()) # 正常数据
print(t2.result()) # {"error": "..."}
陷阱 4:协程饥饿——一个协程霸占 Event Loop 太久
async def cpu_hungry():
"""这个协程计算密集,不给别的协程让路"""
total = 0
for i in range(10_000_000):
total += i * i
return total
async def needs_response():
"""这个协程需要快速响应,但被饥饿了"""
await asyncio.sleep(0.1)
return "quick"
async def main():
t1 = asyncio.create_task(cpu_hungry())
t2 = asyncio.create_task(needs_response())
# t2 会等 t1 算完 1000万 次循环才能拿到结果
# 因为 asyncio 是单线程,t1 没有 await 就不会让出控制权
# ✅ 修复:在长循环中手动让出控制权
async def cpu_friendly():
total = 0
for i in range(10_000_000):
total += i * i
if i % 100_000 == 0:
await asyncio.sleep(0) # 主动让出控制权
return total
排查工具:当 Event Loop 假死时怎么办?
我在 strace 生产调试文章 和 Python 性能剖析三件套文章 中分别介绍了通用的排查工具,这里聚焦 asyncio 特有的调试手段。
1. 启用 asyncio debug 模式
import asyncio
import logging
# 方式1:环境变量
# PYTHONASYNCIODEBUG=1 python app.py
# 方式2:代码中启用
loop = asyncio.get_event_loop()
loop.set_debug(True)
logging.basicConfig(level=logging.DEBUG)
# debug 模式会检测:
# - 慢回调(执行超过 100ms 的协程)
# - 未 await 的协程("coroutine was never awaited" 警告)
# - 错误的 loop 线程调用
2. 打印所有正在运行的 Task
# 当服务假死时,通过 signal handler 触发
import signal
def dump_tasks(signum, frame):
tasks = asyncio.all_tasks()
for task in tasks:
stack = task.get_stack()
print(f"Task: {task.get_name()}")
for frame in stack:
print(f" {frame.f_code.co_filename}:{frame.f_lineno} "
f"in {frame.f_code.co_name}")
signal.signal(signal.SIGUSR1, dump_tasks)
# 发送信号: kill -USR1 <pid>
3. 用 py-spy dump 查看协程堆栈
# 实时查看 Python 进程在做什么
$ py-spy dump --pid <pid>
# 输出示例:
# Thread 1 (idle): "MainThread"
# select.epoll.poll (select.py:469)
# _run_once (base_events.py:1823)
# run_forever (base_events.py:602)
# run (runners.py:188)
# main (app.py:45)
#
# 如果卡在 epoll.poll 且没有协程在执行 → Event Loop 在空闲等待
# 但如果所有协程都在等一个不存在的 I/O 事件 → 就是调度问题
常见问题(FAQ)
Q: asyncio.create_task() 和直接 await 有什么区别?
create_task() 把协程包装成 Task 并立即提交给 Event Loop 调度,当前协程不会等待它完成。直接 await 会在原地等待协程完成才继续。简单说:create_task() = "发射后不管",await = "必须等它完"。如果需要并发执行多个协程,必须用 create_task() 或 gather()。
Q: 为什么 asyncio.gather() 比逐个 await 快?
gather() 内部会先把所有协程包装成 Task 提交给 Event Loop,然后统一等待它们全部完成。所有 Task 并发执行,总耗时 = 最慢的那个 Task 的耗时。而逐个 await 是在每个协程完成后才启动下一个,总耗时 = 所有协程耗时之和。
Q: asyncio.sleep(0) 的作用是什么?它比 sleep(0.001) 好吗?
asyncio.sleep(0) 是一个特殊的调用:它不引入任何延迟,只做一件事——把当前协程的控制权立即交还给 Event Loop,允许其他就绪协程执行。它在长循环中用于防止协程饥饿。与 sleep(0.001) 相比,sleep(0) 不会进入定时器队列(而是用 call_soon() 直接回到就绪队列),因此开销更小、响应更快。
Q: 一个 Event Loop 能同时运行多少个协程?
理论上没有硬限制,可以创建数十万个协程(它们只是内存中的 Python 对象,不像线程有栈开销)。实际限制取决于:① 协程同时等待的 I/O 操作数(受文件描述符限制,默认 ulimit -n 通常是 1024);② 单个协程的执行时间(如果一个协程霸占 CPU 太久,其他协程就会被饥饿)。
总结
这篇文章我们拆解了 asyncio 的三个核心层次:
- 协程调度:Event Loop 通过就绪队列 + 休眠队列 + I/O 多路复用,实现单线程协作式并发
- await 机制:每次 await 都是一次控制权交出,Event Loop 在背后调度"谁下一个跑"
- Task/Future/Coroutine 关系:Coroutine 是菜谱,Task 是锅里煮的菜,Future 是出餐铃
回到开头那个假死的订单服务:问题最终定位在——一个协程里调了 requests.post()(同步阻塞),而它的 timeout 设了 30 秒。在这 30 秒内,其他 99 个协程全部饿死。一行代码,瘫痪了整个服务。
异步编程的难度不在语法,而在理解"控制权"在谁手里。当你写的每一行 await 都能在脑子里对应一次"让出控制权",你才算真正掌握了 asyncio。
如果你遇到过类似的 asyncio 排坑经历,欢迎在评论区分享。下一篇我们来聊 Python 内存管理的底层实现——引用计数、GC 分代回收和循环引用检测。
相关阅读: