【Unity】Unity填坑筆記——記一次“內存泄露”的排查

原文鏈接:http://www.sohu.com/a/272882099_667928

【參考原文】Unity填坑筆記——記一次“內存泄露”的排查

1.起因

遊戲上線之前大約不到一週的時間,安卓和iOS包都提給渠道之後,合作方的質檢部門給出了一個測試報告,說我們遊戲有嚴重的內存泄露……

我褲子都……呃,不好意思,我包都提上去了,這個時間點你跟我說這個,早幹嘛去了!而且內存部分也一直是我們關注的內容,不管是我們內部的周常測試還是定期的UWA的性能測試,在內存這塊都沒有發現特別明顯的問題。

2. 初步處理

先初步溝通了下,內存泄露的結論是在做頻繁開關ui的測試時得出的,依據是PSS內存一直在增長,而且在中低配機器上都超過了建議的閾值。溝通到這裏心裏稍微放鬆了下,因爲我們爲了減少UI的頓卡,針對ui做了緩存機制——對大內存設備(android設備1.5G以上,iOS設備1G以上),較爲複雜的界面會做一定時長的緩存,提高近期再打開的時候的體驗。

這個緩存最初並沒有設置上限,因爲設想玩家在正常流程中,並不太會連續打開非常多界面,而到一定時間界面沒再打開過就會釋放掉了。而合作方的這種測試正好和我們的緩存機制衝突,因此得出內存泄露的結論也可以理解。

首先嚐試跟合作方解釋了一下原因,然後着手做了一下緩存個數的限定,並順手把安卓設備上的大內存定義從1.5G提高到了2G。通過Patch更新完成之後讓質檢部門測試,得出結論是——有略微好轉,但是依然有泄露風險。並且很好心地做了一個測試:

手動測試,針對每個界面執行30次打開和關閉操作,並且每次之後手動打點,所有的17個主要依次打開之後PSS內存的增長曲線如下圖所示。
在這裏插入圖片描述
PSS內存增長曲線圖

這下就有點尷尬了…

3. 復現和定位

看合作方給出的內存曲線圖,問題的確比較嚴重,PSS內存從400M增長到600M+,雖說中間有部分降低的過程,但是整體上升的趨勢還是非常明顯。

這樣看來,和界面緩存機制並沒有特別直接的關係,之前的判斷並不正確。雖然上線之前事情很多,這個還是要花時間來處理。於是先嚐試復現,方法很簡單,作爲程序不需要手動開關界面,編寫一個debug功能,針對列表中的界面模擬開關操作就好了。

PSS內存不好查看,用兩種方式分別驗證:

  1. 用adb連接手機,使用adb shell dumpsys meminfo 命令來手動查看內存變化;
  2. 讓QA同學幫忙跑了一下UWA GOT工具的OverView測試,查看PSS曲線。

結論都是一樣的,PSS內存的確存在較快的增長,應該是UI導致遊戲內存泄露了。

我們平常對於內存的關注中,雖然PSS內存一直也偏高,但是沒有觀察到過這麼明顯的泄露現象,也去翻了下近期的UWA測試報告,基本PSS內存的曲線是這樣的:
在這裏插入圖片描述

UWA測試中的PSS內存曲線

整體偏高,但有升有降,後半部分還是趨於平緩的。但是爲什麼在這種連續開關ui的極限測試下會有這麼明顯的泄露現象呢?

復現之後,接下來的問題就是定位具體泄露的部分是什麼。因爲增長的是PSS內存,所以要分別看下各個部分的內存佔用變化。

首先懷疑的是貼圖等資源泄露了,因爲這種上百兆的內存增長,感覺資源泄露的可能比較大,同樣使用UWA的GOT工具來看Assets部分的變化,在針對一個ui頻繁開關的時候,並沒有什麼變化,使用Unity Profiler的Memory部分的Detail視圖在設備上來看也是沒有任何的變化的。

切換回Profiler的Simple視圖來看,發現Mono有很明顯的增長!整個測試做下來的話,可以從最初的30多M增長到大約160M+,這跟PSS的內存增長規模是比較契合的。也看了下日常UWA測試中Mono內存的增長曲線,並沒有特別明顯的泄露:
在這裏插入圖片描述
UWA測試中的Mono內存曲線

那麼,現在的結論就是——

