13.协程核心设计思想
# 3.13 协程核心设计思想
📍 本篇位置:第 3 卷 · 并发之道 · 第 13 篇(范式篇核心) 🎯 核心矛盾:线程太重(万级就崩)vs 回调太反人类(嵌套到地狱)—— 50 年来工程界一直在两个极端之间摇摆,能不能既要线程的"看起来同步",又要事件驱动的"百万并发"? 🧭 设计灵魂:协程不是"轻量级线程"——它是对"函数只能从头到尾执行"这条铁律的根本性打破。函数能在中间暂停、把状态存起来、之后再恢复——这个能力一旦获得,整个并发世界为之改写 🌐 跨语言覆盖:Go goroutine(有栈)· Kotlin suspend(无栈)· Rust async fn(无栈+Future)· Python asyncio · JavaScript generator/async · C# Task(鼻祖)· Java Virtual Thread(Project Loom) 🔗 延伸阅读:← 3.11 异步和同步的设计 · ← 3.12 单线程模型的思想 · → 3.14 Actor 与 CSP 并发模型 · → 3.18 结构化并发设计思想 · → 2.4 函数调用栈与栈帧设计
上一篇我们看到了"单线程模型 + 事件循环"如何应对高并发——但写出来的代码满是
then().then().then()、Promise 套 Promise,异步逻辑被打散成无数回调。本篇要回答一个工程师追问 50 年的问题:能不能既保留事件驱动的高并发能力,又让代码"看起来像同步的样子"?答案是——协程。本篇从一个"线程池打满"的真实事故切入,揭开协程的本质:一个能在中间暂停、保存状态、之后恢复的函数。
# 目录介绍
- 00.真实事故引入
- 01.为什么需要协程:线程与回调的困境
- 02.协程的本质:可挂起的函数
- 03.两大流派:有栈 vs 无栈
- 04.协程实现三件套:挂起点/状态机/调度器
- 05.跨语言对比与设计哲学
- 06.协程的经典陷阱
- 07.共性抽象与未来演进
- 08.一句话总结
# 00.真实事故引入
# 0.1 一次线程池打满CPU闲置雪崩
我维护过一个 Spring Boot 微服务,处理订单查询。架构很经典:
HTTP 请求 → Tomcat 线程池(200) → 调用下游 5 个微服务(HTTP)→ 聚合返回
某次大促当晚 22:00,监控告警:
22:00:00 QPS 1000,正常
22:05:00 QPS 上升到 3000,正常
22:10:00 P99 延迟从 200ms 飙到 5 秒
22:11:00 线程池满,新请求被拒绝
22:12:00 服务"假死"——CPU 使用率只有 15%!
22:15:00 上游网关熔断,业务受损
2
3
4
5
6
最诡异的现象——线程池满了,CPU 却闲着。这违反直觉:通常线程池满意味着 CPU 忙不过来。
排查发现,Tomcat 200 个线程全部阻塞在下游 HTTP 调用的 read() 上:
@RestController
public class OrderController {
@GetMapping("/order/{id}")
public OrderDetail getOrder(@PathVariable String id) {
OrderInfo info = orderService.get(id); // 100ms
UserInfo user = userService.get(info.userId); // 100ms
ProductInfo product = productService.get(...); // 100ms
AddressInfo addr = addressService.get(...); // 100ms
ShippingInfo ship = shippingService.get(...); // 100ms
return aggregate(info, user, product, addr, ship);
}
}
2
3
4
5
6
7
8
9
10
11
12
每个请求要顺序调用 5 个下游 → 总耗时 500ms。这 500ms 里:
CPU 实际工作时间:~5ms(解析、序列化、聚合)
网络等待时间: ~495ms(5 × 99ms 的 socket read 阻塞)
→ Tomcat 线程 99% 时间在干等 IO
→ 200 个线程同时干等 → 物理 CPU 完全闲置
→ 但新请求来了没线程接(线程池满)→ 雪崩
2
3
4
5
6
根因:阻塞 IO + 线程模型 = 资源使用极度浪费。每个连接占一个线程,但 99% 时间这个线程在睡觉。
修复方案:改用 WebFlux + Reactor。但写出来的代码长这样:
return orderService.get(id)
.flatMap(info -> userService.get(info.userId)
.flatMap(user -> productService.get(...)
.flatMap(product -> addressService.get(...)
.flatMap(addr -> shippingService.get(...)
.map(ship -> aggregate(info, user, product, addr, ship))))));
2
3
4
5
6
性能上去了——同样 200 线程能扛 50 倍流量。但代码可读性崩塌——5 层嵌套,调试困难,异常处理混乱。
这就是 50 年来并发界的死循环:
线程模型:代码好读,但扩展性差(万级就崩)
事件驱动:扩展性强(百万),但代码难读(回调地狱)
2
直到协程出现——它说"我两个都要":
// Kotlin 协程:看起来同步,本质异步
suspend fun getOrder(id: String): OrderDetail {
val info = orderService.get(id)
val user = userService.get(info.userId)
val product = productService.get(...)
val addr = addressService.get(...)
val ship = shippingService.get(...)
return aggregate(info, user, product, addr, ship)
}
2
3
4
5
6
7
8
9
这段代码看起来和原始阻塞代码一模一样——但每个 await 不阻塞线程,单线程能扛百万并发。这就是协程的魔力。
# 0.2 一段Java 21看似未改的革命
2023 年 Java 21 发布的最重磅特性是虚拟线程(Virtual Thread / Project Loom):
// 老代码(线程模型)
ExecutorService executor = Executors.newFixedThreadPool(200);
executor.submit(() -> handleRequest(req));
// 新代码(虚拟线程)
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> handleRequest(req)); // 看起来一模一样!
2
3
4
5
6
7
只改了一个 API——但同样的硬件,从扛 200 并发变成扛 100 万并发。
这怎么做到的? 答案就是协程——只是 Java 把它"伪装"成线程。
# 0.3 灵魂三问
这两个真实场景让我反复追问三个问题:
- 协程到底是个什么东西?为什么"它说能解决一切"——既要同步代码,又要百万并发?这不是矛盾的吗? —— 协程的物理本质是什么?
- 协程是 1958 年 Conway 发明的——为什么直到 2017 年(Kotlin)/ 2023 年(Java)才大爆发?中间这 60 年发生了什么? —— 是哪些条件改变了?
- Go 选择"有栈协程"(goroutine),Kotlin/Rust/Python 选择"无栈协程"(async/await)—— 这两种选择哲学上有什么根本差别? —— 它们各自适合什么场景?
如果你能回答这三个问题,你就理解了为什么 2020 年代被称为"协程的时代"。
# 0.4 本篇的探索路径
flowchart LR
A[问题: 线程 vs 回调] --> B[协程的本质<br/>可挂起的函数]
B --> C[两大流派]
C --> C1[有栈协程<br/>Go]
C --> C2[无栈协程<br/>Kotlin/Rust/JS]
C1 --> D[实现三件套]
C2 --> D
D --> E[挂起点]
D --> F[状态机]
D --> G[调度器]
style B fill:#cfe2ff
style C1 fill:#fff3cd
style C2 fill:#d4edda
2
3
4
5
6
7
8
9
10
11
12
13
14
# 0.5 为什么这个问题值得讲透
我想抛三个几乎所有协程使用者都答不全的问题:
async函数返回的不是结果,而是"对结果的承诺"——为什么编译器要这么做? —— 因为函数的"中间状态"必须能被存下来,承诺对象就是这个状态机的"句柄"。- 协程切换的成本是 ~50ns,线程切换是 ~5μs,差距 100 倍——这 100 倍从哪来? —— 因为协程切换全在用户态完成,不进内核、不刷 TLB、不涉及虚拟内存切换。
- 为什么 Go 的协程 ~2KB 栈而 Java Loom 的虚拟线程也号称"轻量"——它们的"轻"凭什么不一样? —— Go 用栈复制,Loom 用栈挂起到堆,机制完全不同。
读完本章你会懂:协程不是"轻量级线程"——是函数能力的根本扩展。它把"控制流"从硬件中抽出来,让程序员第一次能精确地设计"暂停"和"恢复"。
# 01.为什么需要协程:线程与回调的困境
# 1.1 线程模型的三大天花板
§0.1 那个事故就是这个问题的真实写照。线程有三个绕不开的硬伤:
| 瓶颈 | 数字 | 后果 |
|---|---|---|
| 内存开销 | 默认栈 1-8 MB(Linux 8MB) | 1 万线程 = 80 GB 栈 → 物理内存爆 |
| 切换成本 | 内核线程切换 ~3-10 μs | C10K 时 CPU 全花在切换上 |
| 阻塞浪费 | 一次 IO 阻塞 = 一个线程睡觉 | 1 万连接要 1 万线程常驻 |
flowchart LR
A[1 万并发连接] --> B[1 万 OS 线程]
B --> C[80 GB 栈内存]
B --> D[频繁上下文切换]
B --> E[大量线程阻塞 IO]
C --> F[OOM]
D --> G[CPU 全在切换]
E --> H[吞吐上不去]
style F fill:#f8d7da
style G fill:#f8d7da
style H fill:#f8d7da
2
3
4
5
6
7
8
9
10
11
12
这就是著名的 C10K 问题——线程模型在这个规模上被物理限制卡死。
根本症结:线程是 OS 资源——OS 不知道我们想干什么,只能假设"线程很贵",做最保守的资源分配。一旦应用确实只是想"等待 IO",那 8MB 栈和昂贵的内核切换都是浪费。
# 1.2 异步回调的"反人类"本质
为了解决线程瓶颈,工程界发明了事件驱动 + 回调:
// 早期 Node.js:回调地狱
fs.readFile('a.txt', (err, dataA) => {
if (err) return handleError(err);
fs.readFile('b.txt', (err, dataB) => {
if (err) return handleError(err);
process(dataA, dataB, (err, result) => {
if (err) return handleError(err);
db.save(result, (err) => {
if (err) return handleError(err);
console.log("done");
});
});
});
});
2
3
4
5
6
7
8
9
10
11
12
13
14
痛点:
1. 横向缩进——每嵌套一层就右移一级
2. 错误处理散落——每层 if (err) return
3. 控制流碎裂——本来是顺序的逻辑被切成 N 个回调
4. 调试困难——栈追踪丢失(因为已经跨函数了)
5. 共享变量——闭包捕获带来生命周期管理噩梦
2
3
4
5
Promise 缓解了横向缩进,但链式调用本质还是回调:
fs.readFile('a.txt')
.then(dataA => fs.readFile('b.txt')
.then(dataB => process(dataA, dataB)))
.then(result => db.save(result))
.catch(handleError);
2
3
4
5
仍然是"控制流碎裂"——本来一行的逻辑变成了 then 链。
# 1.3 协程要解决的根本矛盾
flowchart LR
A[线程模型] -->|致命伤| A1[资源昂贵<br/>万级即崩]
B[回调模型] -->|致命伤| B1[控制流碎裂<br/>难写难读]
A1 --> C{能不能两全?}
B1 --> C
C --> D[协程<br/>看似同步,实为异步]
D --> D1[百万并发<br/>用户态切换]
D --> D2[同步代码<br/>编译器变换]
style C fill:#fff3cd
style D fill:#d4edda
2
3
4
5
6
7
8
9
10
11
12
13
14
协程的核心承诺:
| 承诺 | 实现机制 |
|---|---|
| 代码看起来同步 | 在挂起点用 await/suspend 标记,避开回调 |
| 支持百万并发 | 不占 OS 线程,用户态调度 |
| 自动 IO 复用 | 挂起时让出线程,IO 就绪后再恢复 |
| 可结构化错误处理 | try/catch 跨 await 自然工作 |
这是一个看起来"既要又要还要"的需求——但协程做到了。
# 1.4 协程史前史:60年的等待
§0.5 第二题。协程的概念其实非常古老:
1958 - Melvin Conway 发明 coroutine 概念,用于编译器构造
1963 - Simula 67 第一次实现协程
1975 - Donald Knuth 在《编程艺术》第 1 卷讨论协程
1980 - C 中模拟协程的 setjmp/longjmp 技术
1990 - Lua 1.0 提供协程
2007 - Python 用 yield 提供有限协程能力
2009 - Go goroutine 发布 ★
2012 - C# 5.0 引入 async/await ★
2015 - JavaScript ES7 提议 async/await
2017 - Kotlin 1.1 协程 GA ★
2018 - Python 3.7 asyncio 稳定
2019 - Rust 1.39 async/await 稳定
2023 - Java 21 虚拟线程 GA ★
2
3
4
5
6
7
8
9
10
11
12
13
为什么 1958-2010 这 50 年没爆发?
1. 单核时代:线程开销可接受(万级并发场景少)
2. 没有标准化语法:每种语言的协程 API 都不同
3. IDE/调试器支持差:协程的栈追踪难以实现
4. 生态不齐:缺少协程友好的库(数据库、HTTP)
2009 年后改变:
1. 多核+云时代:百万并发成为常态
2. async/await 语法被 C# 标准化、被各大语言抄走
3. 编译器/调试器工具链成熟
4. Reactor / RxJava 之类的"前协程"框架积累生态
2
3
4
5
6
7
8
9
10
协程是"概念早熟、生态晚熟"的典型——和 Actor 模型一样。
# 02.协程的本质:可挂起的函数
# 2.1 从"子程序"到"协程"的飞跃
经典函数(子程序)的执行模型:
调用方 → 进入函数 → 执行完所有代码 → 返回结果 → 调用方继续
它的特征:
1. 单一入口:从函数开头进入
2. 单一出口:return 后函数完全结束
3. 完整执行:除非异常,必须从头跑到尾
4. 完全清栈:返回时栈帧被销毁,所有局部变量没了
2
3
4
协程则打破了这个模型:
调用方 → 进入协程 → 执行到 yield/await → 暂停 → 把状态存起来 → 让出 CPU
↓
其他事情发生
↓
调用方/调度器 → 调用 resume → 协程从挂起点继续 → 直到下一个 yield 或完成
2
3
4
5
两个核心新增能力:
- 多入口:可以从挂起点继续执行,不只能从函数开头进入
- 不完整执行:可以暂停在中间,把执行状态保存下来
flowchart LR
subgraph SUB["子程序(Subroutine)"]
S1[入口] --> S2[执行] --> S3[return]
end
subgraph CORO["协程(Coroutine)"]
C1[首次进入] --> C2[执行到 yield]
C2 --> C3[暂停]
C3 -.resume.-> C4[继续]
C4 --> C5[再次 yield 或 return]
end
style C3 fill:#fff3cd
style C4 fill:#d4edda
2
3
4
5
6
7
8
9
10
11
12
13
14
# 2.2 挂起与恢复的机器视角
核心问题:函数的"执行状态"包括什么?怎么把它"存起来"?
一个执行中的函数,它的"状态"由 4 部分组成:
1. PC(程序计数器):当前执行到哪一行
2. 局部变量:函数内部所有 var 的值
3. 调用栈:上层函数的调用关系
4. 寄存器:CPU 正在用的临时值
2
3
4
普通线程切换:CPU 把 1-4 全部保存到内核栈,加载新线程的状态。这是内核态操作,开销 ~5μs。
协程切换:把 1-4 保存到用户态的内存对象里,跳转到另一个协程。全程用户态,无系统调用,开销 ~50ns——比线程快 100 倍。
§0.5 第二题的答案:协程切换的"100 倍速度优势"来自三处:
1. 不进内核:避免用户态↔内核态切换的特权级转换(~1μs)
2. 不刷 TLB:协程间地址空间一样,TLB 不用清空(~1μs)
3. 不调度公平性:协程切换不需要更新 OS 调度器统计(~2μs)
→ 总差距 ~5μs,正是线程切换的全部成本
2
3
4
5
# 2.3 与线程、回调的关键区别
flowchart TB
subgraph THREAD["线程模型"]
T1[阻塞 read]
T2[OS 内核切换<br/>5μs]
T3[另一个线程跑]
end
subgraph CALLBACK["回调模型"]
CB1[发起 read]
CB2[注册回调]
CB3[handler<br/>函数A 已结束]
end
subgraph CORO["协程模型"]
CR1[await read]
CR2[挂起<br/>用户态保存状态<br/>50ns]
CR3[同一函数<br/>恢复执行]
end
style T2 fill:#f8d7da
style CB3 fill:#fff3cd
style CR2 fill:#d4edda
style CR3 fill:#d4edda
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| 维度 | 线程 | 回调 | 协程 |
|---|---|---|---|
| 代码形态 | 同步顺序 | 嵌套回调 | 同步顺序 |
| 切换开销 | ~5μs(内核) | ~0(栈帧已销毁) | ~50ns(用户态) |
| 栈占用 | 1-8 MB | 0 | 2KB-几十KB |
| 错误处理 | try/catch | 每层 if(err) | try/catch |
| 调试 | 完整栈 | 栈丢失 | 完整栈 |
| 百万并发 | ❌ | ✅ | ✅ |
协程拿到了线程的可读性 + 回调的可扩展性——这就是它的革命性。
# 2.4 协程是语法糖还是机器特性
很多人把协程当作"async/await 语法糖"——这是误解。协程本质上要求语言/编译器/运行时三方协作:
语法层: async/await 标记挂起点
编译器层: 把函数变换成"状态机"
运行时层: 调度器决定何时恢复
2
3
没有这三层协作,协程根本不可能存在——这就是为什么 C 没有协程(C 标准不支持),Java 必须等到 21 才有(JVM 改造了 5 年)。
# 03.两大流派:有栈 vs 无栈
§0.5 第三题。这是协程世界最重要的分野。
# 3.1 有栈协程Stackful:Goroutine设计
特征:每个协程拥有完整的、独立的栈,和线程的栈一模一样——只不过这个栈是用户态分配的,不是 OS 分配。
goroutine 1 的栈(2KB 起,按需扩展):
func main → func handle → func read → func parse
goroutine 2 的栈(独立):
func main → func compute → func encrypt
切换时:保存 SP/PC/寄存器 到 goroutine 结构体,加载另一个的
2
3
4
5
6
7
优点:
1. 任何函数都能挂起——不需要标记,不需要语法
2. 调用 C 函数也能挂起(Go 通过 cgo 桥接)
3. 学习曲线低:和写线程代码一模一样
2
3
缺点:
1. 每个协程要预留栈空间(Go 默认 2KB,按需增长)
2. 栈大小难以静态确定
3. 调度器和运行时复杂
2
3
Go 的天才设计——连续栈复制(Continuous Stack Copy):
初始栈:2 KB
栈不够时:
1. 分配新的 4KB 栈
2. 把旧栈所有内容 memcpy 到新栈
3. 修正所有指向栈的指针(这是难点!)
4. 释放旧栈
→ 始终保持单段连续栈
2
3
4
5
6
7
为什么 Go 能做到,C 做不到?(详见 2.4 函数调用栈与栈帧设计)
栈复制需要修正指针:
局部变量 x 在旧栈地址 0x1000
栈复制后 x 在新栈地址 0x5000
所有指向 x 的指针(&x)都要改
Go:编译器知道每个栈帧的指针布局(GC 元信息)→ 可以精确修正
C:编译器不跟踪指针布局 → 无法做到
2
3
4
5
6
7
这是 Go 能轻松开 100 万 goroutine 的关键:
2 KB × 100 万 = 2 GB(可接受)
Java 线程:1 MB × 100 万 = 1 TB(不可能)
2
# 3.2 无栈协程Stackless:async/await
特征:协程没有独立的栈——它的"状态"被编译器变换成一个堆上对象(state machine)。
suspend fun fetchUser(id: Int): User {
val rawData = api.get(id) // 挂起点 1
val parsed = parse(rawData)
val enriched = api.enrich(parsed) // 挂起点 2
return enriched
}
2
3
4
5
6
编译器把这个函数变换成一个状态机类:
class FetchUserCont(
val cont: Continuation<User>,
val id: Int
) : Continuation<Any?> {
var state = 0
var rawData: String? = null
var parsed: User? = null
override fun resumeWith(result: Result<Any?>) {
when (state) {
0 -> {
state = 1
api.getAsync(id, this) // 异步调用,注册自己为回调
return // ★ 函数返回,但状态保留在 this
}
1 -> {
rawData = result as String
parsed = parse(rawData!!)
state = 2
api.enrichAsync(parsed!!, this)
return
}
2 -> {
cont.resumeWith(Result.success(result as User))
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
协程的"挂起点"被翻译成"return + 状态保存"——下次 resume 时重新进入函数,根据 state 跳到正确位置继续。
这就是 async/await 的物理本质——编译器把"看起来同步"的代码自动变换成"等价的回调链"。
优点:
1. 无栈占用:状态机对象通常只有几十字节
2. 单机能开千万级协程
3. 编译期可见——挂起点在源码里就是 await
2
3
缺点:
1. "颜色"问题:suspend/async 函数会"传染"
只有 suspend 函数能调用 suspend 函数
2. 没有挂起点的代码不能被中断
3. 不能从普通同步函数挂起
2
3
4
# 3.3 两派对比
| 维度 | 有栈协程(Go) | 无栈协程(Kotlin/Rust/JS) |
|---|---|---|
| 栈 | 独立栈(2KB+) | 没有,状态在堆上对象 |
| 挂起标记 | 隐式(任何位置) | 显式(await/suspend) |
| 传染性 | 无 | 有(颜色问题) |
| 单机规模 | 100 万级 | 1000 万级 |
| 调度器复杂度 | 高(要管理栈) | 低 |
| 学习曲线 | 极低 | 中等 |
| C 互操作 | 困难(栈不兼容) | 容易(无栈,普通函数) |
| 代表语言 | Go, Lua, Erlang | Kotlin, Rust, JS, C#, Python |
# 3.4 为什么现代语言都转向无栈
§0.5 第三题。2010 年后新设计的语言协程几乎全是无栈:
有栈派:Go (2009), Lua, Erlang, Java Virtual Thread (2023)
无栈派:C# (2012), Kotlin (2017), Rust (2019), JavaScript, Python, Swift
2
为什么无栈成主流?
理由 1:内存占用
Go goroutine 平均 4-8 KB
Kotlin coroutine 平均 100-500 字节
→ Kotlin 单机能开千万级,Go 百万级
2
3
理由 2:编译器优化
无栈协程是普通对象 + 状态机
JIT/AOT 能完整内联、逃逸分析、特化
有栈协程的栈是独立内存 → 编译器分析受限
2
3
理由 3:跨语言互操作
JavaScript 协程 = Promise(普通对象)→ 完美兼容已有 JS 代码
Rust 协程 = Future(trait)→ 完美适配 Rust 类型系统
Go 的有栈 goroutine 调用 C 必须切换栈 → cgo 调用慢 100×
2
3
4
理由 4:精确控制
async/await 让程序员明确看到"哪里会挂起"
goroutine 任何函数都可能挂起 → 不可见
2
Java 21 的虚拟线程是个例外——它选择有栈,但用了一个聪明的折中:
虚拟线程的栈在挂起时被"复制到堆",恢复时再"复制回栈"
→ 平时不占栈空间(只在运行的虚拟线程占用 carrier 线程的栈)
→ 兼容老 Java 代码(不需要 async 关键字)
→ 代价:挂起/恢复比 Kotlin 协程慢一些(栈拷贝开销)
2
3
4
这是 Java 为了兼容性做出的工程妥协——不引入新语法,让百万旧 Java 代码自动获得协程能力。
# 04.协程实现三件套:挂起点/状态机/调度器
# 4.1 挂起点(Suspend Point)
挂起点是协程的"暂停按钮"——在这个位置,协程把自己的状态存起来,让出 CPU。
显式挂起点(Kotlin/Rust/JS):
suspend fun fetch() {
val a = api.get1() // ★ 挂起点
val b = api.get2() // ★ 挂起点
return a + b
}
2
3
4
5
隐式挂起点(Go):任何 channel 操作、网络 IO、time.Sleep 都可能挂起。
挂起点的设计哲学差异:
显式(async/await):
优势:程序员明确知道"哪里会挂起"
劣势:必须写 await,颜色传染
隐式(goroutine):
优势:代码看起来纯同步
劣势:不知道哪里会切换上下文,调试更难
2
3
4
5
6
7
# 4.2 状态机:CPS 变换
CPS = Continuation Passing Style(续延传递风格)——把"剩下的代码"当作参数传给异步操作。
// 源代码
suspend fun process() {
val a = await readA()
val b = await readB()
println(a + b)
}
// 编译器变换后(伪代码)
fun process(cont: Continuation) {
var state = 0
var a: Int = 0
fun resume(result: Any?) {
when (state) {
0 -> {
state = 1
readA(::resume) // ← 把"剩下的代码"作为回调
return
}
1 -> {
a = result as Int
state = 2
readB(::resume)
return
}
2 -> {
val b = result as Int
println(a + b)
cont.resume(Unit)
}
}
}
resume(null) // 启动状态机
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
关键洞察:编译器把"看起来线性的代码"变换成"状态机 + 多次进入"。
状态机的存储:
Kotlin: 状态机对象(继承 Continuation)—— 几十字节
Rust: Future trait 的实现 —— 编译期已知大小
JS: Promise 对象 —— V8 内部优化
2
3
# 4.3 调度器:谁决定何时恢复
挂起后,协程靠调度器决定何时恢复。
Go 的 GMP 调度器:
G (Goroutine):协程
M (Machine):OS 线程
P (Processor):逻辑处理器(持有可运行 G 队列)
调度策略:
M 始终绑定一个 P,从 P 的队列取 G 执行
G 调用阻塞操作 → 把 P 转给另一个 M(避免线程被锁住)
P 队列空 → 从其他 P 偷一半(work-stealing)
G 数量多于 M:多对一调度
2
3
4
5
6
7
8
9
Kotlin 协程的 Dispatcher:
launch(Dispatchers.Default) { ... } // CPU 密集,N 核 N 线程
launch(Dispatchers.IO) { ... } // IO 密集,弹性扩到 64 线程
launch(Dispatchers.Main) { ... } // UI 主线程
2
3
Rust tokio 的 multi-threaded runtime:
#[tokio::main]
async fn main() {
// 默认创建 N 个 worker 线程,每个跑事件循环
// 协程在 worker 之间 work-stealing
}
2
3
4
5
调度器的核心职责:
1. 多协程到少量线程的多对一映射(M:N)
2. 阻塞协程时让出 CPU 给其他协程
3. IO 完成时唤醒等待的协程
4. work-stealing 避免线程负载不均
2
3
4
# 05.跨语言对比与设计哲学
# 5.1 Kotlin协程:状态机+Continuation
suspend fun fetchUser(id: Int): User = withContext(Dispatchers.IO) {
val raw = api.get(id)
parseUser(raw)
}
2
3
4
实现要点:
suspend 关键字 → 编译器为这个函数生成 Continuation 参数
每个挂起点 → state 变量 + 状态机分支
withContext → 切换 Dispatcher
2
3
Kotlin 的优势:和 Java 完全互操作、Android 生态原生支持、Flow 提供完整响应式流。
# 5.2 Go goroutine:有栈协程+GMP
func fetchUser(id int) User {
raw := api.Get(id) // 看似同步,实际可能挂起
return parseUser(raw)
}
go fetchUser(123) // 启动协程,立即返回
2
3
4
5
6
Go 的优势:
1. 没有 async 标记 → 没有颜色问题
2. 任何函数都能阻塞,调度器自动处理
3. select + channel 是 CSP 模型的完美实现
2
3
Go 的劣势:
1. cgo 调用慢(栈不兼容)
2. 单协程内存比无栈多 10×
3. 抢占式调度直到 Go 1.14 才支持
2
3
# 5.3 Rust async/await:零成本抽象
async fn fetch_user(id: u32) -> User {
let raw = api.get(id).await;
parse_user(raw)
}
2
3
4
Rust 的特殊:没有内置的协程运行时——async fn 只是返回一个 Future,需要外部 runtime(tokio / async-std)来调度。
Future trait:
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>;
}
2
3
4
poll 是核心——runtime 反复调用 poll:返回 Pending 表示还在等,返回 Ready(value) 表示完成。
Rust 的优势:
1. 零运行时开销(编译期状态机生成)
2. 无 GC,无垃圾回收暂停
3. 类型系统保证内存安全(借用检查器跨 await)
2
3
Rust 的痛点:Pin 概念极其复杂,初学者望而生畏。
# 5.4 JavaScript:Generator到async/await
JavaScript 协程的进化路径很有教育意义:
// 第一代:回调
fs.readFile('a.txt', (err, data) => { ... });
// 第二代:Promise(链式)
fs.readFile('a.txt').then(data => process(data));
// 第三代:Generator + co
function* fetch() {
const data = yield fs.readFile('a.txt');
return process(data);
}
co(fetch()); // 第三方库 co 把 generator 变成自动恢复的协程
// 第四代:async/await(标准化)
async function fetch() {
const data = await fs.readFile('a.txt');
return process(data);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
JavaScript 的 async 函数本质上就是 generator + automatic runner——编译器自动加 co 的功能。
# 5.5 Python asyncio
import asyncio
async def fetch_user(id):
raw = await api.get(id)
return parse_user(raw)
async def main():
user = await fetch_user(1)
print(user)
asyncio.run(main())
2
3
4
5
6
7
8
9
10
11
Python 的特点:
1. 全局解释器锁(GIL)→ 单线程,协程间真正并发只在 IO 等待时
2. asyncio 是核心库,但 Twisted/Tornado 是历史替代
3. async 函数必须在 event loop 内运行(asyncio.run)
2
3
# 5.6 Java虚拟线程Project Loom
§0.2 那个例子。Java 21 的虚拟线程故意伪装成线程:
Thread.startVirtualThread(() -> {
// 在这里写阻塞代码——但不会阻塞 OS 线程!
String result = httpClient.send(request, BodyHandlers.ofString());
db.insert(result);
});
2
3
4
5
实现机制:
虚拟线程 = 协程
但 API 完全和 java.lang.Thread 一样
→ 老代码不需要改一行就能受益
→ 整个 Java 生态自动获得协程能力
2
3
4
这是 Java 的工程美学——通过运行时改造,让 100 万行旧代码"免费升级"。
# 5.7 跨语言横评
| 语言 | 类型 | 关键字 | 颜色问题 | 单机规模 | 内置 runtime |
|---|---|---|---|---|---|
| Go | 有栈 | go | 无 | 100万 | 内置 |
| Kotlin | 无栈 | suspend | 有 | 1000万 | kotlinx.coroutines |
| Rust | 无栈 | async/await | 有 | 1000万 | 外部(tokio) |
| JS | 无栈 | async/await | 有 | 100万 | 内置(V8) |
| Python | 无栈 | async/await | 有 | 10万(GIL) | 内置(asyncio) |
| C# | 无栈 | async/await | 有 | 100万 | 内置(TPL) |
| Swift | 无栈 | async/await | 有 | 100万 | 内置 |
| Java 21 | 有栈(特殊) | 无(透明) | 无 | 100万 | 内置 |
# 06.协程的经典陷阱
# 6.1 陷阱一:async函数写阻塞调用
// ❌ 致命错误
suspend fun fetchData(): Data {
val conn = jdbcConnection()
val result = conn.execute("SELECT ...") // ★ JDBC 是阻塞 IO!
return parse(result)
}
2
3
4
5
6
后果:协程在挂起前先把整个 dispatcher 线程阻塞了——一个协程拖死整个调度器。
修复:
suspend fun fetchData(): Data = withContext(Dispatchers.IO) {
val conn = jdbcConnection()
val result = conn.execute("SELECT ...")
parse(result)
}
2
3
4
5
Dispatchers.IO 是为阻塞调用设计的——它有更多线程,且阻塞它不会影响 CPU 密集型协程。
# 6.2 陷阱二:忘await让协程消失
async function badStart() {
fetchData(); // ❌ 没 await,promise 被丢弃
console.log("done");
}
2
3
4
后果:fetchData 启动了,但调用方根本不等它,可能错过它的错误,可能引发资源泄漏。
修复:明确决定"等"还是"不等":
async function good() {
await fetchData(); // 等结果
// 或
void fetchData(); // 明确表示"启动后不等"
// 或
fetchData().catch(handleError); // 不等但处理错误
}
2
3
4
5
6
7
# 6.3 陷阱三:协程泄漏
// ❌ 全局协程,没有取消机制
GlobalScope.launch {
while (true) {
try {
doWork()
delay(1000)
} catch (e: Exception) { /* ignore */ }
}
}
2
3
4
5
6
7
8
9
后果:协程永远跑下去,内存中累积越来越多协程对象。
修复:使用结构化并发(详见 3.18 结构化并发):
class MyService(scope: CoroutineScope) {
init {
scope.launch { // 绑定到外部 scope,scope 取消时自动结束
// ...
}
}
}
2
3
4
5
6
7
# 6.4 陷阱四:上下文丢失ThreadLocal失效
// ❌ 在虚拟线程中
ThreadLocal<User> currentUser = new ThreadLocal<>();
currentUser.set(getUser());
executor.submit(() -> {
User u = currentUser.get(); // ❓ 是否能拿到?
});
2
3
4
5
6
7
问题:协程切换 carrier 线程时,ThreadLocal 不会跟着走(默认行为)。
修复:
// Java 21+ 用 ScopedValue 替代 ThreadLocal
ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
ScopedValue.where(CURRENT_USER, getUser()).run(() -> {
executor.submit(() -> {
User u = CURRENT_USER.get(); // ✅ 自动跟随协程
});
});
2
3
4
5
6
7
# 6.5 陷阱五:细粒度切换反而慢
suspend fun computeSum(list: List<Int>): Int {
var sum = 0
for (x in list) {
sum += withContext(Dispatchers.Default) { x * 2 } // ❌ 每个元素都切换
}
return sum
}
2
3
4
5
6
7
问题:withContext 的切换开销 ~50ns——但 x * 2 只要 1ns。切换比计算还贵。
修复:批量处理:
suspend fun computeSum(list: List<Int>): Int = withContext(Dispatchers.Default) {
list.sumOf { it * 2 } // 一次切换,批量计算
}
2
3
铁律:协程切换适合"中粒度任务"(毫秒级),不适合纳秒级操作。
# 6.6 陷阱六:异常吞没
// ❌ launch 启动的协程,异常默认吞掉
val job = GlobalScope.launch {
throw RuntimeException("oops") // ★ 静默死亡
}
job.join() // 主流程完全感知不到
2
3
4
5
修复:用 async(异常包装在 Deferred 里)或注册 CoroutineExceptionHandler:
val handler = CoroutineExceptionHandler { _, e -> log.error("协程出错", e) }
GlobalScope.launch(handler) { ... }
2
# 6.7 陷阱七:CPU密集任务错用Dispatcher
// ❌ CPU 密集任务用 IO Dispatcher
withContext(Dispatchers.IO) {
encryptHugeFile() // 占用一个 IO 线程几分钟
}
2
3
4
问题:Dispatchers.IO 是给"短时间阻塞"用的——长时间占用一个线程,IO 线程池就被打满。
修复:自己开个 dispatcher:
val cryptoDispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher()
withContext(cryptoDispatcher) { encryptHugeFile() }
2
# 07.共性抽象与未来演进
# 7.1 去掉语法糖后的共同骨架
不管是 Go goroutine、Kotlin suspend、还是 JS async,协程的骨架只有 5 件事:
flowchart LR
A[1. 启动<br/>spawn / launch] --> B[2. 执行<br/>到挂起点]
B --> C[3. 挂起<br/>保存状态]
C --> D[4. 让出 CPU<br/>调度器接管]
D --> E[5. 恢复<br/>从挂起点继续]
E --> B
2
3
4
5
6
任何语言的协程都是这 5 件事的不同包装——具体语法、调度策略、状态机生成方式有差异,但骨架不变。
# 7.2 协程的面向对象时刻结构化并发
协程让"启动并发任务"变得极其简单——但简单到一个程度,就成了灾难:
// 谁负责取消这些协程?谁负责处理它们的异常?
fun bad() {
GlobalScope.launch { task1() }
GlobalScope.launch { task2() }
GlobalScope.launch { task3() }
}
2
3
4
5
6
这就像 1960 年代的 GOTO 语句——能跳到任何地方,但难以推理。
结构化并发(Structured Concurrency) 是协程的"面向对象时刻"——让协程的生命周期跟随作用域,详见 3.18 结构化并发设计思想。
suspend fun good() = coroutineScope {
launch { task1() }
launch { task2() }
launch { task3() }
// 离开 scope 时,所有协程要么已完成,要么被取消
}
2
3
4
5
6
# 7.3 历史的回望:50 年的循环
flowchart LR
A[1958 Conway<br/>提出 coroutine] --> B[1980s 线程兴起<br/>协程被遗忘]
B --> C[2000s 回调地狱]
C --> D[2009 Go 重生协程]
D --> E[2012 C# async/await]
E --> F[2017+ Kotlin/Rust/JS<br/>全面拥抱]
F --> G[2023 Java 虚拟线程<br/>透明化]
style D fill:#fff3cd
style F fill:#d4edda
style G fill:#cfe2ff
2
3
4
5
6
7
8
9
10
11
协程花了 60 年从概念到工业主流——这是计算机科学的"螺旋式上升"。
# 7.4 未来:协程会怎么演进
趋势 1:透明化
Java Loom 的方向——"让协程看起来像线程"
未来:业务代码完全不需要懂 async,运行时自动决定如何调度
2
趋势 2:和类型系统深度结合
Rust 的 async/await + 借用检查器
未来:编译期检查"在 await 之间不持有锁"
2
趋势 3:跨语言互操作
WebAssembly 的 Component Model
未来:JS 协程能 await Rust 协程,统一通过 ABI 交互
2
# 08.一句话总结
# 8.1 三层认知阶梯
第一层(知其然):会写 async/await 或 go 关键字
↓
第二层(知其所以然):理解状态机变换、有栈/无栈差异、调度器机制
↓
第三层(知其将所以然):能根据场景选择协程策略,能避开 6 大陷阱,能设计结构化并发
2
3
4
5
读完本章后,你应该能回答开头§0.3 提出的三个问题:
- 协程的物理本质? → 一个能在中间暂停、保存状态、之后恢复的函数。通过编译器变换(无栈派)或独立栈(有栈派)实现"非完整执行"。
- 为什么 60 年才爆发? → 1958-2010 单核时代线程够用、async/await 语法没标准化、生态不齐;2010 后多核 + 云原生让百万并发成常态,协程的所有优势才落地。
- 有栈 vs 无栈怎么选? → 有栈(Go/Loom):兼容旧代码、无颜色问题;无栈(Kotlin/Rust/JS):内存占用极小、单机千万并发、编译器优化更激进。新设计的语言基本都选无栈。
# 8.2 协程选型决策树
flowchart TD
A[需要协程?] --> B{是否要兼容<br/>大量阻塞代码?}
B -->|是| B1[选有栈<br/>Go / Java Loom]
B -->|否| C{追求<br/>极致性能?}
C -->|是| C1[Rust async]
C -->|否| D{语言生态?}
D -->|Android/JVM| D1[Kotlin]
D -->|Web/Node| D2[JS async/await]
D -->|Python/科学计算| D3[asyncio]
D -->|.NET| D4[Task/async]
style B1 fill:#fff3cd
style C1 fill:#d4edda
style D1 fill:#cfe2ff
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 8.3 七字真言
- 协程是函数能力的扩展——不是"轻量线程"。
- 挂起点必须显式或隐式标记——编译器靠它生成状态机。
- 不要在协程里阻塞——切到专用 IO Dispatcher。
- 每个协程必须有归宿——绑定到 scope 避免泄漏。
- 细粒度切换是反模式——切换开销 > 计算开销时浪费。
- 异常处理要显式——launch 默认吞异常。
- ThreadLocal 用 ScopedValue 替代——协程不绑定线程。
# 8.4 与下篇的承接
本篇我们看到了协程如何让"百万并发 + 同步代码"成为可能。但还有一个问题——协程之间怎么通信?
最朴素的方法是共享变量 + 锁,但这又回到了线程时代的问题。有没有更优雅的方法?
下一篇 3.14 Actor 与 CSP 并发模型 我们会看到——让协程通过消息传递协作,这是 Erlang 和 Go 共同的答卷。
# 🔗 延伸阅读
- 同卷上篇:3.11 异步和同步的设计 | 3.12 单线程模型的思想
- 同卷下篇:3.14 Actor 与 CSP 并发模型 | 3.18 结构化并发设计思想
- 第 2 卷视角:2.4 函数调用栈与栈帧设计(理解协程栈的物理基础)
- 经典文献:
- Continuations and Coroutines(Christopher Strachey, 1974)
- The Art of Computer Programming Vol 1(Knuth, 1968)—— 协程章节经典论述
- Goroutines vs Coroutines(Russ Cox 博客)
- Coroutines in Kotlin(Roman Elizarov, KotlinConf 演讲)
- Java Loom: Virtual Threads(Brian Goetz, JEP 444)
- Async/Await Under the Hood in Rust(withoutboats 博客)