Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

V8/Node.js 内存泄漏回归测试(第 3 部分)——基于堆迭代的测试 #17

Open
BUPTlhuanyu opened this issue Feb 26, 2025 · 0 comments

Comments

@BUPTlhuanyu
Copy link
Owner

BUPTlhuanyu commented Feb 26, 2025

原文:https://joyeecheung.github.io/blog/2024/03/17/memory-leak-testing-v8-node-js-3/

之前的博客文章中,我将堆快照技巧描述为对堆快照 API 的“滥用”,因为堆快照并非设计用于与堆中的终结器(finalizers)交互。但使用堆快照来识别或排除内存泄漏的概念本身并不是滥用——这正是它们的设计目的。那么,我们是否可以使用堆快照,或其简化版本,来测试内存泄漏呢?

Chrome DevTools 控制台 API

从技术上讲,我们已经可以使用现有的 API 进行测试,无需依赖终结器。只需在进行一定量的分配前后分别获取两个堆快照,并对比它们的差异,就能判断某些对象是否可以被垃圾回收(GC)。这正是堆快照的预期用途,并且在文档中有所说明。
只是相比之下,使用终结器监视特定对象比解析和比较生成的堆快照要更简单、更快捷,因此我们选择了这一捷径。但如果我们能够在不生成堆快照的情况下进行差异比较呢?

实际上,Chromium 的 DevTools 早已提供了一个类似的控制台 API——queryObjects(constructor)。该 API 触发一次与堆快照相同的强制垃圾回收,并在当前执行上下文中查找原型链包含指定构造函数原型的对象。

示例(在 DevTools 控制台中运行):

class A {}
const a = new A();
// 首先返回 undefined,然后输出一个数组 [a]
queryObjects(A);

Node.js 内部 API 及新的测试辅助工具

由于这个功能已经在 V8 中实现,我在 V8:HeapProfiler API 中添加了一个类似的接口,使嵌入者(如 Node.js)可以使用自定义谓词搜索堆中的对象并收集其引用。

有了这个新的 V8 API,我们可以制定一个基于它的内存泄漏测试策略。

首先,我们在 Node.js 的内部绑定中添加了 countObjectsWithPrototype(prototype) 辅助方法(该方法最初仅用于 Node.js 自身的测试,以验证该策略是否足够好)。这个方法类似于 queryObjects(),但它直接接收原型参数,并返回找到的对象数量,因为这正是测试所需的信息。

示例:

class A {}
const a = new A();
countObjectsWithPrototype(A.prototype);  // 1

借助这个新 API,我们可以实现一个简单的内存泄漏检查器:

async function checkIfCollectableByCounting(fn, klass, count) {
  const initialCount = countObjectsWithPrototype(klass.prototype);

  for (let i = 0; i < count; ++i) {
    // 这里 fn() 需要创建一个且仅一个 klass 的实例
    const obj = await fn(i);
  }
  const remainingCount = countObjectsWithPrototype(klass.prototype);
  const collected = initialCount + count - remainingCount;
  if (collected > 0) {
    console.log(`${klass.name} is collectable (${collected} collected)`);
    return;
  }
  throw new Error(`${klass.name} cannot be collected`);
}

// 用法示例:
const leakMe = [];
class A { constructor() { leakMe.push(this); } }
function factory() { return new A; }

// 由于所有创建的 A 实例都存储在数组中,因此不会被回收,调用将抛出错误
checkIfCollectableByCounting(factory, A, 1000);

然而,当我尝试使用这种策略修复一个脆弱的内存泄漏测试时,发现效果并不理想。如果 fn() 生成的对象图包含大量弱引用,并且对象创建循环导致堆增长过快,在限制堆大小的情况下,V8 的紧急垃圾回收可能不足以阻止程序在到达第二次 countObjectsWithPrototype() 调用之前耗尽内存。这会导致误报,但在正常应用中,这种情况很少发生,因为用户通常不会在循环中创建如此多的弱引用对象。

为了优化测试,我们可能会尝试在对象创建循环的每次迭代中进行计数,并在检测到对象被回收后提前终止测试。然而,checkIfCollectableByCounting() 需要执行 GC 并遍历整个堆,导致测试运行时间显著增加。

经过一些优化,我发现以下方法可以提高测试的可靠性:

  1. 批量创建对象,而不是每次只创建一个对象。这有助于加快测试速度,同时避免堆增长过快,以至于 V8 的 GC 无法高效清理复杂对象图。
  2. 在每个批次之后,给 GC 一点时间进行回收
  3. 在每个批次后检查对象是否已被回收,而不是等到所有对象都创建完成后才检查。这可以加快测试进度。

最终版本如下:

const wait = require('timers/promises').setTimeout;

async function checkIfCollectableByCounting(fn, klass, count, waitTime = 20) {
  const initialCount = countObjectsWithPrototype(klass.prototype);

  let totalCreated = 0;
  for (let i = 0; i < count; ++i) {
    const created = await fn(i);
    totalCreated += created;
    await wait(waitTime);  // 给 GC 一些时间

    const currentCount = countObjectsWithPrototype(klass.prototype);
    const collected = initialCount + totalCreated - currentCount;

    if (collected > 0) {
      console.log(`Detected ${collected} collected ${klass.name}, finish early`);
      return;
    }
  }

  await wait(waitTime);
  const currentCount = countObjectsWithPrototype(klass.prototype);
  const collected = initialCount + totalCreated - currentCount;

  if (collected > 0) {
    console.log(`Detected ${collected} collected ${klass.name}`);
    return;
  }

  throw new Error(`${klass.name} cannot be collected`);
}

// 用法示例:
const leakMe = [];
class A { constructor() { leakMe.push(this); } }
function factory() {
  for (let i = 0; i < 100; ++i) new A;
  return 100;
}
checkIfCollectableByCounting(factory, A, 10);

新的 Node.js API:v8.queryObjects()

最终版本的测试效果良好,CI 不再出现不稳定的情况。此外,和其他人讨论后,我认为这个功能对 Node.js 用户也很有用,因此我提交了一个 PR,将其暴露到 Node.js 内置的 v8 模块中。

新 API 的行为与 Chrome DevTools 控制台 API 类似,但返回的是对象数量,而不仅仅是日志信息。为了避免意外泄露对象引用,API 默认仅返回计数,而不会返回对象本身。此外,为了帮助调试,它还可以返回对象的字符串摘要。

示例:

const { queryObjects } = require('v8');
class A { foo = 'bar'; }
console.log(queryObjects(A)); // 0
const a = new A();
console.log(queryObjects(A)); // 1
console.log(queryObjects(A, { format: 'summary' })); // [ "A { foo: 'bar' }" ]

由于 queryObjects() 是基于原型链搜索的,因此如果类有子类,子类的实例也会被视为匹配项。

class B extends A { bar = 'qux'; }
const b = new B();
console.log(queryObjects(A));  // 3
console.log(queryObjects(A, { format: 'summary' }));

通过这个新 API,我们可以直接用 v8.queryObjects(klass) 替代 countObjectsWithPrototype(klass.prototype),不再依赖内部 API,从而减少测试中的误报。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant