协程与挂起点

用你自己的 Initial/client.py 逐帧看懂:线程在 await 处如何被交还与调度
目的能自信地读写 async 的 MCP 客户端/服务端代码——下一步要给 server 加真实工具、接入 Claude Code,都绕不开这套模型。
学完标准看完后你应该能回答:
  1. 指着 client.py 的每个 await,说出"此刻谁被冻结、线程去了哪、谁负责唤醒"。→ 视图 ①
  2. 预测 gather / create_task 版本的执行顺序,并解释为什么更快。→ 视图 ② ④
  3. 说出为什么协程"必须"长这样,以及 time.sleep 为什么会毒死整个程序。→ 视图 ③
深度预算4 个视图。每看完一个,问自己:上面哪个问题我现在能答了?能答满 3 个就可以关掉这个页面了。

你的 client.py,一帧一帧放

左边是真实代码,右边是舞台。舞台上有三个角色:main 协程(你写的)、读协程 stdout_reader(Client 创建的后台任务,负责盯 server 的 stdout)、server 子进程(你的 server.py)。注意:"内部"只是说它藏在 mcp 库的实现里、你看不见——在事件循环眼里,它和 main 是平级的两个独立任务,谁也不是谁的一部分。任何时刻,线程只在一个协程手里——点「下一步」,看它怎么流转。

# 终端输出
🧵 线程此刻在:
持有线程,运行中 冻结,书签留在 await 行 server 处理中 空闲 / 不存在
按「下一步」开始

asyncio.run(main()) 那一行开始播放。

⚠️ 看完这个视图记住一件事:main 冻结时,线程去的是「其他就绪的任务」(这里是读协程 stdout_reader),永远不会去执行 main 自己的下一行——下一行是 main 身体的一部分,main 整个被冻着,书签夹在 await 那一行。

串联 vs 并联:协程挂接的两种方式

「main 冻结了,为什么读协程还能跑?它不是 main 运行期间创建的吗?」——因为协程之间有两种完全不同的挂接方式,创建者是谁不重要,挂接方式才决定命运:

串联:await

main
 └─ await list_tools()
     └─ await 底层发送…
↑ 一条链,冻结时整串一起冻

子协程接在我的调用链上,成为我的一部分,和我同生共死。视图 ① 的三个 await 都是串联——所以只能排队。

并联:create_task / start_soon

main        stdout_reader
 │ (平级,互不包含) │
 冻结 ──→ 事件循环 ──→ 它照样跑
↑ 户口单独立在事件循环

出生在谁的代码里无所谓——注册那一刻就切断脐带、独立进就绪队列,与创建者平起平坐。gather 的并发、Client 的读协程,靠的都是它。

读协程的真身:mcp 库源码 stdio.py:141

它不是我为了画图虚构的角色——这是你 .venv 里 mcp 库的真实代码。async with Client(...) 进入时(__aenter__ 协程,async with 的隐藏挂起点),它被 start_soon 注册为独立任务:

# .venv/lib/python3.12/site-packages/mcp/client/stdio.py
141async def stdout_reader() -> None:
async for chunk in stdout: # 无限循环:等子进程 stdout 吐数据(每轮迭代都是挂起点)
for line in lines:
155 await read_stream_writer.send(_parse_line(line)) # 解析 JSON,按请求 id 唤醒等它的协程
201tg.start_soon(stdout_reader) # ← 并联!注册为独立任务(≈ create_task)
202tg.start_soon(stdin_writer) # 还有个写协程,负责往 stdin 发请求

为什么必须有它?main 冻着等回复,而冻着的人无法叫醒自己——必须有个独立的值班员盯着信箱:回复到了,按请求 id 查「这是谁在等的」,精确唤醒对应协程(gather 时两条回复乱序到达也不会张冠李戴)。类比:main 是寄了信就睡的人,stdout_reader 是收发室大爷。

把两个请求同时放上天:asyncio.gather

视图 ① 里,call_toolread_resource 是排队的:第二个请求要等第一个完全回来才发出。改用 gather 后,它们成为两个独立的 Task,进入事件循环的就绪队列——main 一冻结,线程就轮流分给它们,两个请求几乎同时飞向 server。

🧵 线程此刻在:
持有线程,运行中 冻结等待 server 处理中 空闲 / 已完成
按「下一步」开始

main 执行到 await asyncio.gather(...)

⏱ 拖一拖:顺序 vs 并发,时间差从哪来

顺序 await(视图①)
gather 并发

