幕 IV · 深水区CH 24进度 24/25

别人的答案:JS、Java 虚拟线程、Erlang、Rust

Kotlin、Go、Python 是三条路,但不是全部的路。这一章走出去看看另外四个 —— 它们各自在某一个维度上走到了极端,而正因为极端,它们把权衡暴露得最清楚。JS 说:干脆别要多线程。Java 说:别改语言,改运行时。Erlang 说:什么都别共享。Rust 说:让编译器来管四个答案,四种代价。看完它们,你回头再看 Kotlin/Go/Python 的选择,会看得更清楚。

JS 事件循环虚拟线程ActorSend / Sync四种极端

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;    // 编译器满意了
});
◆ 定论 · Rust 是唯一在编译期消灭了数据竞争的主流语言

「Fearless Concurrency」不是一句口号,它是一个可证明的性质:

在 safe Rust 里,数据竞争(data race)在编译期就是不可能的。

回到第 8 章那个区分:

  • Data race(数据竞争)Rust 编译期消灭。
  • Race condition(竞态条件,逻辑上的)Rust 也救不了你。那个「先检查余额再扣款」的 bug,在 Rust 里照样能写出来。
  • 死锁Rust 也救不了你。两把 Mutex 按不同顺序拿,照样死锁。

但即便如此,「消灭 data race」也是巨大的胜利 —— 因为 data race 是最难查、最不可复现、后果最不可预测的那一类。

代价:学习曲线。你要和借用检查器搏斗几个月。Rust 把「运行时的痛苦」提前成了「编译期的痛苦」 —— 这是一笔好交易,但账单是预付的。

⚠ 陷阱 · Rust 的 async 是这份名单里最痛的

Rust 的 async 有一堆额外的复杂度,而且它们不会消失:

  • 没有内建的 runtime。你必须选一个(tokio / async-std / smol),而且它们互不兼容(一个库依赖 tokio,另一个依赖 async-std → 你要跑两个运行时)。
  • 函数染色,而且比 Python 更严重。因为 Rust 还有 trait 里的 async 的问题(async fn in 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 章压成一个你能在设计评审上当场用出来的决策流程。