协程 · 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,不能因为在等日志就不理人。
aio_demo.py,延迟按全景页的假日志代理设定(120 / 80 / 150 / 不回 / 90 ms)。节省下来的 440 ms 不靠多开线程,靠的是“等的时候让出线程”。②没有它会怎样:要么一个请求占一个线程,要么回调套回调
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);
fetchAsync(h)
.thenCompose(log -> parseAsync(log))
.thenApply(s -> s.maxMs())
.exceptionally(e -> null)
.orTimeout(1, SECONDS);
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)
await 是一个“我要等了,线程先给别人”的点。- 线程方案能用,但贵:Python 里还多一层 GIL,线程多了切换开销更大,而且任何时刻只有一个线程在跑 Python 代码。
- 回调方案不占线程,但代码被拆碎,出错时调用栈也是碎的。
- 缺的那一块就是这页的精华:让函数自己能在等待处暂停、之后原地继续。这样既能像同步代码一样顺着写,又不用为每个等待占一个线程。
③相邻方案: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 里的代码只在两个地方让出线程:await 和函数结束。两个 await 之间的代码一口气跑完,中途不会被别的协程打断。这一条既是 asyncio 好用的原因(共享的字典不用加锁),也是它最大的坑的根源(中间那段如果很慢,所有人都得等)。2事件循环:就绪队列、定时器、epoll,一圈一圈地转 ≈ Netty 的 EventLoop
await 的那一行接着往下跑。3Task、gather、TaskGroup、timeout:把协程交给循环,再一起收回来 ≈ CompletableFuture.allOf
asyncio.create_task(coro) 把协程包成任务、放进就绪队列,立刻返回;它在后台跑,你 await 它时拿结果。
把几个协程各包成 Task,等全部完成,按传入顺序返回结果。一个出错,它立刻把异常抛给你,但不取消其他的 实测。
async with 块里开任务;一个出错就取消同组其他任务,等它们收尾后抛 ExceptionGroup 实测。
到点在任务里抛 CancelledError,任务从当前 await 处退出;出了 async with 块,变成 TimeoutError。
“取消”也是协作式的:事件循环只是在任务下一次被唤醒时,往它停住的那个 await 里扔一个 CancelledError。任务正在跑一段不 await 的计算时,取消和超时都要等它算完才生效。
实验台:一个线程的时间线
5 台机器的延迟和全景页一样,order-04 不回、1 秒超时;第 200 ms 有人来打 /healthz,它只要 1 ms 的线程时间。改下面四个选项,看总耗时和 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 怎么写 细节、版本差异放到 ⑥
| Java | Python 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 醒一次,记下自己最多迟到了多久,迟到多少就说明事件循环被占了多久。
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'),)
- 看第 1 段:总耗时从“五台相加”变成“最慢那台”,order-04 的超时在 gather 里也被
fetch_t自己兜住了。 - 看第 2 段:
time.sleep(1)让探针迟到了整整 1000 ms,这 1 秒里同一进程的所有请求都不会被处理;同样的 1 秒放进to_thread,事件循环几乎没受影响。 - 看第 3 段:gather 抛出异常后,a、b 还在后台“跑完了”,结果却没人收;TaskGroup 则把 a、b 取消了才抛出。
实测 本机 uv 0.13.7、CPython 3.13.18,输出逐字复制;多次运行 gather 在 1001~1002 ms,逐个 await 在 1442~1444 ms。
⑥细节·规范·优化
第一圈 8 个坑里,7 个在本机复现过 实测,第 7 个按官方文档。
注意事项:不知道就会出事
async 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 会放进线程池。
await,函数根本没执行← ④-1 调用只造对象
- 现象
x = fetch("order-02")得到一个coroutine对象,过后只留下一行警告:coroutine 'fetch' was never awaited。- 根源
- 调用 async 函数只是造出协程对象,没人 send 它就一行都不跑。
- 做法
- 调 async 函数一律
await或交给create_task;开启PYTHONASYNCIODEBUG=1或在 CI 里把这个警告当错误。
- 现象
[await fetch_t(h) for h in HOSTS]用了 1442 ms,gather 是 1001 ms。- 根源
- 每个
await都要等这一台回来才往下走,一次只有一个请求在路上。 - 做法
- 要同时等,就先把它们都变成 Task:
gather或TaskGroup。
- 现象
- 50 ms 时 gather 就抛出
ValueError,a、b 照样在后台跑完,结果没人收。 - 根源
- gather 默认把第一个异常立即抛给调用方,但不取消其他任务。
- 做法
- 要“全成或全取消”用
TaskGroup;要“各管各的”用return_exceptions=True,或像全景那样在每个任务里自己兜住。
to_thread,没有变快← ④ 先知道:GIL
- 现象
- 一个计算任务 341 ms;两个用
to_thread并发是 669 ms,正好两倍;换成进程池是 369 ms。 - 根源
- GIL 让同一时刻只有一个线程执行 Python 字节码。
to_thread只能让事件循环不被卡住,不能让计算变快。 - 做法
- 真要多核算,用
ProcessPoolExecutor,或者起多个 uvicorn worker 进程。
Can't get local object设计如此
- 现象
- 把定义在函数里的
cpu()交给进程池,报AttributeError: Can't get local object 'main.<locals>.cpu'。 - 根源
- 任务要用 pickle 序列化后送进子进程,子进程按“模块名 + 函数名”去找函数,局部函数和 lambda 找不到。
- 做法
- 交给进程池的函数写在模块顶层;入口加
if __name__ == "__main__":。
create_task 不保存引用,任务可能半路消失按文档
- 现象
- “发出去不管”的后台任务偶尔没执行完。
- 根源
- 官方文档明确写着:事件循环只保留对任务的弱引用,没有别处引用的任务可能在完成前被垃圾回收。
- 做法
- 把 Task 存进一个集合,完成时用
add_done_callback移除;能用 TaskGroup 就用 TaskGroup。
- 现象
- 50 个人同时查 = 250 个请求同时打到日志代理。协程便宜,下游的连接和 CPU 不便宜。
- 根源
- 开一个 Task 几乎没有成本,asyncio 自己不会替你限流。
- 做法
asyncio.Semaphore(10):250 个任务,同时进行的峰值正好是 10,总耗时 1264 ms 实测;httpx 也能用连接池上限兜底。
使用规范:团队里怎么写
async def 里只调异步库HTTP 用 httpx 的 AsyncClient,数据库用异步驱动;同步库一律 to_thread。
整个进程被一个请求卡住。
asyncio.timeout 或客户端自己的 timeout,二选一,别都不设。
一台机器不回,整个接口挂住。
Semaphore 或连接池,按下游能扛的量设。
自己没事,把日志代理和大模型 API 打到限流。
需要“部分失败也继续”时,在每个任务里自己兜住异常。
异常后的任务泄漏、结果丢失。
几毫秒的可以直接算;上百毫秒的交给进程池或单独的服务。
健康检查超时、被 K8s 重启。
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 活的三种去处
本机 2 核,实测。进程池多出的 28 ms 是把参数和结果 pickle 来回传的成本,任务越小越不划算。
uvloop 和多个 worker
uvicorn 装了 uvloop 就换成 libuv 写的事件循环,模型不变,循环本身的开销更小。一个进程只有一个事件循环线程,只吃得满一个核;要用满多核,起多个 worker 进程(--workers N),每个进程各有一个循环。
版本差异
| 版本 | 变化 |
|---|---|
| 3.4 | asyncio 进入标准库(PEP 3156),当时用 yield from |
| 3.5 | async / await 语法(PEP 492) |
| 3.7 | asyncio.run、create_task |
| 3.8 | CancelledError 改为继承 BaseException |
| 3.9 | asyncio.to_thread |
| 3.11 | TaskGroup、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 站“常出的事”。