掌握了 async/await 基本写法之后,想要真正写出高效的异步程序,或者排查诡异的并发问题,就必须深入理解事件循环和异步框架底层的工作方式。这一节用尽量直白的语言讲清楚这两件事。
事件循环的核心原理
事件循环(Event Loop)本质是一个“待办任务调度器”,它持续不断地检查:
- 有没有已经就绪的 I/O 操作(比如网络响应返回了、文件读完了)?
- 有没有到时间的定时任务?
- 有没有被
call_soon安排立即执行的回调?
事件循环的工作流程可以简化为三步循环:
- 收集:从操作系统获取哪些文件描述符(socket)已经可读/可写。
- 调度:把对应这些就绪事件的协程/回调拿到执行队列里。
- 执行:逐个运行这些协程/回调,直到
await一个尚未就绪的 Future(或 I/O 操作),这时控制权交回给事件循环;事件循环回到第一步。
为什么这能实现高并发?
因为整个过程中只使用一个线程。等待 I/O 时,这个线程不阻塞,而是去处理别的协程。当 I/O 就绪时,对应的协程又会被唤醒继续执行。这就是经典的“协作式多任务”——协程在自己 await 时主动交出控制权,不会像线程那样随时被系统中断。
一个简化的概念模型
# 伪代码,示意事件循环内部如何工作
event_loop = EventLoop()
async def task_a():
await socket_recv(sock_a) # 让循环去等待 sock_a 可读
process(data)
async def task_b():
await sleep(2) # 让循环在 2 秒后唤醒自己
process(other)
event_loop.create_task(task_a())
event_loop.create_task(task_b())
event_loop.run_forever() # 启动循环
在 socket_recv(sock_a) 内部,实际是把 (sock_a, 可读事件) 注册到事件循环,然后 yield(暂停)当前协程,循环去跑 task_b。等 sock_a 真正可读时,循环把 task_a 放回执行队列,从上次暂停点继续执行。
Python 异步框架的实现机制
Python 的异步生态建立在 asyncio 标准库之上,而 asyncio 又建立在生成器和 Future/Task 对象之上。
核心组件拆解:
- 协程与生成器
Python 的协程在底层是生成器的“增强版”。await 相当于 yield from,但加入了异步上下文。解释器在碰到 await 时,把当前协程挂起,直到等待的 Future 完成。
- Future / Task
Future是一个占位符,代表“未来会得到的结果”。协程await一个 Future,本质上就是订阅它的通知。Task继承自Future,是事件循环实际调度的最小单元。当你asyncio.create_task(coro())时,事件循环会创建一个 Task,把它放进就绪队列等待调度。
- 事件循环实现
asyncio 底层依赖 Selector(或 kqueue、epoll)来高效监听多个 I/O 事件。每当循环检查到事件就绪,它会找到关联的 Task,将 Task 移动到“就绪队列”等待执行。
- 调度策略
事件循环严格执行“单线程串行调度”。某一时刻只有一个协程在运行,其他要么在等待 I/O,要么在就绪队列排队。await 点就相当于协程主动退让,让其他协程有机会运行。如果某个协程里执行长时间不 await 的计算(CPU 密集操作),整个循环都会被阻塞,其他所有协程都得不到执行。这就是为什么异步编程需要“只在 IO 密集处用 await”,计算密集任务应该丢给线程/进程池。
异步 Web 框架(如 FastAPI / aiohttp)利用了什么?
这些框架在拿到一个 HTTP 请求时,会创建一个 Task 来处理该请求。在请求处理过程中,每当需要查询数据库(异步驱动)、调用外部 API 或读写文件,框架就用 await 挂起当前请求的 Task,让事件循环去处理别的请求。因此一个线程就能同时承载成千上万个连接,内存开销比多线程模型小很多。
几点实用理解:
- 异步不等于快,而是不阻塞:它擅长提升 IO 密集型场景的吞吐量,而不是单个请求的处理延迟。
- 混用同步阻塞代码是灾难:如果在协程中调用
time.sleep()或同步的网络请求库(如requests),整个事件循环都会被堵住。一定要用await asyncio.sleep()和异步化的库(aiohttp、httpx异步模式等)。 - async/await 传染性:一旦某层用了异步,所有调用这条链的函数都要写成
async def,除非在某个边界将异步任务通过run_in_executor()交给线程池隔离。
理解了事件循环的调度逻辑,你就知道协程为什么会“协作”、为什么需要非阻塞依赖,也能在写异步程序时更有把握地避免阻塞、死锁和活跃度问题。