Quarkus 与虚拟线程的邂逅

Java 21 提供了一项新功能,将重塑 Java 中并发应用程序的开发。两年多来,Quarkus 团队一直在探索集成这项新功能,以简化分布式应用程序(包括微服务和事件驱动应用程序)的开发。

这篇博文是一系列演示如何在 Quarkus 应用程序中使用虚拟线程的文章和视频的第一部分。该系列涵盖了 REST、消息传递、容器、原生编译以及我们的未来计划。但首先,让我们看看虚拟线程,它们带来了哪些改变,以及您应该了解它们哪些方面。

Java 21 之前的 Java 世界

在 Java 的早期,Java 有用户线程。用户线程是由 Java 虚拟机 (JVM) 调度的用户级线程,而不是由底层操作系统 (OS) 本机调度的。它们在不依赖本机 OS 功能的情况下模拟多线程环境。它们在用户空间而不是内核空间进行管理,这使得它们能够在不支持本机线程的环境中工作。用户线程在 Java 中于 1997 年至 2000 年间短暂可用。我用过用户线程;它们并没有给我留下美好的回忆。

在 2000 年发布的 Java 1.3 中,Java 迈出了重要一步,开始集成操作系统线程。因此,线程由操作系统管理。这仍然是我们今天使用的模型。每次 Java 应用程序创建一个线程时,都会创建一个平台线程,它包装一个 OS 线程。所以,创建平台线程就是创建 OS 线程,而阻塞平台线程就是阻塞 OS 线程

当您使用 Java 应用程序框架时,您很少自己创建线程。它由框架为您完成。例如,当您的应用程序收到 HTTP 请求时,框架会创建或重用一个平台线程(以及一个 OS 线程),并在该线程上执行处理。整个处理在该线程上运行,直到处理完成(即响应发送回),该线程才能被重用。当处理执行阻塞 I/O 操作时,例如调用另一个服务、写入文件系统或与数据库交互,该线程就会被阻塞,等待响应。如上所述,OS 线程在等待时也会被阻塞。当收到此响应后,处理将继续。

Threads involved with the imperative model

这种模型的好处是易于编程。代码遵循命令式模型。代码按顺序执行。它简单易写,易于理解。例如,以下代码片段展示了如何使用 Quarkus 接收 HTTP 请求、调用另一个 HTTP 服务并返回响应。它遵循了上面的时序图。

@Path("/greetings")
public class ImperativeApp {

  @RestClient RemoteService service;

  @GET
  public String process() {
    // Runs on a worker (platform) thread because the
    // method uses a synchronous signature

    // `service` is a rest client, it executes an I/O operation
    // (send an HTTP request and waits for the response)
    var response = service.greetings(); // Blocking, it waits for the response
                                        // The OS thread is also blocked

    return response.toUpperCase();
  }

}

但是,这种命令式模型有一个限制。您只能并发处理n个请求,其中n是框架可以创建的线程数。OS 线程成本高昂。它们消耗内存(每个线程约 1MB),创建成本高,使用 CPU 进行调度……框架使用线程池来允许重用空闲线程,但当并发级别超过您的线程数时,您就开始堆积请求,增加响应时间,最坏的情况下甚至拒绝请求。增加线程池大小,从而增加内存使用量,可能会导致您的云账单和部署密度爆炸。此外,正如利特尔法则所解释的那样,添加更多线程甚至可能无法提高并发性。

响应式方法提出了一种替代模型来解决这个问题。它提倡使用非阻塞 I/O 和异步开发模型来更有效地利用资源(CPU 和内存)。使用响应式模型,单个线程可以处理多个并发请求。因此,您不会拥有一个大型线程池,而是拥有最少数量的线程(通常等于 CPU 核心数)。这少量线程,通常称为事件循环,会处理您的所有请求。收到请求时,它会在其中一个线程上调用处理代码。当处理需要执行 I/O 操作时,它不会使用阻塞 I/O,而是安排操作并传递一个续调。这个续调是在 I/O 完成时要调用的代码,因此,基本上是处理的其余部分。

Thread involved with the reactive model

响应式模型效率很高,但有一个缺点。如前所述,您需要将代码编写为一系列续调。虽然有多种方法,例如回调、Future、响应式编程或协程,但这使得代码更难理解。代码的结构方式可能并非对所有开发人员都自然。这限制了该解决方案的采用。此外,代码不仅可以在 I/O 操作期间阻塞;它不能执行耗时的处理(我们称之为垄断)。该模型的效率来自同时处理许多请求的能力。如果线程被长时间使用,它就不允许处理其他请求,并且,就像命令式模型一样,您会开始堆积请求。

为了说明命令式模型和响应式模型之间的区别,以下代码片段与前面的片段等效:它接收一个 HTTP 请求,调用另一个 HTTP 服务,并返回响应。但这次,它使用了 Quarkus 的响应式模型。

@Path("/greetings")
public class ReactiveApp {

  @RestClient RemoteService service;

