在閱讀這篇文章: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#
.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 在收拾垃圾的過程當中到底作了什麼呢?首先要了解 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.Collect()
的時候,GC 都會馬上能夠開始回收,沒有商量的餘地。因而全部線程都會被掛起(也並不都是這樣)
GC 會在 Generation 0 中開始巡查,若是是 死對象,就把他們的內存釋放,若是是 活的對象,那麼就標記這些對象。接着把這些活的對象升級到下一代:移動到下一代 Generation 1 中。
同理 在 Generation 1 中也是如此,釋放死對象,升級活對象。
三個 Generation 中,Generation 0 被 GC 清理的最頻繁,Generation 1 其次,Generation 2 被 GC 訪問的最少。由於要清理 Generation 2 的消耗太大了。
GC 在每個 Generation 進行清理都要進行三個步驟:
標記: GC 循環遍歷每個對象,給它們標記是 死對象 仍是 活對象
從新分配:從新分配活對象的引用
清理:將死對象釋放,將活對象移動到下一代中
GC 有兩種形式:WorkStation GC
和 Server 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
GC 有兩種模式:Concurrent
和 Non-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 進行配置
又來了一種新的 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 的同時,工做線程分配對象 和正常的時候分配對象 是不同的,它會對性能形成拖累。
堆內存的大小進行了限制: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的時候,把heap
和Segment
都分配好了。在Server GC
模式下,一個核心 CPU 對應一個進程,對應一個heap
, 而一個segment
大小 就是 limit / number of heaps
。
因此程序啓動時,若是分配CPU 是一核,那麼就會分配一個heap
,一個heap
中只有一個segment
,大小就是 limit
,GC 也不會再去問CLR要內存了。請注意這裏的 limit
和 GCHeapHardLimit
不是同一個,這裏的limit
應該就是容器內存限制。因此GC 堆大小是多少?就是容器的內存限制limit
特殊的判斷segment結束標誌,以判斷是否超過GCHeapHardLimit
若是發現,在 segment
中分配內存的時候超出了GCHeadHardLimit
,那麼不會把此次分配看作失敗的,因此就不會發生GC。結合上面兩點的鋪墊咱們能夠發現:
首先從上述代碼咱們能夠發現GCHeapHardLimit
只是一個數字而已。它就是一個閾值。
其次 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
太氣人了,那麼就手動修改它的數值吧。