异步:Rust、Go 与 Java 如何处理等待
Futures, Goroutines, and Virtual Threads
目录
- 等待和计算不是一回事
- 从一个线程一个请求开始
- Rust:异步函数先变成一个值
- .await 时发生了什么
- 两个请求怎样并发等待
- 阻塞代码为什么危险
- Go:普通函数运行在 goroutine 上
- goroutine 保存了一条栈
- 并发请求同样的两个服务
- goroutine 便宜,但不会自动结束
- Java:Thread 还在,只是变轻了
- Virtual Thread 怎样使用 OS 线程
- 不要建立 virtual thread 池
- Pinning 需要按 JDK 版本理解
- 三种任务分别保存了什么
- 任务什么时候开始
- 阻塞意味着什么
- 取消不是把任务从机器上抹掉
- 任务很轻,不代表并发可以无限增加
- 回到服务代码
一个 Web 服务收到请求后,需要查询用户资料和最近订单:
GET /users/42/home
│
├── Profile Service 80 ms
└── Order Service 120 ms
真正执行 JSON 编解码、参数检查和结果组装的时间可能只有几毫秒。大部分时间里,程序只是在等网络返回。
如果请求不多,最普通的写法就很好:
读取用户资料
↓
等待
↓
读取最近订单
↓
等待
↓
组装结果
问题出现在并发量增加以后。
假设同时有一万个请求,每个请求都需要等待下游服务。如果它们各自占住一个操作系统线程,那么机器上会有大量线程并没有执行计算,只是保留着调用栈,等 I/O 完成。
于是便有了一个很实际的问题:
一段代码暂时无法继续时,能不能先让出线程,等条件满足以后再回来?
Rust 的 async、Go 的 goroutine 和 Java 的 virtual thread 都能让大量等待任务共享较少的操作系统线程,但它们不是三个版本的同一种 API。
Rust 把异步函数编译成 Future 状态机;Go 让运行时调度拥有独立栈的 goroutine;Java 仍然保留 Thread,只是让 Thread 不再与操作系统线程一一绑定。
这篇文章不比较某个简单基准测试里谁更快。我们沿着一次请求的执行过程,看看代码停下来以后,三种模型分别保存了什么,又由谁负责让它继续运行。
等待和计算不是一回事
先把几个容易混在一起的概念分开。
假设程序要计算一千万个数字的哈希。工作一直在使用 CPU:
CPU
████████████████████████
这类任务想要更快,通常要利用更多核心并行计算。
远程调用则不同:
发送请求 等待网络和对方处理 解析响应
████ .................. ███
等待期间,当前任务没有可执行的代码。异步模型的价值在于,这段时间可以让线程去运行别的任务。
因此:
并发
让多个任务在一段时间内共同推进
并行
让多个任务在同一时刻由不同核心执行
异步
让暂时无法继续的任务暂停,并在稍后恢复
异步可以提高 I/O 密集服务的并发能力,但不会凭空增加 CPU。把一段耗时计算放进 async fn、goroutine 或 virtual thread,它仍然需要真实的处理器时间。
从一个线程一个请求开始
传统的 Thread-per-request 模型很直观:
Request 1 ── Thread 1 ── query ── wait ── response
Request 2 ── Thread 2 ── query ── wait ── response
Request 3 ── Thread 3 ── query ── wait ── response
线程保留了完整调用栈,所以代码可以按照同步顺序书写:
Profile profile = profileClient.load(userId);
List<Order> orders = orderClient.recent(userId);
return new Home(profile, orders);
发生异常时,异常沿着调用栈返回;调试器也能看到请求经过了哪些方法。
代价是,平台线程通常对应一个 OS 线程。创建和切换大量 OS 线程并不便宜,每个线程的栈也要占用内存。线程数量越来越多以后,调度、内存和上下文切换都会形成压力。
一种解决办法是使用少量线程运行事件循环。I/O 没有完成时,任务登记回调并返回;就绪以后,再由事件循环执行后续逻辑。
早期 Java 异步代码常常会写成:
profileClient.loadAsync(userId)
.thenCombine(
orderClient.recentAsync(userId),
Home::new
)
.exceptionally(this::fallback);
这种模型可以支撑很高的并发,但调用链、异常和取消也换了一种写法。
Rust、Go 和后来的 Java virtual thread,分别在“代码应该怎样暂停”这件事上做出了不同选择。
Rust:异步函数先变成一个值
Rust 里可以这样定义异步函数:
async fn fetch_profile(user_id: u64) -> Result<Profile> {
let response = client()
.get(format!("/profiles/{user_id}"))
.send()
.await?;
response.json().await
}
它的返回类型表面上写的是 Result<Profile>,但调用 fetch_profile(42) 时,并不会立刻得到结果,也不会马上发送网络请求。
调用返回的是一个实现了 Future 的值:
let future = fetch_profile(42);
只创建 future 而不 .await 或提交给 Runtime,这段异步计算通常不会自行向前执行。
这一点和普通函数很不一样:
普通函数
调用以后立即进入函数体
async fn
调用以后先构造 Future
.await 时发生了什么
Rust 的 Future 核心接口可以简化为:
trait Future {
type Output;
fn poll(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Self::Output>;
}
一次 poll 有两种结果:
Poll::Ready(value)
Poll::Pending
Ready 表示计算完成。Pending 表示当前还不能继续,比如 socket 里还没有数据。
Future 返回 Pending 前,会登记一个 Waker。I/O 就绪后,驱动唤醒任务,Executor 才会再次调用 poll。
Executor poll
│
├── Ready → 得到结果
│
└── Pending → 暂停任务
│
I/O ready
│
Waker
│
再次进入 poll
这里没有为每个 Future 分配一条传统调用栈。编译器会检查哪些局部变量需要跨越 .await 保存,并把异步函数转换成状态机。
下面这段代码:
async fn load_user(user_id: u64) -> Result<User> {
let profile = fetch_profile(user_id).await?;
let orders = fetch_orders(user_id).await?;
Ok(User { profile, orders })
}
可以粗略理解为:
Start
│
▼
WaitingProfile {
user_id,
profile_future
}
│
▼
WaitingOrders {
profile,
orders_future
}
│
▼
Ready(User)
实际生成的状态机由编译器完成,并不会长成这段手写代码的样子。这个模型只是在说明:跨越 .await 还要继续使用的数据,必须保存在 Future 自身。
Rust async 中经常遇到的生命周期、Send、Pin 问题,也与此有关。一个 Future 可能暂停后被移动到另一条工作线程上,那么它内部保存的数据就必须满足相应条件。
两个请求怎样并发等待
如果依次 .await:
let profile = fetch_profile(user_id).await?;
let orders = fetch_orders(user_id).await?;
总耗时接近:
80 ms + 120 ms = 200 ms
两个请求互不依赖时,可以一起推进:
async fn load_home(user_id: u64) -> Result<Home> {
let (profile, orders) = tokio::try_join!(
fetch_profile(user_id),
fetch_orders(user_id),
)?;
Ok(Home { profile, orders })
}
try_join! 会交替轮询两个 Future。它们都在等待 I/O 时,当前任务返回 Pending,Executor 可以去运行其他任务。
这不意味着两个 Future 各自占用一条线程:
一个 async task
├── profile future
└── orders future
由 Executor 在可推进时 poll
如果要让任务拥有独立的调度和生命周期,可以使用 tokio::spawn。join! 和 spawn 都能形成并发,但任务边界并不相同。
阻塞代码为什么危险
Executor 的工作线程数量通常远小于异步任务数量。
如果在 async 任务中调用普通阻塞函数:
async fn handle() {
std::thread::sleep(Duration::from_secs(10));
}
当前工作线程会真的睡眠十秒。原本应该由它轮询的其他 Future 也无法继续。
异步环境中应该使用运行时提供的异步操作:
tokio::time::sleep(Duration::from_secs(10)).await;
不可避免的阻塞调用或 CPU 密集任务,可以交给专门的阻塞线程池:
let result = tokio::task::spawn_blocking(|| {
run_blocking_library()
})
.await?;
Rust 这条路线很节省任务本身的运行时成本,也给程序较细的控制,但代价同样明显:异步会出现在函数签名、库接口和生命周期里。调用链上某一层需要 .await,上层通常也要进入异步上下文。
Go:普通函数运行在 goroutine 上
Go 没有要求函数声明为 async:
func fetchProfile(
ctx context.Context,
userID int64,
) (Profile, error) {
// 普通函数
}
在调用前加上 go,函数就会在新的 goroutine 中并发执行:
go fetchProfile(ctx, userID)
goroutine 启动以后可以由运行时调度,不像一个尚未被轮询的 Rust Future 那样保持惰性。
它也不要求调用链上的每个函数改成另一种类型。函数可以使用同步形式读取网络、等待锁或者接收 Channel:
response, err := client.Do(request)
if err != nil {
return Profile{}, err
}
代码看起来在阻塞当前执行流程。被阻塞的是这个 goroutine,并不一定是承载它的 OS 线程。
goroutine 保存了一条栈
每个 goroutine 都有自己的调用栈。栈从较小的空间开始,并能根据需要增长。
Go Runtime 再把大量 goroutine 调度到较少的 OS 线程上:
Goroutine 1 ─┐
Goroutine 2 ─┼── Go Scheduler ── OS Thread 1
Goroutine 3 ─┤ └─ OS Thread 2
Goroutine 4 ─┘
运行时内部常用 G、M、P 描述调度:
G Goroutine,待执行的工作
M Machine,操作系统线程
P Processor,运行 Go 代码所需的调度资源
理解异步 I/O 并不要求先掌握调度器全部实现。这里最重要的是,goroutine 和 OS 线程不是一一对应的。
当 goroutine 等待 Channel、定时器或运行时管理的网络 I/O 时,调度器可以暂停它,让其他 goroutine 使用执行资源。阻塞系统调用的处理更复杂,Runtime 会尽量避免一个被阻塞的线程拖住其他可运行的 goroutine。
从使用者的角度,调用栈仍然在那里:
handleRequest
└── loadHome
└── fetchProfile
└── read socket
这也是 Go 代码可以保持同步外观的原因。
并发请求同样的两个服务
使用 errgroup 可以并发执行两个调用,并把错误和取消放在同一个作用域里:
func loadHome(
ctx context.Context,
userID int64,
) (Home, error) {
group, ctx := errgroup.WithContext(ctx)
var profile Profile
var orders []Order
group.Go(func() error {
result, err := fetchProfile(ctx, userID)
if err != nil {
return err
}
profile = result
return nil
})
group.Go(func() error {
result, err := fetchOrders(ctx, userID)
if err != nil {
return err
}
orders = result
return nil
})
if err := group.Wait(); err != nil {
return Home{}, err
}
return Home{
Profile: profile,
Orders: orders,
}, nil
}
两个 goroutine 各自执行普通函数,group.Wait() 等待它们结束。只要底层调用接受 ctx,其中一个失败时,另一个也能收到取消信号。
Go 也可以直接使用 Channel 传递结果。Channel 很适合表达任务之间的数据流和同步关系,但不是说 goroutine 之间绝对不能共享内存。计数器或简单状态用 Mutex 保护,往往比为了使用 Channel 绕一圈更直接。
goroutine 便宜,但不会自动结束
下面的代码很容易留下一个 goroutine:
func load() Result {
ch := make(chan Result)
go func() {
ch <- slowRequest()
}()
select {
case result := <-ch:
return result
case <-time.After(time.Second):
return Result{}
}
}
外层超时返回以后,如果 slowRequest 无法取消,内部 goroutine 仍然会继续运行。它最后向无人接收的无缓冲 Channel 发送结果,于是永远阻塞。
这就是 goroutine leak。
生产代码通常需要让取消沿着 context.Context 传下去,并确保发送结果的一方在接收者离开后也有退出路径。
go f() 写起来很轻松,因此 Go 中更需要主动说明:
这个 goroutine 什么时候结束?
谁负责取消它?
结果由谁接收?
并发数量有没有上限?
运行时解决了调度,不会替业务解决生命周期。
Java:Thread 还在,只是变轻了
Java 很早就有线程,也有 Future、CompletableFuture 和各种响应式框架。
Virtual Thread 走了另一条路:与其要求大量业务代码改成回调或异步链,不如继续让开发者写阻塞式代码,再降低 Thread 本身的成本。
Virtual Thread 在 JDK 21 成为正式特性:
Thread.startVirtualThread(() -> {
handleRequest();
});
也可以使用每个任务创建一个 virtual thread 的 Executor:
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
Future<Profile> profile =
executor.submit(() -> fetchProfile(userId));
Future<List<Order>> orders =
executor.submit(() -> fetchOrders(userId));
return new Home(
profile.get(),
orders.get()
);
}
profile.get() 看起来会阻塞线程,这正是设计目标。业务代码不必变成一串 Completion Stage。
但这里阻塞的是 virtual thread。
Virtual Thread 怎样使用 OS 线程
JVM 把 virtual thread 调度到 platform thread 上执行。这个 platform thread 通常称为 Carrier:
Virtual Thread
│ mount
▼
Carrier Thread
│
▼
OS Thread
运行到 JDK 能够识别的阻塞操作时,virtual thread 可以从 Carrier 上卸载:
Virtual Thread A
│ blocking I/O
▼
unmount
Carrier Thread
│
└── mount Virtual Thread B
I/O 就绪后,Virtual Thread A 再次进入可运行状态。它以后可能被挂载到另一条 Carrier 上继续执行。
开发者仍然看到一条 Thread、一个调用栈和普通的异常传播;JVM 负责在背后移动它。
这与 Go 的使用感受有些接近:都能用同步形式写等待中的任务。但 goroutine 是 Go 从语言、标准库到运行时共同设计的并发单元;virtual thread 则要兼容已经存在多年的 java.lang.Thread、阻塞 API 和 Java 生态。
不要建立 virtual thread 池
平台线程池的一个目的,是限制昂贵线程的数量并复用它们。
Virtual Thread 本来就应该按任务创建,任务结束后随之结束。把它们放进固定大小的池:
Executors.newFixedThreadPool(100)
会重新引入任务排队,抵消一部分使用 virtual thread 的意义。
如果真正需要限制的是数据库连接数,就限制数据库连接:
var permits = new Semaphore(50);
permits.acquire();
try {
return queryDatabase();
} finally {
permits.release();
}
连接池本身也已经在做类似的事情。
轻量线程可以很多,数据库连接、下游并发额度和内存并不会跟着变多。
Pinning 需要按 JDK 版本理解
Virtual Thread 只有在能够卸载时,才能释放 Carrier。
JDK 21 中,一个 virtual thread 在 synchronized 区域内阻塞时可能被固定在 Carrier 上。大量长时间 pinning 会降低扩展能力。
JDK 24 的 JEP 491 改进了 monitor 的实现,synchronized 引起的绝大多数 pinning 已经被消除。因此看到早期文章一概要求把 synchronized 替换成 ReentrantLock 时,要先确认它讨论的是哪个 JDK。
当前仍有少量与 native method、foreign function 或 JVM 内部过程有关的 pinning 场景。它们通常不是写业务代码时遇到的第一问题,但使用包含本地调用的驱动和库时,仍然应该通过 JFR 和压测确认真实行为。
三种任务分别保存了什么
现在把同一个等待动作放到一起:
Rust
Future 返回 Pending
状态保存在编译器生成的状态机里
Go
goroutine 阻塞
调用过程保存在可增长的 goroutine 栈里
Java
virtual thread 阻塞并尝试卸载
调用过程由 virtual thread 保留
Rust 通常被称为 stackless async。一个 Future 只保存恢复执行需要的字段,不需要为每个任务维护传统线程栈。
Go 和 Java 的模型更接近 stackful concurrency。调用栈仍然是程序模型的一部分,只是运行时用更轻量的方式保存和调度它。
这会直接影响代码的样子。
Rust 的暂停点写在 .await 上,异步边界在类型中可见:
async fn load() -> Result<Data>
Go 的函数本身不区分同步和异步,调用者决定是否创建 goroutine:
go load()
Java 的方法也不需要声明自己运行在 virtual thread 上。调用它的 Thread 是 platform thread 还是 virtual thread,由外层决定:
executor.submit(this::load);
任务什么时候开始
这个细节很容易在跨语言阅读代码时造成误解。
Rust Future 默认是惰性的:
let future = fetch_profile(42);
这里只构造了 Future。它需要被 .await、spawn,或者由其他组合器继续轮询。
Go 的 go statement 会创建 goroutine,并让它进入可运行状态:
go fetchProfile(ctx, 42)
调用方继续向下执行,新 goroutine 由 Runtime 决定何时真正获得 CPU。
Java 需要显式启动 virtual thread 或提交任务:
Thread.startVirtualThread(task);
executor.submit(task);
构造 Thread.Builder 或普通 Runnable 本身不会让任务运行。
这个差别会影响副作用发生的时机。把一个 Rust async 函数调用保存到变量里,和把一个 Go 函数放进 go statement,并不是对应操作。
阻塞意味着什么
“这三种模型都能暂停任务”不代表任何阻塞调用都可以随便使用。
| 模型 | 遇到等待时的典型行为 | 需要留意 |
|---|---|---|
| Rust async | Future 返回 Pending,工作线程执行其他任务 | 普通阻塞函数会堵住 Executor Worker |
| Go goroutine | Runtime 暂停 goroutine,调度其他 G | 无法取消的调用会留下 goroutine,cgo 等路径成本不同 |
| Java virtual thread | Virtual Thread 从 Carrier 卸载 | native/foreign 等少量场景仍可能 pinning |
因此,库是否配合当前运行时很重要。
Rust HTTP Client 必须提供真正的 async 接口,才能在等待时返回 Pending。Java 中长期使用的阻塞式 JDBC 代码更可能直接运行在 virtual thread 上,但具体驱动是否包含长时间 native blocking,仍需实际验证。Go 标准网络库从一开始就与 Runtime 的网络轮询器配合。
语言模型减轻了等待的成本,没有取消底层库和操作系统的差异。
取消不是把任务从机器上抹掉
请求超时以后,上游可能已经不需要结果了。剩余工作应该尽快停止,否则高峰期会积累大量失去调用方的任务。
Rust Future 在不再被轮询并被丢弃后,通常不会继续执行。但被 spawn 的独立任务拥有自己的生命周期,需要通过 JoinHandle::abort、Cancellation Token 或任务作用域明确停止。Future 也可能正执行到一个对取消敏感的操作,编写 select! 时需要考虑 cancellation safety。
Go 不提供强制杀死某个 goroutine 的操作。惯用做法是传递 context.Context:
select {
case result := <-resultCh:
return result
case <-ctx.Done():
return ctx.Err()
}
前提是下游函数也检查或使用这个 ctx。仅仅让父函数返回,不会自动终止它创建的所有 goroutine。
Java 的取消通常通过 Future.cancel(true) 或 Thread.interrupt() 表达。中断也是协作式机制:被调用代码要正确响应 InterruptedException,不能捕获后悄悄忽略。
三种模型都不能从外部随意终止一段正在执行的代码。可靠的取消需要沿调用链传播,也需要库本身愿意配合。
任务很轻,不代表并发可以无限增加
把平台线程换成 Future、goroutine 或 virtual thread,只解决了任务表示和调度成本的一部分。
假设数据库连接池只有 50 条连接,同时创建十万个查询任务:
100,000 tasks
│
▼
50 database connections
剩下的任务仍然需要排队,并占用内存、超时器、请求对象和日志上下文。
三种语言最终都要面对背压:
Rust
Semaphore
bounded channel
buffer_unordered(limit)
Go
buffered channel
worker pool
semaphore
Java
Semaphore
connection pool
bounded application queue
区别只是等待许可的任务不再昂贵地占用一条 OS 线程。
对于 CPU 密集任务,也应该限制与核心数量相匹配的并行度。创建更多异步任务不会让四个核心同时执行四百份计算。
回到服务代码
Rust 把异步成本和暂停点暴露得最清楚。Future 的状态由编译器精确保存,适合希望控制内存、分配和运行时行为的系统。相应地,Runtime、Send、生命周期和阻塞边界会进入日常编程。
Go 把 goroutine 做成语言最普通的执行单元。大多数代码继续按照调用栈思考,Runtime 负责调度,Channel 和 Context 处理协作。它降低了写并发程序的门槛,但 goroutine 的退出、背压和共享状态仍然需要设计。
Java virtual thread 更关注已有代码和生态。许多同步客户端、框架和调试习惯可以保留,JVM 在 Thread API 后面改变调度方式。它不会自动修复资源池过小、锁竞争或错误的超时策略,但能让 Thread-per-request 在高并发 I/O 服务中重新变得可行。
| 维度 | Rust async | Go goroutine | Java virtual thread |
|---|---|---|---|
| 任务表示 | Future 状态机 | 带栈 goroutine | 轻量 Thread |
| 暂停点 | 显式 .await | 普通阻塞操作 | 普通阻塞操作 |
| 调度者 | Tokio 等 Runtime | Go Runtime | JVM Scheduler |
| 默认启动方式 | 惰性,等待 poll | go f() 后可运行 | start / submit 后可运行 |
| 调用栈体验 | async 调用链 | 普通函数调用栈 | 普通 Thread 调用栈 |
| 主要约束 | async 传播、阻塞边界 | 生命周期、无界并发 | 外部资源、兼容库行为 |
这张表不能用来脱离场景判断哪一种“更先进”。
多数项目也不会为了一个异步模型更换语言。更有用的是知道当前语言替你承担了什么,又留下了什么:
Rust
编译器保存状态
程序员明确异步边界
Go
Runtime 保存栈并调度 goroutine
程序员管理协作与退出
Java
JVM 虚拟化 Thread 与 Carrier 的关系
程序员继续使用阻塞式调用模型
回到最初的一万个请求。
它们都不需要一万条 OS 线程,但并没有因此消失。用户 ID、超时、局部变量、调用进度和错误处理仍然需要保存在某个地方。
Rust 把这些状态放进 Future,Go 把它们留在 goroutine,Java 把它们留在 virtual thread。
所谓异步,处理的正是这段等待时间:任务暂时走不下去时,把线程交给别人;条件满足以后,再从原来的位置继续。
References
- The Rust Programming Language: Fundamentals of Asynchronous Programming
- Rust Standard Library: Future
- The Rust Reference: Await Expressions
- Tokio Tutorial: Spawning
- Tokio Documentation: CPU-bound Tasks and Blocking Code
- Effective Go: Goroutines
- The Go Programming Language Specification: Go Statements
- Go Runtime: Scheduler Structures
- Go Blog: Pipelines and Cancellation
- JEP 444: Virtual Threads
- Oracle Java 25 Guide: Virtual Threads
- JEP 491: Synchronize Virtual Threads without Pinning