1. 职责
协程能够暂停和恢复,但协程本身不知道应该在什么时候恢复,也不知道暂停期间应该运行谁。事件循环(event loop)就是负责这项工作的调度器。
假设程序中有三个任务:
- 任务 A 正在等待 HTTP 响应;
- 任务 B 正在等待数据库结果;
- 任务 C 已经准备好继续运行。
事件循环不会一直守着任务 A。它会先运行已经就绪的任务 C,同时监听任务 A 和任务 B 等待的 IO。某个 IO 完成后,对应任务重新获得运行资格,事件循环再选择合适的时机恢复它。
事件循环主要负责:
| 职责 | 说明 |
|---|---|
| 调度 Task | 运行已经准备好的异步任务 |
| 执行回调 | 执行 call_soon() 等 API 注册的普通函数 |
| 监听 IO | 等待 socket 可读、可写等操作系统事件 |
| 管理定时器 | 在延迟到期后调度回调或恢复协程 |
| 传递结果 | Future 完成后,唤醒正在等待它的 Task |
| 管理生命周期 | 启动、运行、清理并关闭异步程序 |
事件循环不是线程池,也不是让代码自动并发的语法。通常一个事件循环运行在一个操作系统线程中,这个线程在同一时刻只执行一个 Task 或回调。任务必须运行到 await、结束或抛出异常,事件循环才有机会运行其他任务。
因此,asyncio 采用的是协作式调度:任务主动在可等待的位置让出执行权,而不是由事件循环在任意一行代码上强制打断任务。
2. 结构
理解事件循环之前,需要先看清它周围的几个对象:
| 对象 | 作用 |
|---|---|
| 协程对象 | 一次尚待执行的异步函数调用 |
| Task | 包装协程,并负责推进协程执行 |
| Future | 表示一个未来才会完成的结果 |
| 就绪工作 | 已经可以运行的 Task 或回调 |
| 定时任务 | 到达指定时间后才能运行的回调 |
| IO 监听器 | 等待 socket 等资源变为可读或可写 |
它们之间的关系可以简化为:
01协程对象02↓ 包装03Task04↓ 进入就绪状态05事件循环运行 Task06↓ 遇到尚未完成的 await07Task 暂停,等待 Future 或 IO08↓ 等待结果完成09Task 重新进入就绪状态10↓11事件循环恢复 Task
Task 是事件循环真正调度的协程执行单元。Future 更像结果占位符:结果没有准备好时,等待它的 Task 暂停;Future 完成后,会安排等待它的 Task 重新进入就绪状态。
事件循环不会不停遍历所有暂停的协程,询问它们是否完成。IO 监听器、定时器和 Future 会在条件满足时通知事件循环,把相关工作重新放回可运行的集合中。
「就绪队列」「定时器集合」和「IO 监听器」适合用来建立概念模型。不同操作系统和事件循环实现的内部数据结构可能不同,业务代码不应该依赖它们的具体实现细节。
3. 调度
一次事件循环迭代可以大致理解为以下步骤:
- 检查哪些定时任务已经到期;
- 计算最多可以等待 IO 多久;
- 向操作系统查询哪些 IO 已经就绪;
- 把到期定时器、IO 回调和其他已就绪工作加入待执行集合;
- 依次运行这些 Task 或回调;
- Task 遇到尚未完成的
await后暂停,事件循环继续处理其他工作; - 开始下一次迭代。
这是便于学习的简化过程,不是要求应用开发者手写的循环。平时使用 asyncio.run(),Python 会负责驱动事件循环。
下面可以观察两个 Task 如何交替执行:
01import asyncio0203async def worker(name):04print(f"{name}:开始")05await asyncio.sleep(0)06print(f"{name}:恢复")0708async def main():09print("main:创建任务")10first = asyncio.create_task(worker("任务一"))11second = asyncio.create_task(worker("任务二"))1213print("main:等待任务")14await asyncio.gather(first, second)15print("main:结束")1617asyncio.run(main())
通常可以观察到下面的顺序:
1main:创建任务2main:等待任务3任务一:开始4任务二:开始5任务一:恢复6任务二:恢复7main:结束
create_task() 把两个协程包装成 Task,并安排它们运行。main() 执行到 await gather(...) 后暂停,事件循环开始推进两个子任务。
示例中的 await asyncio.sleep(0) 不是真的等待时间,而是主动暂停当前 Task,让事件循环有机会运行其他就绪任务。它适合演示调度,不应该被当成修复阻塞代码的通用办法。
协作式调度意味着公平性不是自动保证的。如果一个 Task 长时间计算且没有遇到 await,它会一直占用事件循环线程:
1async def calculate():2total = 03for number in range(50_000_000):4total += number56return total
虽然 calculate() 使用了 async def,但函数内部没有真正的等待点。它开始运行后,其他 Task 只能等它计算结束。
4. IO
IO 是 Input/Output(输入/输出)的缩写。在异步服务中,它通常表示等待网络、数据库、文件或其他外部资源。socket 可以先理解为程序与网络连接交互的对象。
异步 IO 的关键不是反复询问网络请求有没有完成,而是把等待交给操作系统。
以异步 HTTP 请求为例,可以把过程理解为:
- HTTP 客户端创建非阻塞 socket;
- socket 暂时还不能读取完整响应;
- 客户端创建一个等待结果,并把 socket 注册给事件循环;
- 当前 Task 在
await位置暂停; - 事件循环继续运行其他 Task;
- 操作系统发现 socket 已经可以读取;
- 事件循环收到 IO 通知,执行对应处理逻辑;
- 等待结果完成,原 Task 重新进入就绪状态;
- 事件循环恢复原 Task。
底层通常依赖操作系统提供的高效 IO 机制,例如 Linux 的 epoll、macOS 的 kqueue 和 Windows 的 IOCP。具体实现会因平台与 Python 版本而不同,但核心目标都是让一个线程同时管理大量处于等待状态的连接。
所以,异步 HTTP 通常不是「一个 HTTP 请求对应一个线程」,也不需要事件循环额外创建一个「HTTP 线程」。事件循环所在的线程负责运行 Python 回调和 Task,操作系统负责监控大量 IO 状态。
定时等待也是类似的思路:
1import asyncio23async def remind():4print("开始等待")5await asyncio.sleep(2)6print("恢复执行")78asyncio.run(remind())
asyncio.sleep(2) 会注册定时等待并暂停当前 Task,通常不会创建一个专门的定时器线程。两秒后只表示这个 Task 具备恢复资格;如果事件循环此时正在执行其他代码,实际恢复时间可能稍晚。
5. 回调
大多数业务代码只需要协程和高层 API,但事件循环也提供了调度普通回调的低层接口:
| API | 作用 |
|---|---|
loop.call_soon() | 在事件循环的后续迭代中尽快执行回调 |
loop.call_later() | 延迟一段时间后执行回调 |
loop.call_at() | 在事件循环单调时钟的指定时间执行回调 |
loop.call_soon_threadsafe() | 从其他线程安全地提交回调 |
asyncio.run_coroutine_threadsafe() | 从其他线程向事件循环提交协程 |
01import asyncio0203def ready_callback():04print("就绪回调")0506def timer_callback():07print("定时回调")0809async def main():10loop = asyncio.get_running_loop()1112ready_handle = loop.call_soon(ready_callback)13timer_handle = loop.call_later(1, timer_callback)1415print(type(ready_handle).__name__)16print(type(timer_handle).__name__)17await asyncio.sleep(1.2)1819asyncio.run(main())
call_soon() 返回 Handle,call_later() 返回 TimerHandle。只要回调尚未执行,就可以通过它们的 cancel() 方法取消调度。
call_later() 使用相对延迟,call_at() 使用与 loop.time() 相同的单调时钟。单调时钟只适合计算时间间隔,不应该把 loop.time() 当成日期时间戳。
这些回调是普通同步函数。回调一旦开始运行,在返回之前会占用事件循环线程,因此回调内容也应该保持短小,不能直接执行阻塞 IO 或长时间计算。
大部分 asyncio 对象都不是线程安全的。在事件循环线程之外调用 call_soon() 并不安全,必须使用 call_soon_threadsafe();从其他线程提交协程时,则使用 run_coroutine_threadsafe()。
Python asyncio 没有 JavaScript 那种公开且严格对应的「宏任务队列」和「微任务队列」。call_soon()、Task 恢复、Future 完成和定时回调都遵循 asyncio 自己的调度规则,不应该机械套用 Promise.then() 与 setTimeout() 的优先级结论。
6. 运行
普通 Python 程序应该优先使用 asyncio.run() 作为异步入口:
01import asyncio0203async def main():04loop = asyncio.get_running_loop()05print(loop.is_running()) # True06await asyncio.sleep(0.1)07return "完成"0809result = asyncio.run(main())10print(result)
asyncio.run() 会完成一整套生命周期管理:
- 创建事件循环;
- 把入口协程作为主任务运行;
- 驱动事件循环直到入口任务完成;
- 完成异步生成器等清理工作;
- 关闭默认执行器;
- 关闭事件循环;
- 返回入口协程的结果,或者向外抛出异常。
asyncio.run() 不能在同一线程已经运行事件循环时再次调用。FastAPI、异步测试框架和部分交互式环境已经管理事件循环,此时应该直接 await:
1async def load_data():2return "数据"34async def endpoint():5return await load_data()67# 不要在 endpoint() 中调用 asyncio.run(load_data())
在协程和回调内部需要取得当前事件循环时,优先使用 asyncio.get_running_loop()。旧教程经常展示 get_event_loop()、new_event_loop()、run_forever() 和手动 close(),这些是低层控制接口,不是普通应用的首选写法。事件循环 policy 体系也已经弃用,因此不建议新项目把 policy 配置作为主线方案。
事件循环不能解决同步阻塞代码。下面的接口会让同一事件循环上的其他 Task 一起等待:
1import time23async def bad_task():4time.sleep(3)5return "完成"
优先把同步库替换为异步库。确实无法替换的短时间阻塞 IO,可以使用 asyncio.to_thread() 隔离:
01import asyncio02import time0304def blocking_work():05time.sleep(1)06return "完成"0708async def main():09result = await asyncio.to_thread(blocking_work)10print(result)1112asyncio.run(main())
开发阶段还可以启用调试模式,帮助发现忘记等待的协程、错误的跨线程调用和执行时间过长的回调:
1import asyncio23async def main():4await asyncio.sleep(0.1)56asyncio.run(main(), debug=True)
调试模式会增加额外开销,适合开发和排查问题,不应在不了解成本的情况下直接作为高负载生产环境默认配置。