Python 的 asyncio 已经有近十年的历史了,但很多开发者对它的理解仍然停留在「加了 async 就不会阻塞」的层面。这篇文章深入协程调度和事件循环的内部机制,帮你从「能用」升级到「真正理解」。
await 到底做了什么
很多人以为 await 就是「等待结果」,但这只说对了一半。在 CPython 内部,await 表达式会调用对象的 __await__ 方法,返回一个迭代器,然后事件循环通过 send() 和 throw() 来驱动这个迭代器前进。当协程遇到 IO 操作时,它会 yield 一个控制权给事件循环,事件循环把这个协程挂起,转而去处理其他就绪的任务。换句话说,await 不是阻塞等待,而是主动让出 CPU。
# await 的本质等价于:
async def demo():
result = await some_io() # 协程在此挂起
# 事件循环内部类似:
coro = demo()
try:
coro.send(None) # 驱动协程到第一个 await
except StopIteration as e:
result = e.value
Task vs Future:谁才是主角
Task 和 Future 经常被混用,但它们有本质区别。Future 是一个底层的「占位符」对象,表示一个尚未完成的结果,它本身不包含任何执行逻辑。Task 是 Future 的子类,它包装了一个协程并在事件循环中调度执行。可以这样理解:Future 是容器,Task 是容器+执行器。
在日常开发中,你应该优先使用 asyncio.create_task() 而不是直接操作 Future。创建 Task 意味着协程被立即提交到事件循环中调度,而如果不创建 Task 直接 await 一个协程,它会在当前上下文中串行执行。
run_in_executor:混合同步代码的正确姿势
现实项目中不可避免地会调用同步库。直接在 async 函数中调用同步代码会阻塞整个事件循环,导致其他协程全部挂起。正确的做法是使用 run_in_executor 将同步任务卸载到线程池:
import asyncio
import time
def sync_blocking():
time.sleep(2)
return "done"
async def main():
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, sync_blocking)
# None 表示使用默认的 ThreadPoolExecutor
默认线程池的大小是 min(32, os.cpu_count() + 4),对于 CPU 密集型任务建议改用 ProcessPoolExecutor 来绕过 GIL。
gather vs create_task:性能差异在哪
当需要并发执行多个协程时,asyncio.gather() 是最常用的工具。但很多人不知道,gather 内部实际上就是为每个传入的协程自动创建 Task。两者的性能差异在于控制粒度:
gather是「全有或全无」——任何一个子任务抛出异常,默认行为是立即取消其他任务(可通过return_exceptions=True改变)- 手动
create_task可以逐个处理异常,适合需要部分失败继续执行的场景
FastAPI 中 async def vs def 的选择原则
FastAPI 最让人困惑的设计之一就是同一套框架同时支持同步和异步路由。选择原则其实很清晰:如果你的路由函数主要在做 IO 操作(数据库查询、HTTP 请求、文件读写),用 async def;如果主要是 CPU 计算,用 def。FastAPI 会把 def 路由放到外部线程池中执行,避免阻塞事件循环。但注意:在线程池中执行的 def 路由不能使用 async 的数据库客户端(如 asyncpg),否则会报错。
总结
asyncio 的核心思想不是「更快」,而是「更高效地等待」。理解事件循环的调度机制、掌握 Task 的正确用法、知道何时用 run_in_executor 卸载同步任务——这三者构成了 asyncio 实战的基石。下次遇到 asyncio 相关的问题,不妨从事件循环的角度思考:这个协程在什么时候 yield 了控制权?
※ 全文约 750 字