從ASP.NET Core 3.0 preview 特性,瞭解CLR的Garbage Collection

Shendu.CC發表於2019-06-14

前言

在閱讀這篇文章:Announcing Net Core 3 Preview3的時候,我看到了這樣一個特性:

Docker and cgroup memory Limits

We concluded that the primary fix is to set a GC heap maximum significantly lower than the overall memory limit as a default behavior. In retrospect, this choice seems like an obvious requirement of our implementation. We also found that Java has taken a similar approach, introduced in Java 9 and updated in Java 10.

大概的意思呢就是在 .NET Core 3.0 版本中,我們已經通過修改 GC 堆記憶體的最大值,來避免這樣一個情況:在 docker 容器中執行的 .NET Core 程式,因為 docker 容器記憶體限制而被 docker 殺死。

恰好,我在 docker swarm 叢集中跑的一個程式,總是被 docker 殺死,大都是因為記憶體超出了限制。那麼升級到 .NET Core 3.0 是不是會起作用呢?這篇文章將淺顯的瞭解 .NET Core 3.0 的 Garbage Collection 機制,以及 Linux 的 Cgroups 核心功能。最後再寫一組 實驗程式 去真實的瞭解 .NET Core 3.0 帶來的 GC 變化。

GC

CLR

.NET 程式是執行在 CLR : Common Language Runtime 之上。CLR 就像 JAVA 中的 JVM 虛擬機器。CLR 包括了 JIT 編譯器,GC 垃圾回收器,CIL CLI 語言標準。

那麼 .NET Core 呢?它執行在 CoreCLR 上,是屬於 .NET Core 的 Runtime。二者大體我覺得應該差不多吧。所以我介紹 CLR 中的一些概念,這樣才可以更好的理解 GC

  • 我們的程式都是在操作虛擬記憶體地址,從來不直接操作記憶體地址,即使是 Native Code。

  • 一個程式會被分配一個獨立的虛擬記憶體空間,我們定義的和管理的物件都在這些空間之中。
    虛擬記憶體空間中的記憶體 有三種狀態:空閒 (可以隨時分配物件),預定 (被某個程式預定,尚且不能分配物件),提交(從實體記憶體中分配了地址到該虛擬記憶體,這個時候才可以分配物件)

  • CLR 初始化GC 後,GC 就在上面說的虛擬記憶體空間中分配記憶體,用來讓它管理和分配物件,被分配的記憶體叫做 Managed Heap 管理堆,每個程式都有一個管理堆記憶體,程式中的執行緒共享一個管理堆記憶體

  • CLR 中還有一塊堆記憶體叫做LOH Large Object Heap 。它也是隸屬於 GC 管理,但是它很特別,只分配大於 85000byte 的物件,所以叫做大物件,為什麼要這麼做呢?很顯然大物件太難管理了,GC 回收大物件將很耗時,所以沒辦法,只有給這些 “大象” 另選一出房子,GC 這個“管理員” 很少管 “大象”。

那麼什麼時候物件會被分配到堆記憶體中呢?

所有引用型別的物件,以及作為類屬性的值型別物件,都會分配在堆中。大於 85000byte 的物件扔到 “大象房” 裡。

堆記憶體中的物件越少,GC 乾的事情越少,你的程式就越快,因為 GC 在幹事的時候,程式中的其他執行緒都必須畢恭畢敬的站著不動(掛起),等 GC 說:我已經清理好了。然後大家才開始繼續忙碌。所以 GC 一直都是在幹幫執行緒擦屁股的事情。

所以沒有 GC 的程式語言更快,但是也更容易產生廢物。

GC Generation

那麼 GC 在收拾垃圾的過程中到底做了什麼呢?首先要了解 CLR 的 GC 有一個Generation 的概念 GC 通過將物件分為三代,優化物件管理。GC 中的代分為三代:

  • Generation 0 零代或者叫做初代,初代中都是一些短命的物件,shorter object,它們通常會被很快清除。當 new 一個新物件的時候,該物件都會分配在 Generation 0 中。只有一段連續的記憶體

  • Generation 1 一代,一代中的物件也是短命物件,它相當於 shorter object 和 longer object 之間的緩衝區。只有一段連續的記憶體

  • Generation 2 二代,二代中的物件都是長壽物件,他們都是從零代和一代中選拔而來,一旦進入二代,那就意味著你很安全。之前說的 LOH 就屬於二代,static 定義的物件也是直接分配在二代中。包含多段連續的記憶體

