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

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

发布于 2026-05-25 12:26 · 最后编辑于 2026-07-31 15:53 · 字数 5,403 👁 134 次阅读

本文在「有栈/无栈 × 调度模型」的框架下,分析 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

  • 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关键引入说明
2001PEP 255yield生成器,可暂停的函数
2012PEP 380yield from委托子生成器,消除样板代码
2013PEP 3156@asyncio.coroutine + yield from第一代协程,语义不够清晰
2015PEP 492async 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.MainUI 线程(Android/Compose)1
Dispatchers.IOIO 密集(数据库、文件、网络)max(64, CPU 核数)
Dispatchers.DefaultCPU 密集(计算、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. 将局部变量序列化到堆上(类似无栈协程的协程帧),但保留完整调用链语义

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

  • 所有可挂起的方法必须标注 @Suspendable(或加入 throws SuspendExecution),传染性接近无栈协程
  • 字节码增强在运行时有额外开销
  • 调试困难,stack trace 经过增强后变形

Quasar → Loom 的关键洞察

Quasar 的工程实践给 Ron Pressler 带来了一个清晰认识:在 JVM 层之外做有栈协程,成本太高@Suspendable 的传染性几乎和 async/await 一样严重,字节码增强的脆弱性也难以大规模推广。

真正的解法只有一个:把协程支持下沉到 JVM 本身。这就是 Project Loom 的起点——Virtual Thread 在 JVM 内核层面实现挂起/恢复,对上层代码完全透明,不需要任何注解。

维度Quasar FiberJava 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 ThreadJVM 实现限制,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 coroutineCoroutineScope 边界清晰
大量 JDBC / 第三方阻塞 SDKVirtual Thread无传染性,SDK 不需要改

C++20 coroutine

选择:无栈协程 + 框架决定调度

为什么这么选

C++ 的核心哲学是零开销抽象:你不用的特性,不应该让你付出代价。强制用户接受某种调度器(像 Go runtime 那样)违背这一哲学。

C++20 coroutine 的设计选择:编译器只提供机制,不提供策略

  • co_awaitco_yieldco_return 是语言关键字
  • Promise typepromise_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 goroutinePython asyncioJavaScriptKotlin coroutineJava VTC++20
协程类型有栈无栈无栈无栈有栈无栈
调度模型M:N(GMP)1:N(事件循环)1:N(libuv)M:N(Dispatcher)M:N(JVM)库决定
传染性
真正并行❌(GIL)
内存开销中(2KB+ 初始栈)中(~1KB 初始栈)极低
改造成本极低
运行时复杂度高(GC + 调度器)高(JVM)极低
典型场景网络服务/系统编程IO 密集脚本前端/Node.jsAndroid/JVM 新项目遗留 Java 改造嵌入式/游戏

一个根本性的工程哲学分歧

有栈协程派(Go、Java VT):运行时替你搞定

优势:
- 对用户透明,现有阻塞代码无需改写
- 学习曲线低,写法与同步代码无异
- 生态迁移成本极低

代价:
- 运行时复杂(GC、调度器、栈拷贝)
- 内存开销较高(每个协程一份栈)
- 调试困难(协程在哪个线程上不确定)

无栈协程派(Python、JS、Kotlin、C++):你明确表达异步意图,编译器帮你优化

优势:
- 内存极省(只存状态机快照,无完整栈)
- 编译期可见的并发边界(await 点)
- 类型系统可以表达 async 约束(Kotlin suspend)

代价:
- async 传染(调用链必须都是 async)
- 标准库需要配套 async 版本
- 回调/Promise/await 带来的心智负担

选型一句话:如果你在改造遗留系统或写新 Go 服务,优先有栈方案(透明、低成本);如果你在写新项目且语言支持良好的 async 生态(Kotlin、TypeScript),无栈方案的结构化并发和类型安全更值钱。

参考资料

← 返回列表

评论 (0)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论