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[]DataTableMemoryStream 等中並作為參數進行某種操作(比如壓縮,如上述代碼中的 Zip.Compress(byte[] 對象)),那麼你仍然無法節約內存。你必須確保你對流的操作是分塊流式進行的。

壓縮是典型的非流式操作,因為壓縮算法幾乎都是基於上下文的,而不只是當前位置。

使用外部命令代替大內存操作

編寫另外一個可執行文件,這個文件不能是 dll,so 等模塊,保證其內存空間的獨立,然後將耗費內存的操作放在這個程序中,將數據的位置作為參數。好處是這個程序處完數據會退出,所有資源得到了充分的釋放。

為什麼不使用 GC.Collect()

  • GC.Collect() 進行全代垃圾回收,暫停所有線程的運行,而且資源消耗大,對於對速度要求高的程序(比如服務器端程序)不太合適
  • GC 未必可以正確的回收所有垃圾(如本文代碼在 Mono 環境下就無法正常回收)
  • 外置程序可以在多核計算機中和原服務器中並行執行,因為數據處理很多時候是高 IO 操作,不會卡住服務器程序,邏輯簡單性能又高

通過弱引用(WeekReference)管理緩存

這並不是標準庫推薦的做法,但是如果你想簡單的防止程序緩存佔用過多的資源,可以使用它。詳細介紹可以移步 MSDN。

当前页阅读量为: