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:谁才是主角

TaskFuture 经常被混用,但它们有本质区别。Future 是一个底层的「占位符」对象,表示一个尚未完成的结果,它本身不包含任何执行逻辑。TaskFuture 的子类,它包装了一个协程并在事件循环中调度执行。可以这样理解: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。两者的性能差异在于控制粒度:

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 字