异步:Rust、Go 与 Java 如何处理等待

Futures, Goroutines, and Virtual Threads

3,008 words 20 min read
目录 20 节
  1. 等待和计算不是一回事
  2. 从一个线程一个请求开始
  3. Rust:异步函数先变成一个值
  4. .await 时发生了什么
  5. 两个请求怎样并发等待
  6. 阻塞代码为什么危险
  7. Go:普通函数运行在 goroutine 上
  8. goroutine 保存了一条栈
  9. 并发请求同样的两个服务
  10. goroutine 便宜,但不会自动结束
  11. Java:Thread 还在,只是变轻了
  12. Virtual Thread 怎样使用 OS 线程
  13. 不要建立 virtual thread 池
  14. Pinning 需要按 JDK 版本理解
  15. 三种任务分别保存了什么
  16. 任务什么时候开始
  17. 阻塞意味着什么
  18. 取消不是把任务从机器上抹掉
  19. 任务很轻,不代表并发可以无限增加
  20. 回到服务代码

一个 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 中经常遇到的生命周期、SendPin 问题,也与此有关。一个 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::spawnjoin!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 很早就有线程,也有 FutureCompletableFuture 和各种响应式框架。

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。它需要被 .awaitspawn,或者由其他组合器继续轮询。

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 asyncFuture 返回 Pending,工作线程执行其他任务普通阻塞函数会堵住 Executor Worker
Go goroutineRuntime 暂停 goroutine,调度其他 G无法取消的调用会留下 goroutine,cgo 等路径成本不同
Java virtual threadVirtual 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 asyncGo goroutineJava virtual thread
任务表示Future 状态机带栈 goroutine轻量 Thread
暂停点显式 .await普通阻塞操作普通阻塞操作
调度者Tokio 等 RuntimeGo RuntimeJVM Scheduler
默认启动方式惰性,等待 pollgo f() 后可运行start / submit 后可运行
调用栈体验async 调用链普通函数调用栈普通 Thread 调用栈
主要约束async 传播、阻塞边界生命周期、无界并发外部资源、兼容库行为

这张表不能用来脱离场景判断哪一种“更先进”。

多数项目也不会为了一个异步模型更换语言。更有用的是知道当前语言替你承担了什么,又留下了什么:

Rust
    编译器保存状态
    程序员明确异步边界

Go
    Runtime 保存栈并调度 goroutine
    程序员管理协作与退出

Java
    JVM 虚拟化 Thread 与 Carrier 的关系
    程序员继续使用阻塞式调用模型

回到最初的一万个请求。

它们都不需要一万条 OS 线程,但并没有因此消失。用户 ID、超时、局部变量、调用进度和错误处理仍然需要保存在某个地方。

Rust 把这些状态放进 Future,Go 把它们留在 goroutine,Java 把它们留在 virtual thread。

所谓异步,处理的正是这段等待时间:任务暂时走不下去时,把线程交给别人;条件满足以后,再从原来的位置继续。

References

  1. The Rust Programming Language: Fundamentals of Asynchronous Programming
  2. Rust Standard Library: Future
  3. The Rust Reference: Await Expressions
  4. Tokio Tutorial: Spawning
  5. Tokio Documentation: CPU-bound Tasks and Blocking Code
  6. Effective Go: Goroutines
  7. The Go Programming Language Specification: Go Statements
  8. Go Runtime: Scheduler Structures
  9. Go Blog: Pipelines and Cancellation
  10. JEP 444: Virtual Threads
  11. Oracle Java 25 Guide: Virtual Threads
  12. JEP 491: Synchronize Virtual Threads without Pinning