协程 · await · 事件循环 · epoll · Task · gather · TaskGroup · timeout · to_thread · GIL

asyncio:一个线程,怎么同时等 5 台机器

协程是能在 await 处暂停、之后从原地接着跑的函数;事件循环是一个线程上的循环:从就绪队列里取一个协程跑到下一个 await,把它要等的 socket 交给 epoll,再取下一个。精华:等待不占线程,只有执行占线程。所以 1 万个请求可以挂在一个线程上一起等;反过来,任何不经过 await、一直占着线程的东西(time.sleep、同步网络库、大段计算)会把所有请求一起卡住。不懂这一点的典型误用:在 async def 里调 requests.get;或者以为写了 async 就能多核并行算东西。

全景:一个 FastAPI 请求的一生 · 第 5、6、7 站地图:站 01 · asyncio:事件循环、async/await、并发请求以 CPython 3.13 为例

①为什么需要:时间几乎全花在“等”上

在全景第 5、6、7 站,这一步要解决的是:5 台机器各要等 80 毫秒到 1 秒,等的时候 CPU 什么也不干。一台一台等,时间是相加的;同时等,只取决于最慢的那台。而且这一个进程还要同时接住别人发来的 /healthz,不能因为在等日志就不理人。

逐个 await:一台等完再等下一台 order-01 order-02 order-03 order-041 s 超时 order-05 1442 ms gather:5 个一起发出去,一起等 1001 ms 5 台重叠 两种写法都只有一个线程 实测
数字来自 ⑤ 的 aio_demo.py,延迟按全景页的假日志代理设定(120 / 80 / 150 / 不回 / 90 ms)。节省下来的 440 ms 不靠多开线程,靠的是“等的时候让出线程”。
需求工作要用,而且要到懂设计的深度:站 03 起,调大模型一次要几秒,流式输出要边收边推,站 04 的 agent 要同时调几个工具,站 10 要扛 1000 个告警同时进来。这些全建在“一个线程同时等很多东西”上。

②没有它会怎样:要么一个请求占一个线程,要么回调套回调

线程池 + 阻塞调用 Tomcat 的老办法
ExecutorService pool =
    Executors.newFixedThreadPool(5);
List<Future<String>> fs = hosts.stream()
    .map(h -> pool.submit(() -> fetch(h)))
    .toList();
for (var f : fs) f.get(1, SECONDS);
每个“在等的请求”占住一个线程。线程有自己的栈,切换要操作系统调度;同时等 1 万个,就要 1 万个线程。
回调 / CompletableFuture 链 不占线程,但难读
fetchAsync(h)
  .thenCompose(log -> parseAsync(log))
  .thenApply(s -> s.maxMs())
  .exceptionally(e -> null)
  .orTimeout(1, SECONDS);
逻辑被拆成一段段回调,try/except、循环、局部变量都没法自然地写。
协程 写起来像同步,跑起来不占线程
async def fetch_t(host):
    try:
        async with asyncio.timeout(1.0):
            r = await client.get(url(host))
    except TimeoutError:
        return "timeout"
    return parse(r.text)
顺着往下写,try、循环都照常用;每个 await 是一个“我要等了,线程先给别人”的点。

③相邻方案:Python 里“同时做几件事”有四条路

asyncio

await asyncio.gather(...)

一个线程,在 await 处切换。大量网络等待的首选。

≈ Netty、Redis 的单线程事件循环

线程

await asyncio.to_thread(fn)
ThreadPoolExecutor

操作系统随时切换。阻塞 IO 时会释放 GIL,所以对 IO 有用。

只有同步库可用时:老驱动、文件 IO

多进程

ProcessPoolExecutor
uvicorn --workers 4

每个进程有自己的解释器和 GIL,真正用上多核。

CPU 密集:解析大日志、算 embedding

gevent 绿色线程

from gevent import monkey
monkey.patch_all()

把标准库的阻塞调用换成会自动让出的版本,不用写 await。

≈ Java 21 虚拟线程的思路;老项目还在用

本页学 asyncio。选择规则:等网络 → asyncio;只有同步库 → to_thread 包一层;算 CPU → 进程。FastAPI、httpx、各家大模型 SDK 的异步客户端都在 asyncio 上。

