本文将使用开源的 Chronicle Queue(OpenHFT/Chronicle-Queue: Micro second messaging that stores everything to disk) 在两个线程之间传递 256 字节的消息,其中所有消息都存储在共享内存中。
Chronicle Queue 是一个超低延迟、高吞吐、持久化的消息和事件驱动的内存数据库,延迟只有16纳秒以及支持每秒钟 500-2000 万消息/记录。由于 Chronicle Queue 在本机内存上运行,所以它不需要进行垃圾回收,具有非常高的性能。
在benchmarks测试中,单个生产者线程将消息写入具有纳秒时间戳的队列。另一个消费者线程从队列中读取消息并在直方图中记录时间增量。生产者保持每秒 100,000 条消息的持续输出速率,每条消息中的有效负载为 256 字节。数据的测试时间范围是 100 秒内,因此大多数抖动将反映在测量中,并确保较高百分位数的合理置信区间。
目标机器是 AMD Ryzen 9 5950X 16 核处理器,在 Linux 5.11.0-49-generic #55-Ubuntu SMP 下以 3.4 GHz 运行。CPU 核数是 2-8 ,这意味着操作系统不会自动调度任何用户进程,并且会避免这些核上的大多数中断。
生产者 - 内部循环的部分代码
// Pin the producer thread to CPU 2
Affinity.setAffinity(2);
try (ChronicleQueue cq = SingleChronicleQueueBuilder.binary(tmp)
.blockSize(blocksize)
.rollCycle(ROLL_CYCLE)
.build()) {
ExcerptAppender appender = cq.acquireAppender();
final long nano_delay = 1_000_000_000L/MSGS_PER_SECOND;
for (int i = -WARMUP; i < COUNT; i) {
long startTime = System.nanoTime();
try (DocumentContext dc = appender.writingDocument()) {
Bytes bytes = dc.wire().bytes();
data.writeLong(0, startTime);
bytes.write(data,0, MSGSIZE);
}
long delay = nano_delay - (System.nanoTime() - startTime);
spin_wait(delay);
}
}
消费者 - 内部循环的部分代码
// Pin the consumer thread to CPU 4
Affinity.setAffinity(4);
try (ChronicleQueue cq = SingleChronicleQueueBuilder.binary(tmp)
.blockSize(blocksize)
.rollCycle(ROLL_CYCLE)
.build()) {
ExcerptTailer tailer = cq.createTailer();
int idx = -APPENDERS * WARMUP;
while(idx < APPENDERS * COUNT) {
try (DocumentContext dc = tailer.readingDocument()) {
if(!dc.isPresent())
continue;
Bytes bytes = dc.wire().bytes();
data.clear();
bytes.read(data, (int)MSGSIZE);
long startTime = data.readLong(0);
if(idx >= 0)
deltas[idx] = System.nanoTime() - startTime;
idx;
}
}
}
从上面的代码中可以看出,消费者线程会读取每个纳秒时间戳,并把相关的耗时延迟记录在数组中。这些时间戳也会放入直方图中,当benchmark测试完成时会打印该直方图。只有在 JVM 正确预热(warmed up)并且 C2 编译器具有 JIT: ed 热执行路径后,才会开始测量(Measurements)。
JVM 版本
Chronicle Queue 正式支持所有最近的 LTS 版本:Java 8、Java 11 和 Java 17,因此这些将用于benchmark测试。还会使用 GraalVM 社区版和企业版。下面是使用特定 JVM 版本的列表:
Legend (JVM Variant) | Detail |
OpenJDK 8 | 1.8.0_322, vendor: Temurin |
OpenJDK 11 | 11.0.14.1, vendor: Eclipse Adoptium |
OpenJDK 17 | 17.0.2, vendor: Eclipse Adoptium |
Graal VM CE 17 | 17.0.2, vendor: GraalVM Community |
Graal VM EE 17 | 17.0.2, vendor: Oracle Corporation |
Measurements
由于每秒产生 100,000 条消息,并且benchmarks测试运行 100 秒,因此在每个benchmarks测试期间将采样 100,000 * 100 = 1000 万条消息。使用的直方图将每个样本置于某个百分位:50%(中位数)、90%、99%、99.9% 等。下表显示了针对某些百分位接收到的消息总数
Percentile | # Messages |
0% (all) | 10,000,000 |
50% (“Median”, used below) | 5,000,000 |
99% | 100,000 |
99.9% | 10,000 |
99.99% (used below) | 1,000 |
99.999% | 1000 |
假设测量值地方差相对较小,对于高达 99.99% 的百分位数,置信区间可能是合理的。99.999% 的百分位数可能需要至少半小时左右而不是仅仅 100 秒的时间来收集数据,以生成任何具有合理置信区间的数据。
Benchmarks Results
对于每个 Java 版本,benchmarks测试运行如下:
# shell 命令
mvn exec:java@QueuePerformance
需要特别注意的是,生产者和消费者线程都将被锁定,分别在隔离的 CPU 内核 2 和 4 上运行
$ top
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME COMMAND
3216555 per.min 20 0 92.3g 1.5g 1.1g S 200.0 2.3 0:50.15 java
可以看出来,生产者和消费者线程在每条消息之间旋转等待,因此每个都消耗整个 CPU 内核。LockSupport.parkNanos(1000)如果 CPU 消耗是一个问题,则延迟和确定性可以通过在没有消息可用的情况下将线程停放一小段时间(例如)来降低功耗。
下面的数字以纳秒 (ns) 为单位给出,这对于理解是必不可少的。
许多其他延迟测量以微秒(= 1,000 ns)甚至毫秒(= 1,000,000 ns)为单位进行。1 ns 大致对应于CPU L1 高速缓存的访问时间。
以下是所有值均以 ns 为单位的benchmarks测试结果:
JDK Variant | Median | 99.99% |
OpenJDK 8 | 280 | 3,951 |
OpenJDK 11 | 370 | 4,210 |
OpenJDK 17 | 290 | 4,041 |
GraalVM CE 17 (*) | 310 | 3,950 |
GraalVM EE 17 (*) | 270 | 3 |
(*) 意味着Chronicle Queue 尚未正式支持。
Typical Latency (Median)
对于典型(中值)值,各种 JDK 之间没有显着差异,除了 OpenJDK 11 比其他版本慢约 30%。
其中最快的是 GraalVM EE 17,但与 OpenJDK 8/OpenJDK 17 相比差异很小。
这是一个图表,其中包含使用的各种 JDK 变体的典型 256 字节消息延迟(越低越好):
Higher Percentiles
从较高的百分位数来看,受支持的 JDK 变体之间也没有太大差异。GraalVM EE 再次稍微快一点,但这里的相对差异甚至更小。OpenJDK 11 似乎比其他变体稍差(- 5%),但在估计的误差范围内,增量是相当的。
这是另一个图表,显示了各种 JDK 变体的 99.99% 百分位数的延迟(越低越好):
Conclusions
Chronicle Queue 低延迟性能非常好。从主存访问 64 位数据大约需要 100 个周期(在当前硬件上相当于大约 30 ns)。上面的代码有一些必须执行的逻辑。此外,Chronicle Queue 从生产者那里获取数据,持久化数据(写入内存映射文件),为线程间通信和发生前发生的保证应用适当的内存防护,然后将数据提供给消费者。与 30 ns 的单个 64 位内存访问相比,所有这些通常发生在 256 字节的 600 ns 左右。
从上面的图表中可以观察到,OpenJDK 17和 GraalVM EE 17 是应用程序的最佳选择,提供了最佳延迟数据性能。如果需要抑制异常值或确实需要尽可能低的总体延迟,可以考虑使用 GraalVM EE 17 而不是 OpenJDK 17。