别人的答案:JS、Java 虚拟线程、Erlang、Rust
Kotlin、Go、Python 是三条路,但不是全部的路。这一章走出去看看另外四个 —— 它们各自在某一个维度上走到了极端,而正因为极端,它们把权衡暴露得最清楚。JS 说:干脆别要多线程。Java 说:别改语言,改运行时。Erlang 说:什么都别共享。Rust 说:让编译器来管。四个答案,四种代价。看完它们,你回头再看 Kotlin/Go/Python 的选择,会看得更清楚。
JavaScript:干脆别要多线程
JS 的答案最激进,而且它是被迫的(浏览器 DOM 不是线程安全的,1995 年就定死了):整个语言只有一个线程。
while (true) {
// ① 从【宏任务队列】取一个任务执行
task = macrotaskQueue.shift() // setTimeout、IO 回调、事件
execute(task)
// ② 【把微任务队列清空】—— 注意:是清空,不是取一个!
while (microtaskQueue.length) { // Promise.then、queueMicrotask
execute(microtaskQueue.shift())
}
// ↑ 微任务的优先级【高于】宏任务,而且会一直执行到队列空为止。
// 这就是为什么 Promise.then 总是比 setTimeout(0) 先执行。
// ③ 渲染(浏览器里)
render()
}
买到的(被严重低估):
- 没有数据竞争。没有死锁。不需要锁。不需要内存模型。
- 整个第 6~10 章的内容,JS 程序员一辈子不用学。
- 「运行到完成」(run-to-completion)语义:一段同步代码不会被打断。这是一个极强的保证 —— 你的
counter++永远是原子的。
付出的:
- 一个死循环卡死整个页面 / 整个服务。
- 用不了多核。(Web Worker / Node 的
worker_threads能用,但它们是独立的隔离环境,只能通过消息通信 —— 本质上是 Actor 模型。) - CPU 密集的活基本无解。
这是 Python asyncio 的处境,但更纯粹 —— 因为 JS 从来没有过多线程,所以它的整个生态天生就是异步的。
而 Python 是「后来才有 asyncio」,于是生态被劈成了两半。这个时间差,是 Python 异步一切痛苦的根源。
Java 21:别改语言,改运行时
虚拟线程(JEP 444,2023)是一个非常聪明的赌注:不引入任何新的编程模型,而是让老的编程模型突然变得可扩展。
// 你的代码一行不用改。就这样:
Thread.ofVirtual().start(() -> {
var response = httpClient.send(request, ofString()); // 阻塞式!
var user = jdbcTemplate.query(...); // 阻塞式!
Thread.sleep(1000); // 阻塞式!
});
// 但这些「阻塞」全都不是真的阻塞:
// JVM 在挂起点【把整个栈从载体线程上卸下来,搬到堆上】
// 载体线程立刻回去跑别的虚拟线程
// 数据好了,再把栈装回去
//
// 一百万个虚拟线程,几个载体线程。
// Spring Boot 3.2:一行配置
// spring.threads.virtual.enabled=true
// → 你的整个应用突然能扛十万并发,代码一个字没改。
| 协程(Kotlin/Python) | 虚拟线程(Java 21+) | |
|---|---|---|
| 在哪层 | 编译器(把函数变成状态机) | JVM(把栈搬来搬去) |
| 染色 | 有 | 无 |
| 老代码 | 要重写 | 一行不用改 |
| 栈追踪 | 可能是断的 | 完整(它就是一个线程) |
| 调试器 | 体验一般 | 完美 |
| 结构化并发 | Kotlin 内建 | 还在预览(StructuredTaskScope,JDK 25 仍是 preview) |
| 取消 | 一等公民,自动传播 | 还是 interrupt(老一套,很难用对) |
公平地说,虚拟线程在「无染色」这一点上确实更优雅。它让二十年的阻塞式代码一夜之间变得可扩展。这是一个巨大的、罕见的、几乎白送的胜利。
但它没有解决真正难的两个问题:结构化并发和取消。Java 正在补(JEP 505),但还在预览。
① 钉住(pinning)。虚拟线程在 synchronized 块里挂起时,会把载体线程一起钉死(因为 monitor 是绑定线程的)。一个热点的 synchronized 就能让你的百万虚拟线程退化成几十个载体线程。
好消息:JDK 24 的 JEP 491 修掉了这个(synchronized 不再钉住)。坏消息:JNI 调用和 native 帧仍然会钉住。
② 池化虚拟线程是反模式。
Executors.newFixedThreadPool(200) 存在的理由是「线程很贵,要复用」。虚拟线程很便宜 —— 你应该「一个任务一个虚拟线程」,用完就扔。
Executors.newVirtualThreadPerTaskExecutor() —— 名字就说明了一切。
但注意:这样一来你就失去了「线程池」提供的那个隐形限流器(第 21 章)。你需要显式地重建它(信号量)。
Erlang / Elixir:什么都别共享
Erlang 是这份名单里最老(1986)也最激进的:它从根上取消了「共享内存」这个概念。
| 特性 | 做法 | 后果 |
|---|---|---|
| 完全隔离 | 每个进程有自己的堆。进程之间只能发消息(消息是拷贝的)。 | 数据竞争在语言层面不可能存在。不需要锁,不需要内存模型。 |
| 抢占式调度 | 按「归约计数」抢占:每个进程执行大约 2000 次归约(函数调用/操作)就被换下来。 | 一个死循环不会饿死任何人。 这是它和所有「协作式协程」的根本区别 —— 连 Go 的信号抢占都没这么彻底。 |
| 每进程独立 GC | 因为堆是独立的,GC 也是独立的。 | 没有全局 STW。GC 一个小进程只要几微秒。这是 Erlang 延迟极其可预测的原因。 |
| Let it crash | 不写防御性代码。进程崩了就让它崩。 | 监督树(supervisor)发现它崩了,把它重启到一个已知的好状态。 |
「Let it crash」在共享内存的世界里是不可能的。
因为:一个线程崩了,谁知道它有没有把共享状态改坏一半?(这正是第 22 章 Thread.stop() 被废弃的原因。)
只有当进程之间完全隔离,「杀掉并重启」才是安全的。
这一条洞察的影响力,远远超出了 Erlang:
- Kubernetes 的「Pod 挂了就重建」 —— 同一个思想,只是粒度从「Erlang 进程」变成了「容器」。
- 微服务的「熔断 + 重启」 —— 同一个思想。
- Serverless 的「每次调用一个干净的执行环境」 —— 同一个思想。
Erlang 在 1986 年就把云原生的核心思想想明白了,只是当时它叫「电话交换机」。
(本站有一本 《九个九》 专门讲 Elixir 和 BEAM。)
Rust:让编译器来管
Rust 的答案在这份名单里最独特:它不改变运行时模型(它就是 OS 线程 + async),它改变的是类型系统。
Send —— 「这个类型可以被【移动】到另一个线程」 Sync —— 「这个类型的【引用】可以被多个线程同时持有」 编译器规则: · 你想把一个值 move 进另一个线程? → 它必须是 Send · 你想在多个线程间共享一个 &T? → T 必须是 Sync 而这两个 trait 是【自动推导】的: · Rc不是 Send(引用计数不是原子的)→ 编译器【拒绝】你跨线程用它 · Arc 是 Send(原子引用计数) → 可以 · RefCell 不是 Sync(运行时借用检查不是线程安全的) · Mutex 是 Sync → 可以
let mut counter = 0;
thread::spawn(|| {
counter += 1; // ❌ 编译错误!
});
// error[E0373]: closure may outlive the current function,
// but it borrows `counter`, which is owned by the current function
// ✅ 必须显式地用同步原语
let counter = Arc::new(Mutex::new(0));
let c = Arc::clone(&counter);
thread::spawn(move || {
*c.lock().unwrap() += 1; // 编译器满意了
});
「Fearless Concurrency」不是一句口号,它是一个可证明的性质:
在 safe Rust 里,数据竞争(data race)在编译期就是不可能的。
回到第 8 章那个区分:
- Data race(数据竞争):Rust 编译期消灭。✅
- Race condition(竞态条件,逻辑上的):Rust 也救不了你。那个「先检查余额再扣款」的 bug,在 Rust 里照样能写出来。
- 死锁:Rust 也救不了你。两把
Mutex按不同顺序拿,照样死锁。
但即便如此,「消灭 data race」也是巨大的胜利 —— 因为 data race 是最难查、最不可复现、后果最不可预测的那一类。
代价:学习曲线。你要和借用检查器搏斗几个月。Rust 把「运行时的痛苦」提前成了「编译期的痛苦」 —— 这是一笔好交易,但账单是预付的。
Rust 的 async 有一堆额外的复杂度,而且它们不会消失:
- 没有内建的 runtime。你必须选一个(tokio / async-std / smol),而且它们互不兼容(一个库依赖 tokio,另一个依赖 async-std → 你要跑两个运行时)。
- 函数染色,而且比 Python 更严重。因为 Rust 还有 trait 里的 async 的问题(
async fnin trait 直到 1.75 才稳定,而且还有一堆限制)。 Pin/Unpin—— 一个只有在写 async 库时才需要理解、但会漏到用户代码里的抽象。- 「async 的 Rust 是另一门语言」 —— 这句抱怨在社区里很常见,而且不算夸张。
Rust 在同步并发上是最优雅的,在异步并发上是最痛苦的。这个反差本身很值得玩味。
七门语言,一张总表
| 并发单元 | 用多核 | 染色 | 数据竞争 | 结构化并发 | 一个死循环 | |
|---|---|---|---|---|---|---|
| Go | goroutine (M:N) | ✅ | ❌ 无 | 会有(-race 能查) |
❌ 靠纪律 | ✅ 抢占 |
| Kotlin | 协程 (M:N) | ✅ | ✅ 有 | 会有 | ✅ 语言内建 | ❌ 占死线程 |
| Python | Task (N:1) | ❌ GIL (3.14t 起可以) |
✅ 有(最重) | GIL 挡住了大半 (3.14t 起不挡了) |
✅ TaskGroup (3.11+) | ❌ 全场停摆 |
| JavaScript | 事件循环 (N:1) | ❌ | ✅ 有 | ✅ 不可能 | ❌ | ❌ 全场停摆 |
| Java 21+ | 虚拟线程 (M:N) | ✅ | ❌ 无 | 会有 | 🚧 预览中 | ✅ 抢占 |
| Erlang | Actor (M:N) | ✅ | ❌ 无 | ✅ 不可能 | ✅ 监督树 | ✅ 抢占(最彻底) |
| Rust | 线程 / async (M:N) | ✅ | ✅ 有(最痛) | ✅ 编译期消灭 | 🚧 库层面 | 看 runtime |
看那张表。没有任何一门语言在所有列上都是绿的。
而且这些权衡不是「设计者不够聪明」—— 它们是真实的、无法回避的取舍:
- 要「无染色」?→ 那你要么改运行时(Java、Go),要么根本不做异步。而改运行时的门槛极高(你得控制整个 runtime)。
- 要「没有数据竞争」?→ 要么不共享(Erlang、JS),要么上重型类型系统(Rust)。两条路都要求你重新组织代码。
- 要「结构化并发」?→ 那你需要一个能表达「作用域」的机制,而这通常意味着染色。
- 要「抢占式」?→ 那你需要能在任意点中断执行,这要求 runtime 有很强的控制力(信号 / 归约计数)。
你的工作不是找到「最好的语言」。是知道你手上这门语言的账单在哪一栏,然后刻意地去补它。
- 写 Go?你的账单是生命周期(第 18、20 章)→ 用 errgroup + goleak,把纪律工具化。
- 写 Kotlin?你的账单是CPU 密集会占死调度器 → 严守「CPU 活滚出 Default」。
- 写 Python?你的账单是一行阻塞毁全场 + 用不了多核 → 全栈异步 + 多进程。
1. 如果你在 JVM 上且能上 Java 21:花一天试试虚拟线程。把 spring.threads.virtual.enabled=true 打开,压测。这可能是你今年投入产出比最高的一天。
2. 写十行 Rust,故意制造一个数据竞争,看编译器怎么骂你。这个体验比读十篇文章都有用 —— 你会突然理解「类型系统能做什么」。
3. 在上面那张总表里,找到你的语言那一行,把红色的格子圈出来。那些就是你需要靠纪律和工具去补的地方。
最后一章。把 24 章压成一个你能在设计评审上当场用出来的决策流程。