Mono 環境下跟蹤和優化 .NET 程序內存分配
.NET 程序在 Mono 環境之下的內存分配和在 Microsoft .NET Framework 環境是不太相同的,雖然它們的兼容性很高,但仍有區別。尤其是在 GC 方面,Mono 的 GC 實現似乎仍有不足(在 3.0.6 版本下),據我觀察,有一些大對象(估計是二代對象,譬如很大的 byte[]
數組)的釋放仍然有些問題。下面就以 TCP 服務器端程序為例。
場景
假設在服務器端有這樣的語句,用來備份 SQLite 數據庫文件:
private void BackupDB(string dest)
{
byte[] content = getAllContent(this.DBFile);
File.WriteAllBytes(dest, Zip.Compress(content));
content = null;
}
private byte[] getAllContent(string path)
{
try
{
byte[] b;
using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
b = new byte[fs.Length];
fs.Read(b, 0, b.Length);
}
return b;
}
catch
{
throw new Exception("數據庫正在讀寫,請稍後再備份。");
}
}
備份數據庫需要讀取整個數據庫。這段代碼直接將整個數據庫的本地文件鏡像讀出,並放在了一個 Byte[]
數組之中,作為 getAllContent
函數的返回值。在讀取之後, getAllContent
方法的 b
中存儲了大量的字節。另外,在 BackupDB
函數中,使用 Zip.Compress
函數壓縮數據庫後,也會返回一個較大的 byte[] 數組。
經過測試,這段代碼在 Windows 的 .NET Framework 平臺下工作沒有問題。因為這些大對象可以幾乎在使用後即刻被銷燬。但是,在 Mono 下,即使在代碼的最後顯式調用 GC.Collect()
方法進行全代垃圾回收,也無法減少內存。
使用 Profiler 跟蹤內存分配和垃圾回收
雖然我們可能不知道原因出在哪裡,但是我們可以通過 Mono 自帶的工具來跟蹤內存分配。這裡用到的工具是 Profiler,這個工具只支持 Mono 2.9+,如果你使用的發行版過於穩定,可能需要安裝測試版本的 Mono 或手工編譯安裝 Mono 才可以使用這個工具。
Profiler 特別像在 Visual Studio 中的「性能分析」工具。它們的原理也大同小異。它們都是通過運行時採樣和日誌,來跟蹤程序運行的狀態。
具體來說,Profiler 會在託管程序運行時記錄這些內容:
- 方法進入和退出
- 對象分配
- 垃圾回收
- JIT 編譯
- 元數據加載
- 鎖上下文
- 異常
另外,它還提供了堆快照的功能,可以在你的程序進行過一次垃圾回收之後,記錄下目前託管堆的情況。
使用 Profile 日誌
要使用 Profiler 進行分析,首先要以附加參數啟動 Mono 代碼:
mono –profile=log program.exe
在程序運行結束後,在當前目錄下會生成一個 output.mlpd
文件。這個文件稍後可以被用來做性能分析。在程序運行結束之後,輸入命令
mprof-report output.mlpd
會生成分析報告。
你也可以使用一些參數,常用參數有
mono –profile=log:report program.exe
這樣可以不保留日誌,而是直接產生分析報告,有時候這可能很有用,因為日誌文件實在是太大了。
mono –profile=log:noalloc program.exe
noalloc
會導致日誌中不記錄分配情況。
mono –profile=log:nocalls program.exe
nocalls
不記錄調用方法。如果不使用 nocalls,要預留足夠大的硬盤空間。在我的實驗中,幾分鐘產生了 500MB 甚至 2GB 的數據(即使取消一些日誌,文件仍然很大)。
mono –gc=sgen –profile=log:heapshot program.exe
這可以記錄託管堆的快照。
mono –profile=log:sample program.exe
CPU 採樣,和 .NET 自帶的特別像,通過一般不超過 10% 的性能損耗,在運行時動態採樣,以便獲取關於 CPU 時間消耗在什麼運算中的第一手資料。
分析日誌
現在我們開始分析日誌,輸入命令
mprof-report output.mlpd
即可看到結果。
詳細的 Mono Profiler 使用方法請參考它的官方文檔。
兩處優化
使用 Stream 代替 byte[]
如果可能,在操作大量 byte[]
數據的地方,考慮使用 Stream 對象。比如,如果你使用 FileStream 操作大文件,你將不需要將文件性一次讀入內存。進行此項優化成功節約了 20% 的內存。但是要注意:
- 確保 Stream 的回收,儘可能使用
using
關鍵字來管理 Stream 的釋放 - 如果你使用的是
MemoryStream
,其內部實現其實是一個動態變長的byte[]
數組,當它增長到最大的時候,文件仍然是完全放在了內存之中。 - 最重要的是,你對數據的處理必須是流式的,如果你只是把 FileStream 分塊讀入到一個
byte[]
,DataTable
,MemoryStream
等中並作為參數進行某種操作(比如壓縮,如上述代碼中的 Zip.Compress(byte[] 對象)),那麼你仍然無法節約內存。你必須確保你對流的操作是分塊流式進行的。
壓縮是典型的非流式操作,因為壓縮算法幾乎都是基於上下文的,而不只是當前位置。
使用外部命令代替大內存操作
編寫另外一個可執行文件,這個文件不能是 dll,so 等模塊,保證其內存空間的獨立,然後將耗費內存的操作放在這個程序中,將數據的位置作為參數。好處是這個程序處完數據會退出,所有資源得到了充分的釋放。
為什麼不使用 GC.Collect()
- GC.Collect() 進行全代垃圾回收,暫停所有線程的運行,而且資源消耗大,對於對速度要求高的程序(比如服務器端程序)不太合適
- GC 未必可以正確的回收所有垃圾(如本文代碼在 Mono 環境下就無法正常回收)
- 外置程序可以在多核計算機中和原服務器中並行執行,因為數據處理很多時候是高 IO 操作,不會卡住服務器程序,邏輯簡單性能又高
通過弱引用(WeekReference)管理緩存
這並不是標準庫推薦的做法,但是如果你想簡單的防止程序緩存佔用過多的資源,可以使用它。詳細介紹可以移步 MSDN。
© 轉載需附帶本文連結,依 CC BY-NC-SA 4.0 釋出。