前言
在閱讀這篇文章: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 中還有一塊堆記憶體叫做
Large Object Heap 。它也是隸屬于 GC 管理,但是它很特别,隻配置設定大于 85000byte 的對象,是以叫做大對象,為什麼要這麼做呢?很顯然大對象太難管理了,GC 回收大對象将很耗時,是以沒辦法,隻有給這些 “大象” 另選一出房子,GC 這個“管理者” 很少管 “大象”。LOH
那麼什麼時候對象會被配置設定到堆記憶體中呢?
所有引用類型的對象,以及作為類屬性的值類型對象,都會配置設定在堆中。大于 85000byte 的對象扔到 “大象房” 裡。
堆記憶體中的對象越少,GC 幹的事情越少,你的程式就越快,因為 GC 在幹事的時候,程式中的其他線程都必須畢恭畢敬的站着不動(挂起),等 GC 說:我已經清理好了。然後大家才開始繼續忙碌。是以 GC 一直都是在幹幫線程擦屁股的事情。
是以沒有 GC 的程式設計語言更快,但是也更容易産生廢物。
GC Generation
那麼 GC 在收拾垃圾的過程中到底做了什麼呢?首先要了解 CLR 的 GC 有一個
Generation
**代 ** 的概念 GC 通過将對象分為三代,優化對象管理。GC 中的代分為三代:
-
零代或者叫做初代,初代中都是一些短命的對象,shorter object,它們通常會被很快清除。當 new 一個新對象的時候,該對象都會配置設定在 Generation 0 中。隻有一段連續的記憶體Generation 0
-
一代,一代中的對象也是短命對象,它相當于 shorter object 和 longer object 之間的緩沖區。隻有一段連續的記憶體Generation 1
-
二代,二代中的對象都是長壽對象,他們都是從零代和一代中選拔而來,一旦進入二代,那就意味着你很安全。之前說的 LOH 就屬于二代,static 定義的對象也是直接配置設定在二代中。包含多段連續的記憶體。Generation 2
零代和一代 占用的記憶體因為他們都是短暫對象,是以叫做短暫記憶體塊。 那麼他們占用的記憶體大小是多大?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 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
Concurrent GC 和 Non-Concurrent GC
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 進行配置
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 實際上對一個高密度計算的程式是有性能提升的。
參考文章
- https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals
- https://devblogs.microsoft.com/premier-developer/understanding-different-gc-modes-with-concurrency-visualizer/
- https://devblogs.microsoft.com/dotnet/running-with-server-gc-in-a-small-container-scenario-part-1-hard-limit-for-the-gc-heap/
- https://devblogs.microsoft.com/dotnet/running-with-server-gc-in-a-small-container-scenario-part-0/
- https://devblogs.microsoft.com/dotnet/announcing-net-core-3-preview-3/
更新=
對于.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不需要去問虛拟記憶體要新的
,因為初始化CLR的時候,把Segments
heap
都配置設定好了。在Segment
模式下,一個核心 CPU 對應一個程序,對應一個Server GC
, 而一個heap
大小 就是segment
limit / number of heaps
。
是以程式啟動時,如果配置設定CPU 是一核,那麼就會配置設定一個
,一個heap
中隻有一個heap
,大小就是segment
,GC 也不會再去問CLR要記憶體了。請注意這裡的limit
limit
不是同一個,這裡的GCHeapHardLimit
應該就是容器記憶體限制。是以GC 堆大小是多少?就是容器的記憶體限制limit
limit
- 特殊的判斷segment結束标志,以判斷是否超過
GCHeapHardLimit
- 如果發現,在
中配置設定記憶體的時候超出了segment
,那麼不會把這次配置設定看做失敗的,是以就不會發生GC。結合上面兩點的鋪墊我們可以發現:GCHeadHardLimit
- 首先從上述代碼我們可以發現
隻是一個數字而已。它就是一個門檻值。GCHeapHardLimit
- 其次 GC堆的大小: 請注意,GC堆大小不是 HeapHardLimit 而是 容器記憶體限制 limit。GC 配置設定對象的時候,如果溢出了這個
數字,GC 也會睜一隻眼閉一隻眼,否則隻要溢出,它就要去整個GCHeapHardLimit
中 GC 一遍。是以heap
不是 GC堆申請的GCHeadHardLimit
的大小,而是 GC 會管住自己的手腳,不能碰的東西咱盡量不要去碰,要是真碰了,也隻有那麼一次。segment
- 首先從上述代碼我們可以發現
如果你的程式使用記憶體超出了
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
太氣人了,那麼就手動修改它的數值吧。