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

前言

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

Docker and cgroup memory Limitsgithub

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.docker

大概的意思呢就是在 .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 變化。c#

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。併發

  • 一個進程會被分配一個獨立的虛擬內存空間,咱們定義的和管理的對象都在這些空間之中。
    虛擬內存空間中的內存 有三種狀態:空閒 (能夠隨時分配對象),預約 (被某個進程預約,尚且不能分配對象),提交(從物理內存中分配了地址到該虛擬內存,這個時候才能夠分配對象)app

  • 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 實際上對一個高密度計算的程序是有性能提高的。

參考文章

========================更新=========================

對於.NET Core 3.0 GC的變化,我有針對Github 上做者的Merge Request 作出瞭如下總結:

.NET Core3.0 對GC 改動的 Merge Request

代碼就不看了,一是看不懂,二是根本沒發現對內存的限制,只是添加了獲取容器是否設置內存限制的代碼,和HeapHardLimit的宏定義,那就意味着,GCHeadHardLimit 只是一個閾值而已。由次可見,GCHeapHardLimit 屬於GC的一個小部件。

其中有一段很重要的總結,是.NET Core 3.0 GC的主要變化

// + we never need to acquire new segments. This simplies the perf
    // calculations by a lot.
    //
    // + we now need a different definition of "end of seg" because we
    // need to make sure the total does not exceed the limit.
    //
    // + if we detect that we exceed the commit limit in the allocator we
    // wouldn't want to treat that as a normal commit failure because that
    // would mean we always do full compacting GCs.
  • 首先就是,在有內存限制的 Docker 容器中,GC不須要去問虛擬內存要新的Segments,由於初始化CLR的時候,把heapSegment都分配好了。在Server GC 模式下,一個核心 CPU 對應一個進程,對應一個heap, 而一個segment 大小 就是 limit / number of heaps
    因此程序啓動時,若是分配CPU 是一核,那麼就會分配一個heap ,一個heap 中只有一個segment ,大小就是 limit ,GC 也不會再去問CLR要內存了。請注意這裏的 limitGCHeapHardLimit 不是同一個,這裏的limit 應該就是容器內存限制。因此GC 堆大小是多少?就是容器的內存限制limit

  • 特殊的判斷segment結束標誌,以判斷是否超過GCHeapHardLimit

  • 若是發現,在 segment 中分配內存的時候超出了GCHeadHardLimit ,那麼不會把此次分配看作失敗的,因此就不會發生GC。結合上面兩點的鋪墊咱們能夠發現:

    1. 首先從上述代碼咱們能夠發現GCHeapHardLimit只是一個數字而已。它就是一個閾值。

    2. 其次 GC堆的大小: 請注意,GC堆大小不是 HeapHardLimit 而是 容器內存限制 limit。GC 分配對象的時候,若是溢出了這個GCHeapHardLimit數字,GC 也會睜一隻眼閉一隻眼,不然只要溢出,它就要去整個heap中 GC 一遍。因此 GCHeadHardLimit 不是 GC堆申請的segment的大小,而是 GC 會管住本身的手腳,不能碰的東西咱儘可能不要去碰,要是真碰了,也只有那麼一次。

若是你的程序使用內存超出了GCHeapHardLimit閾值,segment 中仍是有空餘的,可是 GC 就是不用,它就是等着報OutOfMemoryException錯誤,並且docker根本殺不死你。

可是這並不表明GCHeapHardLimit的設置是不合理的,若是你的程序本身不能合理管理對象,或者你太摳門了,那麼神仙也乏術。

可是人家說了!GCHeapHardLimit 是能夠修改的!

// Users can specify a hard limit for the GC heap via GCHeapHardLimit or
    // a percentage of the physical memory this process is allowed to use via
    // GCHeapHardLimitPercent. This is the maximum commit size the GC heap 
    // can consume.
    //
    // The way the hard limit is decided is:
    // 
    // If the GCHeapHardLimit config is specified that's the value we use;
    // else if the GCHeapHardLimitPercent config is specified we use that 
    // value;
    // else if the process is running inside a container with a memory limit,
    // the hard limit is 
    // max (20mb, 75% of the memory limit on the container).

若是你以爲GCHeapHardLimit 太氣人了,那麼就手動修改它的數值吧。

相關文章
相關標籤/搜索