ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java 转 Rust 异步篇:3 个任务从 3.015s 到 1.006s,CompletableFuture/虚拟线程 vs tokio 一次讲透(附真实报错)

Java 转 Rust 异步篇:3 个任务从 3.015s 到 1.006s,CompletableFuture/虚拟线程 vs tokio 一次讲透(附真实报错)

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运行时。它把「异步」这件事,用显式的方式统一了。

核心就三件事:

  1. async fn返回一个Future:这个 Future 是「一个未来会算出的值」。
  2. .await是挂起点:执行到.await时,如果还没就绪,当前任务挂起、让出线程,线程可以去干别的任务。不阻塞 OS 线程。
  3. 编译器把async fn编译成「状态机」:每个.await就是一个状态,挂起时记录「我停在第几个 await」,恢复时从那里接着跑。

对比三者的「谁负责切换」:

方案谁负责切换切换点可见吗
CompletableFuture你(写回调链)显式,但很啰嗦
Virtual ThreadsJVM隐式(阻塞点自动切,你看不到)
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

两个关键证据:

  1. 耗时 3.015s → 1.006s:3 个 1 秒任务并发跑,总耗时约 1 秒,加速 3 倍。
  2. 完成顺序乱序(任务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 放在一张表里:

维度CompletableFutureVirtual ThreadsRust 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 里原封不动地复用。


九、互动区

投票:你最常用(或最想用)哪种异步方案?

  1. 传统线程池 + Thread(简单直接,就是费资源)🧵
  2. CompletableFuture(显式异步,就是回调链难受)⛓️
  3. Virtual Threads(Java 21 新欢,写同步代码)🪄
  4. Rust async/await + tokio(就是奔着它来的)🦀

评论区聊聊:

  • 你在 Java 里被 CompletableFuture 的thenApply链折磨过吗?最深嵌套了几层?
  • 对于「隐式切换(虚拟线程)vs 显式.await(Rust)」,你更喜欢哪种?为什么?

如果这篇帮你把 async/await 和 tokio 的「为什么」想通了,点个赞 + 收藏,让更多 Java 老兵少在回调地狱里打转。


十、总结:一张表带走

维度JavaRust
异步方案三套(线程池 / 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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进