Python 协程(Coroutine)通俗讲解与技术原理:从 await 挂起到 Agent 高并发
适用读者:有 Java 线程基础、正在做 Agent / LLM 应用、需要理解
async/await到底在干什么的人。 全文结论都对齐 CPython 官方文档与 asyncio 源码语义,可直接作为团队复盘材料。
一、一句话核心
线程由操作系统内核调度;协程由代码(事件循环)在用户态自己调度的"轻量子程序"。
- 线程切换:要陷入内核,OS 保存/恢复寄存器、栈、内核态结构,开销大;
- 协程切换:就是一次函数调用级别的控制权转移,完全在 Python 应用内部完成,不进内核。
类比(修正版)
| 类比 | 调度者 | 切换代价 | |
|---|---|---|---|
| OS 线程 | 每人一间独立办公室,换人办公要行政(OS)介入 | 内核,抢占式 | 陷入内核,开销大 |
| 协程 | 同一间大办公室(一个 OS 线程)里的多个工位,遇到等待主动让出座位 | 事件循环,协作式 | 用户态函数调用,开销极小 |
注意这个类比里最关键的一环:协程必须"主动让出"。如果有人占着工位不挪窝,整间办公室就他一个人干活。
二、三类执行单元对照(先把位置摆正)
| 维度 | 进程 Process | 线程 Thread | 协程 Coroutine |
|---|---|---|---|
| 调度者 | OS 内核 | OS 内核 | 语言运行时(事件循环) |
| 调度方式 | 抢占式 | 抢占式 | 协作式(只在 await 处让出) |
| 切换是否需要进内核 | 是 | 是 | 否 |
| 内存空间 | 独立虚拟内存 | 共享进程内存,私有栈 | 共享线程,帧在堆上 |
| 单实例开销量级 | 最重 | OS 线程栈默认 MB 级(Linux 典型 8 MB 虚拟栈) | KB 级(协程帧在堆上) |
| 能否用多核 | ✅ | ✅ | ❌(单线程事件循环 + GIL,见第八节) |
| 数据共享 | IPC | 共享内存,需锁 | 共享内存,仍需同步 |
关键数字:一个 OS 线程即使什么都不干,也要预留 MB 级的栈空间;一个挂起中的协程只是一个 Python 对象,KB 级。这就是为什么单个线程里可以同时挂起成千上万个等待 LLM 返回的请求------它们大部分时间只是一个安静躺在堆上的对象。
三、await 挂起的完整链路(六步)
以 Agent 场景为例:
python
async def call_agent():
print("准备调用大模型")
resp = await llm.ainvoke(prompt) # ✅ 这里触发挂起
print("拿到结果,继续执行")
执行过程:
- 协程运行到
await,底层发起 HTTP 请求,并把 socket 注册到事件循环; - 网络等待远端推理,本机 CPU 空着;
- 当前协程主动挂起,把唯一的线程交还给事件循环;
- 事件循环立刻切换到其他就绪协程(比如另一个 Agent 的 LLM 请求);
- 远端返回、IO 就绪,事件循环被唤醒;
- 切回该协程,从
await后面那一行继续往下执行。
👉 等待网络这段时间,线程没有空转,去干别的活了。而同步多线程写法里,线程阻塞在 socket 上原地卡住,这段时间完全浪费。
3.1 事件循环靠什么知道"IO 就绪了"
不是 busy-wait 轮询。事件循环用操作系统的 IO 多路复用系统调用:
| 平台 | 机制 |
|---|---|
| Linux | epoll(epoll_ctl 注册 fd,epoll_wait 阻塞等待) |
| macOS / BSD | kqueue |
| Windows | IOCP |
线程在 epoll_wait 上睡眠,由网卡硬件中断 + 内核唤醒,然后内核直接把"就绪的 fd 列表"交给它。所以"一个线程盯上万个连接"不是魔法,是内核在帮你等。
四、协程对象 / Task / Future 三者关系(原稿第 4 节补全)
| 概念 | 是什么 | 怎么产生 |
|---|---|---|
| 协程对象(coroutine) | async def 函数调用后的返回值,一段可暂停/恢复的代码快照,还没跑 |
coro = call_agent() |
| Task | 协程对象被事件循环"接手"后包装出来的东西,已经在被调度执行 | asyncio.create_task(coro) |
| Future | 一个"未来会有结果"的占位符,可 await,由别处 set_result |
一般不由手写,run_in_executor 返回它 |
三条硬规则:
- 不
await/ 不create_task,协程代码一行都不会跑 ,只会得到RuntimeWarning: coroutine ... was never awaited,然后静默什么都没发生; - 协程对象只能
await一次 ,第二次会报RuntimeError: cannot reuse already awaited coroutine; - Task 的异常如果没人
await取回,会被吞掉 ,只打印一句Task exception was never retrieved。
python
coro = call_agent() # 仅创建对象,代码没跑
await coro # 交给事件循环驱动,才真正执行
五、并发的真正开关:await 是串行,create_task / gather 才并发
这是全文最容易被误解的一点,也是原稿缺失的一环。
python
# ❌ 串行:三次 LLM 调用,总耗时 = 三者之和
async def main():
await call_agent("A") # 等 A 完全结束
await call_agent("B") # 才开始 B
await call_agent("C")
await 的语义是"停在这里等它做完 "。连续 await 只是顺序执行,完全没有并发。要并发必须先把协程注册成 Task:
python
# ✅ 并发:总耗时 ≈ 最慢的那个
async def main():
await asyncio.gather(
call_agent("A"),
call_agent("B"),
call_agent("C"),
)
# ✅ 也可以拿句柄,逐个取消/取结果
async def main():
t1 = asyncio.create_task(call_agent("A"))
t2 = asyncio.create_task(call_agent("B"))
await t1
await t2
| 写法 | 行为 | 典型耗时(1s / 2s / 3s 三个任务) |
|---|---|---|
连续 await |
串行 | 约 6 s |
asyncio.gather(...) |
并发,结果按入参顺序返回 | 约 3 s |
create_task + 逐个 await |
并发,可单独 cancel() / done() |
约 3 s |
asyncio.TaskGroup(3.11+) |
并发 + 结构化并发(见下) | 约 3 s |
⚠️ gather 的兄弟任务问题 :默认 gather 在第一个任务抛异常时立刻向外抛,但其他兄弟任务仍在后台继续跑 ,成为无人监管的孤儿任务,继续占连接、占 token。Python 3.11+ 推荐 TaskGroup:任一子任务失败时自动取消所有兄弟任务 ,等它们收尾后统一抛 ExceptionGroup。
python
async def main():
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(call_agent("A"))
t2 = tg.create_task(call_agent("B"))
# 到这里:要么全部成功,要么全部已取消并清理完毕
六、协程的竞态问题(原稿第 5 节的准确版本)
原稿说"协程不是天然并发安全"------方向对,但例子说反了。
python
count = 0
async def add():
global count
for _ in range(1000):
count += 1 # 这里没有 await,循环中途不会切换
这段之所以安全,不是因为"协程比线程安全" ,而是因为它压根没有并发 :整个循环里没有 await,事件循环根本没有插入的机会,它就是一段普通串行代码。
真正的竞态长这样------必须有并发(create_task/gather)+ await 切点:
python
count = 0
async def add():
global count
for _ in range(1000):
await asyncio.sleep(0) # ← 显式让出,切换点
count += 1 # 读-改-写,被打断就丢更新
# 并发跑 10 个 → 结果远小于 10000
await asyncio.gather(*[add() for _ in range(10)])
| 场景 | 是否有竞态 | 原因 |
|---|---|---|
循环内无 await,顺序 await 多个协程 |
❌ 没有 | 完全没有并发,纯串行 |
循环内无 await,但 gather 并发多个 |
❌ 没有 | 每个协程一口气跑完,切换点只在协程边界 |
循环内有 await,且 gather 并发 |
✅ 有 | 读-改-写在 await 两侧被打断 |
用了 asyncio.Lock |
❌ 没有 | 显式同步 |
同步原语要用对:
python
lock = asyncio.Lock()
async def add():
global count
async with lock: # ✅ 协作式加锁
count += 1
⚠️ 在协程里用 threading.Lock 是灾难 :acquire() 阻塞时睡的是整个 OS 线程,整个事件循环连同所有协程一起冻结 。必须用 asyncio.Lock。
另外,单线程 asyncio 照样能死锁:协程 A 持锁 1 等锁 2,协程 B 持锁 2 等锁 1,两个都永久挂起------而且比多线程死锁更隐蔽,因为没有抢占、没有超时。
七、和 Java 对比(原稿第 6 节修正)
原稿说"Java 虚拟线程归 JVM 调度,不是 Python 这种 async 协程"------这个定性不准确。
虚拟线程和协程其实是同一族 :都是 M:N 用户态线程 ,都跑在少量载体线程(carrier thread)之上,都是协作式调度(在阻塞点让出,不是被抢占)。
| 维度 | Python asyncio 协程 | Java 21+ 虚拟线程 |
|---|---|---|
| 调度者 | 事件循环(用户态库) | JVM 调度器(默认 ForkJoinPool) |
| 调度方式 | 协作式(await 处让出) | 协作式(阻塞调用处让出 / park-unmount) |
| 映射 | N 协程 : 1 线程 | M 虚拟线程 : N 载体线程 |
| 挂起点谁决定 | 开发者显式写 await |
JVM 自动,代码零改动 |
| 函数染色 | 有(async def 传染,async 只能被 async 调) |
无,同步代码原样跑 |
| 阻塞调用后果 | 冻结整个事件循环 | 自动 unmount,不占载体线程 |
| 栈 | 堆上帧,动态 | 堆上 Continuation,动态伸缩 |
真正的差异是编程模型,不是调度原理:
- Python 协程:显式 ------你必须写
async/await,且 async 有"传染性"(彩色函数问题); - 虚拟线程:透明 ------你写阻塞式同步代码,JVM 在
Thread.sleep/socket.read/LockSupport.park处自动把虚拟线程栈保存到堆上、卸载(unmount),让载体线程去跑别的虚拟线程,IO 就绪后再挂载(mount)回来。
⚠️ 虚拟线程有个协程没有的坑:Pinning(钉住) 。JDK 21 起,虚拟线程在 synchronized 块内发生阻塞时无法卸载 ,虚拟线程被钉在载体线程上,载体线程被白白占住,并发能力退化。JDK 24(JEP 491)才修复。规避办法:虚拟线程里用 ReentrantLock 替代 synchronized。
一句话总结对比:虚拟线程 = "让阻塞变便宜",协程 = "让等待变显式"。 前者保留同步写法、无痛迁移存量阻塞库;后者吞吐上限更高,代价是函数染色。
八、"协程无法利用多核"的准确边界
这条对,但要说清是两层限制叠加:
- 单线程事件循环:一个 loop 就是一个 OS 线程,天然只用一个核;
- GIL:即使你开多个线程跑多个 loop,CPU 密集的 Python 字节码仍被 GIL 串行化。
绕开方式(不是"协程做不到",而是"要换工具"):
| 任务类型 | 正确工具 |
|---|---|
| 大量网络 IO(LLM、HTTP、DB) | asyncio + async 客户端(httpx / aiohttp / asyncpg) |
| CPU 密集(矩阵、解析、推理前处理) | ProcessPoolExecutor(绕过 GIL,真多核)或释放 GIL 的库(NumPy) |
| 存量同步阻塞库 | asyncio.to_thread(...)(3.9+)或 loop.run_in_executor |
⚠️ CPU 密集任务丢给线程池是无效的 :线程共享 GIL,ThreadPoolExecutor 跑紧循环照样串行,还可能拖慢事件循环。
九、最大的坑:阻塞调用会冻结整个事件循环(原稿未覆盖)
这是 asyncio 生产事故第一名的成因,而且对 Agent 场景尤其致命。
python
async def handler():
time.sleep(1) # ❌ 冻结整个 loop,所有协程陪葬
resp = requests.get(url) # ❌ 同步 HTTP,同样冻结
data = json.loads(big) # ❌ 大对象反序列化,也冻结
事件循环只有一个线程。任何不 await 的耗时操作,都会让全部协程(包括别人的请求)一起停摆,表现为:整体延迟飙升、心跳超时误报、服务像挂了一样。
修法:
python
# 阻塞 IO / 存量同步库 → 丢线程池
result = await asyncio.to_thread(requests.get, url)
# CPU 密集 → 丢进程池(真并行,绕 GIL)
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, heavy_compute, data)
| 阻塞源 | 典型例子 | 处理 |
|---|---|---|
| 同步 sleep | time.sleep |
await asyncio.sleep |
| 同步 HTTP | requests / urllib |
换 httpx.AsyncClient 或 to_thread |
| 同步 DB 驱动 | psycopg2 |
换 asyncpg 或 to_thread |
| 大 JSON / 大正则 | json.dumps 多 MB 对象 |
to_thread 或分片 |
| CPU 循环 | 数值计算 | ProcessPoolExecutor |
对 Agent 项目的直接提醒 :很多 LLM SDK 同时提供 invoke(同步)和 ainvoke(异步)。在 async def 里调 invoke,就是伪异步 ------它照样阻塞事件循环,你以为并发了,实际全串行。必须成对使用 ainvoke / astream。
怎么发现 :asyncio.run(main(), debug=True) 或 PYTHONASYNCIODEBUG=1,会记录任何执行超过 100 ms 的回调;可用 loop.slow_callback_duration = 0.05 调低阈值。
十、取消与超时(协作式的直接推论)
python
task = asyncio.create_task(call_agent())
task.cancel() # 请求取消:只在下一个 await 点生效
- 取消是协作式 的:
cancel()不会立刻杀死协程,而是在它下一个await处抛CancelledError; - 捕获
CancelledError可以做清理,但必须重新抛出,吞掉会破坏取消机制; - 超时基于同一机制:
asyncio.timeout()/asyncio.wait_for()本质就是"定时 cancel"; - ⚠️ 和线程一样:如果协程卡在不响应 await 的地方(比如同步阻塞调用),取消也救不了你。
python
async def worker():
try:
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
cleanup() # 收尾
raise # ⚠️ 必须 re-raise
十一、并发限流:保护下游
协程"轻量"不代表可以无限并发------下游(LLM 供应商、数据库)有速率限制。
python
sem = asyncio.Semaphore(20) # 同时最多 20 个 LLM 请求
async def fetch_one(prompt):
async with sem:
return await llm.ainvoke(prompt)
results = await asyncio.gather(
*[fetch_one(p) for p in prompts],
return_exceptions=True, # 收集异常而不是首个失败就抛
)
return_exceptions=True 让 gather 把异常当结果返回,方便逐条判断哪些文件/请求失败了------这一点和"不能静默丢任务"是同一个工程原则。
十二、选型决策表
| 场景 | 选型 |
|---|---|
| 成千上万并发 LLM / HTTP / DB 调用 | asyncio |
| 存量同步库、无法改 async | asyncio.to_thread 或直接用线程池 |
| CPU 密集(pandas、编码、推理前处理) | 多进程 (ProcessPoolExecutor),不是协程也不是线程 |
| 单一机器放不下 / 要分布式容错 | Spark / Dask |
| Java 侧同类需求、不想改代码风格 | 虚拟线程 (注意 synchronized pinning) |
十三、常见误区纠正
| ❌ 误区 | ✅ 正确 |
|---|---|
| 协程能并行跑多个计算任务 | IO 等待可以并发,CPU 计算是串行的 |
连续写多个 await 就是并发 |
await 是串行;必须 gather / create_task |
| 协程天然并发安全 | 只在"没有并发"时安全;有 await 切点 + 并发就有竞态 |
| 协程比线程快 | 协程切换更省;单个请求不会变快,CPU 密集反而更慢(调度开销) |
| 虚拟线程和协程是两种完全不同的东西 | 同为 M:N 用户态 + 协作式;差异在挂起点是否显式、有无函数染色 |
async def 里调同步库没事 |
会冻结整个事件循环,所有协程一起停 |
| 多线程死锁才要考虑 | 单线程 asyncio 也能死锁(互相等对方的锁) |
十四、全文速记(飞书精简摘要)
协程是用户态、由事件循环调度的轻量子程序 ;遇到 await(IO 等待)时主动挂起、让出线程,线程去处理其他任务。协程跑在同一个 OS 线程内,切换开销极小,适合大量 IO 等待场景(批量调用 LLM API)。
- 协程是协作式 调度,仅在
await处切换,不自动利用多核; - 并发需要显式开启 :连续
await是串行,gather/create_task才并发; - 并发修改共享可变变量依然存在竞态 ,需要
asyncio.Lock; - 协程里不能调同步阻塞代码,否则冻结整个事件循环;
- 与 Java 虚拟线程同属 M:N 用户态协作式模型,区别在于虚拟线程挂起对开发者透明、无函数染色。