④原理:协程是能暂停的函数,事件循环是一个线程上的调度员

先知道这些
  • epoll:Linux 的一个系统调用,一次交给内核一批 socket,内核告诉你哪几个可以读写了。Redis、Netty、Nginx 都靠它。见 网络 IO 模型、Redis 线程模型与 IO 多路复用。
  • GIL(全局解释器锁):CPython 里任何时刻只有一个线程在执行 Python 字节码。线程做阻塞 IO 时会释放它,所以多线程对“等”有用,对“算”没用。本机 3.13 默认构建 GIL 开着(sys._is_gil_enabled() 为 True 实测)。
  • 生成器:带 yield 的函数,每次 next() 跑到下一个 yield 就停住,局部变量都保留着。协程就是从它演化来的。

1协程:调用 async def 不会执行,只会造出一个能一步步推着走的对象 ≈ 虚拟线程的 continuation

async def f(): await asyncio.sleep(0) return 1 c = f() CORO_CREATED 一行都还没执行 c.send(None) 跑到 await 停住,交出控制权 局部变量原样保留 再 send(None) 从停住处接着跑到 return StopIteration(1) 事件循环 = 替你反复调 send 的那个人 谁的等待结束了就 send 谁一下,让它跑到下一个 await;三步的输出都是本机实测 实测 忘了 await: 对象造出来没人推,函数体不执行
所以 async def 里的代码只在两个地方让出线程:await 和函数结束。两个 await 之间的代码一口气跑完,中途不会被别的协程打断。这一条既是 asyncio 好用的原因(共享的字典不用加锁),也是它最大的坑的根源(中间那段如果很慢,所有人都得等)。

2事件循环:就绪队列、定时器、epoll,一圈一圈地转 ≈ Netty 的 EventLoop

① 跑就绪队列 一个个 send,每个跑到下一个 await [Task 02, Task 05, healthz] 这一步里谁慢,后面全等 ② 看定时器堆 sleep、timeout 都登记在这里 最近到期:order-04 的 1 s 超时 算出这一圈最多能睡多久 ③ epoll_wait(线程睡下) 把 5 个 socket 交给内核 有数据到了,或者到时间了,才醒 这段时间线程完全空闲 ④ 唤醒对应的任务 order-02 的 socket 可读了 → 把 Task 02 放回就绪队列 本机:_UnixSelectorEventLoop + EpollSelector 实测 装了 uvloop 时 uvicorn 换成 libuv 实现,模型一样
Java 里对应的是 Netty 的 EventLoop:一个线程守着一个 Selector,处理就绪的 Channel。区别在于 Netty 的 handler 是回调,asyncio 里“被唤醒”的是一个暂停着的协程,它从上次 await 的那一行接着往下跑。

3Task、gather、TaskGroup、timeout:把协程交给循环,再一起收回来 ≈ CompletableFuture.allOf

Task

asyncio.create_task(coro) 把协程包成任务、放进就绪队列,立刻返回;它在后台跑,你 await 它时拿结果。

gather

把几个协程各包成 Task,等全部完成,按传入顺序返回结果。一个出错,它立刻把异常抛给你,但不取消其他的 实测。

TaskGroup(3.11)

async with 块里开任务;一个出错就取消同组其他任务,等它们收尾后抛 ExceptionGroup 实测。

timeout(3.11)

到点在任务里抛 CancelledError,任务从当前 await 处退出;出了 async with 块,变成 TimeoutError。

“取消”也是协作式的:事件循环只是在任务下一次被唤醒时,往它停住的那个 await 里扔一个 CancelledError。任务正在跑一段不 await 的计算时,取消和超时都要等它算完才生效。

实验台:一个线程的时间线

5 台机器的延迟和全景页一样,order-04 不回、1 秒超时;第 200 ms 有人来打 /healthz,它只要 1 ms 的线程时间。改下面四个选项,看总耗时和 healthz 等了多久。

第 6 站怎么写
端点开头有没有阻塞调用
第 8 站每台解析耗时
解析放在哪

先猜:这次 /healthz 大概要等多久?

在等网络(不占线程) 占着事件循环线程 在工作线程里算 healthz 被处理

4为什么这样设计:显式 await,而不是自动切换

