基于多线程的一个Node补环境框架(踩坑2)

背景

Node补环境框架在执行不同的任务,部分任务会造成内存不断堆积,导致内存oom;

多线程是基于Piscina实现的,它会有设置默认有几个工作线程常驻,设置了minThreads>=1,并且执行任务完成后不立即杀死线程;

根因

VM补环境每任务编译不同代码,V8把编译产物存入 Isolate 级 code cache(不随 context/script 回收,是 V8 设计);而 Piscina 默认 复用 Worker(`idleTimeout:30000` + `minThreads≥1` 保活),code cache 在被复用的线程内单调累积。

想读懂上面这句话,先理解一下这几个概念;

什么是V8/isolate

 * ----------------------------------------------------------------------------
 * 概念 0:什么是 V8 / isolate
 * ----------------------------------------------------------------------------
 *
 * V8 是 Google 的 JavaScript 引擎(Node.js 和 Chrome 都用它)。
 *
 * "isolate"(隔离区)是 V8 的核心概念:
 *   - 一个 isolate = 一个独立的 V8 运行时实例 = 一块独立的堆 + 一套独立的 GC
 *   - isolate 内的所有 JS 对象、编译产物、context 都共享同一块堆
 *   - 不同 isolate 之间内存完全隔离(不能直接访问对方的对象)
 *
 * 在 Node.js 里:
 *   - 主线程是一个 isolate
 *   - 每个 worker_threads(worker_threads.Worker)是独立的 isolate
 *   - Piscina 的每个 worker = 一个 isolate
 *
 * 关键:isolate 是"内存回收的边界"。isolate 销毁(线程终止)时,
 *       内部的所有对象、编译产物、code cache 全部随 OS 回收。
 *       这就是"杀线程回收"的底层原因。

什么是builtin

 * ----------------------------------------------------------------------------
 * 概念 1:什么是 builtin
 * ----------------------------------------------------------------------------
 *
 * builtin = V8 为每个 context 预置的"内置全局对象/函数"。
 *
 * 当你 vm.createContext() 创建一个新 context 时,V8 会自动给它注入一套
 * 内置对象:Object, Array, Math, Date, JSON, Promise, Function, ...
 * 这些是 V8 用 C++ 实现的,每个 context 都有自己独立的一份。
 *
 * 为什么要强调"独立的一份"?
 *   - context A 的 Math 和 context B 的 Math 是两个不同的对象
 *   - 在 context A 里 `Math.foo = 123` 不会影响 context B 的 Math
 *   - 这就是 vm_utils.js 修复后"让 context 用自身 intrinsics"的含义:
 *     不再把主域的 Math 注入 vm,让 vm 用 V8 给它生成的私有 builtin

什么是code cache,为什么不随context回收

 * ----------------------------------------------------------------------------
 * 概念 3:什么是 code cache,为什么"不随 context 回收"
 * ----------------------------------------------------------------------------
 *
 * code cache 是 V8 在 isolate 级别(不是 context 级别)维护的缓存。
 *
 *   - isolate 级 = 属于整个 V8 实例,不属于某个 context
 *   - context 级 = 属于某个 vm.createContext(),context 销毁就回收
 *
 * 为什么 code cache 在 isolate 级?
 *   - 假设你用同一段代码 `new vm.Script('function f(){}')` 创建了 10 个 Script
 *   - V8 只需要编译一次 f,10 个 Script 共用同一份编译产物
 *   - 如果 code cache 在 context 级,每个 context 都要编译一次,浪费
 *
 * "不随 context 回收"的含义:
 *   - 即使你 vm.createContext() 创建的 context 被销毁、被 GC
 *   - code cache 里那份编译产物仍然在(属于 isolate,不属于 context)
 *   - 只要 isolate 还活着(线程没终止),code cache 就不回收
 *
 * 这不是 bug,是 V8 的设计:万一以后又遇到同样的代码,就不用重新编译了。
 *
 * ----------------------------------------------------------------------------
 * 那什么时候会累积?
 * ----------------------------------------------------------------------------
 *
 * 如果每任务编译的代码"不同",V8 会为每个不同版本各编译一份,存入 code cache:
 *
 *   任务1:编译 code_v1 -> 存入 code cache
 *   任务2:编译 code_v2(和 v1 不同)-> 存入 code cache
 *   任务3:编译 code_v3(又不同)-> 存入 code cache
 *   ...
 *
 * code cache 里积压了 v1, v2, v3, ... 每份都占内存,且都不回收。
 * 这就是 worker 堆单调爬升的根源。
 *
 * 如果每任务代码"相同",V8 命中 code cache,不重新编译,不累积。

总结

Piscina在初始化的时候设置`idleTimeout=1` + `minThreads=0`,可以避免这个问题;