在脚本中使用可选链接时 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::Isolate
和 v8::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 使用图:
- 使用
gc()
调用开始负载测试:没有“泄漏”
- 删除了
gc()
调用并启动了另一个负载测试会话:“泄漏”
- 恢复了低负载下的手动
gc()
调用:内存使用率开始逐渐下降。
- 开始另一个负载测试会话(
gc()
仍在脚本中):内存使用率迅速下降到基线值。
我的问题是:
- peak_malloced_memory能超过total_heap_size正常吗?
- 为什么只有在使用 JS 的可选链时才会出现这种情况?
- 对于这个问题,除了一直强制执行完整 GC 之外,还有其他更正确的解决方案吗?
(此处为 V8 开发人员。)
- 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()
调用的影响。
- Why could this occur only when using JS's optional chaining?
这说不通,我敢打赌肯定有其他事情发生。
- 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 的访问,而没有在其中分配太多内存。
我已经将 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::Isolate
和 v8::Context
一次,并使用它们来处理 HTTP 请求。
v9.7 中的相同行为。
Ubuntu xenial
用这些 args.gn
构建 V8dcheck_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 使用图:
- 使用
gc()
调用开始负载测试:没有“泄漏” - 删除了
gc()
调用并启动了另一个负载测试会话:“泄漏” - 恢复了低负载下的手动
gc()
调用:内存使用率开始逐渐下降。 - 开始另一个负载测试会话(
gc()
仍在脚本中):内存使用率迅速下降到基线值。
我的问题是:
- peak_malloced_memory能超过total_heap_size正常吗?
- 为什么只有在使用 JS 的可选链时才会出现这种情况?
- 对于这个问题,除了一直强制执行完整 GC 之外,还有其他更正确的解决方案吗?
(此处为 V8 开发人员。)
- 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()
调用的影响。
- Why could this occur only when using JS's optional chaining?
这说不通,我敢打赌肯定有其他事情发生。
- 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 的访问,而没有在其中分配太多内存。