规律:顺序 = t₁ + t₂,并发 = max(t₁, t₂)。两个任务的等待重叠得越多,省得越多——因为省下的全部是「干等」的时间,CPU 计算一点没少。

为什么协程必须长这样

左边是客观约束(世界就是这样),右边是 asyncio 的设计选择。每个选择都标着它由哪些约束逼出来。试着取消勾选某个约束——只靠它支撑的设计会变灰:如果那个约束不存在,这个设计就没有存在的理由。这能帮你区分"设计是必然的"还是"设计是时尚"。

约束(可以试着关掉)

被逼出来的设计

事件循环 + 协作式调度

既然大家都在等 IO(C1),就让一个线程在等待的空档切去推进别的任务;既然线程贵且要加锁(C3),就不开多线程,用一个线程轮转。
C1C3
支撑它的约束都被你关掉了——如果等待不多、线程又便宜,直接开线程更简单,事件循环失去理由。

await:显式的挂起点

单线程必须有「交还线程」的机制(C4);而交还点必须写在代码里明确可见——因为只有你知道哪一行的结果是下一行必需的(C2)。副产品:两个 await 之间的代码绝不会被打断,不用加锁。
C2C4
支撑它的约束都被你关掉了——如果线程可以随意并行、行间又无依赖,就不需要显式挂起点了。

默认顺序执行;并发必须显式写 gather / create_task

因为下一行常依赖上一行(C2),"自动并发"会悄悄拿到空结果、崩掉程序。所以语言默认保守:想并发,你亲手声明,承担"这两件事确实互不依赖"的责任。
C2
支撑它的约束被你关掉了——如果行间从不互相依赖,默认全部并发反而更快。

全套 async 库(httpx 而非 requests;asyncio.sleep 而非 time.sleep)

所有协程共享一个线程(C4),任何一个协程里的「不经过 await 的等待」都会攥着线程不放,全体冻结。所以每样会等的东西(C1)都要有 async 版,把等待变成可挂起的。
C1C4
支撑它的约束都被你关掉了——多线程世界里 time.sleep 只卡自己那条线程,不需要专门的 async 库。
💡 对照着看:线程走的是另一条路——接受 C3 的代价(锁、开销)来换取「不用改写代码风格」;协程走的是「用显式 await 换掉锁和内核切换」。两者不是谁先进谁落后,是对同一组约束的两种取舍。你的 MCP 场景全是 IO 等待,所以 mcp 库选了协程这条路,整个 API 都是 async 的。

预测,然后验证

别急着点答案——先在心里跑一遍,写下你的预测,再揭晓。预测错了才是最有价值的时刻:它精确暴露了模型里哪一环还没建牢。

题 1:请求是在哪一刻发出的?

1task = asyncio.create_task(client.call_tool("add", {"a": 3, "b": 4}))
2print("甲")
3tools = await client.list_tools()
4print("乙")
5result = await task

call_tool 的请求(Task A 开始执行)发生在哪一刻?

答案:第 3 行之后。这题考的是「协作式」三个字的分量:create_task 只是把 Task A 放进就绪队列,但线程还在 main 手里——main 不主动挂起,谁也抢不走。所以第 1、2 行执行完,直到第 3 行 main 撞到 await 冻结、把线程还给事件循环,Task A 才第一次拿到线程、发出请求。打印顺序是 甲 → 乙(list_tools 回来后)→ task 的结果。

如果你选了 A——那是抢占式(线程)世界的直觉;在协程世界里,「注册」和「开始跑」之间隔着一次 main 的主动让位。

题 2:哪个版本会让整个程序假死?

Aasync def slow():
time.sleep(3) # 版本 A
Basync def slow():
await asyncio.sleep(3) # 版本 B

事件循环里同时还跑着另外 10 个协程。哪个版本会让那 10 个协程也冻住 3 秒?

答案:版本 A。time.sleep 是普通函数调用,不是挂起点——它攥着线程原地干等 3 秒,线程回不到事件循环,其他 10 个协程全部冻结。版本 B 的 await asyncio.sleep(3) 是真正的挂起:slow 自己冻结、书签留下、线程立刻还给事件循环,其他协程照常跑,3 秒后事件循环再唤醒它。

这就是视图 ③ 里「全套 async 库」那条设计的由来:async 世界里,每一次等待都必须经过 await,否则就是在毒死整个事件循环。

没看懂?把问题带回给 Claude

勾选没看懂的部分,或者用自己的话把机制讲一遍,然后点复制——把内容粘贴回 Claude Code 的对话里,我来针对性地补讲或检查你的理解。

已复制到剪贴板 ✓