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