专栏文章
专栏文章
协程系列
1. 协程系列 #01:从 C10K 到协程:并发模型的演化史 2. 协程系列 #02:协程的实现机制:有栈与无栈、调度模型深度解析 3. 协程系列 #03:各语言的协程权衡:Go、Python、Kotlin、Java VT、C++20 横向对比

协程系列 #03:各语言的协程权衡:Go、Python、Kotlin、Java VT、C++20 横向对比

发布于 2026-05-25 12:26 👁 18 次阅读
#Go#Python#协程#kotlin#java#javascript#cpp

本文在「有栈/无栈 × 调度模型」的框架下,分析 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/httpos 全都变成 async 函数。

有栈协程解决了这个问题:Go runtime 把系统调用包装成非阻塞 IO,goroutine 阻塞时由 runtime 而非用户代码来切换上下文,现有阻塞式写法"透明地"变成非阻塞。

GMP 模型

gmp schedule

连续栈演化

版本 栈实现 问题
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) setTimeoutsetInterval、IO 回调
微任务(microtask) Promise.thenqueueMicrotask

规则:每个宏任务执行完毕后,清空所有微任务队列,再取下一个宏任务。

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)在类加载时对字节码做变换:

  1. 扫描所有标注了 @Suspendable 的方法
  2. 在每个可能挂起的调用点插入保存/恢复调用栈的字节码
  3. 将局部变量序列化到堆上(类似无栈协程的协程帧),但保留完整调用链语义

本质上是用字节码模拟了有栈协程的调用栈保存,代价是:

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(...),并发能力即刻提升。

实现机制

java vt unmount

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 的设计选择:编译器只提供机制,不提供策略

关键机制

// 一个最简单的 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),无栈方案的结构化并发和类型安全更值钱。


参考资料

← 返回列表

评论 (0)

暂无评论,来留下第一条吧。

发表评论