K 的一隅

Python Python 语言核心

单线程如何切换等待:Python 事件循环在调度什么

事件循环在单线程里如何调度就绪回调、I/O 等待与时钟;asyncio.run 与阻塞调用的关系;与多线程的分工边界。

17 分钟阅读 更新于 2026-07-28
本文目录

一台机器上同时挂着几十个网络连接,每个连接都在等对端回包——如果为每个连接单独开线程,栈内存和上下文切换成本会迅速堆高。asyncio 走另一条路:一个线程、一个事件循环,在「有活可干」和「必须等 I/O」之间来回切换。理解 事件循环调度 什么,是读懂后续 Future、协程、await 的前提;否则 async def 只会像另一套陌生 API,文档里的 create_taskgather 也只会变成死记硬背。

协作式调度:单线程里谁让出 CPU

传统抢占式多线程里,操作系统随时可能打断你的函数,把 CPU 分给别的线程。asyncio事件循环 则是 协作式 的:同一条线程上,只有当前回调 主动让出(几乎总是通过 await),循环才会去跑别的就绪任务。没有魔法并行——同一时刻仍只有一个 Python 字节码在执行;「并发」来自 交错执行:A 在等 socket 可读时,B 的回调可以跑,C 的定时器到期也可以插队进就绪队列。

可以把循环想成餐厅里 一个服务员(单线程):

阶段循环在做什么
就绪队列已有结果、可以立刻执行的回调(协程恢复、Future 完成后的 callback、call_soon 注册的函数)
I/O 等待所有任务都在等网络/文件描述符时,循环 挂起 Python,把 fd 交给 OS 的 select / epoll / kqueue
定时器call_latersleep 到期后,把对应回调放回就绪队列

服务员不会同时给两桌点菜;但他可以在「后厨还没出菜」时去招呼下一桌——这就是 调度。关键约束:没人喊「换桌」就不会换——对应代码里缺少 await 的长计算或 阻塞 系统调用。

与多线程对比时记住一点:线程切换由内核决定;事件循环 切换由你的协程在 await 处配合。协作式模型换来回开销极低,但把「别占着线程」的责任交给了库和应用作者。

事件循环的一轮:ready、poll、timer

CPython 里 asyncio 的默认实现(Unix 上多为 SelectorEventLoop)每轮大致是:

  1. 处理就绪队列(ready queue):按 FIFO 取出回调,逐个执行,直到队列空或达到本轮上限(防止饥饿)。
  2. 计算 I/O 超时:若存在未到期定时器,取最近到期时间与 I/O 等待的超时上限。
  3. selector.select(timeout):在超时或 fd 就绪前 阻塞在 OS 层(此时 Python 线程让出,不占 GIL 忙等);就绪的 handle 被标记,下一轮进 ready。
  4. 处理到期定时器:把 timer handle 推进 ready,下一轮执行。

因此 loop「闲着」并不等价于 Python 在空转——往往是在 等 I/O 或等最近闹钟。一有 ready 回调,立刻回到 Python 层逐个跑。

用代码感知「回调何时进 ready」:

python
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 sleep

await asyncio.sleep(0) 并不真睡,而是把当前协程挂起并注册「下一轮再唤醒」。create_task 把协程包成 Task 并 立刻 在 ready 里排一次初始调度;因此 A startB start 插在 main 第一次让出之前。这就是 调度顺序 可预测、但读起来像「穿插打印」的原因——事件循环 不会替你保证「公平时间片」,只保证 就绪队列 的顺序与 await 注册的唤醒。

call_soon 与 call_later:非协程也能排队

并非所有进 loop 的工作都来自 async def。同步函数可以通过 Handle 预约:

python
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 yield

call_soonsync_cb 放进 就绪队列;当前回调(main 协程体)跑完到第一个 await 之前,通常 不会 立刻执行 sync_cbcall_later(0.1, fn) 则进 定时器堆,到期后再进 ready——与 asyncio.sleep(0.1) 同属 时钟 一路。

从机制上看:协程 是「可暂停的回调」;普通函数是「一次跑完的原子回调」。两者在 loop 眼里都是 callable,区别只在于协程会在 await 处拆成多段。

asyncio.run:谁创建 loop、谁跑完就关

日常入口是 asyncio.run(coro)。它做几件固定的事(概念层,不必背源码行号):

  1. 创建 事件循环(或通过策略取当前线程适用的 loop 类)。
  2. 把传入的 协程 包装成 Task 并驱动直到完成。
  3. 取消仍未完成的 Task、关闭 async generator、关闭 loop、清理默认 executor。
python
import asyncio


async def ping() -> str:
    await asyncio.sleep(0.01)
    return "pong"


result = asyncio.run(ping())
print(result)  # pong

asyncio.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

python
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 就算已在 就绪队列 里也轮不到。

python
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 0tick 1tick 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 库(httpx async、aiohttp 等),底层 fd 注册进 selector,由 loop 等待 可读/可写。
  • 无法避免的长 阻塞:丢进 asyncio.to_threadrun_in_executor,让线程池里的同步调用不占 loop 线程。
  • CPU 密集:多 进程;GIL 与 事件循环 都救不了算力并行。
python
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 线程不让出

python
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」:

python
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 各睡不同时长:

python
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

python
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。

python
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_threadsafeset_result 排回 loop——又回到 Futurepending 到 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 库。

读源码时的名词对照

你看到的 APIloop 里大致对应
asyncio.run新建 loop、run_until_complete、shutdown asyncgen、close loop
create_taskTask(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)

  1. main 跑到 await协程 暂停;sleep 向 定时器堆 注册 1s 后唤醒。
  2. ready 空,loop 算 select 超时 ≈ 1s(若无更近定时器则等 I/O;此处最近是 1s)。
  3. 1s 内若无其它 fd 就绪,定时器到期,main 回到 ready。
  4. 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,并在 Futureset_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 handlersubprocess 完成回调(**asyncio.create_subprocess_exec** 等)。子进程结束时会 set_resultFutureawait协程 恢复。这些 API 表面不同,底层仍是:pendingFuture + 某 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 时,应停止接受新连接、cancelawait 在途 Task、关闭 executor、再 loop.close()。若直接 os._exitpending FutureTask 会被硬切断。**asyncio.run** 在脚本退出时做类似收尾;长期进程要自己实现 shutdown 协程,在 TaskGroupgather汇合 在途工作。

测量 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_threadsafeasyncio.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。