日本熟妇hd丰满老熟妇,中文字幕一区二区三区在线不卡 ,亚洲成片在线观看,免费女同在线一区二区

Co、遞歸調用

前言

我們知道,同步的遞歸寫法,如果在退出遞歸條件失效時,會快速因為棧溢出導致進程掛掉。而在某些場景下,我們會采用異步的遞歸寫法來規避這個問題:

async function recursive() {
  if( active ) return;
  // do something
  await recursive();
}

關鍵字 await 后面的函數調用可能會跨越多個 event loop,這樣的寫法下不會出現棧溢出的錯誤。然而這種寫法其實也不是萬無一失的,我們來看下面這個生產故障案例。

發現問題

客戶接入 Node.js 性能平臺 后,通過監控經常出現內存增長導致的 OOM,于是客戶加上了一條告警規則:@heap_used / @heap_limit > 0.5,目的是在堆較小但是發生泄漏時能正常輸出 heapsnapshot 文件用于分析。

經過授權,我們得以進入客戶的項目,看到獲取到的 heapsnapshot 文件,與此同時,可以通過進程趨勢圖看到內存飆高引發的一些“并發癥”,比如 GC 耗時變久,降低了進程的處理效率:

GC

定位問題

借助這次順利生成的堆快照(heapsnapshot)文件,大致能看出內存泄漏的地方在哪里,但想要完全找出來,還有點難度。

堆快照分析

第一個信息,內存泄漏報表:

report

可以看到,將近 1 個G的文件,當看到 (context) 這個字樣的時候,表明的是它并不是一個普通的對象,而是函數執行期間所產生的上下文對象,比如閉包。函數執行完了,這個上下文對象并不一定就消失了。

另外這個上下文對象跟 co 模塊有關,這說明 co 應該是調度了一個長時期執行的 Generator。否則這類上下文對象會隨著執行結束,進入 GC 回收。

但這點信息完全無法得出任何結論。繼續看。

嘗試根據 @22621725 查看對象內容,嘗試根據 @22621725 查看到 GC root 的引用。無果。

接下來比較有效的信息在對象簇視圖上:

cluster

可以看到從 @22621725 開始,一個 context 引用又一個 context,中間穿插一個 Promise。熟悉 co 的同學會知道 co 會將非 Promise 的調用轉化為一個 Promise,這個地方的 Promise 意味著一個新的 Generator 的調用。

這里的引用關系非常長,筆者展開 20 層之后,Percent 的占比還沒有降低萬分之一。這里線索中斷了。

下一個有用的信息是類視圖:

histogram

這個圖里有不太常見的東西冒出來:scheduleUpdatingTask。

這個堆快照中有 390,285 個 scheduleUpdatingTask 對象,點擊該類,查看詳情:

sche

這個類在文件 function /home/xxx/app/schedule/updateDeviceInfo.js() / updateDeviceInfo.js 中。

目前能提供的線索就僅限這些了,接下來進入代碼分析的階段。

代碼分析

經過客戶授權,拿到了相關的代碼,找到 app/schedule/updateDeviceInfo.js 文件中的 scheduleUpdatingTask

// 執行業務,成功之后稍作等待,繼續
// 如果拿鎖失敗了,停止
const scheduleUpdatingTask = function* (ctx) {
  if (!taskActive) return;
  try {
    yield doSomething(ctx);
  } catch (e) {
    // 需要捕獲業務異常,即使掛了,下一次schedule也能正常跑
    ctx.logger.error(e);
  }
  yield scheduleUpdatingTask(ctx);
};

在整個項目中,唯一能找到對 scheduleUpdatingTask 反復調用的,就只有它自身對自身的調用,也就是通常所說的遞歸調用。

當然,完全說是遞歸調用也不是很符合實際情況。因為如果真的是遞歸調用的話,棧首先就溢出了。

棧沒有溢出的原因在于 Co/Generator 體系中,yield 關鍵字的前后執行實際上是跨多個 eventloop 過程的。

雖然沒有棧溢出,但 Generator 執行之后所附屬的 context 對象要在整個 generator 執行完成之后才會銷毀。因此這個地方的遞歸就導致 context 引用 context 的過程,于是內存就無法得到回收。

在這段代碼中,很明顯的是 if (!taskActive) return; 這個終止條件失效了。

根據這段代碼反推之前的表現,完全符合現象。為了確認這個問題,筆者寫了一段代碼來嘗試重現該問題:

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();
});

執行這段代碼后,應用程序不會立即崩潰,而是內存會逐漸增長,跟 hpmweb 表現得一模一樣。

當然我們猜想,是不是 async functions 不會導致這個問題:

function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, ms);
  });
}

async function task() {
  await sleep(2);
  console.log(process.memoryUsage());
  await task();
}

task();

答案是內存仍然會持續增長。

解決問題

雖然這次的 heapsnapshot 在 Node.js 性能平臺中的分析不是很順暢,但我們還是找到了問題點。既然找到原因了,那么我們繼續看一下該如何解決這個問題。

從上面的例子可以看出,在 co 或者 async functions 中使用遞歸調用,會導致內存回收被延遲,這種延遲會導致內存堆積,引起內存壓力。這是不是意味著在這種場景下不能使用遞歸了。答案當然不是。

但我們需要對應用程序評估,這個遞歸會引起多長的引用鏈路。在本文這個例子中,在退出條件失效的情況下,相當于就是無限遞歸。

那有沒有一種繼續執行,但不引起上下文引用鏈路太長的方案?答案是有:

async function task() {
  while (true) {
    await sleep(2);
    console.log(process.memoryUsage());
  }
}

上文通過將遞歸調用換成 while (true) 循環后,就不再有上下文引用鏈路的問題。由于內部有 await 會引起 eventloop 的調度,所以 while (true) 并不會阻塞主線程。

題外話

普通函數的尾遞歸優化當前都還不是很好,更何況 Generator/Async Functions。