今天講另一個大家可能遇過的狀況:程式噴出 Out Of Memory 記憶體不足錯誤,也確信它用到大量記憶體,但打開工作管理員檢查,該程式的記憶體用量卻只有幾百 MB,看起來很平常。

要探討這個問題,先要用範例程式重現情境。

昨天文章最後有個持續建立大型 byte[] 物件耗光記憶體的程式範例,32bit 程式用 2M 大小的 byte[] 狂塞約可衝到 1.5G。我們修改程式,把 2M byte[] 換成 2M 大小的 String (字串長度 1M,每個字元 2 Bytes,物件大小 2M),物件大小相近,實測一樣可以飆出 1.5G。

static void Main(string[] args)
{
    try
    {
        if (args.Length < 1)
            throw new ApplicationException("missing objType argument");
        var objType = (ObjectTypes)Enum.Parse(typeof(ObjectTypes), args[0]);
        Test_Use_All_Mem(objType);
    }
    catch (Exception ex)
    {
        Console.WriteLine("ERROR-" + ex.Message);
    }
    Console.ReadLine();
}

static void DumpMemSize(string msg)
{
    var memSz = GC.GetTotalMemory(false) / 1024 / 1024;
    Console.WriteLine($"Managed Heap = {memSz}MB {msg}");
}
enum ObjectTypes { String, ByteArray }
static void Test_Use_All_Mem(ObjectTypes objType)
{
    Func<int, byte[]> createBigArray = (mb) =>
    {
        return new byte[mb * 1024 * 1024];
    };
    var list = new List<object>();
    System.Threading.Thread.Sleep(2000);
    int i = 1;
    try
    {
        while (true)
        {
            if (objType == ObjectTypes.ByteArray)
                list.Add(createBigArray(2));
            else if (objType == ObjectTypes.String)
                //String stored as UCS-2 encoding, 2 bytes/char
                list.Add(new String('A', 1 * 1024 * 1024));
            else
                throw new NotImplementedException();
            i++;
        }
    }
    catch (Exception ex)
    {
        DumpMemSize($"{i}*2MB {objType}");
        Console.WriteLine("ERROR-" + ex.Message);
    }
}

二者能吃掉的記憶體量相近,但開啟工作管理員檢查程式的記憶體使用情況,卻會發現一件有趣的事。

用 2M String 塞滿 1.5G,工作管理員顯示的記憶體用量也是 1.5G:

用 2M byte[] 塞滿 1.5G,工作管理員顯示的記憶體用量卻只有 8.4M!!

解答疑惑前,先溫習程式取用記憶體的步驟:

  1. 呼叫 VirtualAlloc() API 保留(Reserve) (flAllocationType 參數傳 MEM_RESERVE) 指定長度的虛擬記憶體位址,此時程序僅確保該段位址不會移作他用,還沒對映到實體記憶體(或簡稱 RAM)或 Pagefile (用磁碟空間模擬的記憶體)。
  2. 程式嘗試存取虛擬記憶體位址時,若該位置尚未對映 RAM 或 Pagefile,將觸發分頁錯誤 (Page Fault),此時再叫用 VirtualAlloc() 執行 Commit (flAllocationType 參數傳入 MEM_COMMIT),Windows 會以記憶體分頁(Page)為單位取得記憶體(RAM 或 Pagefile)對映虛擬記憶體位址,讓該段記憶體位址可用來讀寫資料。
  3. 使用完畢,呼叫 VirtualFree 釋放先前 Commit 的記憶體分頁

其中巧妙在於 VirtualAlloc() MEM_COMMIT 時,作業系統還不會真的配置 RAM,依據 VirtualAlloc() API 文件

Allocates memory charges (from the overall size of memory and the paging files on disk) for the specified reserved memory pages. The function also guarantees that when the caller later initially accesses the memory, the contents will be zero. Actual physical pages are not allocated unless/until the virtual addresses are actually accessed.

Commit 時,Windows 並不會馬上配置實體記憶體分頁,要一直等到程式存取內容時才會,「有必要才配置」(把 RAM 用在刀口上)策略可提高 RAM 的使用效率。一般我們在工作管理員常說的「記憶體用量」,正式術語為「 Memory - Active Private Working Set / 記憶體(使用中的私人工作集)」,嚴謹定義是「程序 Commit 取得且不與其他程序共用的實體記憶體量 (不含 Suspended UWP 程序)」。Working Set 是指 Committed 中已配置實體記憶體的部分(換言之,Committed = Working Set + Pagefile),Private 是指 Committed 中該程序專用不與其他程序共用的部分,Active 則指不含 Suspended UWP 暫停狀態的部分。

在工作管理員欄位按右鍵選擇欄位加入「認可大小」(Committed),可得到 1.5G 左右,此一數字接近 GC.GetTotalMemory(false) 值。

所以,用 2M byte[] 塞滿 1.5G,Committed 值會上升到 1.5G,但因為我們沒並沒有存取 byte[] 內容,Windows 不會急著找 RAM 放資料。

依據這個理論,只要我們存取 byte[] 內容,就會開始消耗 RAM,來試試。修改 byte[] 建立函式,由於記憶體分頁大小為 4K,故我每隔 4096 修改一個 byte(讀取也成),確保 byte[] 涵蓋的每個分頁都被存取到,強迫 Windows 找 RAM 來對映:

Func<int, byte[]> createBigArray = (mb) =>
{
    var b = new byte[mb * 1024 * 1024];
    for (int j = 0; j < b.Length; j += 4096)
        b[j] = 255;
    return b;
};

測試成功。

另外還有一種狀況,是 Windows 會在記憶體吃緊時將不常存取的記憶體分頁由 RAM 移到 Pagefile,也可能是 Working Set 減少的原因,但一般多發生於閒置不活躍的程式,實務上不難區分。

【參考資料】

This article explains the difference between committed size and working set size.


Comments

Be the first to post a comment

Post a comment