一个 IO 线程和一个工作线程走进一家酒吧:一个关于微基准测试的故事
最近,一位竞争对手发布了一个微基准测试,将其技术栈的性能与 Quarkus 进行了比较。Quarkus 团队认为,这个微基准测试不应被照单全收,因为它没有进行一对一的比较,从而得出了错误的结论。被比较的两种框架都支持响应式处理。响应式处理允许业务逻辑直接在IO 线程上运行,这最终在关注响应时间和并发性的微基准测试中表现更好。这个微基准测试应该这样编写,使得两种框架(或都不)都能获得这种好处。无论如何,这Turns out to be an interesting topic and good information for Quarkus users, so read on.
简而言之;
Quarkus 在命令式和响应式工作负载方面都具有出色的性能。这是因为 Quarkus 本身基于 Eclipse Vert.x,一个成熟且高性能的响应式框架,它允许您根据用例来选择、组合和匹配最适合的 IO 范例。
如果您的 REST 场景非常适合完全在 IO 线程上运行,请使用Quarkus Reactive Routes添加 Vert.x Reactive Route,您的应用程序将获得比使用 Quarkus RESTEasy 更好的性能。
我们运行了这个由竞争对手编写的低工作负载 REST + 验证微基准测试,该测试没有阻塞操作,只是返回静态数据。当使用 Quarkus Reactive Routes 让 Quarkus完全在 IO 线程上运行时,我们观察到每秒请求数是 Quarkus RESTEasy(混合了 IO 线程和工作线程)的 2.6 倍,内存使用量(RSS)减少了 30%。但这只是在一个专门为此特定场景设计的微基准测试中(稍后会详细介绍)。

更多有趣的文章
微基准测试本身并不有趣,但它很好地演示了响应式技术栈中可能发生的现象。让我们以此为载体,更多地了解 Quarkus 及其响应式引擎。
命令式与响应式:电梯里的简述
这篇博客文章不会解释命令式执行模型和响应式执行模型之间的根本区别。但是,要理解为什么在上述微基准测试中会看到如此大的差异,我们需要一些概念。
通常,Java Web 应用程序使用命令式编程结合阻塞 IO 操作。这非常受欢迎,因为它更容易理解代码。事物是按顺序执行的。为了确保一个请求不受另一个请求的影响,它们是在不同的线程上运行的。当您的工作负载需要与数据库或其他远程服务交互时,它依赖于阻塞 IO。线程会阻塞以等待响应。在不同线程上运行的其他请求不会受到显著减慢。但这表示每个并发请求都需要一个线程,这限制了整体并发性。
另一方面,响应式执行模型采用异步开发模型和非阻塞 IO。在这种模型下,同一个线程可以处理多个请求。当请求的处理无法继续进行时(因为它请求了远程服务或与数据库交互),它会使用非阻塞 IO。这会立即释放线程,然后该线程可以用于处理另一个请求。当 IO 操作的结果可用时,请求的处理会恢复并继续执行。这种模型允许使用IO 线程来处理多个请求。有两个显著的好处。首先,响应时间更短,因为它不必跳到另一个线程。其次,它减少了内存消耗,因为它减少了线程的使用。响应式模型更有效地利用硬件资源,但是……有一个显著的缺点。如果请求的处理开始阻塞,情况会变得非常糟糕。没有其他请求可以被处理。为了避免这种情况,您需要学习如何编写非阻塞代码,如何构建异步处理,以及如何使用非阻塞 IO。这是一个范式转变。
在 Quarkus 中,我们希望使这一转变尽可能容易。然而,我们观察到大多数用户应用程序都是使用命令式模型编写的。这就是为什么当用户应用程序使用 JAX-RS 时,Quarkus 默认将(命令式)工作负载执行到工作线程。
Hello World 微基准测试:IO 线程还是工作线程?
回到竞争对手的微基准测试,我们有一个 REST 端点,它执行一些微不足道的处理和同样微不足道的验证。几乎没有有意义的业务工作。从所有意图和目的来看,这都是 REST 的 Hello World。
当您使用 Quarkus RESTEasy 运行微基准测试时,请求由 IO 线程上的响应式引擎处理,然后处理工作被移交给来自工作线程池的第二个线程。这称为分派。当您的微基准测试所做的很少,例如 Hello World 时,分派开销就会相对较大。分派开销在大多数(现实生活)应用程序中不可见,但在微基准测试等人工构造中却非常明显。
然而,竞争对手的堆栈默认在 IO 线程上运行所有请求操作。所以,这个微基准测试实际上比较的是分派到工作线程池的成本。坦率地说(根据竞争对手的数据),尽管有额外的工作分派,Quarkus 的表现仍然非常好,达到了竞争对手吞吐量的约 95%!我说“今天”是因为我们一直在改进性能,而且我们预计在即将发布的 1.4 版本中会有进一步的提升。
在不利条件下(分派到工作线程)进行比较时,Quarkus 的吞吐量仍然几乎一样快。
……但是等等,Quarkus 也可以完全避免分派,并在 IO 线程上运行操作。这与竞争对手的堆栈配置方式更准确地比较,因为在两种情况下,由用户负责在需要时请求分派。为了进行公平的比较,让我们使用由 Eclipse Vert.x 支持的Quarkus Reactive Routes。在此模型中,操作默认在 IO 线程上运行。
@ApplicationScoped
public class MyDeclarativeRoutes {
@Inject
Validator validator;
@Route(path = "/hello/:name", methods = HttpMethod.GET)
void greetings(RoutingExchange ex) {
RequestWrapper requestWrapper = new RequestWrapper(ex.getParam("name").orElse("world"));
Set<ConstraintViolation<RequestWrapper>> violations = validator.validate(requestWrapper));
if( violations.size() == 0) {
ex.ok("hello " + requestWrapper.name);
} else {
StringBuilder validationError = new StringBuilder();
violations.stream().forEach(violation -> validationError.append(violation.getMessage()));
ex.response().setStatusCode(400).end(validationError.toString());
}
}
private class RequestWrapper {
@NotBlank
public String name;
public RequestWrapper(String name) {
this.name = name;
}
}
}
这与您的 JAX-RS 等效项没什么不同。
吞吐量数据
我们在一个 Docker 容器中运行了微基准测试应用程序,该容器被限制以反映 Kubernetes 编排的典型容器资源分配。
-
4 个 CPU
-
256 MB RAM
-
以及
-Xmx128m
的 Java 进程堆使用量
我们发现使用 Reactive Routes 的 Quarkus 的每秒请求数是原来的 2.6 倍。2.6 倍!这是有道理的!请记住,应用程序代码几乎不做任何事情,因此分派成本相对较高。如果您编写更现实的工作负载(甚至可能有一个阻塞操作,如 JPA 访问,从而强制分派),那么结果将大不相同。上下文很重要!
您可以在 GitHub 上找到代码以及如何重现微基准测试。
Quarkus - 1.3.1.Final - 4 个 CPU | 工作线程 | IO 线程 | 比例 |
---|---|---|---|
平均开始到第一个请求的时间 (ms) [1] |
993.9 |
868.3 |
87.4% |
最大 RSS (MB) |
138.8 |
97.9 |
70.5% |
最大吞吐量 (req/sec) |
46,172.2 |
123,520.4 |
267.5% |
每 MB 最大吞吐量 (req/sec/MB) |
332.7 |
1,262.1 |
379.4% |

