本文在「有栈/无栈 × 调度模型」的框架下,分析 Go、Python、JavaScript、Kotlin、Java Virtual Thread、C++20 各自做了哪些权衡,以及这些权衡背后的工程原因。核心结论:有栈协程倾向对用户透明,无栈协程倾向编译器支持用户显式控制,两种哲学没有绝对优劣,只有场景匹配度。
目录
| 章节 | 说明 |
|---|---|
| Go goroutine | 有栈 + M:N,GMP 模型,透明阻塞 |
| Python asyncio | 无栈 + 1:N,事件循环,GIL 制约 |
| JavaScript(Node.js) | 无栈 + 1:N,Promise 演化,微任务顺序 |
| Kotlin coroutine(JVM) | 无栈 + M:N,CPS 变换,结构化并发 |
| Java Virtual Thread | 有栈 + M:N,零改造,pin 问题 |
| C++20 coroutine | 无栈 + 框架决定,机制与策略分离 |
| 横向对比与选型 | 对比表 + 工程哲学分歧 |
Go goroutine
选择:有栈协程 + M:N 调度(GMP 模型)
为什么这么选
Go 的目标是替代 C/Java 写系统软件。这个目标带来一个硬约束:不能让 async/await 的传染性渗透整个标准库——你不可能让 net/http、os 全都变成 async 函数。
有栈协程解决了这个问题:Go runtime 把系统调用包装成非阻塞 IO,goroutine 阻塞时由 runtime 而非用户代码来切换上下文,现有阻塞式写法"透明地"变成非阻塞。
GMP 模型
GOMAXPROCS:P 的数量,默认 = CPU 核数,决定最大并行度- 工作窃取:某个 P 的本地队列空了,去其他 P 偷一半任务
- 全局队列:每隔 61 个调度周期,M 会从全局队列取一个 G,防止饥饿
连续栈演化
| 版本 | 栈实现 | 问题 |
|---|---|---|
| Go 早期 | 分段栈(segmented stack) | Hot split:频繁进出递归小函数导致大量栈段分配/释放 |
| Go 1.4+ | 连续栈(copying stack) | 栈满时分配更大连续内存,复制旧栈,更新所有指针 |
连续栈的一个重要推论:goroutine 栈上的地址在栈增长后会失效,这就是为什么不能把 goroutine 栈上变量的地址传给 C 代码(cgo 的限制之一)。
调度抢占演化
| 版本 | 抢占方式 | 问题 |
|---|---|---|
| Go 1.13 及之前 | 协作式:只在函数调用时检查抢占标志 | 无函数调用的紧密循环(如 for {} 计算)会饿死调度器 |
| Go 1.14+ | 基于 SIGURG 信号的异步抢占 | 任意时刻都能抢占,解决了计算密集 goroutine 独占 P 的问题 |
真实的坑
goroutine 泄漏——最常见的问题。
func leak() {
ch := make(chan int) // 无缓冲 channel
go func() {
val := <-ch // 永远没人发送,goroutine 阻塞在这里
fmt.Println(val)
}()
// 函数返回,但 goroutine 永远不会结束
}
排查工具:
runtime.NumGoroutine()、pprof goroutine profile
阻塞 syscall 导致线程数暴增:阻塞 syscall 时 M 和 G 绑定无法解绑,P 会转移到新 M。大量并发阻塞 syscall(如非 Go 封装的 CGO 调用)会创建大量 OS 线程。
Python asyncio
选择:无栈协程 + 1:N 调度(事件循环)
为什么这么选
CPython 有 GIL(全局解释器锁),同一时刻只有一个线程执行 Python 字节码。M:N 调度在这里意义不大——即使有多个 OS 线程,也无法真正并行执行 Python 代码。1:N 事件循环简单可控,直接对接 epoll/kqueue,IO 密集场景效果很好。
协程语法演化路径
理解这条演化路径,是理解 Python 协程设计决策的关键。
| 时间 | PEP | 关键引入 | 说明 |
|---|---|---|---|
| 2001 | PEP 255 | yield |
生成器,可暂停的函数 |
| 2012 | PEP 380 | yield from |
委托子生成器,消除样板代码 |
| 2013 | PEP 3156 | @asyncio.coroutine + yield from |
第一代协程,语义不够清晰 |
| 2015 | PEP 492 | async def + await |
专用语法,await 只接受 Awaitable |
事件循环驱动机制
async def fetch(url):
# await 在这里挂起当前协程,把控制权还给事件循环
response = await aiohttp.get(url)
return await response.text()
内部流程:
sequenceDiagram
participant EL as EventLoop
participant T as Task
participant CO as 协程
participant EP as epoll/IO
EL->>T: Task.step()
T->>CO: coroutine.send(None)
CO->>CO: 执行到 await
CO->>EP: 底层注册 epoll 事件
CO-->>EL: 挂起,控制权回到 EventLoop
Note over EP: IO 就绪
EP-->>EL: 事件通知
EL->>T: 取出对应 Task → Task.step()
T->>CO: coroutine.send(result)
CO->>CO: 协程继续执行
真实的坑
async 传染:标准库大量函数是阻塞的
async def bad():
# time.sleep 是同步的,会阻塞整个事件循环!
time.sleep(1)
# 正确写法:
await asyncio.sleep(1)
async def also_bad():
# open() / read() 是同步文件 IO,会阻塞事件循环
with open("large_file.txt") as f:
data = f.read()
# 正确写法:用 asyncio.to_thread() 或 aiofiles
data = await asyncio.to_thread(open("large_file.txt").read)
GIL + asyncio 的常见误解
asyncio ──► IO 密集,单核,协程并发 ✅ 合适
多线程 ──► IO 密集,GIL 释放期间并行 ✅ 也可以
多进程 ──► CPU 密集,真正并行 ✅ 唯一选择
asyncio ──► CPU 密集 ❌ 不但不并行,还会阻塞事件循环
JavaScript(Node.js)
选择:无栈协程 + 1:N(libuv 事件循环)
为什么这么选
JavaScript 天生单线程,事件循环是语言基因,不是后来加上去的。async/await 是 Promise 的语法糖,Promise 是对回调的封装,三者一脉相承。
演化路径
回调函数(Callback Hell)
↓
Promise(ES6, 2015)
↓
async/await(ES2017)
微任务 vs 宏任务(执行顺序经常被误解)
| 类型 | 来源 | 优先级 |
|---|---|---|
| 宏任务(macrotask) | setTimeout、setInterval、IO 回调 |
低 |
| 微任务(microtask) | Promise.then、queueMicrotask |
高 |
规则:每个宏任务执行完毕后,清空所有微任务队列,再取下一个宏任务。
console.log('1'); // 同步
setTimeout(() => console.log('2'), 0); // 宏任务,进宏任务队列
Promise.resolve()
.then(() => console.log('3')); // 微任务,进微任务队列
console.log('4'); // 同步
// 输出顺序:1 → 4 → 3 → 2
// 解释:
// 同步代码先跑完(1, 4)
// 当前宏任务结束,清空微任务队列(3)
// 取下一个宏任务 setTimeout(2)
真实的坑
for await 是串行的,并发要用 Promise.all
// 错误:三个请求串行执行,总耗时 = 3 × 单次耗时
async function serial() {
for (const url of urls) {
await fetch(url); // 每次都等上一个完成
}
}
// 正确:三个请求并发执行,总耗时 ≈ 单次最长耗时
async function concurrent() {
await Promise.all(urls.map(url => fetch(url)));
}
Kotlin coroutine(JVM)
选择:无栈协程 + M:N(Dispatcher 抽象)
为什么这么选
JVM 线程模型已经足够成熟,Kotlin 协程的目标是复用 JVM 线程池而不是绕开它。无栈协程内存占用小(每个协程只需保存状态机快照),编译器负责做 CPS 变换,不需要修改 JVM 规范。
Continuation 变换(CPS)
suspend 函数在编译期会被插入一个隐式的 Continuation 参数,函数体变成状态机。
// 源码
suspend fun loadData(): String {
val a = fetchA() // 挂起点 1
val b = fetchB() // 挂起点 2
return a + b
}
// 编译器生成的伪代码(简化)
fun loadData(continuation: Continuation<String>): Any {
val sm = continuation as? LoadDataSM ?: LoadDataSM(continuation)
when (sm.label) {
0 -> {
sm.label = 1
return fetchA(sm) // 挂起,返回 COROUTINE_SUSPENDED
}
1 -> {
sm.a = sm.result as String
sm.label = 2
return fetchB(sm) // 挂起
}
2 -> {
return sm.a + (sm.result as String) // 完成
}
}
}
这与第二篇「状态机变换」对应的具体实现。
Dispatcher 选择
| Dispatcher | 用途 | 线程数 |
|---|---|---|
Dispatchers.Main |
UI 线程(Android/Compose) | 1 |
Dispatchers.IO |
IO 密集(数据库、文件、网络) | max(64, CPU 核数) |
Dispatchers.Default |
CPU 密集(计算、JSON 解析) | CPU 核数 |
Dispatchers.Unconfined |
不限制线程,继承调用者线程 | 不确定,慎用 |
结构化并发
// CoroutineScope 约束协程生命周期
fun processData(scope: CoroutineScope) {
scope.launch { // 父协程
val a = async { fetchA() } // 子协程 1
val b = async { fetchB() } // 子协程 2
println(a.await() + b.await())
}
// scope 取消时,launch、async 内所有子协程自动取消
// 避免了 goroutine 泄漏类问题
}
真实的坑
withContext(Dispatchers.IO) + synchronized 导致线程数暴增
// 危险:IO Dispatcher 遇到竞争锁时会创建新线程等待,而不是挂起
withContext(Dispatchers.IO) {
synchronized(lock) { // ❌ 阻塞 IO 线程,调度器会再开新线程
heavyWork()
}
}
// 正确:用 Mutex(可挂起的锁)
val mutex = Mutex()
withContext(Dispatchers.IO) {
mutex.withLock { // ✅ 挂起而不是阻塞线程
heavyWork()
}
}
Java Virtual Thread
Loom 之前:Quasar 的探路
Project Loom(JDK 21,2023)并非从零开始,它的核心设计者 Ron Pressler 在加入 Oracle 之前,主导开发了 Quasar(2013,Parallel Universe 公司)——一个在 JVM 上实现有栈协程(Fiber)的开源库。Quasar 是 Loom 的直接思想来源,也是 Java 社区在 Loom 落地前的工程实践。
Quasar 的实现方式
Quasar 无法修改 JVM,只能用字节码增强来实现协程:
// Quasar Fiber:Loom 之前的 Java 协程
Fiber<String> fiber = new Fiber<>(() -> {
// @Suspendable 注解的方法会被 Quasar 字节码增强
String data = Fiber.sleep(100); // 挂起当前 Fiber,让出 ForkJoinPool 线程
return "result: " + data;
});
fiber.start();
String result = fiber.get();
// 必须用 @Suspendable 标注所有可挂起的方法调用链
@Suspendable
public String fetchData() throws SuspendExecution {
return SomeService.blockingCall(); // Quasar 在字节码层面把这里变成挂起点
}
字节码增强的原理
Quasar 通过 Java Agent(-javaagent:quasar-core.jar)在类加载时对字节码做变换:
- 扫描所有标注了
@Suspendable的方法 - 在每个可能挂起的调用点插入保存/恢复调用栈的字节码
- 将局部变量序列化到堆上(类似无栈协程的协程帧),但保留完整调用链语义
本质上是用字节码模拟了有栈协程的调用栈保存,代价是:
- 所有可挂起的方法必须标注
@Suspendable(或加入throws SuspendExecution),传染性接近无栈协程 - 字节码增强在运行时有额外开销
- 调试困难,stack trace 经过增强后变形
Quasar → Loom 的关键洞察
Quasar 的工程实践给 Ron Pressler 带来了一个清晰认识:在 JVM 层之外做有栈协程,成本太高。@Suspendable 的传染性几乎和 async/await 一样严重,字节码增强的脆弱性也难以大规模推广。
真正的解法只有一个:把协程支持下沉到 JVM 本身。这就是 Project Loom 的起点——Virtual Thread 在 JVM 内核层面实现挂起/恢复,对上层代码完全透明,不需要任何注解。
| 维度 | Quasar Fiber | Java Virtual Thread |
|---|---|---|
| 实现层 | 字节码增强(JVM 外) | JVM 内核原生支持 |
| 传染性 | 有(@Suspendable) |
无 |
| 调试体验 | 差(stack trace 变形) | 正常(与平台线程一致) |
| 生产可用性 | 有限(需要精心配置) | ✅ JDK 21 正式支持 |
| 历史价值 | Loom 的思想验证 | Loom 的工程实现 |
选择:有栈协程 + M:N(JVM 内置调度)
为什么这么选(Project Loom 的核心价值判断)
Java 生态有数百万行阻塞式 IO 代码:JDBC、老版本 HttpClient、大量中间件 SDK。即使 Kotlin coroutine 再好用,让所有这些代码改成 suspend 函数也是不现实的。
Virtual Thread 的核心价值是零改造:现有阻塞代码不改一行,直接从 new Thread(...) 换成 Thread.ofVirtual().start(...),并发能力即刻提升。
实现机制
Carrier Thread 数量默认 = CPU 核数,类似 Go 的 GOMAXPROCS。
使用示例
// 传统:一个请求一个平台线程,并发受限于线程池大小
ExecutorService pool = Executors.newFixedThreadPool(200);
// Virtual Thread:一个请求一个 VT,并发上限远更高
ExecutorService vte = Executors.newVirtualThreadPerTaskExecutor();
// 代码完全一样,只换了 Executor
vte.submit(() -> {
String result = httpClient.send(request, ...).body(); // 阻塞,但 VT 会自动 unmount
System.out.println(result);
});
限制(重要)
| 限制 | 原因 | 解决方案 |
|---|---|---|
synchronized 块内阻塞会 pin Carrier Thread |
JVM 实现限制,monitorexit 无法 unmount | 改用 ReentrantLock |
| Native 方法内阻塞也会 pin | 同上 | 尽量避免在 VT 内调用长耗时 native 方法 |
| CPU 密集任务不适合 VT | 多 VT 争抢少量 Carrier Thread,无法提速 | CPU 密集用平台线程池(ForkJoinPool) |
// 危险:synchronized 导致 pin
synchronized (lock) {
Thread.sleep(1000); // ❌ pin 住 Carrier Thread,相当于阻塞了一个 OS 线程
}
// 正确
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
Thread.sleep(1000); // ✅ VT 正常 unmount,Carrier Thread 可去执行其他 VT
} finally {
lock.unlock();
}
Virtual Thread vs Kotlin coroutine 的选择
| 场景 | 推荐 | 原因 |
|---|---|---|
| 遗留 Java 项目改造 | Virtual Thread | 零改造成本,换 Executor 即可 |
| 新 Kotlin 项目 | Kotlin coroutine | 类型安全、结构化并发、Dispatcher 灵活 |
| 需要精确控制并发上下文 | Kotlin coroutine | CoroutineScope 边界清晰 |
| 大量 JDBC / 第三方阻塞 SDK | Virtual Thread | 无传染性,SDK 不需要改 |
C++20 coroutine
选择:无栈协程 + 框架决定调度
为什么这么选
C++ 的核心哲学是零开销抽象:你不用的特性,不应该让你付出代价。强制用户接受某种调度器(像 Go runtime 那样)违背这一哲学。
C++20 coroutine 的设计选择:编译器只提供机制,不提供策略。
co_await、co_yield、co_return是语言关键字Promise type(promise_type)由用户/库定义,控制协程如何分配、挂起、调度- 没有内置的调度器
关键机制
// 一个最简单的 Generator(懒求值序列)
Generator<int> fibonacci() {
int a = 0, b = 1;
while (true) {
co_yield a; // 挂起,返回 a 给调用者
auto tmp = a + b;
a = b;
b = tmp;
}
}
int main() {
auto gen = fibonacci();
for (int i = 0; i < 10; i++) {
std::cout << gen.next() << ' '; // 每次 next() 恢复协程到下一个 co_yield
}
}
// 输出:0 1 1 2 3 5 8 13 21 34
协程帧(coroutine frame)存储所有局部变量和挂起点状态,通常分配在堆上(编译器可优化到栈上)。
高度定制化的代价
开发者需要自己实现(或依赖第三方库):
├── Promise type:定义协程的初始挂起、最终挂起、返回值类型
├── Awaitable:定义 co_await 后的行为(是否挂起、如何恢复)
├── Executor / Scheduler:决定恢复的协程在哪个线程上运行
└── 错误处理:异常如何从协程传播到调用者
实际状态
| 层次 | 状态 |
|---|---|
| 语言机制(co_await / co_yield / co_return) | C++20 标准,编译器支持完善 |
std::generator(惰性序列) |
C++23 |
| Executor / Scheduler 框架 | 标准库尚无,需用第三方库 |
| 推荐第三方库 | asio(网络)、cppcoro(通用工具)、libunifex(P2300 sender/receiver) |
C++20 coroutine 更像是一个「给库作者用的元协程机制」,而不是像 Go goroutine 那样「给应用开发者直接用的并发原语」。
横向对比与选型
对比表
| 维度 | Go goroutine | Python asyncio | JavaScript | Kotlin coroutine | Java VT | C++20 |
|---|---|---|---|---|---|---|
| 协程类型 | 有栈 | 无栈 | 无栈 | 无栈 | 有栈 | 无栈 |
| 调度模型 | M:N(GMP) | 1:N(事件循环) | 1:N(libuv) | M:N(Dispatcher) | M:N(JVM) | 库决定 |
| 传染性 | 无 | 有 | 有 | 有 | 无 | 有 |
| 真正并行 | ✅ | ❌(GIL) | ❌ | ✅ | ✅ | ✅ |
| 内存开销 | 中(2KB+ 初始栈) | 低 | 低 | 低 | 中(~1KB 初始栈) | 极低 |
| 改造成本 | 低 | 高 | 高 | 中 | 极低 | 高 |
| 运行时复杂度 | 高(GC + 调度器) | 低 | 低 | 中 | 高(JVM) | 极低 |
| 典型场景 | 网络服务/系统编程 | IO 密集脚本 | 前端/Node.js | Android/JVM 新项目 | 遗留 Java 改造 | 嵌入式/游戏 |
一个根本性的工程哲学分歧
有栈协程派(Go、Java VT):运行时替你搞定
优势:
- 对用户透明,现有阻塞代码无需改写
- 学习曲线低,写法与同步代码无异
- 生态迁移成本极低
代价:
- 运行时复杂(GC、调度器、栈拷贝)
- 内存开销较高(每个协程一份栈)
- 调试困难(协程在哪个线程上不确定)
无栈协程派(Python、JS、Kotlin、C++):你明确表达异步意图,编译器帮你优化
优势:
- 内存极省(只存状态机快照,无完整栈)
- 编译期可见的并发边界(await 点)
- 类型系统可以表达 async 约束(Kotlin suspend)
代价:
- async 传染(调用链必须都是 async)
- 标准库需要配套 async 版本
- 回调/Promise/await 带来的心智负担
选型一句话:如果你在改造遗留系统或写新 Go 服务,优先有栈方案(透明、低成本);如果你在写新项目且语言支持良好的 async 生态(Kotlin、TypeScript),无栈方案的结构化并发和类型安全更值钱。
参考资料
- Go FAQ: Why goroutines instead of threads?
- PEP 492 — Coroutines with async and await syntax
- PEP 3156 — Asynchronous IO Support Rebooted
- Python asyncio 官方文档
- Kotlin coroutines 官方指南
- C++ coroutines — cppreference
- JEP 444: Virtual Threads(Java 21)— openjdk.org/jeps/444(需翻墙访问)
- 从 C10K 到协程(协程诞生的历史背景与动机)
- 协程的实现机制(有栈/无栈、调度模型的底层原理)
- ../../02 编程语言/03 GoLang/03 GoLang 并发编程(Go goroutine/channel 的工程实践)
- ../../02 编程语言/02 Java/02 Java 并发编程(Java 传统线程模型,Virtual Thread 的对比基础)
- ../../02 编程语言/09 Kotlin/01 Kotlin 核心特性(Kotlin coroutine 的语言层面介绍)
- JavaScript 核心原理(JS 事件循环、Promise、async/await 的底层机制)
评论 (0)
发表评论