Co、遞歸調(diào)用
前言
我們知道,同步的遞歸寫法,如果在退出遞歸條件失效時(shí),會(huì)快速因?yàn)闂R绯鰧?dǎo)致進(jìn)程掛掉。而在某些場(chǎng)景下,我們會(huì)采用異步的遞歸寫法來(lái)規(guī)避這個(gè)問(wèn)題:
async function recursive() {
if( active ) return;
// do something
await recursive();
}
關(guān)鍵字 await 后面的函數(shù)調(diào)用可能會(huì)跨越多個(gè) event loop,這樣的寫法下不會(huì)出現(xiàn)棧溢出的錯(cuò)誤。然而這種寫法其實(shí)也不是萬(wàn)無(wú)一失的,我們來(lái)看下面這個(gè)生產(chǎn)故障案例。
發(fā)現(xiàn)問(wèn)題
客戶接入 Node.js 性能平臺(tái) 后,通過(guò)監(jiān)控經(jīng)常出現(xiàn)內(nèi)存增長(zhǎng)導(dǎo)致的 OOM,于是客戶加上了一條告警規(guī)則:@heap_used / @heap_limit > 0.5,目的是在堆較小但是發(fā)生泄漏時(shí)能正常輸出 heapsnapshot 文件用于分析。
經(jīng)過(guò)授權(quán),我們得以進(jìn)入客戶的項(xiàng)目,看到獲取到的 heapsnapshot 文件,與此同時(shí),可以通過(guò)進(jìn)程趨勢(shì)圖看到內(nèi)存飆高引發(fā)的一些“并發(fā)癥”,比如 GC 耗時(shí)變久,降低了進(jìn)程的處理效率:
定位問(wèn)題
借助這次順利生成的堆快照(heapsnapshot)文件,大致能看出內(nèi)存泄漏的地方在哪里,但想要完全找出來(lái),還有點(diǎn)難度。
堆快照分析
第一個(gè)信息,內(nèi)存泄漏報(bào)表:
可以看到,將近 1 個(gè)G的文件,當(dāng)看到 (context) 這個(gè)字樣的時(shí)候,表明的是它并不是一個(gè)普通的對(duì)象,而是函數(shù)執(zhí)行期間所產(chǎn)生的上下文對(duì)象,比如閉包。函數(shù)執(zhí)行完了,這個(gè)上下文對(duì)象并不一定就消失了。
另外這個(gè)上下文對(duì)象跟 co 模塊有關(guān),這說(shuō)明 co 應(yīng)該是調(diào)度了一個(gè)長(zhǎng)時(shí)期執(zhí)行的 Generator。否則這類上下文對(duì)象會(huì)隨著執(zhí)行結(jié)束,進(jìn)入 GC 回收。
但這點(diǎn)信息完全無(wú)法得出任何結(jié)論。繼續(xù)看。
嘗試根據(jù) @22621725 查看對(duì)象內(nèi)容,嘗試根據(jù) @22621725 查看到 GC root 的引用。無(wú)果。
接下來(lái)比較有效的信息在對(duì)象簇視圖上:
可以看到從 @22621725 開(kāi)始,一個(gè) context 引用又一個(gè) context,中間穿插一個(gè) Promise。熟悉 co 的同學(xué)會(huì)知道 co 會(huì)將非 Promise 的調(diào)用轉(zhuǎn)化為一個(gè) Promise,這個(gè)地方的 Promise 意味著一個(gè)新的 Generator 的調(diào)用。
這里的引用關(guān)系非常長(zhǎng),筆者展開(kāi) 20 層之后,Percent 的占比還沒(méi)有降低萬(wàn)分之一。這里線索中斷了。
下一個(gè)有用的信息是類視圖:
這個(gè)圖里有不太常見(jiàn)的東西冒出來(lái):scheduleUpdatingTask。
這個(gè)堆快照中有 390,285 個(gè) scheduleUpdatingTask 對(duì)象,點(diǎn)擊該類,查看詳情:
這個(gè)類在文件 function /home/xxx/app/schedule/updateDeviceInfo.js() / updateDeviceInfo.js 中。
目前能提供的線索就僅限這些了,接下來(lái)進(jìn)入代碼分析的階段。
代碼分析
經(jīng)過(guò)客戶授權(quán),拿到了相關(guān)的代碼,找到 app/schedule/updateDeviceInfo.js 文件中的 scheduleUpdatingTask
// 執(zhí)行業(yè)務(wù),成功之后稍作等待,繼續(xù)
// 如果拿鎖失敗了,停止
const scheduleUpdatingTask = function* (ctx) {
if (!taskActive) return;
try {
yield doSomething(ctx);
} catch (e) {
// 需要捕獲業(yè)務(wù)異常,即使掛了,下一次schedule也能正常跑
ctx.logger.error(e);
}
yield scheduleUpdatingTask(ctx);
};
在整個(gè)項(xiàng)目中,唯一能找到對(duì) scheduleUpdatingTask 反復(fù)調(diào)用的,就只有它自身對(duì)自身的調(diào)用,也就是通常所說(shuō)的遞歸調(diào)用。
當(dāng)然,完全說(shuō)是遞歸調(diào)用也不是很符合實(shí)際情況。因?yàn)槿绻娴氖沁f歸調(diào)用的話,棧首先就溢出了。
棧沒(méi)有溢出的原因在于 Co/Generator 體系中,yield 關(guān)鍵字的前后執(zhí)行實(shí)際上是跨多個(gè) eventloop 過(guò)程的。
雖然沒(méi)有棧溢出,但 Generator 執(zhí)行之后所附屬的 context 對(duì)象要在整個(gè) generator 執(zhí)行完成之后才會(huì)銷毀。因此這個(gè)地方的遞歸就導(dǎo)致 context 引用 context 的過(guò)程,于是內(nèi)存就無(wú)法得到回收。
在這段代碼中,很明顯的是 if (!taskActive) return;
這個(gè)終止條件失效了。
根據(jù)這段代碼反推之前的表現(xiàn),完全符合現(xiàn)象。為了確認(rèn)這個(gè)問(wèn)題,筆者寫了一段代碼來(lái)嘗試重現(xiàn)該問(wèn)題:
const co = require('co');
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, ms);
});
}
function* task() {
yield sleep(2);
console.log(process.memoryUsage());
yield task();
}
co(function* () {
yield task();
});
執(zhí)行這段代碼后,應(yīng)用程序不會(huì)立即崩潰,而是內(nèi)存會(huì)逐漸增長(zhǎng),跟 hpmweb 表現(xiàn)得一模一樣。
當(dāng)然我們猜想,是不是 async functions 不會(huì)導(dǎo)致這個(gè)問(wèn)題:
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, ms);
});
}
async function task() {
await sleep(2);
console.log(process.memoryUsage());
await task();
}
task();
答案是內(nèi)存仍然會(huì)持續(xù)增長(zhǎng)。
解決問(wèn)題
雖然這次的 heapsnapshot 在 Node.js 性能平臺(tái)中的分析不是很順暢,但我們還是找到了問(wèn)題點(diǎn)。既然找到原因了,那么我們繼續(xù)看一下該如何解決這個(gè)問(wèn)題。
從上面的例子可以看出,在 co 或者 async functions 中使用遞歸調(diào)用,會(huì)導(dǎo)致內(nèi)存回收被延遲,這種延遲會(huì)導(dǎo)致內(nèi)存堆積,引起內(nèi)存壓力。這是不是意味著在這種場(chǎng)景下不能使用遞歸了。答案當(dāng)然不是。
但我們需要對(duì)應(yīng)用程序評(píng)估,這個(gè)遞歸會(huì)引起多長(zhǎng)的引用鏈路。在本文這個(gè)例子中,在退出條件失效的情況下,相當(dāng)于就是無(wú)限遞歸。
那有沒(méi)有一種繼續(xù)執(zhí)行,但不引起上下文引用鏈路太長(zhǎng)的方案?答案是有:
async function task() {
while (true) {
await sleep(2);
console.log(process.memoryUsage());
}
}
上文通過(guò)將遞歸調(diào)用換成 while (true) 循環(huán)后,就不再有上下文引用鏈路的問(wèn)題。由于內(nèi)部有 await 會(huì)引起 eventloop 的調(diào)度,所以 while (true) 并不會(huì)阻塞主線程。
題外話
普通函數(shù)的尾遞歸優(yōu)化當(dāng)前都還不是很好,更何況 Generator/Async Functions。