Python asyncio:只在写了 await 的地方切换选了它

  • 切换点看得见。两个 await 之间不会被打断,单线程里改共享的字典不用加锁。
  • 一个协程只占几 KB,1 万个同时等,峰值内存 12.4 MB 实测。
  • 代价一:“函数染色”。调异步函数的函数也得是 async,一路传染到入口。
  • 代价二:任何不 await 的阻塞调用都会卡住所有人,而解释器不会提醒你。

Java 21 虚拟线程:阻塞调用处自动让出没选

  • 不用改写法,socket.read() 照常写,JDK 在底层把它变成“挂起虚拟线程、让出载体线程”。
  • 没有染色问题,老代码直接受益。
  • 前提是 JDK 能改写所有阻塞点。JDK 21 里在 synchronized 块内阻塞会钉住载体线程,到 JDK 24 才解决(JEP 491)。见 JDK 8、17、21、25。

决定胜负的约束:Python 的生态里有大量 C 扩展和同步库,解释器没法像 JDK 那样把每一个阻塞调用都改写成“自动让出”。gevent 试过用 monkeypatch 去替换,替换不到的地方就悄悄失效。既然做不到全自动,就让程序员显式标出每个让出点:麻烦一点,但切换点一目了然。

最少的语法:Java 怎么写,Python 怎么写 细节、版本差异放到 ⑥

JavaPython asyncio说明
CompletableFuture<String> f()async def f() -> str调用得到协程对象,不执行
future.join()await f()只能写在 async def 里
main 里启动asyncio.run(main())建事件循环、跑完、关掉
executor.submit(task)asyncio.create_task(coro)要保存返回的 Task 引用
CompletableFuture.allOf(...)await asyncio.gather(*coros)一个出错不取消其他
StructuredTaskScope(预览)async with asyncio.TaskGroup() as tg:一个出错取消全组
future.get(1, SECONDS)async with asyncio.timeout(1.0):抛 TimeoutError
Thread.sleep(1000)await asyncio.sleep(1)千万别用 time.sleep
Semaphore(10)asyncio.Semaphore(10) + async with sem:限制同时进行的数量
把阻塞调用丢给线程池await asyncio.to_thread(fn, *args)3.9 起
CPU 活丢给别的核loop.run_in_executor(ProcessPoolExecutor(), fn)函数要能被 pickle

⑤亲眼看到:在值班助手里,把第 5、6、7 站的三件事各跑一遍

还在 gc-helper 项目里,只用标准库。日志代理换成了 asyncio.sleep,延迟和全景页一样,这样不用起服务就能看清事件循环在干什么。第 2 段放了一个“探针”任务:它每 10 ms 醒一次,记下自己最多迟到了多久,迟到多少就说明事件循环被占了多久。

1aio_demo.py 同时等、卡住循环、出错时的两种收尾
import asyncio, time

DELAYS = {"order-01": .12, "order-02": .08, "order-03": .15, "order-04": None, "order-05": .09}

async def fetch(host):                          # 假装拉日志:order-04 永远不回
    d = DELAYS[host]
    await asyncio.sleep(d if d is not None else 3600)
    return f"{host} ok"

async def fetch_t(host):                        # 全景第 7 站:1 秒超时
    try:
        async with asyncio.timeout(1.0):
            return await fetch(host)
    except TimeoutError:
        return f"{host} timeout"

def ms(t): return round((time.perf_counter() - t) * 1000)

async def lag_probe(stop, out):                 # 每 10 ms 醒一次,记下最晚迟到了多久
    worst = 0
    while not stop.is_set():
        t = time.perf_counter(); await asyncio.sleep(0.01)
        worst = max(worst, (time.perf_counter() - t - 0.01) * 1000)
    out.append(round(worst))

