赶超Netty:基于Java19虚拟线程的Nima发布


Níma 是一个基于 Java 19(目前是早期访问)的服务器实现,专为 Java 虚拟线程(Project Loom 的产品)而设计。

Helidon 4.0.0-ALPHA1 现在与我们全新的 Helidon Níma 一起发布,提供基于虚拟线程的 Web 服务器。对于那些对最新 Java 技术感兴趣的人来说,这是一个早期访问版本,但它还不适合生产使用!
要试用可用于生产的 Helidon,请查看我们的最新版本Helidon 3.0.0

在 Alpha 版本中,我们提供了以下协议的实现:

  • 带有流水线的 HTTP/1.1 — 服务器和客户端
  • HTTP/2 服务器(原型,已知问题)
  • gRPC 服务器(原型,已知问题)
  • WebSocket 服务器(原型)

线程
该实现使用虚拟线程,其设计和实现是为了提供一个恒定的、低开销的、高并发的服务器,同时保持一个阻塞线程模型。这让你写的代码不会因为反应式编程中经常遇到的问题而变得复杂,比如说。

套接字监听器

  • 套接字监听器是平台线程(数量非常少,每个打开的服务器套接字都有一个)。

HTTP/1.1
  • 1个虚拟线程来处理连接(包括路由)
  • 1个虚拟线程用于在该连接上进行写操作(可以被禁用,因此写操作发生在连接处理线程上)
  • 单个连接的所有请求都由连接处理程序来处理

HTTP/2.2:
  • 1个虚拟线程来处理连接
  • 1个虚拟线程处理该连接的写操作(可以禁用,以便写操作发生在连接处理程序线程上)
  • 每个HTTP/2流有一个虚拟线程(包括路由)。

虚拟线程执行器服务使用无边界的执行器。

协议
以下协议在我们的Alpha版本中已经实现。

  • 具有可扩展升级机制的HTTP/1.1
  • HTTP/1.1 WebSocket的升级实现
  • HTTP/1.1到HTTP/2明文(h2c)的升级实现
  • 具有可扩展的 "子协议 "机制的HTTP/2
  • HTTP/2 gRPC子协议的实现
  • 对其他TCP协议(包括非HTTP)的可扩展性
  • 对任何协议的服务器端TLS支持
  • 对任何协议的相互TLS支持
  • 可扩展的应用层协议协商(ALPN),由HTTP/2(h2)使用

路由
同一个 Web 服务器可用于路由到多个协议(例如,您可以拥有一个服务于 HTTP/1.1、HTTP/2、WebSocket 和 gRPC 的端口)。默认情况下实现 HTTP/1.1 路由(因为其他协议从它升级);其他协议是可以添加的单独模块。

  • HTTP 路由与版本无关——相同的路由可用于 HTTP/1.1 和 HTTP/2
  • 支持特定协议的路由——只服务于 HTTP/2 的路由是可能的
  • gRPC 路由(一元、服务器流式传输、客户端流式传输、双向)
  • WebSocket 路由(目前不实现子协议)

特征
实现了以下功能,可以尝试:

  • 跟踪支持——使用现有的 Helidon 跟踪实现,例如 Jaeger 或 Zipkin
  • 静态内容支持——来自类路径或文件系统
  • CORS 支持
  • 访问日志支持
  • 可观察性端点(健康、应用程序信息、配置)
  • 容错(隔板、断路器、重试和超时功能)
  • HTTP/1.1 客户端
  • 测试支持


阻塞式与反应式
让我们比较一下Níma(阻塞式)和Helidon SE(反应式)在同一任务中的实现。

我们需要建立每个框架的基本规则。

  • 反应式--你不能阻塞请求的线程,这是通过反应式流API支持的,比如Helidon的Multiand Single
  • 阻塞式--你不能异步地完成响应(例如,响应必须从发出请求的同一线程发出)

注意:在这两种情况下,你都不应该 "阻塞 "线程。阻碍是对线程的长期、充分的利用。
  • 在一个反应式框架中,这将消耗一个事件循环线程,有效地停止了服务器。
  • 在阻塞式(Níma)中,这可能会导致 "钉子线程 "的问题。

在这两种情况下,可以通过使用平台线程将重载卸载到专门的执行器服务来解决。

堵塞:

private void one(ServerRequest req, ServerResponse res) {
  String response = callRemote(client());
  res.send(response);
}

反应式:

private void one(ServerRequest req, ServerResponse res) {
  Single<String> response = client.get()
          .request(String.class);

  response.forSingle(res::send)
          .exceptionally(res::send);
}

我们可以看到,使用阻塞代码,我们可以获取响应并发送它。如果发生任何事情,默认异常处理程序将正确处理异常,或发送内部服务器错误。

另一方面,对于反应、响应式,我们必须使用响应式流并处理异常,否则异常会丢失。
阻塞代码的优点:

  • 简单的异常处理
  • 有意义的堆栈跟踪(包括线程转储)
  • 易于调试
  • 更少的脚手架

复杂案例
在此示例中,我们将并行调用另一个服务,将结果组合成一个响应。
我们期望从查询参数中获取并行请求的数量;我们将把它放在变量“count”中。此外,此处未显示对 InterruptedException 和 ExecutionException 的处理,即使必须这样做(在下一个 Alpha 版本中,处理程序将允许抛出已检查的异常)。
阻塞:

List<String> responses = new LinkedList<>();

// list of tasks to be executed in parallel
List<Callable<String>> callables = new LinkedList<>();
for (int i = 0; i < count; i++) {
  callables.add(() -> client.get().request(String.class));
}

// execute all tasks (blocking operation)
for (var future : EXECUTOR.invokeAll(callables)) {
  responses.add(future.get());
}

// send it
res.send(
"Combined results: " + responses);

反应性:

// create a dummy stream from numbers 0 to count
Multi.range(0, count)
 
// for each number, call the task on a different thread
  .flatMap(i -> Single.create(CompletableFuture.supplyAsync(() -> 
        client().get().request(String.class), EXECUTOR))
 
// flat map from Single<Single<String>> to Single<String>
  .flatMap(Function.identity()))
  .collectList()
  .map(it ->
"Combined results: " + it)
  .onError(res::send)
  .forSingle(res::send);

尽管我们用响应式代码实现了相同的效果,但代码的可读性要差得多(并且很难正确编写)。

性能
我们主要关注的是性能。下面我们展示了与纯Netty(4.1.36.Final版--没有额外的功能,只有HTTP)上的非阻塞实现相比的当前数据(ALPHA-1版)。

这些是在一台机器上使用环回接口进行的相当简单的基准测试(例如,有一个已知的客户端和服务器进程的干扰,因为它们共享CPU)。然而,它为我们提供了一个快速的性能比较,看看我们是否可以与非阻塞的实现相比较。与任何性能测试一样,结果会因很多因素而不同(特别是对Linux环境如何进行性能优化)。

测试结果:
Nima可以实现与极简 Netty 服务器相当的性能,同时保持简单、易于使用的编程模型。



Performance results (requests/second)


详细点击标题