零代和一代 佔用的記憶體因為他們都是短暫物件,所以叫做短暫記憶體塊。 那麼他們佔用的記憶體大小是多大?32位和63位的系統是不一樣的,不同的GC型別也是不一樣的。

WorkStation GC:

32 位作業系統 16MB ,64位 作業系統 256M

Server GC:

32 w位作業系統 65MB,64 位作業系統 4GB!

GC 回收過程

當 管理堆記憶體中使用到達一定的閾值的時候,這個閾值是GC 決定的,或者系統記憶體不夠用的時候,或者呼叫 GC.Collect() 的時候,GC 都會立刻可以開始回收,沒有商量的餘地。於是所有執行緒都會被掛起(也並不都是這樣)

GC 會在 Generation 0 中開始巡查,如果是 死物件,就把他們的記憶體釋放,如果是 活的物件,那麼就標記這些物件。接著把這些活的物件升級到下一代:移動到下一代 Generation 1 中。

同理 在 Generation 1 中也是如此,釋放死物件,升級活物件。

三個 Generation 中,Generation 0 被 GC 清理的最頻繁,Generation 1 其次,Generation 2 被 GC 訪問的最少。因為要清理 Generation 2 的消耗太大了。

GC 在每一個 Generation 進行清理都要進行三個步驟:

  • 標記: GC 迴圈遍歷每一個物件,給它們標記是 死物件 還是 活物件

  • 重新分配:重新分配活物件的引用

  • 清理:將死物件釋放,將活物件移動到下一代中

WorkStation GC 和 Server GC

GC 有兩種形式:WorkStation GCServer GC

預設的.NET 程式都是 WorkStation GC ,那麼 WorkStation GC 和 Server GC 有什麼區別呢。

上面已經提到一個區別,那就是 Server GC 的 Generation 記憶體更大,64位作業系統 Generation 0 的大小居然有4G ,這意味著啥?在不呼叫GC.Collect 的情況下,4G 塞滿GC 才會去回收。那樣效能可是有很大的提升。但是一旦回收了,4GB 的“垃圾” 也夠GC 喝一壺的了。

還有一個很大的區別就是,Server GC 擁有專門用來處理 GC的執行緒,而WorkStation GC 的處理執行緒就是你的應用程式執行緒。WorkStation 形式下,GC 開始,所有應用程式執行緒掛起,GC選擇最後一個應用程式執行緒用來跑GC,直到GC 完成。所有執行緒恢復。

而ServerGC 形式下: 有幾核 CPU ,那麼就有幾個專有的執行緒來處理 GC。每個執行緒都一個堆進行GC ,不同的堆的物件可以相互引用。

所以在GC 的過程中,Server GC 比 WorkStation GC 更快。但是有專有執行緒,並不代表可以並行GC 哦。

上面兩個區別,決定了 Server GC 用於對付高吞吐量的程式,而WorkStation GC 用於一般的客戶端程式足以。

如果你的.NET 程式正在疲於應付 高併發,不妨開啟 Server GC : https://docs.microsoft.com/en-us/dotnet/framework/configure-apps/file-schema/runtime/gcserver-element

Concurrent GC 和 Non-Concurrent GC

GC 有兩種模式:ConcurrentNon-Concurrent,也就是並行 GC 和 不併行 GC 。無論是 Server GC 還是 Concurrent GC 都可以開啟 Concurrent GC 模式或者關閉 Concurrent GC 模式。

Concurrent GC 當然是為了解決上述 GC 過程中所有執行緒掛起等待 GC 完成的問題。因為工作執行緒掛起將會影響 使用者互動的流暢性和響應速度。

Concurrent 並行實際上 只發生在Generation 2 中,因為 Generation 0 和 Generation1 的處理是在太快了,相當於工作執行緒沒有阻塞。

在 GC 處理 Generation 2 中的第一步,也就是標記過程中,工作執行緒是可以同步進行的,工作執行緒仍然可以在 Generation 0 和 Generation 1 中分配物件。

所以並行 GC 可以減少工作程式因為GC 需要掛起的時間。但是與此同時,在標記的過程中工作程式也可以繼續分配物件,所以GC佔用的記憶體可能更多。

而Non-Concurrent GC 就更好理解了。

.NET 預設開啟了 Concurrent 模式,可以在 https://docs.microsoft.com/en-us/dotnet/framework/configure-apps/file-schema/runtime/gcconcurrent-element 進行配置

Background GC

又來了一種新的 GC 模式: Background GC 。那麼 Background GC 和 Concurrent GC 的區別是什麼呢?在閱讀很多資料後,終於搞清楚了,因為英語水平不好。以下內容比較重要。

首先:Background GC 和 Concurrent GC 都是為了減少 因為 GC 而掛起工作執行緒的時間,從而提升使用者互動體驗,程式響應速度。

其次:Background GC 和 Concurrent GC 一樣,都是使用一個專有的GC 執行緒,並且都是在 Generation 2 中起作用。

最後:Background GC 是 Concurrent GC 的增強版,在.NET 4.0 之前都是預設使用 Concurrent GC 而 .NET 4.0+ 之後使用Background GC 代替了 Concurrent GC。

那麼 Background GC 比 Concurrent GC 多了什麼呢:

之前說到 Concurrent GC 在 Generation 2 中進行清理時,工作執行緒仍然可以在 Generation 0/1 中進行分配物件,但是這是有限制的,當 Generation 0/1 中的記憶體片段 Segment 用完的時候,就不能再分配了,知道 Concurrent GC 完成。而 Background GC 沒有這個限制,為啥呢?因為 Background GC 在 Generation 2 中進行清理時,允許了 Generation 0/1 進行清理,也就說是當 Generation 0/1 的 Segment 用完的時候, GC 可以去清理它們,這個GC 稱作 Foreground GC ( 前臺GC ) ,Foreground GC 清理完之後,工作執行緒就可以繼續分配物件了。

所以 Background GC 比 Concurrent GC 減少了更多 工作執行緒暫停的時間。

GC 的簡單概念就到這裡了以上是閱讀大量英文資料的精短總結,如果有寫錯的地方還請斧正。

作為最後一句總結GC的話:並不是使用了 Background GC 和 Concurrent GC 的程式執行速度就快,它們只是提升了使用者互動的速度。因為 專有的GC 執行緒會對CPU 造成拖累,此外GC 的同時,工作執行緒分配物件 和正常的時候分配物件 是不一樣的,它會對效能造成拖累。

.NET Core 3.0 的變化

  • 堆記憶體的大小進行了限制:max (20mb , 75% of memory limit on the container)

  • ServerGC 模式下 預設的Segment 最小是16mb, 一個堆 就是 一個segment。這樣的好處可以舉例來說明,比如32核伺服器,執行一個記憶體限制32 mb的程式,那麼在Server GC 模式下,會分配32個Heap,每個Heap 大小是1mb。但是現在,只需要分配2個Heap,每個Heap 大小16mb。

  • 其他的就不太瞭解了。

實際體驗

從開頭的 介紹 ASP.NET Core 3.0 文章中瞭解到 ,在 Docker 中,對容器的資源限制是通過 cgroup 實現的。cgroup 是 Linux 核心特性,它可以限制 程式組的 資源佔用。當容器使用的記憶體超出docker的限制,docker 就會將改容器殺死。在之前 .NET Core 版本中,經常出現 .NET Core 應用程式消耗記憶體超過了docker 的 記憶體限制,從而導致被殺死。而在.NET Core 3.0 中這個問題被解決了。

為此我做了一個實驗。

這是一段程式碼:

using System;
using System.Collections.Generic;
using System.Threading;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
                if (GCSettings.IsServerGC == true)
                    Console.WriteLine("Server GC");
            else
                    Console.WriteLine("GC WorkStationGC");
            byte[] buffer;
            for (int i = 0; i <= 100; i++)
            {
                buffer = new byte[ 1024 * 1024];


                Console.WriteLine($"allocate number {i+1} objet ");

                var num = GC.CollectionCount(0);
                var usedMemory = GC.GetTotalMemory(false) /1024 /1024;

                Console.WriteLine($"heap use {usedMemory} mb");
                Console.WriteLine($"GC occurs {num} times");
                Thread.Sleep(TimeSpan.FromSeconds(5));
           }
        }
    }
}