async def main():
    print("== 1. 一台一台等 vs 同时等 ==")
    t = time.perf_counter(); [await fetch_t(h) for h in DELAYS]; print("  逐个 await:", ms(t), "ms")
    t = time.perf_counter(); r = await asyncio.gather(*(fetch_t(h) for h in DELAYS))
    print("  gather    :", ms(t), "ms", r)

    print("== 2. 谁会卡住事件循环 ==")
    for name in ("await asyncio.sleep(1)", "time.sleep(1)", "await asyncio.to_thread(time.sleep, 1)"):
        stop, out = asyncio.Event(), []
        probe = asyncio.create_task(lag_probe(stop, out)); await asyncio.sleep(0.05)
        if name.startswith("await asyncio.sleep"): await asyncio.sleep(1)
        elif name == "time.sleep(1)": time.sleep(1)
        else: await asyncio.to_thread(time.sleep, 1)
        stop.set(); await probe
        print(f"  {name:40s} 事件循环最多迟到 {out[0]} ms")

    print("== 3. 一个出错,其他怎样 ==")
    async def boom(): await asyncio.sleep(.05); raise ValueError("max() arg is an empty sequence")
    async def slow(name):
        try:
            await asyncio.sleep(.2); print("    ", name, "跑完了"); return name
        except asyncio.CancelledError:
            print("    ", name, "被取消"); raise
    try: await asyncio.gather(slow("a"), boom(), slow("b"))
    except ValueError as e: print("  gather 抛出:", e)
    await asyncio.sleep(.3)
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(slow("a")); tg.create_task(boom()); tg.create_task(slow("b"))
    except* ValueError as eg: print("  TaskGroup 抛出:", eg.exceptions)

asyncio.run(main())
$ uv run aio_demo.py
== 1. 一台一台等 vs 同时等 ==
  逐个 await: 1442 ms
  gather    : 1001 ms ['order-01 ok', 'order-02 ok', 'order-03 ok', 'order-04 timeout', 'order-05 ok']
== 2. 谁会卡住事件循环 ==
  await asyncio.sleep(1)                   事件循环最多迟到 1 ms
  time.sleep(1)                            事件循环最多迟到 1000 ms
  await asyncio.to_thread(time.sleep, 1)   事件循环最多迟到 1 ms
== 3. 一个出错,其他怎样 ==
  gather 抛出: max() arg is an empty sequence
     a 跑完了
     b 跑完了
     b 被取消
     a 被取消
  TaskGroup 抛出: (ValueError('max() arg is an empty sequence'),)

实测 本机 uv 0.13.7、CPython 3.13.18,输出逐字复制;多次运行 gather 在 1001~1002 ms,逐个 await 在 1442~1444 ms。

到这里已经能交差了:会用 gather 并发、给每个请求加超时、在 fetch 里兜住异常,知道阻塞调用要交给 to_thread。
已知还没处理的细节
  • 第 8 站的解析是 CPU 活,放进 to_thread 也快不了 → ⑥ 坑 5
  • 50 个人同时查,就是 250 个请求同时打到日志代理 → ⑥ 坑 8
  • gather 里一个出错,其他结果白跑 → ⑥ 坑 4

⑥细节·规范·优化

第一圈 8 个坑里,7 个在本机复现过 实测,第 7 个按官方文档。

第一圈

注意事项:不知道就会出事