  @GET
  public Uni<String> process() {
    // Runs on an event loop (platform) thread because the
    // method uses a asynchronous signature

    // `service` is a rest client, it executes an I/O operation
    // but this time it returns a Uni<String>, so it's not blocking
    return service.asyncGreetings() // Non-blocking
        .map(resp -> {
          // This is the continuation, the code executed once the
          // response is received
          return resp.toUpperCase();
        });
    }
}

具有此代码的应用程序可以处理更多并发请求,并且使用的内存比命令式应用程序少,但是,开发模型不同。

大多数时候,响应式模型和命令式模型是相对立的。这不一定是这样。Quarkus 使用响应式核心,并允许您决定是使用响应式模型还是命令式模型。有关此功能的更多详细信息,请参阅“是阻塞还是不阻塞”文章

虚拟线程带来了哪些改变?

Java 19 引入了一种新型线程:虚拟线程。在 Java 21 中,此 API 已正式发布。

但什么是虚拟线程?虚拟线程重用了响应式范式的思想,但允许命令式开发模型。您将获得响应式和命令式模型的优点,而没有缺点!

与用户线程一样,虚拟线程由 JVM 管理。虚拟线程在内存中占用的空间比平台线程少。因此,可以同时使用比平台线程更多的虚拟线程,而不会耗尽内存。虚拟线程被设计为可丢弃的实体,我们在需要时创建它们;不建议将它们用于不同的任务。

但这带来了什么改变?阻塞虚拟线程通常非常便宜!有一些魔力让虚拟线程非常有吸引力。当运行在虚拟线程上的代码需要执行 I/O 操作时,它会使用阻塞 API。所以,代码会等待结果,就像命令式模型一样。然而,由于 JVM 管理虚拟线程,当它们执行此阻塞操作时,不会阻塞底层的 OS 线程。虚拟线程的状态存储在堆中,并且另一个虚拟线程可以在同一个 Java 平台(载体)线程上执行,就像在响应式模型中一样。当 I/O 操作完成时,虚拟线程可以再次执行,并且当有可用的载体线程时,虚拟线程的状态将被恢复,并且执行将继续。对开发人员而言,这种魔力是不可见的!您只需编写同步代码,它就可以像真正的响应式代码一样执行,而不会阻塞 OS 线程。

您的代码运行在虚拟线程之上,但在底层,只有少数载体线程在执行它们。

总结一下,虚拟线程是:

  1. 轻量级 - 您可以拥有大量虚拟线程

  2. 创建成本低 - 无需再对它们进行池化

  3. 阻塞成本低,当使用阻塞操作时 - 阻塞虚拟线程不会阻塞底层 OS 线程,当执行 I/O 操作时

如何在 Quarkus 中使用虚拟线程?

在 Quarkus 中使用虚拟线程非常简单。您只需要使用@RunOnVirtualThread注解。它指示 Quarkus 在虚拟线程而不是常规平台线程上调用带注解的方法。

这种新策略扩展了“是阻塞还是不阻塞”文章中解释的智能分派。除了签名之外,Quarkus 现在还查找此特定注解。如果您的 JVM 不支持虚拟线程,它将回退到平台线程。

让我们使用虚拟线程重写相同的示例(完整的代码可在此存储库中找到)。

@Path("/greetings")
public class VirtualThreadApp {

  @RestClient RemoteService service;

  @GET
  @RunOnVirtualThread
  public String process() {
    // Runs on a virtual thread because the
    // method uses the @RunOnVirtualThread annotation.

    // `service` is a rest client, it executes an I/O operation
    var response = service.greetings(); // Blocking, but this time, it
                                        // does neither block the carrier thread
                                        // nor the OS thread.
                                        // Only the virtual thread is blocked.
	return response.toUpperCase();
  }

}

这是第一个代码片段(命令式代码),但其执行模型更接近响应式模型。

Threads involved with virtual threads

对于每个请求,都会创建一个虚拟线程。当载体线程空闲时,虚拟线程会挂载在该载体线程上并执行。当虚拟线程需要执行 I/O(调用远程服务)时,它只阻塞虚拟线程。载体线程被释放,并且可以挂载另一个虚拟线程(例如,在第一个虚拟线程的 I/O 挂起时处理第二个请求的线程)。当 I/O 完成时,载体线程(不一定是同一个线程)会恢复被阻塞的虚拟线程并继续其执行,直到响应准备好发送回客户端。代码片段如所述工作,因为 Quarkus REST 客户端对虚拟线程友好;我们将在下一节中看到异常。

Quarkus 中的虚拟线程不仅限于 HTTP 端点。以下代码片段展示了如何使用虚拟线程处理 Kafka/Pulsar/AMQP 消息。

@Incoming("events")
@Transactional
@RunOnVirtualThread
public void persistEventInDatabase(Event event) {
  event.persist(); // Use Hibernate ORM with Panache
}

细心的读者可能已经注意到,虚拟线程集成依赖于响应式扩展。这些扩展提供了更大的灵活性(例如,对处理在哪个线程上执行的控制),以便正确有效地集成虚拟线程。重要的是要理解,对开发人员而言,它是不可见的(除了@RunOnVirtualThread注解)。

在使用虚拟线程处理一切之前需要知道的五件事

