V8

http://www.2ality.com/2015/06/tail-call-optimization.html
正确的尾调用(PTC, proper tail calls)是语言规范的一部分, 但除了Safari的引擎以外都没实现它, 因此程序运行仍然会栈溢出:
https://www.mgmarlow.com/words/2021-03-27-proper-tail-calls-js/
V8曾经支持尾调用, 但由于种种原因, 它不幸地被移除:
https://stackoverflow.com/questions/42788139/es6-tail-recursion-optimisation-stack-overflow/42788286
由V8开发团队提出的作为替代提案的语法尾调用(Syntactic Tail Calls, STC)被视为无效提案.
https://github.com/getify/Functional-Light-JS/blob/master/manuscript/ch8.md/#trampolines
https://www.npmjs.com/package/fext
这可能是对函数可读性下降程度最少的解决方案, 只需要在每一个函数的开头添加一个 await Promise.resolve() 就能消除掉栈.
缺点在于处理Promise会产生性能成本.
相同不可变字符串的内存共享被称为String interning,
Java是支持String interning的典型例子.
在Java中, 动态创建的字符串除非手动调用intern方法, 否则不会被共享.
JavaScript的引擎通常是支持String interning的, 在不同的实现中会有差异.
子字符串可能被引擎存储为父字符串的切片(slice), 以节省内存.
Clinic.js是一个Node.js诊断工具包, 可以辅助发现长时间运行的Node.js程序的性能问题.
该工具通过监视CPU, 内存, 事件循环延迟, I/O句柄数量来发现可能的故障点.
doctor得出的结果可能不会令人满意, 因为会有很多unknown issues.
在这种情况下, 工具仅仅是展示性能图表, 这与采用其他手段监控运行情况的功能是一样的.
该工具用于得出火焰图(flame chart).
火焰图可以清晰地展示出函数执行时间的长短, 从而发现性能瓶颈.
该工具能够生成独特的泡泡图, 可用于发现连续的异步操作中的性能瓶颈.
对象本身的(指针)大小.
通常只有数组和字符串有显著的浅层大小.
对象本身的(指针)大小, 加上由该对象引用的存储的大小.
使用Chrome Devtool远程调试, 保存堆快照, 进而分析堆占用.
在Node.js v12之前, 需要使用heapdump/heap-profile模块(都是C++插件)来保存堆快照.
在Node.js v12之后, 提供了官方API v8.getHeapSnapshotv8.writeHeapSnapshot, 用于保存堆快照.
无论以哪种方式生成堆快照, 都需要消耗堆大小2倍的内存, 并且会阻塞事件循环一段时间.
内存未被回收的一种可能性是V8的垃圾回收器还没有执行.
为了减少因垃圾回收造成的程序暂停, V8的垃圾回收被设计得偏向懒惰.
使用该v8命令行参数可在运行时注入 global.gc 函数, 调用该函数会触发垃圾回收的"清理".
需要注意的是, 由于只是触发"清理", 没有被收集的垃圾不会被清理.
Node.js导入模块的开销非常高, 因为它不支持部分导入, 并且具有缓存, 会明显导致堆内存增加, 这是没有内存泄漏的项目内存增加的主要原因.
以下模块导入语句会占用同样级别的内存:
  • require('mod')
  • const {} = require('mod')
  • const mod = require('mod')
  • import 'mod' 无论导入的模块是CommonJS模块还是ESM
  • import {} from 'mod' 无论导入的模块是CommonJS模块还是ESM
  • import mod from 'mod' 无论导入的模块是CommonJS模块还是ESM
    这成为了Node.js程序莫名其妙的内存需求的主要来源.
利用打包工具的tree shaking特性显然是解决此问题的最佳方式,
没有被使用的代码在打包时被移除, 因此在运行时占用内存会有减少.
现有的打包工具在处理ESM相关任务时普遍存在无法将CommonJS模块转换为ESM打包的问题, 以至于生成的ESM文件无法在Node.js里运行.
暂时可以选择以CommonJS作为输出来解决此问题.
包管理器如果没有去重复, 就会导致同一个模块的不同版本被多次导入, 从而导致内存消耗不必要地增加.
Node.js默认使用glic allocator, 它会产生内存碎片, 进而导致Node.js的内存用量增加.
使用jemalloc可以解决此问题, 但jemalloc在一些情况下会使用比glic allocator更多的内存.
当rss增长, 而heap相对稳定时, 说明发生了堆以外的内存泄漏.
由于缺乏调试工具, 堆以外的内存泄漏通常很难寻找故障点.
堆以外的内存泄漏会反映到进程的堆上(因为v8自身运行也需要堆, 因此V8的堆会比应用的堆要大),
具体表现为进程的堆比应用的堆要大很多.
这可能由于以下原因导致:
  • Buffer泄漏: Node.js的Buffer是分配在V8引擎外部的, 会显示在memoryUsage的external值里.
  • DLL泄漏:
    使用的本机模块存在内存泄漏, 会显示在memoryUsage的external值里.
    如果程序使用了C/C++编写的本机模块, 则有很大可能出现DLL泄漏, 因为这些语言依赖手动内存管理.
  • 栈泄漏:
    这种情况很少见, 因为往往在开发过程中就可以被排查出来.
    鲜有程序会仅仅因为栈被变量塞满而爆栈,
    爆栈的原因基本上是因为函数一直在调用别的函数而没有返回, 导致调用栈一直在加深.
    • 线程泄漏:
      栈泄漏的另一种形式.
      与栈泄漏的区别在于, 栈泄漏是栈的深度在增加, 而线程泄漏是因为栈的总数在增加.
