Node.js SetTimeout 引起的內(nèi)存泄露問(wèn)題
這是之前寫(xiě)的一篇文章,分享一下避免大家踩坑。
定時(shí)器回調(diào)通常會(huì)通過(guò)閉包持有外部的對(duì)象,比如下面的例子。
function demo() {
const dummy = {}
setTimeout(() => {
dummy;
}, 10000)
}
demo();
demo 執(zhí)行完后,demo 函數(shù)里的 dummy 對(duì)象是不會(huì)釋放的,因?yàn)樗€被 setTimeout 引用著,如果執(zhí)行很多次 demo 的話(huà),就會(huì)導(dǎo)致大量的內(nèi)存無(wú)法被釋放,直到執(zhí)行完 setTimeout,這通常不是什么問(wèn)題,除非 dummy 對(duì)象非常大。
但是如果是 setInterval 的話(huà),情況就不一樣了。
function demo() {
const dummy = {}
setInterval(() => {
dummy;
}, 10000)
}
demo();
上面的代碼會(huì)導(dǎo)致 dummy 永遠(yuǎn)不會(huì)被釋放,當(dāng)然這個(gè)例子很直接,大家并不會(huì)寫(xiě)出這樣的代碼,但是有時(shí)候代碼復(fù)雜的時(shí)候,就不好說(shuō)了,比如之前幫助業(yè)務(wù)排查問(wèn)題的時(shí)候經(jīng)常發(fā)現(xiàn) setInterval 導(dǎo)致的內(nèi)存泄露問(wèn)題,使用場(chǎng)景基本如下。
class Demo {
timer = null
start() {
this.timer = setInterval(() => {
this;
}, 10000)
}
stop() {
// 通常會(huì)漏了這一句
// clearInterval(this.timer);
}
}
const demo = new Demo();
demo.start();
demo.stop();
所以使用 setInterval 的時(shí)候需要特別注意。
setInterval 導(dǎo)致內(nèi)存泄露很好理解,但是 setTimeout 導(dǎo)致的內(nèi)存泄露并不常見(jiàn),因?yàn)?setTimeout 執(zhí)行完后,相應(yīng)的內(nèi)存都會(huì)被釋放了。下面分享一個(gè)因?yàn)?Node.js Core 導(dǎo)致的 setTimeout 內(nèi)存泄露問(wèn)題,相關(guān) issue 可以參考這里。復(fù)現(xiàn)代碼如下。
for (i = 0; i < 500000; i++) {
+setTimeout(() => {}, 0);
}
上面的代碼會(huì)導(dǎo)致 setTimeout 創(chuàng)建的 timer 對(duì)象無(wú)法釋放,乍一看,我們可以會(huì)被嚇到,這不就是我們平時(shí)的用法嗎?但是不用擔(dān)心,下面的例子并不會(huì)出現(xiàn)這個(gè)問(wèn)題。
for (i = 0; i < 500000; i++) {
setTimeout(() => {}, 0);
}
仔細(xì)一看,有問(wèn)題的例子中 setTimeout 還有個(gè) + 號(hào),那么這個(gè)是做什么的呢?
這個(gè)還要說(shuō)起 setTimeout 在瀏覽器的實(shí)現(xiàn),在瀏覽器中,setTimeout 返回的是一個(gè) id,但是 Node.js 中返回的是一個(gè)對(duì)象,為了和瀏覽器兼容,Node.js 支持把返回的對(duì)象轉(zhuǎn)成 id,這個(gè) id 是定時(shí)器對(duì)應(yīng)的 async_hooks id,那么這個(gè)是怎么實(shí)現(xiàn)的呢?下面看一個(gè)例子。
const dummy = {
[Symbol.toPrimitive]() {
return 1
}
};
console.log(+dummy)
上面例子會(huì)輸出 1,可以看到通過(guò) Symbol.toPrimitive 可以定義對(duì)象轉(zhuǎn)成原生類(lèi)型時(shí)的行為。下面是另一個(gè)例子。
const dummy = {
[Symbol.toPrimitive]() {
return "hello ";
}
};
console.log(dummy + "world")
Node.js 正是利用這個(gè)能力實(shí)現(xiàn)了和瀏覽器的兼容,源碼如下。
Timeout.prototype[SymbolToPrimitive] = function() {
const id = this[async_id_symbol];
if (!this[kHasPrimitive]) {
this[kHasPrimitive] = true;
knownTimersById[id] = this;
}
return id;
};
所以一開(kāi)始那個(gè)例子中 +setTimeout 最終會(huì)執(zhí)行上面的代碼,從而拿到一個(gè) id。但是事情沒(méi)有那么簡(jiǎn)單,從上面的代碼中可以看到,除了返回一個(gè) id 外,還有另外一個(gè)邏輯,那就是把定時(shí)器對(duì)象保存到了一個(gè) map 中,其中 key 正是給用戶(hù)返回的 id,那么這個(gè)有什么用呢?看一下 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 刪除定時(shí)器,而之前只支持傳入定時(shí)器對(duì)象。一切看起來(lái)沒(méi)問(wèn)題,但是實(shí)現(xiàn)這個(gè)特性的時(shí)候,忘了一種場(chǎng)景,那就是如果用戶(hù)沒(méi)有執(zhí)行 clearTimeout,而是定時(shí)器正常觸發(fā),因?yàn)樵诙〞r(shí)器正常觸發(fā)的邏輯中沒(méi)有刪除映射關(guān)系,從而導(dǎo)致了內(nèi)存泄露。具體修復(fù)方案就是刪除這個(gè)映射關(guān)系就行,具體可以參考這個(gè) PR。
1. issue: https://github.com/nodejs/node/issues/53335.
2: pr: https://github.com/nodejs/node/pull/53337