前言
在閱讀這篇文章: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/