在公平的比较中(完全保留在 IO 线程上 - 无分派),Quarkus 的吞吐量增加了一倍多。
随着生成的负载趋向于被测系统的最大吞吐量,客户端经历的响应时间呈指数级增长。因此,最好的系统(针对工作负载)应该有一条尽可能向右延伸的垂直线。同样重要的是,在尽可能长的时间内保持尽可能平坦的线。您不希望在系统达到最大吞吐量之前响应时间就开始下降。
顺便说一下,在竞争对手的微基准测试中,Quarkus 显示的 RSS(内存)消耗更多。这也源于使用了工作线程池,而竞争对手没有使用工作线程池。Quarkus Reactive Routes 解决方案(在纯 IO 事件运行时)显示 RSS 使用量减少了 30%。

在此图中,越低越好。我们看到纯 IO 线程解决方案在内存使用量(RSS)几乎没有变化的情况下增加了吞吐量,这非常好!
结论
Quarkus 使您能够安全地运行阻塞操作,在 IO 线程上运行非阻塞操作,或混合这两种模型。Quarkus 团队非常重视性能,我们认为 Quarkus 无论您使用命令式还是响应式模型,都能提供出色的数据。在更现实的工作负载中,分派成本会不那么重要,您不会看到这两种方法之间如此剧烈的差异。像往常一样,测试应该尽可能接近您的实际应用程序。
谜团已解。基准测试很难,挑战它们。但故事的寓意是,在坏事中总会带来一些好事。我们现在知道了如何让 Quarkus 应用程序完全在 IO 线程上运行。以及在某些情况下这能带来多大的改变。记住,不要阻塞!事实上,Quarkus 可以警告您是否这样做。哦,我们还了解到 Quarkus 非常快,甚至可以击败它自己;p