天天看點

Co、遞歸調用引發的記憶體洩漏前言發現問題定位問題代碼分析解決問題題外話

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

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

經過授權,我們得以進入客戶的項目,看到擷取到的 heapsnapshot 檔案,與此同時,可以通過程序趨勢圖看到記憶體飙高引發的一些“并發症”,比如 GC 耗時變久,降低了程序的處理效率:

Co、遞歸調用引發的記憶體洩漏前言發現問題定位問題代碼分析解決問題題外話

借助這次順利生成的堆快照(heapsnapshot)檔案,大緻能看出記憶體洩漏的地方在哪裡,但想要完全找出來,還有點難度。

第一個資訊,記憶體洩漏報表:

Co、遞歸調用引發的記憶體洩漏前言發現問題定位問題代碼分析解決問題題外話

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

另外這個上下文對象跟 co 子產品有關,這說明 co 應該是排程了一個長時期執行的 Generator。否則這類上下文對象會随着執行結束,進入 GC 回收。

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

嘗試根據 @22621725 檢視對象内容,嘗試根據 @22621725 檢視到 GC root 的引用。無果。

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

Co、遞歸調用引發的記憶體洩漏前言發現問題定位問題代碼分析解決問題題外話

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

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

下一個有用的資訊是類視圖:

Co、遞歸調用引發的記憶體洩漏前言發現問題定位問題代碼分析解決問題題外話

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

這個堆快照中有 390,285 個 scheduleUpdatingTask 對象,點選該類,檢視詳情:

Co、遞歸調用引發的記憶體洩漏前言發現問題定位問題代碼分析解決問題題外話

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

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

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

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

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

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

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

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

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

執行這段代碼後,應用程式不會立即崩潰,而是記憶體會逐漸增長,跟出問題的客戶項目表現得一摸一樣。

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

答案是記憶體仍然會持續增長。

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

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

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

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

上文通過将遞歸調用換成 while (true) 循環後,就不再有上下文引用鍊路的問題。由于内部有 await 會引起 eventloop 的排程,是以 while (true) 并不會阻塞主線程。

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