使用指标监控工具采集Node.js进程的相关信息.
这有助于发现一些指标之间的关联, 从而辅助推测故障点.
Linux上的pmap命令可以打印一个进程的内存映射, 可以配合gdp调查内存泄漏.
执行pmap命令需要相应的权限.
pmap -x {PID}
gdb可以转储进程的内存, 同样需要相应的权限.
sudo gdb -pid {PID}
dump memory dump.bin {内存地址开始} {内存地址结尾}
# pmap打印出的第1列内容是内存地址, 第2列内容是这段内存占用的Kbytes(当pmap不带-x时, 值的末尾会有K),
# 可以以此作为dump的参数.
# 在此我们假设第1列是00007f8b00000000, 第2列是65512, gdp可以直接理解以下命令:
# dump memory dump.bin 0x00007f8b00000000 0x00007f8b00000000+65512
在转储完成后, 可以通过xxd查看十六进制的内存数据, 或通过strings将转储的内存数据转换为字符串以定位问题.
Docker容器使用了不同的命名空间, 在宿主机用gdb转储进程内存可能得到全0的数据文件
(不清楚这否是因为内存的确为全0导致的, 不像是正常结果).
在容器内部使用gdb需要给容器设置 privileged: true, 但在我的用例中同样得到全0的数据文件.
以字节为单位, 包括所有的C++和JavaScript对象在内的驻留集(resident set)的大小.
可视作进程实际占用的物理内存大小, 它只有在其他进程需要内存时才会将内存返回给操作系统, 因此可能出现rss比其他项目的总和高很多的情况.
堆(heap)的总大小, 是驻留集的一部分.
实际使用的堆大小(used heap), 是堆的一部分.
绑定到由V8引擎管理的JavaScript对象的C++对象的内存, 是驻留集的一部分.
代码.
值类型, 指针.
V8引擎会动态分配堆空间, 堆的大小会在必要时增大或缩小.
所有引用类型(对象, 字符串, 闭包等).
已使用的堆空间.
与之相对的是已分配但还未被使用的堆空间, 是被V8引擎申请但还暂未使用的内存.
简而言之, 将JavaScript视作静态语言(最好是像Rust这样的系统级语言)来编码, 往往可以得到更好的优化.
V8的优化大部分与TurboFan有关.
V8引擎会推测JavaScript里的字面量对象的功能, 从而使用性能更好的内部表示.
例如当V8发现程序会用delete删除该字面量对象的成员时, V8可能会认为该字面量对象是一个字典,
从而使用加强其作为字典时性能的内部表示.
不可避免的, 在一些复杂情况下, V8的推测会得出不合预期的结果, 这会使字面量对象的性能下降.
使用像Map和Set这样具有语义的类型, 可以免去V8对字面量对象的推测, 让引擎直接使用确定的模式.
只有一种类型数据的连续数组速度最快, 把数组当做静态语言的 int[], float[] 来使用(不需要预先设定数组大小).
如果数组的数据类型刚好适合TypedArray, 则应该使用TypedArray.
V8的IC(Inline Caching, 内联缓存)根据函数调用时的参数位置和参数类型判断此次函数调用是否与之前的优化相同,
如果IC认为函数不同, 则会重新优化函数, 该函数之前的优化会被抛弃.
IC会根据优化次数判断函数的类型.
最佳的类型是monomorphic, 该函数在调用时只有一种参数形式, 优化效果最好.
最差的类型是megamorphic, 被以不同的方式调用/优化多次就会被判定为此类型, 此时V8会放弃优化它.
因此, 应该尽可能创建没有不同类型重载的函数, 将单一函数拆分成多个函数, 以确保函数都属于monomorphic.
一些操作会让V8在背后创建隐藏类, 进而降低性能, 这类操作主要来自于JavaScript的动态部分.
例如, 在构造函数以外添加属性会创建隐藏类, 应该避免.
大量使用V8的异步原语会导致性能问题, 原因如下:
  • Promise造成的额外封装会损失性能, 并且增加内存用量.
  • await非常慢, 与正在执行的异步任务无关, 单纯被事件循环的速度拖累.
    这意味着如果一个函数的返回值类型是 T | PromiseLike<T>,
    则在处理同步结果时, 很有可能因为await而得到本不应该存在的性能惩罚.
  • v8对await的实现方式导致了由代码习惯导致的很小的代码差异会造成相当不同的结果:
    https://stackoverflow.com/a/70979225/5462167
function syncExample() {
return '123'
}
async function asyncExample1() {
return await '123'
}
async function asyncExample2() {
return '123'
}
function asyncExample3() {
return Promise.resolve('123')
}
console.time('sync1')
for (let i = 10000; i--; ) {
syncExample()
}
console.timeEnd('sync1') // 浏览器 0.31ms, Node.js 0.3ms
console.time('sync2')
for (let i = 10000; i--; ) {
await syncExample()
}
console.timeEnd('sync2') // 浏览器 25ms, Node.js 3ms
console.time('async1')
for (let i = 10000; i--; ) {
await asyncExample1()
}
console.timeEnd('async1') // 浏览器 55ms, Node.js 4ms
console.time('async2')
for (let i = 10000; i--; ) {
await asyncExample2()
}
console.timeEnd('async2') // 浏览器 25ms, Node.js 2.4ms
console.time('async3')
for (let i = 10000; i--; ) {
await asyncExample3()
}
console.timeEnd('async3') // 浏览器 26ms, Node.js 2ms