在脚本中使用可选链接时 V8 内存泄漏

V8 Memory leak when using optional chaining in script

我已经将 V8 9.5 嵌入到我的应用程序(C++ HTTP 服务器)中。当我开始在我的 JS 脚本中使用可选链接时,我注意到在重负载 (CPU) 下内存消耗异常增加导致 OOM。虽然有一些空闲 CPU,内存使用是正常的。 我在 grafana 中显示了 V8 HeapStats(这仅适用于 1 个隔离,我的应用程序中有 8 个)

在重负载下,peak_malloced_memory 出现峰值,而其他统计数据受到的影响要小得多,看起来很正常。 我已将 --expose-gc 标志传递给 V8,并在我的脚本末尾调用了 gc()。彻底解决了问题,peak_malloced_memory也没有那么涨了。另外,通过 反复调用 gc() 如果没有它,我可以释放所有消耗的额外内存。 --gc-global 也有效。但这些方法看起来更像是一种变通方法,而不是一种生产就绪的解决方案。 --max-heap-size=64--max-old-space-size=64 没有效果 - 内存消耗仍然大大超过 8(我的应用程序中的隔离数)*64Mb(>2Gb 物理 RAM)。

我没有在我的应用中使用任何 GC 相关的 V8 API。

我的应用创建了 v8::Isolatev8::Context 一次,并使用它们来处理 HTTP 请求。

v9.7 中的相同行为。

Ubuntu xenial

用这些 args.gn

构建 V8
dcheck_always_on = false
is_debug = false
target_cpu = "x64"
v8_static_library = true
v8_monolithic = true
v8_enable_webassembly = true
v8_enable_pointer_compression = true
v8_enable_i18n_support = false
v8_use_external_startup_data = false
use_thin_lto = true
thin_lto_enable_optimizations = true
x64_arch = "sandybridge"
use_custom_libcxx = false
use_sysroot = false
treat_warnings_as_errors = false # due to use_custom_libcxx = false
use_rtti = true # for sanitizers

然后手动将静态库转换为动态库(由于 LTO,我不想在将来处理静态库的一些链接问题):

../../../third_party/llvm-build/Release+Asserts/bin/clang++ -shared -o libv8_monolith.so -Wl,--whole-archive libv8_monolith.a -Wl,--no-whole-archive -flto=thin -fuse-ld="lld"

我做了一些负载测试(因为问题只在负载下发生),有和没有手动 gc() 调用,这是带有时间戳的负载测试期间的 RAM 使用图:

  1. 使用 gc() 调用开始负载测试:没有“泄漏”
  2. 删除了 gc() 调用并启动了另一个负载测试会话:“泄漏”
  3. 恢复了低负载下的手动gc()调用:内存使用率开始逐渐下降。
  4. 开始另一个负载测试会话(gc() 仍在脚本中):内存使用率迅速下降到基线值。

我的问题是:

  1. peak_malloced_memory能超过total_heap_size正常吗?
  2. 为什么只有在使用 JS 的可选链时才会出现这种情况?
  3. 对于这个问题,除了一直强制执行完整 GC 之外,还有其他更正确的解决方案吗?

(此处为 V8 开发人员。)

  1. Is it normal that peak_malloced_memory can exceed total_heap_size?

Malloced 内存与堆无关,所以是的,当堆很小时,Malloced 内存(通常也不是很多)可能会超过它,也许只是短暂的。请注意,peak malloced 内存(屏幕截图中为 53 MiB)不是 current malloced 内存(屏幕截图中为 24 KiB);它是过去任何时候使用的最大数量,但此后已被释放(因此不是泄漏,并且不会随着时间的推移导致 OOM)。

不是堆的一部分,分配的内存不受 --max-heap-size--max-old-space-size 的影响,也不受手动 gc() 调用的影响。

  1. Why could this occur only when using JS's optional chaining?

这说不通,我敢打赌肯定有其他事情发生。

  1. Are there any other, more correct solutions to this problem other than forcing full GC all the time?

我不确定“这个问题”是什么。 malloced 内存的短暂峰值(很快再次释放)应该没问题。您的问题标题提到“泄漏”,但我没有看到任何泄漏的证据。您的问题还提到了 OOM,但图表没有显示任何相关内容(在绘制时间结束时小于 10 MiB 当前内存消耗 window,具有 2GB 物理内存),所以我不确定该怎么做做到这一点。

手动强制 GC 运行当然不是一个好主意。它甚至影响 (non-GC'ed!) malloced 内存这一事实令人惊讶,但可能有一个完全平凡的解释。例如(我在这里疯狂猜测,因为您没有提供重现案例或其他更具体的数据),short-term 峰值可能是由优化编译和强制 GC 引起的运行你正在破坏太多的类型反馈,以至于优化编译永远不会发生。

如果您提供更多数据(例如重现案例),我们很乐意仔细查看。如果您看到的唯一“问题”是 peak_malloced_memory 大于堆大小,那么解决方案就是不用担心它。

我想我已经弄清楚了...

事实证明,这是由 V8 的 --concurrent-recompilation 特性和我们的 jemalloc 配置引起的。

看起来像使用可选链接而不是手写函数时,V8 更积极地尝试 并发 优化代码并为此分配更多内存(区域统计显示每个隔离 > 70Mb 内存)。它在高负载下特别这样做(也许只有到那时 V8 才会注意到热门功能)。

jemalloc,反过来,默认有 128 个竞技场和 background_thread 禁用。 因为并发重新编译优化是在一个单独的线程上完成的,V8 的 TurboFan 优化器最终在单独的 jemalloc 的竞技场中分配了大量内存,即使 V8 释放了这个内存,因为 jemalloc 的衰减策略并且因为这个竞技场没有被访问在其他任何地方,页面都没有被清除,因此增加了常驻内存。

Jemalloc 统计数据:
内存失控前:

Allocated: 370110496, active: 392454144, metadata: 14663632 (n_thp 0), resident: 442957824, mapped: 570470400, retained: 240078848

内存失控后:

Allocated: 392623440, active: 419590144, metadata: 22934240 (n_thp 0), resident: 1712504832, mapped: 1840152576, retained: 523337728

如您所见,虽然分配的内存小于 400Mb,但由于约 300000 个脏页 (~1.1Gb),RSS 为 1.7Gb。所有这些脏页都分布在少数几个关联 1 个线程的区域(V8 的 TurboFan 优化器进行并发重新编译的线程)。

--no-concurrent-recompilation 解决了这个问题,我认为在我们的用例中是最佳的,我们为每个 CPU 核心分配一个隔离并平均分配负载,因此从并发执行重新编译几乎没有意义带宽的观点。

这也可以在 jemalloc 方面通过 MALLOC_CONF="background_thread:true"(据称可能会崩溃)或通过减少竞技场数量 MALLOC_CONF="percpu_arena:percpu"(这可能会增加争用)来解决。 MALLOC_CONF="dirty_decay_ms:0" 也解决了这个问题,但这是一个次优的解决方案。

不确定强制 GC 如何帮助重新获得内存,也许它以某种方式触发了对那些 jemalloc arenas 的访问,而没有在其中分配太多内存。