Python 协程(Coroutine)通俗讲解与技术原理:从 await 挂起到 Agent 高并发

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("拿到结果,继续执行")

执行过程:

  1. 协程运行到 await,底层发起 HTTP 请求,并把 socket 注册到事件循环;
  2. 网络等待远端推理,本机 CPU 空着;
  3. 当前协程主动挂起,把唯一的线程交还给事件循环;
  4. 事件循环立刻切换到其他就绪协程(比如另一个 Agent 的 LLM 请求);
  5. 远端返回、IO 就绪,事件循环被唤醒;
  6. 切回该协程,从 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 返回它

三条硬规则:

  1. 不 await / 不 create_task,协程代码一行都不会跑 ,只会得到 RuntimeWarning: coroutine ... was never awaited,然后静默什么都没发生;
  2. 协程对象只能 await 一次 ,第二次会报 RuntimeError: cannot reuse already awaited coroutine;
  3. 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。

一句话总结对比:虚拟线程 = "让阻塞变便宜",协程 = "让等待变显式"。 前者保留同步写法、无痛迁移存量阻塞库;后者吞吐上限更高,代价是函数染色。


八、"协程无法利用多核"的准确边界

这条对,但要说清是两层限制叠加:

  1. 单线程事件循环:一个 loop 就是一个 OS 线程,天然只用一个核;
  2. 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 用户态协作式模型,区别在于虚拟线程挂起对开发者透明、无函数染色。
相关推荐
阿里云云原生1 小时前
AgentLoop 审计:如何让企业级 Dify 应用的风险看得见、查得清
云原生·agent
2601_966949652 小时前
如何判断实时行情数据是否已经更新?从时间戳、交易时段到行情有效性
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
MoRanzhi12032 小时前
106.正则问题(分治)
python·算法·正则表达式·蓝桥杯·递归·分治算法·字符串处理
Pingred2 小时前
我把一个私有化 RAG 系统真正跑通了:LocalRAG v0.1.0 发布
人工智能·python
Dmz丶2 小时前
前端转 AI 100 天 Day 24:API 测试——用 httpx + pytest 测 FastAPI
python
code2cat2 小时前
【随笔】LLM裁判如何打分:先写评分标准,再用人工案例校准
java·人工智能·llm·agent·评估·langsmith
东莞市云毅网络有限公司2 小时前
监测问句清单去重:停用词表、数字归一与贪心聚类的参数取法
python·数据清洗·rag·企业知识库·文档解析
Yifeng2 小时前
拒绝上下文爆炸!大一新生手搓“状态树”,管理 10+ 个 AI Agent 协同开发
agent
雪域迷影3 小时前
Python Django 7 小时课程
开发语言·python·django