Worker, Libuv, Rust, Go, Java Loom

8 阅读6分钟

这是一场精彩的并发模型大乱斗。提到的这几个技术(Worker, Libuv, Rust, Go, Java Loom)涵盖了计算机科学在过去30年里解决“如何同时做多件事”的所有主流方案。

为了讲透,我们需要把它们放在一个坐标系里:内核态(OS) vs 用户态(Runtime) ,以及 资源开销 vs 开发体验


1. Google Chrome / V8 Web Workers: “多进程”的幻象

很多人以为 Web Worker 是多线程,但从 V8 的视角看,它更像是一个 轻量级的“多进程”模型(虽然在 OS 层面是线程,但在 V8 内部是隔离的)。

  • 原理:  V8 的设计核心是 Isolate(隔离实例)。一个 Isolate 拥有自己独立的堆内存(Heap)、垃圾回收器(GC)和编译管线。

  • Worker 的本质:  当你 new Worker('script.js') 时,浏览器实际上启动了一个全新的 V8 Isolate

  • 通信代价:

    • 主线程和 Worker 不能共享内存(除了特殊的 SharedArrayBuffer)。
    • 它们必须通过 postMessage 通信。这意味着数据需要被 序列化(Copy)  -> 传输 -> 反序列化(Copy) 。传一个大对象是非常慢的。
  • 评价:  极其昂贵。它不是为了像 Rust 那样让你开 1000 个线程并行计算的,而是为了防止一个计算任务把 UI 线程卡死。


2. Node.js (Libuv):单线程的面具,多线程的心

Node.js 号称单线程,这其实是最大的“谎言”。

  • 主线程(JS):  确实是单线程。你的 if/else、业务逻辑都在这里跑。

  • Libuv(C++):  这是 Node 的幕后黑手,它维护了一个 Thread Pool(线程池) (默认 4 个线程,可通过 UV_THREADPOOL_SIZE 调整)。

  • 分工:

    • 真·非阻塞 I/O(网络):  像 TCP/UDP 请求,Libuv 会调用操作系统的 epoll (Linux), kqueue(macOS), IOCP (Windows)。这完全不需要线程池,是 OS 内核直接通知的。
    • 假·非阻塞 I/O(文件/DNS):  很多文件系统操作和 DNS 查询在 OS 层面没有完美的非阻塞接口。Libuv 怎么做?它把这些阻塞的任务扔进 C++ 线程池里去跑。
  • 流程:  JS 调用 fs.readFile -> Libuv 把任务扔给线程池 -> 线程池阻塞读取 -> 读完通知 Event Loop -> JS 执行回调。

  • 评价:  事件驱动模型。适合 I/O 密集型,不适合 CPU 密集型(因为主线程只有一个,CPU 算满了就卡了)。


3. Go (Goroutines):M:N 调度的巅峰

Go 语言之所以火,就是因为它彻底解决了“高并发很难写”的问题。它使用的是 M:N 模型

  • M (Machine):  操作系统内核线程(很贵,几 MB 栈)。

  • G (Goroutine):  用户态协程(极轻,几 KB 栈,可动态伸缩)。

  • P (Processor):  Go 运行时的逻辑处理器(通常等于 CPU 核数)。

  • GMP 调度器:

    • Go 程序启动时,会开启 M 个内核线程。
    • 你创建了 100,000 个 go func() (G)。
    • Go 的运行时(Runtime)负责把这 10万个 G,轮流放在那几个 M 上执行。
  • 核心魔法(Work Stealing):  如果某个 M 上的任务做完了,它会去偷别的 M 队列里的任务来做。如果某个 G 阻塞了(比如系统调用),运行时会把那个 M 也就是线程让出来,或者新建一个 M 来顶替,保证 CPU 不闲着。

  • 评价:  开发体验极佳。你写的是同步阻塞的代码(逻辑清晰),底层跑的是异步非阻塞的逻辑。


4. Java (Virtual Threads / Project Loom):迟来的救赎

Java 在 JDK 21 之前,Thread 是 1:1 模型。也就是 new Thread() 直接对应一个 OS 内核线程。这导致 Java 处理高并发(C10K 问题)时内存爆炸。

