Java中小于10毫秒的延迟:使用绿色线程的并发GC

2020-08-05 19:28:13

在第3部分中,我们展示了运行实时流聚合的现代JVM可以实现低于10毫秒的99.99%延迟。这篇帖子的重点是比较JVM可用的不同GC选项。为了保持一个公平的竞争环境,我们尽可能地保持默认设置。

在这一轮中,我们想从相反的角度看同样的问题:我们可以做些什么来帮助Hazelcast Jet在JVM上实现最佳性能?当我们停留在99.99%延迟的紧凑的10ms内时,我们可以获得多少吞吐量?我们在Jet的一个独特的设计特性中找到了更多的机会:协作线程池。

让我们来看一个在四核机器上运行的流式作业的示例。在典型的执行引擎设计中,每个任务(大致对应于DAG顶点)都有自己的线程来执行它:

共有八个线程,操作系统负责决定如何安排它们在四个可用内核上运行。应用程序对此没有直接控制,在同一CPU核心上从一个线程切换到另一个线程的成本约为2-10微秒。

当我们将并发GC线程添加到图中时,它看起来如下所示:

现在又多了一个线程,即并发GC线程,它还会干扰计算流水线。

在Hazelcast Jet中,任务被设计成协作性的:每次你给它一堆数据要处理,任务就会运行一小段时间,然后返回。它不必一次处理所有数据,执行引擎稍后会将所有待决数据的控制权再次交给它。这种基本设计也出现在绿色线程和协程的概念中。在Hazelcast Jet中,我们称它们为微线程。

这种设计允许Jet始终使用相同的固定大小线程池,无论它实例化多少并发任务来运行数据管道。因此,在四核机器的示例中,它看起来如下所示:

默认情况下,Jet为自己创建的线程与可用的CPU内核一样多,每个线程内部都有许多微线程。从一个微线程切换到下一个微线程非常便宜--归根结底就是一个从call()方法返回的微线程,顶层循环从列表中获取下一个微线程,然后调用它的call()方法。如果您现在想知道阻塞IO调用(例如连接到JDBC数据源)会发生什么,Jet确实支持Backdoor,它会为这样的微线程创建一个专用线程。阻塞IO的线程受CPU限制,通常它们的干扰相当低,但在低延迟应用程序中,您应该避免依赖阻塞API。

现在来看这种设计的另一个优点:如果我们知道还会有一个并发的GC线程,我们可以将Jet配置为少使用一个线程:

仍然有和CPU核心一样多的线程,操作系统不需要进行任何上下文切换。我们确实为Jet放弃了一个完整的CPU核心,减少了Jet可用的CPU容量,但我们允许后台GC真正地与Jet任务并发运行。在低延迟的情况下,应用程序不需要100%的CPU,但它需要100%的CPU份额。

我们去看看这个设置是否真的像我们所希望的那样,并且发现它确实对我们测试的两个垃圾收集器(G1和ZGC)的延迟产生了戏剧性的影响。最重要的结果是,我们现在能够将G1推到10毫秒线以下。由于G1在很大的吞吐量范围内是稳定的,我们立即使其在10毫秒内运行,吞吐量是前一轮的两倍。

基于前一个基准测试设置的预期,我们将重点放在ZGC和G1收集器以及Java 15的最新预发布版本上。我们的设置在很大程度上保持不变;我们稍微刷新了代码,现在使用的是带有OpenJDK 15EA33的Hazelcast Jet的4.2版本。

我们还实现了一个并行化的事件源模拟器。其较高的吞吐能力使其在遇到问题后能够更快地赶上,从而有助于进一步减少延迟。处理流水线本身与前一轮无关,这里是完整的源代码。

我们确定给定的GC使用了多少线程,将Jet线程池的大小设置为16(c5.4xLarge vCPU)减去该值,然后进行一些反复试验以找到最优值。G1使用了3个线程,所以我们给了Jet 13个线程。ZGC只使用了2个线程,但是我们发现Jet使用13线程比理论上的14个线程性能要好一些,所以我们使用了它。我们还尝试更改GC对线程计数的自动选择,但没有发现会超过默认值的设置。

此外,对于G1,我们看到在某些情况下,即使MaxGCPauseMillis=5(与上一篇文章相同),新一代的大小也会增长到足以让小的GC暂停到无法处理的程度。因此,根据选择的吞吐量,我们添加了100M、150M和200M中的一个MaxNewSize。这也是通过反复试验确定的,当一次较小的GC发生在大约每秒10-20次时,结果似乎是最好的。

综上所述,以下是我们在上一篇文章中对设置所做的更改:

将ZGC下面的结果与前一轮的结果进行比较,我们可以看到,延迟保持在已经很好的位置上,但吞吐量范围从8M项/秒扩展到10M项/秒,提高了25%。

对G1的影响在某种程度上是以上两方面的:虽然G1已经有了很大的吞吐量,但还没有达到10ms的线以下,在这一轮中,它的延迟全面改善,在某些地方提高了40%。最好的消息是:单个Hazelcast Jet节点在10ms内保持99.99%延迟的最大吞吐量现在达到每秒2000万条,提高了250%!

受到这一强劲结果的鼓舞,我们设想了这样一个场景:我们有100,000个传感器,每个传感器产生100赫兹的测量流。单节点Hazelcast Jet能否处理这种负载,并以10毫秒的延迟在1秒窗口内产生每个传感器测量量的时间积分?这意味着事件速率有了数量级的飞跃,从每秒1M到10M,但窗口长度也减少了同样的因素,从10秒减少到1秒。

名义上,该场景产生与我们已经看到的相同的组合输入+输出吞吐量以及大约相同的状态大小:20M项/秒和10M存储的映射条目。这是G1仍然在10毫秒内的最大值,但即使在25M个项目/秒的情况下,它的延迟也相当低。然而,由于我们尚未确认的原因,投入率似乎对GC有更大的影响,所以当我们用产出换取投入时,结果发现G1根本无法处理这一问题。

但是,由于我们选择了c5.4xLarge实例类型作为中级开发,因此对于这个精英场景,我们也考虑了顶层的EC2盒:c5.Metal。它拥有96个vCPU,并拥有一些我们不需要的可怕的RAM。在这个硬件上,G1决定自己占用16个线程,所以Jet的自然选择是80个线程。然而,通过反复试验,我们找到了真正的最佳值,结果是64个线程。以下是我们得到的信息:

G1轻而易举地达到了20M大关,然后一路达到每秒40M项,优雅地降级,仅用了12ms就达到了60M。超过这一点,就是杰特失去了动力。全速运行的喷气管道就是不能最大限度地冲出G1!我们用更多的线程重复了测试,78岁的Jet,但这并没有什么不同。