是的,您可能已经预料到了。天下没有免费的午餐。在使用虚拟线程处理一切之前,您需要了解一些事情。这些就是为什么目前 Quarkus 中没有全局开关可以仅在虚拟线程上运行的原因。

1. 线程固定 (Pinning)

如上所述,当虚拟线程执行阻塞操作时,它会从载体线程上卸载,从而阻止载体线程被阻塞。但是,有时虚拟线程无法卸载,因为它的状态无法存储在堆中。当线程持有监视器锁或堆栈中包含本机调用时,就会发生这种情况。

Object monitor = new Object();
//...
public void aMethodThatPinTheCarrierThread() throws Exception {
  synchronized(monitor) {
    Thread.sleep(1000); // The virtual thread cannot be unmounted because it holds a lock,
                        // so the carrier thread is blocked.
  }
}

在这种情况下,载体线程会被阻塞,因此 OS 线程也会被阻塞。

Pinning of the carrier thread

不幸的是,截至目前,许多 Java 库都会固定载体线程。Quarkus 团队和 Red Hat 总体上已经修补了许多库(例如 Narayana(Quarkus 的事务管理器)或 Hibernate ORM),以避免线程固定。但是,当您使用库时,请小心。直到所有代码都以更适合虚拟线程的方式重写还需要时间。

2. 线程垄断 (Monopolization)

与响应式模型一样,如果虚拟线程执行密集且耗时的计算,它就会垄断该载体。虚拟线程调度程序不是抢占式的。所以它无法中断正在运行的线程。它需要等待 I/O 或计算完成。在那之前,该载体线程无法执行其他虚拟线程。

Monopolization of the carrier thread

当执行长时间计算时,使用专用的平台线程池可能会更明智。

3. 载体线程池弹性

当存在线程固定或垄断时,JVM 可能会创建新的载体线程(如上图所示)以避免出现过多未调度的虚拟线程。

这些创建正在创建平台/OS 线程。所以,这很昂贵且占用内存。您尤其需要注意第二点。如果您在资源有限的环境中运行,并且您的代码不适合虚拟线程,您可能会达到内存限制,这意味着您应该始终检查线程固定、线程垄断和内存使用情况。如果您不这样做,在内存受限的容器中,应用程序可能会被终止。

4. 对象池化

多年来,线程一直是稀缺资源。建议对它们进行池化和重用。这种良好的做法鼓励使用线程局部变量作为对象池化机制。像Jackson或 Netty 这样的许多库将昂贵的对象存储在线程局部变量中。这些对象只能由在存储对象的线程上运行的代码访问。由于线程数量有限,因此限制了创建数量。此外,由于线程被重用,对象被缓存和重用。不幸的是,这两个假设在虚拟线程下都不成立:您可以拥有很多虚拟线程,它们不会被重用。甚至不鼓励对它们进行池化。因此,利用这些池化模式的库在使用虚拟线程时可能会表现不佳。您将看到大量大型对象的分配,因为每个虚拟线程都会获得自己的对象实例。

替换这种模式并非易事。例如,Mario Fusco 的这个PR为 Jackson 提出了一个 SPI。Quarkus 将实现 SPI 以提供对虚拟线程友好的池化机制。

5. 强调线程安全

虚拟线程提供了一种在 Java 中构建并发应用程序的新方法。您不再受限于线程池中的线程数量。您不必使用异步开发模型。

但是,在重写您的应用程序以利用这种新机制之前,请确保代码是线程安全的。许多库和框架不允许对某些对象进行并发访问。例如,数据库连接不应被并发访问。当您有许多虚拟线程时,您必须格外小心,尤其是在使用结构化并发 API(Java 21 中仍处于预览状态)时。

在使用结构化并发时,可以轻松地并行运行任务。但是,您必须绝对确定这些任务不会访问不支持并发访问的共享状态。

@GET
@RunOnVirtualThread
public String structuredConcurrencyExample() throws InterruptedException, ExecutionException {
    var someState = ... // Must be thread-safe, as multiple virtual thread will access
                        // it concurrently
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        var task1 = scope.fork(() -> {
          // Run in another virtual thread
          return someState.touch();
        });
        var task2 = scope.fork(() -> {
          // Run in another virtual thread
          return someState.touch();
        });

        scope.join().throwIfFailed();

        return task1.get() + "/" + task2.get();
    }
}

总结和后续

本文介绍了 Java 21 中提供的新类型线程以及如何在 Quarkus 中使用它们。虚拟线程并非万能药,虽然它们可以提高并发性,但您需要注意一些限制。

  1. 许多库都在固定载体线程;Java 世界变得对虚拟线程友好还需要时间。

  2. 必须谨慎分析耗时计算,以避免线程垄断问题。

  3. 载体线程池的弹性可能会导致内存使用量过高。

  4. 线程局部对象池化模式可能对分配和内存使用产生严重后果。

  5. 虚拟线程并不能消除线程安全问题。

这是多篇文章系列的第一部分(希望也是最枯燥乏味的部分)。接下来,我们将介绍:

要了解有关 Quarkus 中虚拟线程支持的更多信息,请参阅虚拟线程参考指南