Project Loom 引入了 虚拟线程(Virtual Threads) ,直接对标 Go。

  • 原理:

    • Carrier Thread (载体线程):  也就是原本的 OS 线程(比如只有 10 个)。
    • Virtual Thread (虚拟线程):  存在于 Java 堆内存里的对象。
  • Mount/Unmount (挂载/卸载):

    • 当你的虚拟线程执行代码时,它被“挂载”到一个 Carrier Thread 上运行。
    • 一旦你调用了阻塞操作(比如 socket.read()),JVM 魔改了底层的 IO 实现。它不会真的让 OS 线程阻塞,而是把这个虚拟线程“卸载”(把它的栈帧数据拷贝回堆内存),让 Carrier Thread 去执行别的虚拟线程。
    • 当 IO 完成,再把那个虚拟线程“挂载”回来。
  • 评价:  为了拯救旧代码。Go 是推翻重来,Java 是在不改变旧生态(Tomcat, Spring 里的阻塞代码)的前提下,通过底层替换实现了协程。不需要 async/await 这种“颜色污染”。


5. Rust:把选择权交给你

Rust 没有内置像 Go 那样重的 Runtime,也没有 GC。它的哲学是 零成本抽象,所以它提供了所有选项:

A. 1:1 线程 (std::thread)

  • 原理:  直接调用 OS 的 pthread_create。一个 Rust 线程 = 一个 OS 线程。
  • 适用:  CPU 密集型任务(比如视频编码、挖矿),或者任务数量不多时。
  • 特点:  极其稳定,没有任何 Runtime 开销。

B. M:N 异步 (Tokio / async-std)

  • 原理:  Rust 语言只提供 Future 状态机,Tokio 提供了类似 Go/Node 的调度器。
  • 区别:  Go 强制你用 GMP,Rust 允许你为了极致性能手写 Executor。
  • Work Stealing:  Tokio 的调度器也是 M:N 的,也有 Work Stealing,性能足以匹敌 Go。

C. 多进程 (std::process)

  • 原理:  Fork 一个子进程。
  • 适用:  容错性要求极高的场景(崩了一个进程不影响主进程),或者需要彻底的内存隔离。

D. 独门绝技:所有权与线程安全

  • 这是 Rust 区别于 C++/Go/Java 的最大杀器。

  • C++/Java/Go:  多线程读写共享变量非常容易发生 Data Race(数据竞争) ,导致诡异的 Bug。

  • Rust:  编译器强制检查 Send 和 Sync trait。

    • 如果你试图把一个非线程安全的变量传给另一个线程,代码根本编译不过
    • 它在编译阶段就消灭了 99% 的并发 Bug。

终极对比表

特性Node.js (Libuv)Go (Goroutine)Java (Virtual Thread)Rust (Tokio)Rust (std::thread)
并发模型事件循环 + 回调M:N 协程 (Runtime调度)M:N 协程 (JVM调度)M:N 协作式 (库调度)1:1 抢占式 (OS调度)
代码风格async/await (有色)同步阻塞风格 (无色)同步阻塞风格 (无色)async/await (有色)同步阻塞
栈内存都在堆上 (闭包)2KB 起 (动态伸缩)动态 (挂载/卸载)编译成状态机 (极其紧凑)~2MB (OS决定)
调用开销小 (函数调用)极小 (用户态切换)极小 (用户态切换) (内联+状态推进)大 (系统调用+上下文切换)
适合场景高并发 I/O高并发网络服务升级旧 Java 服务极致性能 I/OCPU 密集计算
心智负担回调地狱/Promise低 (最舒服)低 (最舒服)高 (需懂 Pin/Send)中 (需懂锁)

总结建议

  • Node.js:  I/O 很快,但不要用它算斐波那契数列。
  • Go:  开发网络微服务的最优解,在性能和开发效率之间取得了完美的平衡。
  • Java Loom:  让 Java 重获新生,老旧的阻塞代码瞬间获得高并发能力。
  • Rust:  上限最高。既能做 Go 能做的高并发 I/O (Tokio),也能做 C++ 能做的底层硬核计算。它唯一的缺点就是——难学