本文目录
一台机器上同时挂着几十个网络连接,每个连接都在等对端回包——如果为每个连接单独开线程,栈内存和上下文切换成本会迅速堆高。asyncio 走另一条路:一个线程、一个事件循环,在「有活可干」和「必须等 I/O」之间来回切换。理解 事件循环 在 调度 什么,是读懂后续 Future、协程、await 的前提;否则 async def 只会像另一套陌生 API,文档里的 create_task、gather 也只会变成死记硬背。
协作式调度:单线程里谁让出 CPU
传统抢占式多线程里,操作系统随时可能打断你的函数,把 CPU 分给别的线程。asyncio 的 事件循环 则是 协作式 的:同一条线程上,只有当前回调 主动让出(几乎总是通过 await),循环才会去跑别的就绪任务。没有魔法并行——同一时刻仍只有一个 Python 字节码在执行;「并发」来自 交错执行:A 在等 socket 可读时,B 的回调可以跑,C 的定时器到期也可以插队进就绪队列。
可以把循环想成餐厅里 一个服务员(单线程):
| 阶段 | 循环在做什么 |
|---|---|
| 就绪队列 | 已有结果、可以立刻执行的回调(协程恢复、Future 完成后的 callback、call_soon 注册的函数) |
| I/O 等待 | 所有任务都在等网络/文件描述符时,循环 挂起 Python,把 fd 交给 OS 的 select / epoll / kqueue |
| 定时器 | call_later、sleep 到期后,把对应回调放回就绪队列 |
服务员不会同时给两桌点菜;但他可以在「后厨还没出菜」时去招呼下一桌——这就是 调度。关键约束:没人喊「换桌」就不会换——对应代码里缺少 await 的长计算或 阻塞 系统调用。
与多线程对比时记住一点:线程切换由内核决定;事件循环 切换由你的协程在 await 处配合。协作式模型换来回开销极低,但把「别占着线程」的责任交给了库和应用作者。
事件循环的一轮:ready、poll、timer
CPython 里 asyncio 的默认实现(Unix 上多为 SelectorEventLoop)每轮大致是:
- 处理就绪队列(ready queue):按 FIFO 取出回调,逐个执行,直到队列空或达到本轮上限(防止饥饿)。
- 计算 I/O 超时:若存在未到期定时器,取最近到期时间与 I/O 等待的超时上限。
selector.select(timeout):在超时或 fd 就绪前 阻塞在 OS 层(此时 Python 线程让出,不占 GIL 忙等);就绪的 handle 被标记,下一轮进 ready。- 处理到期定时器:把 timer handle 推进 ready,下一轮执行。
因此 loop「闲着」并不等价于 Python 在空转——往往是在 等 I/O 或等最近闹钟。一有 ready 回调,立刻回到 Python 层逐个跑。
用代码感知「回调何时进 ready」:
import asyncio
async def step(name: str) -> None:
print(f"{name} start")
await asyncio.sleep(0) # 让出一次,重新排队
print(f"{name} after yield")
async def main() -> None:
asyncio.create_task(step("A"))
asyncio.create_task(step("B"))
print("main before sleep")
await asyncio.sleep(0)
print("main after sleep")
asyncio.run(main())逐步推演(同一进程、标准输出):
main before sleep
A start
B start
A after yield
B after yield
main after sleepawait asyncio.sleep(0) 并不真睡,而是把当前协程挂起并注册「下一轮再唤醒」。create_task 把协程包成 Task 并 立刻 在 ready 里排一次初始调度;因此 A start、B start 插在 main 第一次让出之前。这就是 调度顺序 可预测、但读起来像「穿插打印」的原因——事件循环 不会替你保证「公平时间片」,只保证 就绪队列 的顺序与 await 注册的唤醒。
call_soon 与 call_later:非协程也能排队
并非所有进 loop 的工作都来自 async def。同步函数可以通过 Handle 预约:
import asyncio
def sync_cb() -> None:
print("sync callback runs")
async def main() -> None:
loop = asyncio.get_running_loop()
loop.call_soon(sync_cb)
print("main before yield")
await asyncio.sleep(0)
print("main after yield")
asyncio.run(main())输出:
main before yield
sync callback runs
main after yieldcall_soon 把 sync_cb 放进 就绪队列;当前回调(main 协程体)跑完到第一个 await 之前,通常 不会 立刻执行 sync_cb。call_later(0.1, fn) 则进 定时器堆,到期后再进 ready——与 asyncio.sleep(0.1) 同属 时钟 一路。
从机制上看:协程 是「可暂停的回调」;普通函数是「一次跑完的原子回调」。两者在 loop 眼里都是 callable,区别只在于协程会在 await 处拆成多段。
asyncio.run:谁创建 loop、谁跑完就关
日常入口是 asyncio.run(coro)。它做几件固定的事(概念层,不必背源码行号):
- 创建 新 事件循环(或通过策略取当前线程适用的 loop 类)。
- 把传入的 协程 包装成 Task 并驱动直到完成。
- 取消仍未完成的 Task、关闭 async generator、关闭 loop、清理默认 executor。
import asyncio
async def ping() -> str:
await asyncio.sleep(0.01)
return "pong"
result = asyncio.run(ping())
print(result) # pongasyncio.run 不能 在已有 running loop 的线程里嵌套调用(Jupyter、某些 GUI 框架里已有 loop 时会报 RuntimeError)。旧写法 loop = asyncio.get_event_loop()、loop.run_until_complete(coro) 与 asyncio.run 目标相同:拿到 loop → 跑完主协程 → 收尾;3.10+ 新代码应优先 asyncio.run,少在模块 import 时「顺便」创建 loop。
在 async 函数内部要用 当前正在跑的 loop:
import asyncio
async def show_loop() -> None:
loop = asyncio.get_running_loop()
print(type(loop).__name__)
asyncio.run(show_loop())
# _UnixSelectorEventLoop 或 Windows 上 ProactorEventLoop 等get_running_loop() 只在 循环已启动 的上下文里有意义。模块顶层还没有 loop 时,用 asyncio.run 启动;不要用已弃用语义的 get_event_loop() 在 import 阶段「碰运气」绑 loop。
若其它 线程 需要往 loop 丢工作(例如同步回调收到硬件信号),应使用 loop.call_soon_threadsafe(fn, *args)——线程安全地把 handle 排进 就绪队列,由 loop 线程执行。这体现了 事件循环 与 多线程 的典型分工:loop 线程做 调度;其他线程做阻塞 I/O 或计算,再通过 threadsafe 接口 交还 结果。
阻塞:为什么 time.sleep 会冻住整个 loop
事件循环 能切换,前提是回调 愿意让出。任何 长时间占用 GIL、且不让出 loop 的同步代码,都会 阻塞 整个线程——其他 Task 就算已在 就绪队列 里也轮不到。
import asyncio
import time
async def worker(name: str) -> None:
print(f"{name} go")
time.sleep(0.2) # 同步阻塞:占满线程
print(f"{name} done")
async def ticker() -> None:
for i in range(3):
print(f"tick {i}")
await asyncio.sleep(0.05)
async def main() -> None:
asyncio.create_task(worker("W"))
await ticker()
asyncio.run(main())输出里 tick 0、tick 1、tick 2 往往 成批出现,中间很难与 W go 交错——因为 time.sleep(0.2) 的 200ms 内 调度 完全停摆。换成 await asyncio.sleep(0.2) 才会协作:睡眠期间 loop 去 poll 其它 fd、跑其它 ready。
同理,requests.get(...)、大段 CPU 循环、无 async 接口的数据库驱动,在 async 函数里直接调用都是 阻塞 点。症状包括:并发连接数上不去、P99 延迟尖刺、健康检查超时。修复方向:
- I/O:换 原生 async 库(
httpxasync、aiohttp等),底层 fd 注册进 selector,由 loop 等待 可读/可写。 - 无法避免的长 阻塞:丢进
asyncio.to_thread或run_in_executor,让线程池里的同步调用不占 loop 线程。 - CPU 密集:多 进程;GIL 与 事件循环 都救不了算力并行。
import asyncio
def sync_add(a: int, b: int) -> int:
return a + b
async def main() -> None:
x = await asyncio.to_thread(sync_add, 1, 2)
print(x) # 3
asyncio.run(main())to_thread 把 callable 放到默认线程池;loop 线程继续 调度 别的 Task,线程池算完后再把 Future 标成 done,回调进 就绪队列。
I/O 注册:loop 在等什么
抽象一层:当协程 await sock_read(...) 时,库会把 socket 的 fd 注册到 selector,并标记「可读时唤醒某 Task」。此时该 Task 不在 ready 里,而在 waiting 集合;事件循环 在 select 返回后把它挪回 ready。多个 Task 等同一 fd 时,OS 一次就绪可能唤醒多个 handle——具体行为取决于库的实现与边缘触发/水平触发配置。
你不必手写 selector,但读 trace 时要能认出:await 网络 ≈ 「注册 interest + 挂起协程」;数据到达 ≈ 「fd ready → callback → Task 恢复」。整条链路由 调度 串起来,而不是 magically 在后台线程跑 async def。
与多线程的分工:什么留在 loop,什么进线程
| 场景 | 更合适的方式 |
|---|---|
大量 I/O 等待、连接数高、逻辑以 await 串起来 | asyncio + 事件循环 |
| 已有同步库、调用短、数量不多 | 线程池 包一层 |
| CPU 密集、多核吃满 | 多进程(ProcessPoolExecutor 等) |
| 阻塞第三方 SDK、无法改源码 | 线程或进程隔离,loop 只做编排 |
事件循环 不是 线程的替代品,而是 单线程内的调度器。很多生产程序是 混合体:async 网关(如 ASGI 服务器)+ 线程池调旧 HTTP 客户端 + 进程池做图像编码。分工标准很简单:会不会长时间占着 loop 线程不让出。
import asyncio
from concurrent.futures import ThreadPoolExecutor
async def mixed() -> None:
loop = asyncio.get_running_loop()
with ThreadPoolExecutor(max_workers=2) as pool:
a = loop.run_in_executor(pool, pow, 2, 10)
b = loop.run_in_executor(pool, pow, 3, 5)
print(await a, await b) # 1024 243
asyncio.run(mixed())两个 pow 在线程池并行(纯 Python 整数幂仍受 GIL 影响,但 阻塞 不在 loop 上),主协程 await 汇合 结果;loop 线程在等待 Future 完成时可以服务其他 Task。对比 纯多线程 服务器:线程模型把 调度 交给 OS;asyncio 把 调度 收回到 事件循环,用更少线程换更多 in-flight I/O。
Windows 上默认可能是 ProactorEventLoop(IOCP),语义仍是「单线程协作 + 等 I/O」,只是底层 API 与 Unix selector 不同——对你写业务代码而言,阻塞 规则和 asyncio.run 入口不变。
观测:loop 是否真的在转
调试 阻塞 时可临时加「心跳 Task」:
import asyncio
async def heartbeat() -> None:
n = 0
while True:
print(f"heartbeat {n}")
n += 1
await asyncio.sleep(0.05)
async def blocking_once() -> None:
import time
time.sleep(0.3)
async def main() -> None:
asyncio.create_task(heartbeat())
await asyncio.sleep(0.02)
await blocking_once()
await asyncio.sleep(0.1)
asyncio.run(main())time.sleep(0.3) 期间 heartbeat 不会打印——直观证明 调度 停摆。生产环境可用 loop.slow_callback_duration(3.12+)或 APM 监控「callback 执行超过 N 毫秒」的告警,把 阻塞 从「感觉卡」变成可量化指标。
更长的时间线:三个 Task 与定时器交织
把 调度 写成时间线有助于读日志。下面三个 Task 各睡不同时长:
import asyncio
async def job(tag: str, delay: float) -> None:
print(f"{tag} begin")
await asyncio.sleep(delay)
print(f"{tag} end")
async def main() -> None:
asyncio.create_task(job("fast", 0.05))
asyncio.create_task(job("mid", 0.10))
asyncio.create_task(job("slow", 0.15))
print("main waiting")
await asyncio.sleep(0.20)
print("main done")
asyncio.run(main())典型输出顺序:
main waiting
fast begin
mid begin
slow begin
fast end
mid end
slow end
main done解释:create_task 后三者几乎同时 begin,因为都先跑到第一个 await asyncio.sleep 并让出;事件循环 的 定时器 在 0.05s、0.10s、0.15s 分别把它们推回 就绪队列。main 的 0.20s 睡眠最长,因此最后打印 main done。若把 job 里的 sleep 换成 阻塞 的 time.sleep,三条 begin 仍可能连续出现,但 end 会一起卡住,整体时长由最慢 阻塞 决定——这就是 协作 与 阻塞 的核心差别。
run_until_complete 与长期运行的 loop
框架代码(FastAPI / Uvicorn、Sanic 等)往往 不 用 asyncio.run 包整个进程生命周期,而是「创建 loop → run_until_complete(serve_forever) 或等价 async main → 进程退出才关 loop」。你的业务协程跑在 已经存在 的 loop 上;测试或小脚本则用 asyncio.run 一次性跑完。两种模式底层都是同一套 事件循环 状态机,区别只是 谁负责创建与销毁 loop。
import asyncio
async def once() -> int:
await asyncio.sleep(0.01)
return 99
# 脚本风格
print(asyncio.run(once())) # 99
# 等价旧式(不推荐在新代码里手写)
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
print(loop.run_until_complete(once())) # 99
finally:
loop.close()理解 asyncio.run 与「手动持有一个 loop」的关系,读开源服务器源码时才不会把「loop 从哪来」当成黑盒。
何时不必强行 asyncio
事件循环 适合 大量等待、少量 CPU、逻辑能拆成 await 链 的场景。若整个服务只是顺序调用三个同步 HTTP 接口,线程池或普通同步代码可能更简单;若团队没有 async 库生态,硬上 asyncio 会把 阻塞 藏得到处都是。决策可以问三个问题:瓶颈是不是 I/O 等待?有没有成熟的 async 驱动?愿不愿意把 阻塞 调用隔离到线程/进程?三者有一否,就要谨慎。
反例也常见:为了「看起来现代」把十行脚本改成 async def,却没有并发 await,只剩 asyncio.run 的开销——调度 机制再 elegant,也救不了没有并发点的程序。
常见误解
误解一:「用了 async 就是多核并行。」 默认 asyncio 仍是单线程协作;多核要靠多进程或把计算丢出去。
误解二:「create_task 会开新线程。」 Task 只是 loop 上的 调度单元,和 OS 线程无关。
误解三:「loop 空闲就是在睡。」 空闲时 loop 可能在 select 上等 I/O,或在等最近定时器;一有 ready 立刻醒。
误解四:「阻塞一小会儿没关系。」 高 QPS 下毫秒级 阻塞 会叠成尾延迟尖刺;应用 async 睡眠或线程池,并监控 loop lag。
误解五:「asyncio.run 和 loop 是两回事。」 asyncio.run 就是「创建 loop、跑完、销毁」的打包入口;谈 事件循环 离不开 asyncio.run 这类启动方式。
默认线程池与 run_in_executor
asyncio.run 结束时还会 shutdown 默认 executor(线程池)。许多「把 阻塞 调用丢出去」的 API 都依赖它:asyncio.to_thread、无显式 pool 的 run_in_executor(None, ...)。理解这一点有助于解释:为什么 loop 关了以后不能再 to_thread;为什么长生命周期的服务器要在进程退出路径上显式关闭 pool。
import asyncio
def heavy(n: int) -> int:
return sum(i * i for i in range(n))
async def main() -> None:
loop = asyncio.get_running_loop()
a = loop.run_in_executor(None, heavy, 100_000)
b = loop.run_in_executor(None, heavy, 100_000)
print(await a, await b)
asyncio.run(main())两个 Future 在线程池并行,loop 线程在 await 期间仍可 调度 其它 Task。线程池完成时通过 call_soon_threadsafe 把 set_result 排回 loop——又回到 Future 从 pending 到 done 的路径。
GIL、loop 与 CPU:别把三件事搅在一起
事件循环 解决 I/O 等待 时的 调度 问题,不解决 CPU 算力 问题。CPython 的 GIL 让同一进程内多条 Python 线程难以真并行执行字节码;asyncio 单线程则干脆不 pretending 并行算力。纯 Python 的数值循环即使 async def 也不会变快——除非把循环 切块 并在块间 await asyncio.sleep(0) 让出(仅适合协作式公平,不适合加速计算),或改用 C 扩展 / 多进程。
读日志时要分清:卡顿是因为 阻塞 系统调用,还是因为 CPU 占满 GIL 且不让出。前者换 async I/O 或线程池;后者换进程或 native 库。
读源码时的名词对照
| 你看到的 API | loop 里大致对应 |
|---|---|
asyncio.run | 新建 loop、run_until_complete、shutdown asyncgen、close loop |
create_task | Task(Future) + call_soon 首次 调度 |
await sleep(t) | 注册 定时器 handle,协程 移出 ready |
await reader.read() | 注册 fd 读就绪,Future pending |
to_thread(fn) | 线程池 + asyncio.Future 桥接 |
带着这张表重读自己的 trace,「卡在全队 阻塞」还是「卡在等 Future」会快很多。
把一轮 loop 写成故事
假设 ready 里只有一个 Task main,它 await asyncio.sleep(1):
- main 跑到 await,协程 暂停;sleep 向 定时器堆 注册 1s 后唤醒。
- ready 空,loop 算 select 超时 ≈ 1s(若无更近定时器则等 I/O;此处最近是 1s)。
- 1s 内若无其它 fd 就绪,定时器到期,main 回到 ready。
- loop 执行 main 剩余代码直至 return 或下一个 await。
若步骤 1 换成 time.sleep(1),步骤 2–3 不存在——loop 线程卡在 阻塞 调用里,定时器 到期也无法 调度 唤醒 main(其实 main 根本没挂起,是占着线程睡)。用故事核对 trace,比背 API 更快。
深入:Handle、TimerHandle 与就绪队列
事件循环 不直接「执行 协程」这三个字,它执行的是 Handle——包装了 callable 与参数的小对象。call_soon(fn, *a) 产生 Handle 并进 就绪队列;call_later(delay, fn, *a) 产生 TimerHandle 进 定时器堆,到期后再转成 Handle 进 ready。**asyncio.sleep(t)** 在实现上就是注册 TimerHandle,并在 Future 上 set_result(None)。
因此读 trace 时看到「sleep 1s」,应联想到:协程 已 暂停,loop 可能在 select 上等 I/O,也可能在等 最近定时器——绝不是 Python 空转 busy loop。就绪队列 FIFO 并不保证「公平」:若某个 callback 执行很久且不 await,后面的 Handle 全部饿死;这是 协作式 调度 的代价,也是为什么 阻塞 危险。
多连接场景:为何单线程仍够用
假设网关维持一万条 idle 连接:每条连接绝大多数时间在等读。线程 模型下一万线程栈成本惊人;事件循环 模型下,一万条连接注册在一组 fd 上,select 返回可读 fd 后,仅对应 Task 进 就绪队列。CPU 花在 调度 与协议解析,不花在频繁内核线程切换。瓶颈转移到:解析逻辑是否 阻塞、是否单核 CPU 打满、是否有 async 友好的驱动——而不是「单线程一定慢」。
asyncio.run 与测试里的 loop
单元测试有时需要 同一进程内 多次跑 协程。每次 asyncio.run 创建并销毁 loop,干净但略重。**pytest-asyncio** 等插件持有一个 session loop,避免重复 asyncio.run 的开销与「loop 已关闭」错误。无论哪种,业务 协程 仍须 await 可等待对象,仍须避免 阻塞 loop 线程——测试里 time.sleep 同样会拖慢并发用例。
与信号、子进程(点到为止)
事件循环 还可注册 signal handler、subprocess 完成回调(**asyncio.create_subprocess_exec** 等)。子进程结束时会 set_result 到 Future,await 方 协程 恢复。这些 API 表面不同,底层仍是:pending 的 Future + 某 OS 事件触发 set_result + loop 调度 ready。掌握 事件循环 三板斧(ready / I/O / timer)后,新 API 只是多一种注册方式。
回调地狱 vs 协程:loop 始终在同一层
在纯 callback 风格里,每个 Handle 写「完成后 call 下一个 Handle」,逻辑分散。协程 + await 把暂停点串成直线,但 事件循环 底层仍是 Handle 队列——语法换皮,调度 未变。读 asyncio 源码看到 Handle._run,应联想到:这就是 协程 恢复 或 Future callback 的入口之一。
长时间运行的 loop 与 graceful shutdown
服务器收到 SIGTERM 时,应停止接受新连接、cancel 或 await 在途 Task、关闭 executor、再 loop.close()。若直接 os._exit,pending Future 与 Task 会被硬切断。**asyncio.run** 在脚本退出时做类似收尾;长期进程要自己实现 shutdown 协程,在 TaskGroup 或 gather 里 汇合 在途工作。
测量 loop 延迟:debug 模式
开发期可开 asyncio.get_event_loop().set_debug(True)(或 asyncio.run(..., debug=True) 3.11+),慢 callback 会打 warning。配合 loop.slow_callback_duration 调整阈值。把 阻塞 从「体感卡」变成日志里的 Executing ... took 0.5 seconds,定位谁在占 调度 线程。
epoll 边缘触发与 asyncio(了解)
Linux 上 epoll 有水平触发与边缘触发模式。SelectorEventLoop 默认行为对应用开发者透明,但若自写 socket 非阻塞读,须 读尽 缓冲区,否则边缘触发下可能 饿死 后续可读事件——表现为 Task 长期 pending。这类 bug 在 事件循环 之上,属于 I/O 层与协议层交界。
单 loop 多线程:只投递,不直接 await
其它线程 禁止 在 loop 线程外 await 协程;只能 call_soon_threadsafe 或 asyncio.run_coroutine_threadsafe(coro, loop) 把 协程 投递到 loop 线程 调度。违反会在 runtime 报错或数据竞争——这是 asyncio 与 多线程 协作的硬规则。
小结前的最后核对清单
- 当前代码路径是否在 协作式 调度 下运行?
asyncio.run还是 已有 loop?- 是否存在 阻塞 调用占满 loop 线程?
- I/O 是否注册进 selector 而非 阻塞
recv? - 重 CPU / 同步 SDK 是否
to_thread/ 进程池?
小结
事件循环 在单线程里维护 就绪回调、I/O 多路复用 与 定时器 三类工作;调度 只在协作点发生。asyncio.run 负责创建 loop、跑主协程并收尾。阻塞 调用占满 loop 线程时,所有 Task 一起卡住——与 多线程 里一个线程卡住不同,asyncio 里 唯一 的那条 loop 线程卡住等于全局卡住。与 多线程/多进程 搭配时,把「等 I/O、轻量编排」留在 loop,把「同步 阻塞、重 CPU」挪出去。下一篇从 Future 说起:未完成的结果如何在 loop 里变成 done。