這段程式碼是在 for 迴圈 分配物件。buffer = new byte[1024 * 1024] 佔用了 1M 的記憶體
這段程式碼分別在 .NET Core 2.2 和 .NET Core 3.0 執行,完全相同的程式碼。執行的記憶體限制是 9mb

.NET Core 2.2 執行的結果是:

GC WorkStationGC
allocate number 1 objet 
heap use 1 mb
GC occurs 0 times
allocate number 2 objet 
heap use 2 mb
GC occurs 0 times
allocate number 3 objet 
heap use 3 mb
GC occurs 0 times
allocate number 4 objet 
heap use 1 mb
GC occurs 1 times
allocate number 5 objet 
heap use 2 mb
GC occurs 1 times
allocate number 6 objet 
heap use 3 mb
GC occurs 1 times
allocate number 7 objet 
heap use 4 mb
GC occurs 2 times
allocate number 8 objet 
heap use 5 mb
GC occurs 3 times
allocate number 9 objet 
heap use 6 mb
GC occurs 4 times
allocate number 10 objet 
heap use 7 mb
GC occurs 5 times
allocate number 11 objet 
heap use 8 mb
GC occurs 6 times
allocate number 12 objet 
heap use 9 mb

Exit

首先.NET Core 2.2預設使用 WorkStation GC ,當heap使用記憶體到達9mb時,程式就被docker 殺死了。

在.NET Core 3.0 中

GC WorkStationGC
allocate number 1 objet 
heap use 1 mb
GC occurs 0 times
allocate number 2 objet 
heap use 2 mb
GC occurs 0 times
allocate number 3 objet 
heap use 3 mb
GC occurs 0 times
allocate number 4 objet 
heap use 1 mb
GC occurs 1 times
allocate number 5 objet 
heap use 2 mb
GC occurs 1 times
allocate number 6 objet 
heap use 3 mb
GC occurs 1 times
allocate number 7 objet 
heap use 1 mb
GC occurs 2 times
allocate number 8 objet 
heap use 2 mb
GC occurs 2 times
allocate number 9 objet 
heap use 3 mb
GC occurs 2 times
....

執行一直正常沒問題。

二者的區別就是 .NET Core 2.2 GC 之後,堆記憶體沒有減少。為什麼會發生這樣的現象呢?

一下是我的推測,沒有具體跟蹤GC的執行情況
首先定義的佔用 1Mb 的物件,由於大於 85kb 都存放在LOH 中,Large Object Heap,前面提到過。 GC 是很少會處理LOH 的物件的, 除非是 GC heap真的不夠用了(一個GC heap包括 Large Object Heap 和 Small Object Heap)由於.NET Core 3.0 對GC heap大小做了限制,所以當heap不夠用的時候,它會清理LOH,但是.NET Core 2.2 下認為heap還有很多,所以它不清理LOH ,導致程式被docker殺死。

我也試過將分配的物件大小設定小於 85kb, .NET Core 3.0 和.NET Core2.2 在記憶體限制小於10mb都可以正常執行,這應該是和 GC 在 Generation 0 中的頻繁清理的機制有關,因為清理幾乎不消耗時間,不像 Generation 2, 所以在沒有限制GC heap的情況也可以執行。

我將上述程式碼 釋出到了 StackOverFlow 和Github 進行提問,

https://stackoverflow.com/questions/56578084/why-doesnt-heap-memory-used-go-down-after-a-gc-in-clr

https://github.com/dotnet/coreclr/issues/25148

有興趣可以探討一下。

總結

.NET Core 3.0 的改動還是很大滴,以及應該根據自己具體的應用場景去配置GC ,讓GC 發揮最好的作用,充分利用Microsoft 給我們的許可權。比如啟用Server GC 對於高吞吐量的程式有幫助,比如禁用 Concurrent GC 實際上對一個高密度計算的程式是有效能提升的。

參考文章

相關文章