1async def 里调了阻塞函数← ④-2 只有一个线程
现象
time.sleep(1) 让事件循环迟到 1000 ms;全景页实测同时来的 /healthz 从 1 ms 变成 0.8 s。requests.get、同步数据库驱动、读大文件是同一类。
根源
它不经过 await,一直占着事件循环那唯一的线程,第 ① 步的就绪队列里谁都轮不上。
做法
换异步库(httpx、asyncpg);换不了就 await asyncio.to_thread(fn);或者把端点写成普通 def,FastAPI 会放进线程池。
2忘了 await,函数根本没执行← ④-1 调用只造对象
现象
x = fetch("order-02") 得到一个 coroutine 对象,过后只留下一行警告:coroutine 'fetch' was never awaited。
根源
调用 async 函数只是造出协程对象,没人 send 它就一行都不跑。
做法
调 async 函数一律 await 或交给 create_task;开启 PYTHONASYNCIODEBUG=1 或在 CI 里把这个警告当错误。
3看着是异步,其实还是一台一台等← ④-3 gather
现象
[await fetch_t(h) for h in HOSTS] 用了 1442 ms,gather 是 1001 ms。
根源
每个 await 都要等这一台回来才往下走,一次只有一个请求在路上。
做法
要同时等,就先把它们都变成 Task:gather 或 TaskGroup。
4gather 里一个出错,其他白跑、结果丢失← ④-3 gather 不取消
现象
50 ms 时 gather 就抛出 ValueError,a、b 照样在后台跑完,结果没人收。
根源
gather 默认把第一个异常立即抛给调用方,但不取消其他任务。
做法
要“全成或全取消”用 TaskGroup;要“各管各的”用 return_exceptions=True,或像全景那样在每个任务里自己兜住。
5CPU 活放进 to_thread,没有变快← ④ 先知道:GIL
现象
一个计算任务 341 ms;两个用 to_thread 并发是 669 ms,正好两倍;换成进程池是 369 ms。
根源
GIL 让同一时刻只有一个线程执行 Python 字节码。to_thread 只能让事件循环不被卡住,不能让计算变快。
做法
真要多核算,用 ProcessPoolExecutor,或者起多个 uvicorn worker 进程。
6进程池报 Can't get local object设计如此
现象
把定义在函数里的 cpu() 交给进程池,报 AttributeError: Can't get local object 'main.<locals>.cpu'。
根源
任务要用 pickle 序列化后送进子进程,子进程按“模块名 + 函数名”去找函数,局部函数和 lambda 找不到。
做法
交给进程池的函数写在模块顶层;入口加 if __name__ == "__main__":。
7create_task 不保存引用,任务可能半路消失按文档
现象
“发出去不管”的后台任务偶尔没执行完。
根源
官方文档明确写着:事件循环只保留对任务的弱引用,没有别处引用的任务可能在完成前被垃圾回收。
做法
把 Task 存进一个集合,完成时用 add_done_callback 移除;能用 TaskGroup 就用 TaskGroup。
8并发不设上限,把下游打爆← ④-3 Task 很便宜
现象
50 个人同时查 = 250 个请求同时打到日志代理。协程便宜,下游的连接和 CPU 不便宜。
根源
开一个 Task 几乎没有成本,asyncio 自己不会替你限流。
做法
asyncio.Semaphore(10):250 个任务,同时进行的峰值正好是 10,总耗时 1264 ms 实测;httpx 也能用连接池上限兜底。
第二圈

使用规范:团队里怎么写

async def 里只调异步库

HTTP 用 httpx 的 AsyncClient,数据库用异步驱动;同步库一律 to_thread。

整个进程被一个请求卡住。

每个对外的 await 都有超时

asyncio.timeout 或客户端自己的 timeout,二选一,别都不设。

一台机器不回,整个接口挂住。

并发要有上限

Semaphore 或连接池,按下游能扛的量设。

自己没事,把日志代理和大模型 API 打到限流。

新代码用 TaskGroup

需要“部分失败也继续”时,在每个任务里自己兜住异常。

异常后的任务泄漏、结果丢失。

CPU 活不放事件循环

几毫秒的可以直接算;上百毫秒的交给进程池或单独的服务。

健康检查超时、被 K8s 重启。

别吞掉 CancelledError

except Exception 不会接住它(3.8 起它继承自 BaseException),但 except BaseException 和裸 except: 会,接住后要 raise。

超时和取消失效,任务关不掉。

第三圈

优化和深处

协程和线程,到底差多少

1 万个协程同时等 0.5 秒:用时 850 ms,峰值内存 12.4 MB(tracemalloc,只算 Python 对象)实测。2000 个线程同时睡 0.5 秒:用时 633 ms 实测,每个线程还要占一段操作系统分配的栈。

结论不是“协程永远更快”,而是等的数量上万时,协程的成本几乎只有一个对象;几十个并发,线程池完全够用。

CPU 活的三种去处

一个任务
341 ms
两个 to_thread
669 ms
两个进程
369 ms

本机 2 核,实测。进程池多出的 28 ms 是把参数和结果 pickle 来回传的成本,任务越小越不划算。

uvloop 和多个 worker

uvicorn 装了 uvloop 就换成 libuv 写的事件循环,模型不变,循环本身的开销更小。一个进程只有一个事件循环线程,只吃得满一个核;要用满多核,起多个 worker 进程(--workers N),每个进程各有一个循环。

版本差异

版本变化
3.4asyncio 进入标准库(PEP 3156),当时用 yield from
3.5async / await 语法(PEP 492)
3.7asyncio.run、create_task
3.8CancelledError 改为继承 BaseException
3.9asyncio.to_thread
3.11TaskGroup、asyncio.timeout、except*
3.13实验性的无 GIL 构建(PEP 703,单独的 python3.13t),默认构建仍有 GIL

下一圈

  • 异步生成器和 async for:一边收大模型的流式输出一边往前端推(站 03)。
  • asyncio.Queue:生产者消费者,站 10 的长任务队列在进程内的雏形。
  • 结构化并发:TaskGroup 和 Java 的 StructuredTaskScope 对照。
  • 无 GIL 的 Python(3.13t、3.14):多线程真正并行之后,asyncio 和线程的分工会怎么变。
  • 事件循环源码:asyncio/base_events.py 的 _run_once,对照 Netty 的 NioEventLoop.run。

↺回到⑤:拿⑥改进值班助手

1 · 换成 TaskGroup

把全景代码里的 gather 改成 TaskGroup,让 order-04 的 parse 抛 ValueError。对比两种写法下,另外 4 台的结果和接口的状态码。

用到:坑 4

2 · 把大日志解析挪走

造一份 50 MB 的 GC 日志,先在事件循环上解析,用 ⑤ 的探针量迟到多少;再改成 to_thread、再改成进程池,各量一次总耗时和迟到。

用到:坑 1、5、6

3 · 50 个人同时查

开 50 个并发调用 gc_summary,给 fetch 加一个全局的 Semaphore(20),记下峰值并发和总耗时,再把 20 改成 5 看曲线怎么变。

用到:坑 8,全景页的“下一圈”

1. 用一句话说出这一页的精华。

协程在 await 处暂停、让出线程,事件循环用一个线程轮流推动所有协程,靠 epoll 知道谁该醒;等待不占线程、执行占线程,所以能同时等很多,也最怕不 await 的阻塞和计算。

2. 一个请求占一个线程的老办法,痛在哪?

每个“在等的请求”都占住一个线程:要栈、要调度,等的数量上万就扛不住;回调方案不占线程,但把逻辑拆成碎片,难写难读。

3. 坑 1 为什么能把整个进程卡住?是 ④ 哪条原理?

④-2:事件循环只有一个线程,第 ① 步按顺序跑就绪队列;阻塞调用不经过 await,线程一直不回到循环,别的协程和新来的请求都轮不上。④-1 也说了:两个 await 之间的代码不会被打断。

4. 预测:gather 里给 order-04 设的是 asyncio.timeout(1.0),但 order-04 那个任务在等到数据后要做 3 秒的纯计算。超时还能在 1 秒时生效吗?

如果 1 秒时它还在等网络,能:超时往它停住的 await 里扔 CancelledError。如果它已经拿到数据、正在算那 3 秒,不能:取消只能在下一次 await 时生效,要等 3 秒算完,而且这 3 秒里整个事件循环都被占着。

从 ④-3 “取消是协作式的”和 ④-2 推出。

5. 预测:实验台里选 gather、不阻塞、每台解析 300 ms、放在事件循环上,/healthz 要等多久?为什么不是 300 ms 以内?

约 1 秒多(实验台算出 1081 ms)。到第 200 ms 时,order-02、05、01、03 的响应都已经回来了,它们的解析排在就绪队列里 healthz 前面,一个接一个各占 300 ms,healthz 要等它们全部跑完。

从 ④-2 推出:就绪队列先来先跑,前面排着的 CPU 活会一起压在后来者身上。

6. 放回全景:第 5、6、7 站前后各发生了什么?如果第 7 站的 fetch 里改用 requests.get,整条链路会怎样?

之前是第 4 站:参数校验通过。之后是第 8 站:拿到日志文本开始解析,再到第 9 站序列化返回。

requests.get 是同步的,每个 Task 一调用就占住线程直到那台机器回复,gather 实际上退化成一台一台地等;order-04 不回时,asyncio.timeout 也没机会在 await 处生效,只能等 requests 自己的超时(不设就一直等)。这段时间里同一进程的所有接口,包括 /healthz,全都不响应。

见 全景第 5 站“常出的事”。