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