这是之前写的一篇文章,分享一下避免大家踩坑。
定时器回调通常会通过闭包持有外部的对象,比如下面的例子。
function demo() {
const dummy = {}
setTimeout(() => {
dummy;
}, 10000)
}
demo();
demo 执行完后,demo 函数里的 dummy 对象是不会释放的,因为它还被 setTimeout 引用着,如果执行很多次 demo 的话,就会导致大量的内存无法被释放,直到执行完 setTimeout,这通常不是什么问题,除非 dummy 对象非常大。
但是如果是 setInterval 的话,情况就不一样了。
function demo() {
const dummy = {}
setInterval(() => {
dummy;
}, 10000)
}
demo();
上面的代码会导致 dummy 永远不会被释放,当然这个例子很直接,大家并不会写出这样的代码,但是有时候代码复杂的时候,就不好说了,比如之前帮助业务排查问题的时候经常发现 setInterval 导致的内存泄露问题,使用场景基本如下。
class Demo {
timer = null
start() {
this.timer = setInterval(() => {
this;
}, 10000)
}
stop() {
// 通常会漏了这一句
// clearInterval(this.timer);
}
}
const demo = new Demo();
demo.start();
demo.stop();
所以使用 setInterval 的时候需要特别注意。
setInterval 导致内存泄露很好理解,但是 setTimeout 导致的内存泄露并不常见,因为 setTimeout 执行完后,相应的内存都会被释放了。下面分享一个因为 Node.js Core 导致的 setTimeout 内存泄露问题,相关 issue 可以参考这里。复现代码如下。
for (i = 0; i < 500000; i++) {
+setTimeout(() => {}, 0);
}
上面的代码会导致 setTimeout 创建的 timer 对象无法释放,乍一看,我们可以会被吓到,这不就是我们平时的用法吗?但是不用担心,下面的例子并不会出现这个问题。
for (i = 0; i < 500000; i++) {
setTimeout(() => {}, 0);
}
仔细一看,有问题的例子中 setTimeout 还有个 + 号,那么这个是做什么的呢?
这个还要说起 setTimeout 在浏览器的实现,在浏览器中,setTimeout 返回的是一个 id,但是 Node.js 中返回的是一个对象,为了和浏览器兼容,Node.js 支持把返回的对象转成 id,这个 id 是定时器对应的 async_hooks id,那么这个是怎么实现的呢?下面看一个例子。
const dummy = {
[Symbol.toPrimitive]() {
return 1
}
};
console.log(+dummy)
上面例子会输出 1,可以看到通过 Symbol.toPrimitive 可以定义对象转成原生类型时的行为。下面是另一个例子。
const dummy = {
[Symbol.toPrimitive]() {
return "hello ";
}
};
console.log(dummy + "world")
Node.js 正是利用这个能力实现了和浏览器的兼容,源码如下。
Timeout.prototype[SymbolToPrimitive] = function() {
const id = this[async_id_symbol];
if (!this[kHasPrimitive]) {
this[kHasPrimitive] = true;
knownTimersById[id] = this;
}
return id;
};
所以一开始那个例子中 +setTimeout 最终会执行上面的代码,从而拿到一个 id。但是事情没有那么简单,从上面的代码中可以看到,除了返回一个 id 外,还有另外一个逻辑,那就是把定时器对象保存到了一个 map 中,其中 key 正是给用户返回的 id,那么这个有什么用呢?看一下 clearTimeout 代码。
function clearTimeout(timer) {
if (typeof timer === 'number' || typeof timer === 'string') {
const timerInstance = knownTimersById[timer];
if (timerInstance !== undefined) {
timerInstance._onTimeout = null;
/*
if (timerInstance[kHasPrimitive])
delete knownTimersById[timerInstance[async_id_symbol]];
*/
unenroll(timerInstance);
}
}
}
可以看到 clearTimeout 中支持传入 id 删除定时器,而之前只支持传入定时器对象。一切看起来没问题,但是实现这个特性的时候,忘了一种场景,那就是如果用户没有执行 clearTimeout,而是定时器正常触发,因为在定时器正常触发的逻辑中没有删除映射关系,从而导致了内存泄露。具体修复方案就是删除这个映射关系就行,具体可以参考这个 PR。
1. issue: https://github.com/nodejs/node/issues/53335.
2: pr: https://github.com/nodejs/node/pull/53337