看上去UI的頻繁開關會導致Mono內存的泄露!

4. 工具排查

定位了泄露的大頭是Mono內存之後,接下來就是要檢查具體的泄露內容是什麼。設計了一個簡單的測試用例,針對單個ui開關多次,查看前後的mono內存差異。

因爲觀察之前的mono內存會有降低的情況,說明在這個過程中會有GC的觸發,但是並不能釋放掉,所以感覺是真正的泄露,而不是由於沒有觸發到GC導致的“僞泄露”。基於這個推斷,在每次測試之後都手動調用一下完整的GC邏輯,來避免可以被回收內存的干擾。

首先使用UWA的Mono工具進行排查,通過Persistent模式看到的差異數據有些複雜:
在這裏插入圖片描述
UWA Got工具看到的Mono內存駐留情況

UWA目前的功能是每隔1000幀做一次Mono內存駐留的快照,這對於長時間的測試足夠了,而且對於運行性能以及內存影響比較小(最新版本的UWA GOT工具已經支持手動Sample了~~)。但是針對我們這種針對性的測試不是特別理想,看了下wetest,有手動snapshot的過程,申請了一個賬號試用了下。設計了一個單ui開關多次的測試用例,並且在開始和結束做了完整的GC。
在這裏插入圖片描述
通過差異,看似乎在界面的CreateUIChild邏輯中有泄露的可能,review相關的代碼,的確發現有子界面的UI在銷燬邏輯中存在泄露的情況。但是這部分的泄露應該沒有那麼嚴重,修復之後再做測試,對於整體內存增長的降低只有大約個位數,說明泄露的核心部分並不是這裏。

這時開始審視之前對於泄露的假設是否成立——是不是這部分內存的增長在某些情況下是可以被釋放的?於是做了一個很暴力的測試——在每次ui關閉的時候,都手動調用一次完整的GC流程,包括:

音頻等其他由邏輯觸發的資源釋放;

  • C#的GC:GC.Collect();
  • 釋放無用資源:Resources.UnloadUnusedAssets();
  • Lua的GC。

雖然測試的過程變得很卡,但是Mono內存是可以控制住的,整個測試下下來從之前的160M+降低到了峯值50M左右。

這就說明,泄露的大部分是可以被正常的GC回收的,只是有什麼東西Hold住了它,讓它無法被釋放。

5. 最終定位

後續的測試因爲手頭有其他事情,交給的團隊內的其他同事來幫忙做更加詳細的排查和處理。在發現Mono的增長部分其實是可以被GC的時候,逐個測試具體是哪部分的GC可以真正釋放這塊內存。前面已經列舉了一次完整的GC所包含的東西,逐個去掉來進行測試,最終發現是Lua的GC調用影響最大。

這就說明,是由於Lua對於C#對象的引用,導致C#的GC機制無法釋放掉對應的內存對象。

Lua自身是不會拿到C#的對象的,而是通過Tolua這個膠水層來處理。深入ToLua來看,會發現所有對象的引用都是由ObjectTranslator這個類來處理,其中使用了一個ObjectPool對C#對象進行存儲,Lua層拿到的是一個int形式的Handler。對於Lua層拿到的對象,會重寫其__gc函數,當Lua的GC執行的時候,會調用這一函數,從而釋放掉ObjectTranslator這層緩存的C#對象。
在這裏插入圖片描述
Collect函數的定義如下:
在這裏插入圖片描述
在這裏插入圖片描述
爲了驗證這部分泄露的情況,同事又在ToLua層添加了對於對象的監控,通過log diff的形式來排查是哪些對象被泄露在了這一層。最終證明的確是那些在Lua層被訪問過的對象,在不調用Lua GC的情況下會一直駐留在ObjectTranslator這一層。

