Java 转 Rust 异步篇:3 个任务从 3.015s 到 1.006s,CompletableFuture/虚拟线程 vs tokio 一次讲透(附真实报错)
一句话结论:Java 处理异步有三套方案(线程池、CompletableFuture、虚拟线程),各有各的心智负担;Rust 只有一套:
async/await语法 +tokio运行时。核心差异在「谁负责切换」——Java 的 CompletableFuture 靠回调链、虚拟线程靠JVM 在阻塞点偷偷切,而 Rust 靠编译器把async fn编成状态机,.await处显式挂起、不阻塞线程。本文用 3 段真实报错(E0728 / unused Future / E0277)+ 5 组真实耗时数据(含1000 个任务 1.007s)一次讲透。
目录
- 一、Java 的异步为什么这么「分裂」?
- 二、Rust 的答案:一套 async/await + 一个 tokio
- 三、三个必踩的坑(真实报错)
- 四、阻塞 vs 异步:真实耗时 3.015s → 1.006s
- 五、async 的真正威力:1000 个任务 1 秒
- 六、select! 竞速 + 异步 channel
- 七、三方案全景对照表
- 八、Java 老兵的 4 条心智迁移
- 九、互动区
- 十、总结:一张表带走
- 附录:复现命令
一、Java 的异步为什么这么「分裂」?
Java 老兵都懂,Java 的异步是一条不断打补丁的路。你手头至少有三套方案,各有各的痛:
方案 1:线程池(传统)
ExecutorServicepool=Executors.newFixedThreadPool(100);pool.submit(()->{Thread.sleep(1000);// 1 个任务占 1 个线程,阻塞等待return"done";});痛点:1 个任务 = 1 个线程。1000 个并发任务就是 1000 个线程,每个默认 1MB 栈,光内存就约 1GB,还有昂贵的上下文切换。
方案 2:CompletableFuture(显式异步)
CompletableFuture.supplyAsync(()->fetchUser()).thenApply(user->fetchOrders(user)).thenApply(orders->fetchTotal(orders)).exceptionally(ex->{log.error("出错",ex);return0;});痛点:不阻塞线程了,但写法变成了「回调链」,多个异步步骤嵌套就是著名的「回调地狱(callback hell)」,异常处理还要单独写exceptionally,心智负担重。
方案 3:虚拟线程 Virtual Threads(Java 21)
try(varexecutor=Executors.newVirtualThreadPerTaskExecutor()){executor.submit(()->{Thread.sleep(1000);// 阻塞点,JVM 自动切换到别的虚拟线程return"done";});}痛点:解决了「回调地狱」——你还是写同步代码,JVM 在阻塞点(sleep/IO)偷偷帮你切换。但它是隐式的魔法,你看不到切换点在哪,调试、追踪、理解底层行为反而变难。
总结 Java 的困境:三套方案,心智模型完全不同,你得根据场景「选边站」。
二、Rust 的答案:一套 async/await + 一个 tokio
Rust只有一套:async/await语法 +tokio运行时。它把「异步」这件事,用显式的方式统一了。
核心就三件事:
async fn返回一个Future:这个 Future 是「一个未来会算出的值」。.await是挂起点:执行到.await时,如果还没就绪,当前任务挂起、让出线程,线程可以去干别的任务。不阻塞 OS 线程。- 编译器把
async fn编译成「状态机」:每个.await就是一个状态,挂起时记录「我停在第几个 await」,恢复时从那里接着跑。
对比三者的「谁负责切换」:
| 方案 | 谁负责切换 | 切换点可见吗 |
|---|---|---|
| CompletableFuture | 你(写回调链) | 显式,但很啰嗦 |
| Virtual Threads | JVM | 隐式(阻塞点自动切,你看不到) |
| Rust async/await | 编译器 + tokio 运行时 | 显式(.await就在你眼前) |
一个精妙的类比:虚拟线程是「写同步代码,偷偷异步」;Rust 的 async 是「写异步代码,但语法糖让它长得像同步」。两者殊途同归——都是「别让 OS 线程傻等 IO」,但 Rust 把切换点明明白白写在了代码里(那个
.await),而不是藏在 JVM 里。
三、三个必踩的坑(真实报错)
Rust 的 async 对新手有三个几乎人人必踩的坑。我们一个个用真实报错看清楚。
坑 1:.await只能在 async 里用(E0728)
你在普通main里想调用异步函数,直觉是直接.await:
asyncfnfoo()->i32{42}fnmain(){letx=foo().await;// ❌ await 只能在 async 函数或 async 块里println!("{}",x);}真实报错(cargo build --bin a1_await_outside):
error[E0728]: `await` is only allowed inside `async` functions and blocks --> src/bin/a1_await_outside.rs:5:19 | 4 | fn main() { | --------- this is not `async` 5 | let x = foo().await; | ^^^^^ only allowed inside `async` functions and blocks为什么:.await会「挂起」,而挂起只能发生在「异步上下文」里。普通函数一旦返回就没了,没有「挂起再恢复」的能力。解法是加#[tokio::main]把main变成 async:
#[tokio::main]// 把 main 包进 tokio 运行时,让它能 awaitasyncfnmain(){letx=foo().await;// ✅ 现在可以了println!("{}",x);}坑 2:Future 没 await 就什么都不做(编译警告)
这是最隐蔽、坑人最多的一个。你以为调用了async fn就是「执行了」:
asyncfnfoo()->i32{println!("这条不会打印,因为 Future 没被 await");42}#[tokio::main]asyncfnmain(){foo();// ⚠️ 只是创建了 Future,根本没有执行它println!("main 结束");}真实警告(cargo build --bin a2_future_not_awaited):
warning: unused implementer of `Future` that must be used --> src/bin/a2_future_not_awaited.rs:9:5 | 9 | foo(); | ^^^^^ | = note: futures do nothing unless you `.await` or poll them = note: `#[warn(unused_must_use)]` on by default关键:foo()不执行函数体!它只是构造了一个 Future 对象,等你.await它才真正开跑。运行结果就是:"这条不会打印"永远不会出现,只打印main 结束。
这和 Java 完全不同。Java 里
CompletableFuture.supplyAsync(() -> ...)一调用就开始执行了;Rust 里foo()只是「造了个计划」,不.await就永远不执行。Future 是惰性的(lazy)。Rust 特意给Future加了#[must_use],就是为了让你注意到「这个值你必须用」。
坑 3:tokio::spawn要求 Future 是Send(E0277)
想把任务扔到另一个线程跑,用tokio::spawn。但如果任务捕获了Rc(非线程安全),编译器会拦:
usestd::rc::Rc;#[tokio::main]asyncfnmain(){letrc=Rc::new(5);tokio::spawn(asyncmove{println!("{}",rc);// ❌ Rc<i32> 不是 Send,不能跨线程}).await.unwrap();}真实报错(cargo build --bin a3_rc_not_send):
error: future cannot be sent between threads safely --> src/bin/a3_rc_not_send.rs:7:18 | 7 | tokio::spawn(async move { | __________________^ 8 | | println!("{}", rc); 9 | | }).await.unwrap(); | |_____^ future created by async block is not `Send` | = help: within `{async block@...}`, the trait `Send` is not implemented for `Rc<i32>` note: required by a bound in `tokio::spawn` | | pub fn spawn<F>(future: F) -> JoinHandle<F::Output> | where | F: Future + Send + 'static, | ^^^^ required by this bound in `spawn`这正是第6篇Send/Sync在异步世界的延续:tokio::spawn的签名要求F: Future + Send + 'static,而Rc<i32>不是Send,于是整个 Future 就不是Send,编译失败。修复:跨线程用Arc(原子计数):
letrc=Arc::new(5);// 换成 Arc 即可tokio::spawn(asyncmove{println!("{}",rc);}).await.unwrap();四、阻塞 vs 异步:真实耗时 3.015s → 1.006s
先看一个 Java 老兵最容易犯的错:在 async 里用了阻塞的std::thread::sleep。
反例(a4_blocking)——3 个「睡 1 秒」的任务,用join!一起跑:
#[tokio::main]asyncfnmain(){letstart=Instant::now();tokio::join!(async{std::thread::sleep(Duration::from_secs(1));println!("任务1 完成");},async{std::thread::sleep(Duration::from_secs(1));println!("任务2 完成");},async{std::thread::sleep(Duration::from_secs(1));println!("任务3 完成");},);println!("阻塞版耗时: {:?}",start.elapsed());}真实运行输出:
任务1 完成 任务2 完成 任务3 完成 阻塞版耗时: 3.015182875s为什么是 3 秒:std::thread::sleep是同步阻塞,它把整个 OS 线程卡住了。即使join!想并发,任务也一个接一个串行跑完,3 个 × 1s = 3s。
正例(a5_concurrent)——换成异步的tokio::time::sleep:
#[tokio::main]asyncfnmain(){letstart=Instant::now();tokio::join!(async{tokio::time::sleep(Duration::from_secs(1)).await;println!("任务1 完成");},async{tokio::time::sleep(Duration::from_secs(1)).await;println!("任务2 完成");},async{tokio::time::sleep(Duration::from_secs(1)).await;println!("任务3 完成");},);println!("并发版耗时: {:?}",start.elapsed());}真实运行输出:
任务2 完成 任务3 完成 任务1 完成 并发版耗时: 1.006284084s两个关键证据:
- 耗时 3.015s → 1.006s:3 个 1 秒任务并发跑,总耗时约 1 秒,加速 3 倍。
- 完成顺序乱序(任务2 → 任务3 → 任务1):这证明了它们真的在并发,而不是串行。
核心差异一句话:std::thread::sleep阻塞 OS 线程,tokio::time::sleep挂起任务、让出线程。在 async 里,一切阻塞操作都要换成 tokio 的异步版本(tokio::time::sleep、tokio::fs::read、tokio::net……),否则一个阻塞调用就会拖垮整个运行时。
五、async 的真正威力:1000 个任务 1 秒
前面的 3 倍加速只是开胃菜。async 真正的威力是——1000 个并发任务,一个(或几个)线程就够。
看a8_many_tasks:
#[tokio::main]asyncfnmain(){letstart=Instant::now();letmuttasks=vec![];foriin0..1000{tasks.push(tokio::spawn(asyncmove{tokio::time::sleep(Duration::from_secs(1)).await;i}));}letmutsum=0;fortintasks{sum+=t.await.unwrap();}println!("1000 个任务总耗时: {:?}",start.elapsed());println!("求和校验(0..1000): {}",sum);// 应为 499500}真实运行输出:
1000 个任务总耗时: 1.007533042s 求和校验(0..1000): 499500这组数据的震撼点:
- 1000 个「睡 1 秒」的任务,总耗时仍是约 1 秒(不是 1000 秒);
求和校验 499500= 0+1+…+999,说明1000 个任务全部正确完成、一个不漏;- 而这 1000 个任务,只跑在几个 OS 线程上(tokio 多线程运行时默认 = CPU 核心数个线程),每个任务挂起时几乎不占栈内存。
对照 Java 的传统线程池:1000 个线程 × 1MB 栈 ≈ 1GB 内存。而 Rust async 里 1000 个挂起任务,内存占用是几 MB 级别。
配图直观感受一下:
诚实说明:Java 21 的虚拟线程也能高效跑 1000 个任务(这正是它存在的意义),所以「1000 线程」只针对传统线程池。但虚拟线程的切换是 JVM 在阻塞点隐式做的,你看不到;Rust 的
.await是显式写在代码里的。这是写法哲学上的差异,不是「谁更快」的简单高下。
六、select! 竞速 + 异步 channel
再补两个 tokio 的实用工具,直接对标 Java 的CompletableFuture.anyOf和BlockingQueue。
select!:多个 Future 谁先完成就用谁
对标 Java 的CompletableFuture.anyOf(...):
#[tokio::main]asyncfnmain(){tokio::select!{_=tokio::time::sleep(Duration::from_secs(2))=>{println!("慢任务(2s)完成");}_=tokio::time::sleep(Duration::from_secs(1))=>{println!("快任务(1s)完成,select 提前返回");}}}真实运行输出:
快任务(1s)完成,select 提前返回select!同时「监听」多个 Future,谁先完成就执行谁的分支,其余 Future 自动被取消。这是实现超时、竞速、优雅关闭的利器。
异步 channel:生产者和消费者都是.await
对标 Java 的BlockingQueue,但send/recv都是异步的:
#[tokio::main]asyncfnmain(){let(tx,mutrx)=tokio::sync::mpsc::channel(10);tokio::spawn(asyncmove{foriin1..=3{tx.send(i).await.unwrap();// 异步发送}});whileletSome(v)=rx.recv().await{// 异步接收println!("收到: {}",v);}}真实运行输出:
收到: 1 收到: 2 收到: 3和第6篇的std::sync::mpsc是同一个「多生产者单消费者」思想,但异步版:send满时会.await挂起而不是阻塞,recv空时也挂起。在 async 世界,通信也要用异步原语。
七、三方案全景对照表
把 Java 的三套方案和 Rust 的 async 放在一张表里:
| 维度 | CompletableFuture | Virtual Threads | Rust async/await + tokio |
|---|---|---|---|
| 写法 | 显式回调链 | 同步代码 | 显式async/.await |
| 谁来切换 | 你(thenApply 链) | JVM(阻塞点隐式切) | 编译器状态机 + tokio |
| 切换点可见性 | 可见但啰嗦 | 不可见(黑魔法) | 可见(.await就在眼前) |
| 不阻塞线程? | 是 | 是 | 是 |
| 1000 并发任务 | 能(线程池) | 能 | 能(1 秒实测) |
| 学习曲线 | 陡(组合子多) | 缓(写同步) | 中(语法糖) |
| 类型安全 | 弱(泛型 Future) | — | 强(Send/'static编译期查) |
| 生态 | 碎片化 | 新兴 | tokio 一家独大 |
| 一句话 | 回调地狱 | 隐式魔法 | 显式状态机 |
八、Java 老兵的 4 条心智迁移
1. 「调用异步函数」≠「执行异步函数」。Java 里supplyAsync(...)一调就开跑;Rust 里foo()只是造了个惰性 Future,不.await就是白写。这是坑 2 的核心。
2. 阻塞和挂起是两个世界。std::thread::sleep阻塞线程(3.015s 的教训),tokio::time::sleep挂起任务(1.006s 的结果)。进了 async,所有阻塞调用都要换成 tokio 的异步版。
3..await是「协作式」切换点。Java 虚拟线程是「抢占式隐式切换」,你看不到;Rust 的.await是「协作式显式让出」——只有写.await的地方才会让出线程。好处是切换点可控、可读,坏处是你得记得写.await。
4.Send/Sync依然在守门。异步只是换了种「并发」形式,线程安全的规则没变:跨线程spawn的任务必须Send,Rc得换Arc。第6篇的知识在 async 里原封不动地复用。
九、互动区
投票:你最常用(或最想用)哪种异步方案?
- 传统线程池 + Thread(简单直接,就是费资源)🧵
- CompletableFuture(显式异步,就是回调链难受)⛓️
- Virtual Threads(Java 21 新欢,写同步代码)🪄
- Rust async/await + tokio(就是奔着它来的)🦀
评论区聊聊:
- 你在 Java 里被 CompletableFuture 的
thenApply链折磨过吗?最深嵌套了几层? - 对于「隐式切换(虚拟线程)vs 显式
.await(Rust)」,你更喜欢哪种?为什么?
如果这篇帮你把 async/await 和 tokio 的「为什么」想通了,点个赞 + 收藏,让更多 Java 老兵少在回调地狱里打转。
十、总结:一张表带走
| 维度 | Java | Rust |
|---|---|---|
| 异步方案 | 三套(线程池 / CompletableFuture / 虚拟线程) | 一套(async/await + tokio) |
| 核心机制 | 回调链 / JVM 隐式切换 | 编译器生成状态机 + 事件循环 |
| 切换点 | 隐式或啰嗦 | 显式.await |
| Future 语义 | 一调用就执行 | 惰性,不 await 不执行 |
| 1000 并发任务 | 传统线程池 ~1GB 内存 | 几 MB,1.007s 实测 |
| 线程安全 | 靠自觉 | Send/Sync编译期把关 |
| 一句话 | 三套方案三套心智 | 一套方案,.await讲透 |
Rust 的异步哲学:不给你三套方案让你纠结,而是一套async/await,用「编译器生成状态机」+「显式.await」+「Send类型检查」三件事,把异步变得可读、可预测、类型安全。1000 个任务 1 秒,就是这套设计的实力证明。
附录:复现命令
# 所有示例在 async-demo/ 目录(依赖 tokio 1.53.2)cdasync-demo# 三条真实报错/警告cargobuild--bina1_await_outside# E0728: await 只能在 async 里cargobuild--bina2_future_not_awaited# warning: Future 没 await 什么都不做cargobuild--bina3_rc_not_send# error: Rc 不是 Send# 五组真实运行输出cargorun--bina4_blocking# 阻塞版 3.015scargorun--bina5_concurrent# 并发版 1.006s(完成顺序乱序)cargorun--bina6_select# 快任务先完成cargorun--bina7_channel# 收到 1/2/3cargorun--bina8_many_tasks# 1000 任务 1.007s,求和 499500# 配图脚本(可选)python3../scripts/make_async_diagram.py