前言
在閱讀這篇文章: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 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/
朋友會在“發現-看一看”看到你“在看”的內容