我們來對整個邏輯做一下梳理和回顧:

  • 在沒有UI緩存的情況下,每創建一個ui,都會去初始化對應的prefab,並且Lua層會獲取自己需要設置的那些GameObject以及Component,這時候這些對象都會在ObjectTranslator這層有記錄;
  • 當UI關閉的時候,會調用GameObject.Destroy函數,將對應的C# GameObject銷燬;
  • 這時候,Lua中那些對於C#對象的應用並不會銷燬,因爲沒有調用Lua的GC,於是出現了ObjectTranslator這層依然保存着這些對象的引用的情況;
  • 由於Lua的內存增長比較慢,所以對於GC的觸發非常不頻繁;
  • C#部分GC的時候,對於這些在ObjectTranslator層記錄的對象,雖然它們在Unity眼中已經不再被使用了,與null的相等判定結果是true,但是作爲System.Object對象,它們實際上並不是null,而且在被ObjectTranslator對象引用,無法釋放佔用的內存空間,這就導致了內存的增長,即使觸發了C#的GC邏輯,也無法進行釋放;
  • 當Lua的GC被調用過一次之後,下次C#的GC就可以釋放掉這部分的對象。

6. 解決方案

對於跨語言的系統設計,內存釋放一直是要持續關注的部分。這次發現的問題並不是ToLua的bug,而是由於C#和Lua都是基於延遲清理的思路實現GC算法,再加上兩邊的GC無法同步進行而導致的。

雖然遊戲已經上線,對於C#部分的修改也比較難提交,但是我們還是討論和分析了一些解決方案。

  1. 比較理想的方式,其實是在C#觸發GC的時候,先去調用一次Lua的GC,這樣讓兩邊的GC有一個同步的過程,可以多地釋放掉無用的內存。但是這種方式不太好實現,貌似沒找到方便監聽系統觸發GC的邏輯。
  2. 使用更高頻率的Lua GC。我們之前Lua手動GC的方式是在狀態改變的時候,這次針對ui開關的測試是無法觸發到Lua的手動GC的,那麼一種思路是按照一個間隔來手動觸發Lua的GC,儘早釋放掉內存。但是這個實際其實比較難找,做不好會造成莫名其妙的頓卡。
  3. Tolua的作者蒙哥建議在關閉ui這樣的節點,手動做一下一個小Step的GC,這樣可以保證釋放掉一部分內存。這個Step的參數要自己調整好,過大會在關閉ui的時候造成頓卡,過小又沒辦法及時釋放內存。
  4. 在Lua中確定不再需要C#對象的時候,手動使用System.Object的Destroy函數進行釋放,這個Warp出來的函數Tolua做了特殊的處理,會調用Tolua.Destroy來進行釋放。這種就相當於針對這些對象放棄了自動GC的邏輯,需要手動進行釋放,好處是可以精準控制,但是壞處是很繁瑣,需要對於代碼做大量的重構。
    在這裏插入圖片描述
  5. 在C#層,做一個tick邏輯,每幀檢查ObjectTranslator中的objects中的一部分對象,如果是Unity.GameObject類型的,查看其是否等於null,如果作爲Unity.GameObject對象是null,而作爲System.Object對象不是null,說明這個對象已經被Unity標記爲銷燬了,Unity.GameObject重載的==運算符讓遊戲邏輯認爲它是空的,這時候C#對象可以提前銷燬掉,因爲即便Lua層想訪問它,也已經會報錯了。

我們目前選擇的是方法5來進行內部的測試,原因是這種方式對於Lua層代碼的改動最小,也能解決我們的大部分問題。當然這種方法的瑕疵是對於非Unity.GameObject類型的對象,也存在釋放不及時的問題,這種方式無法解決,另外引入了一個update邏輯,也有一些額外的性能消耗。

7. 總結

這個內存泄露的問題困擾了我們大約一個多周的時間,這裏記錄的只是一些排查的關鍵步驟,對於中間的思考、討論、對比等等細節無法完整地記錄。由於項目臨近上線,而合作方給予的測試用例也是一種比較極限的情況,所以最終線上的版本沒有修復這個問題。正常進行遊戲會有相對頻繁的狀態跳轉,因此會有手動觸發Lua GC的邏輯,可以讓Mono內存不會累積到100多兆那麼誇張的程度,因此對於玩家的影響不是很大。

這裏把這個問題排查的大致過程分享出來,也提醒同樣使用ToLua的其他項目可以提前關注下這部分的問題,當然更希望有更好解決方案的朋友來分享一下~

(就像前面提過的一樣,這裏的確有點懷念Python所使用的引用計數+標記清除的GC算法。我們只需要去解開循環引用,就可以讓腳本的對象觸發銷燬邏輯,進而釋放掉其對應的C#層的對象,這樣就可以保證C#的GC可以釋放掉應該釋放的對象……)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章