首页/文章/Rust
Rust

Rust 与 Java 并发模型比较

从线程、Future、所有权、共享状态和阻塞边界对比 Rust 与 Java 并发,理解 Tokio task 与 Java 虚拟线程各自的适用条件。

Rust 和 Java 都能编写高并发网络服务,但常用抽象的成本模型不同。Rust/Tokio 常用 async task 表达大量 I/O 并发;Java 21 虚拟线程让同步阻塞式调用栈也能承载大量 I/O 等待。两者都不能创造更多 CPU、数据库连接或下游容量,也都需要超时、限流、背压和可观测性。

两种运行路径

flowchart LR
  subgraph rust[Rust + Tokio]
    rf[async fn 编译为 Future 状态机]
    rt[Tokio 多线程 runtime worker]
    rf -->|await 时挂起并交还 worker| rt
  end
  subgraph java[Java 21]
    vt[每请求一个虚拟线程]
    sch[JVM 虚拟线程调度器]
    pt[Carrier 平台线程]
    vt -->|支持的阻塞 I/O 时挂起| sch --> pt
  end
  net[网络 / 数据库 / 外部服务]
  rt --> net
  pt --> net

Rust async 函数会生成状态机,await 点保存后续继续运行所需的状态;Tokio worker 在一个 task 等待时可以驱动其他 task。Java 虚拟线程则仍然是 Thread,阻塞式代码在运行时遇到可挂起的 I/O 时让出底层 carrier。一个是显式 async/await 的协作式任务调度,一个是线程 API 上的轻量线程调度。

Tokio task 与虚拟线程调用

Rust/Tokio 的任务通过 runtime 执行:

let customer = tokio::spawn(async move {
    customer_client.fetch(customer_id).await
});

let orders = tokio::spawn(async move {
    order_client.fetch_for(customer_id).await
});

let (customer, orders) = tokio::try_join!(customer, orders)?;
let dashboard = Dashboard::new(customer?, orders?);

上面是结构示意:JoinHandle 的错误、业务错误和取消策略在真实代码中要分别处理。async move 把 task 所需的值移动进 Future;Tokio 多线程 runtime 通常要求 task 满足 Send + 'static,所以把短生命周期引用借进一个可能跨 worker 移动的 task 往往不行。

Java 21 可以用线程式写法启动两个并发 I/O 任务:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    var customer = executor.submit(() -> customerClient.fetch(customerId));
    var orders = executor.submit(() -> orderClient.fetchFor(customerId));
    return new Dashboard(customer.get(), orders.get());
}

Java 代码顺序地写阻塞调用,虚拟线程让等待期间不必占用 carrier 平台线程。它不是任务执行速度加速器;CPU 密集代码仍争用 CPU,阻塞在不支持卸载的 native 调用也需要单独评估。

共享状态:所有权和同步方式

Rust 用类型系统显式表达跨线程安全:Send 表示所有权可转移到另一个线程,Sync 表示可通过共享引用在线程间访问。常见共享形式是 Arc<T>;需要可变共享时再加 Mutex<T>/RwLock<T>,或把状态交给单独 task 并用 channel 发命令。

let totals = Arc::new(std::sync::Mutex::new(0_u64));
let totals_for_task = Arc::clone(&totals);

tokio::spawn(async move {
    {
        let mut value = totals_for_task.lock().expect("mutex poisoned");
        *value += 1;
    } // guard 在 await 前释放
    refresh_remote_snapshot().await;
});

短临界区用同步 mutex 可以简单有效;关键是不要把 guard 跨 .await 持有。若共享状态必须在异步 I/O 中串行处理,消息传递/actor 模型通常更清晰。Java 则使用 synchronized、Lock、并发集合、原子类或消息队列表达共享与同步;虚拟线程并不会让共享可变状态自动安全。

背压与稀缺资源

两种语言都应该限制真正稀缺的资源:数据库连接、HTTP 连接池、每租户并发、队列长度和 CPU 工作队列。Rust 可以用 Semaphore、有限容量 mpsc;Java 可以用 Semaphore、有界队列和下游连接池。无界 task/线程只会把等待从线程数转移到堆、连接池或数据库。

维度Rust + TokioJava 21 虚拟线程
I/O 并发抽象Future/task,代码需标明 .awaitThread-per-task,通常写阻塞调用
调度等待await 挂起 Future,释放 runtime worker支持的阻塞点挂起虚拟线程,释放 carrier
共享安全Send/Sync、所有权和借用在编译期参与约束类型系统不普遍强制线程安全,依靠同步 API 和设计
阻塞陷阱在 worker 上做长时间同步阻塞会卡住 worker;用 spawn_blocking 或专用池CPU 密集任务仍要限制并发;Java 21 的 monitor/native pinning 需关注
生态迁移async API 链路要一致,库需有 async 版本同步 JDBC/HTTP 客户端可较直接保留线程式写法
常见优势精细控制异步边界、低内存 task、编译期共享安全迁移传统阻塞式服务简单,调试调用栈直观

这个对比不是语言性能排名。实际结果取决于 runtime、库、负载结构、连接池、对象分配、CPU 与 SLO。先选团队能正确维护的编程模型,再用同一工作负载压测。

怎么选与怎么迁移

  • 已有大量同步 Java 业务、主要瓶颈是 I/O 等待:先在单个服务启用虚拟线程,确认框架与连接池边界,再比较延迟与资源占用。
  • 新建 Rust 网络服务、依赖生态提供成熟 async API:Tokio task 可以直接覆盖高并发 I/O;把同步工作隔离到 spawn_blocking 或专用 executor。
  • 高 CPU、低延迟数值计算:两者都采用有界 CPU 并发,不依赖 task/虚拟线程数量控制核心使用率。
  • 需要跨任务共享状态:先考虑消息传递与不可变数据,再选择锁。记录锁等待与队列长度。

压测必须覆盖下游慢、连接池耗尽、取消、超时、服务关闭、峰值流量和错误重试。只看吞吐可能掩盖 P99、内存、排队与失败恢复恶化。

常见踩坑

  1. 把 Tokio task 等同于虚拟线程:Future 只在 await 让出执行权;一个阻塞系统调用可能卡住 runtime worker。
  2. 认为虚拟线程自动提升 CPU 性能:它优化的是大量等待的并发承载,不增加核心数。
  3. 无限制地创建 task 或线程:创建便宜不代表工作量和依赖有界;用 semaphore/有界队列实施背压。
  4. 在 Tokio task 里持同步锁跨 .await:容易造成 worker 阻塞和 Send 编译错误。缩短锁范围或改消息传递。
  5. Java 21 长时间在 synchronized 内做阻塞 I/O:可能 pin carrier。避免持 monitor 调用外部服务,结合 JFR 观察。
  6. 忽略取消和中断语义:Rust task abort、timeout 与取消安全,和 Java Future.cancel/线程中断不是一回事;分别设计幂等和清理逻辑。

参考资料

文章反馈

如果这篇文章对你有帮助,欢迎发邮件交流。

通过邮箱联系 ↗继续阅读 →