GC 的 STW 問題#
GC,垃圾回收器,本質上是一種能夠自動管理自己分配的內存的生命周期的內存分配器。這種方法被大多數流行編程語言采用,然而當你使用垃圾回收器時,你會失去對應用程序如何管理內存的控制。C# 允許在自動控制內存的基礎之上局部對內存進行手動控制,但是自動控制仍然是主要的場景。
然而 GC 總是需要暫停程序的運行以遍歷和識別存活的對象,從而刪除無效對象以及進行維護操作(例如通過移動對象到更緊湊的內存區域以減少內存碎片,這個過程也叫做壓縮)。GC 暫停整個程序的行為也叫做 STW(Stop-The-World)。這個暫停時間越長,對應用的影響越大。
長期以來,.NET 的 GC 都一直在朝著優化吞吐量性能和內存占用的方向不斷優化,這對于 Web 應用以及跑在容器中的服務而言非常適合。而在客戶端、游戲和金融領域,開發人員一直都需要格外注意代碼中的分配問題,例如使用對象池、值類型以及非托管內存等等,避免產生大量的垃圾和各種 GC 難以處理的反模式,以此來減少 GC 的單次暫停時間。例如在游戲中,要做到 60fps,留給每一幀的時間只有 16ms,這其中如果 GC 單次暫停時間過長,用戶就會觀察到明顯的掉幀。
Workstation GC?Server GC?DATAS GC?#
.NET 一直以來都有兩種 GC 模式 —— Workstation GC 和 Server GC。
Workstation GC 是 .NET 最古老的 GC 模式,其目標之一是最小化內存占用,以適配資源有限的場景。在 Workstation GC 中,它只會利用你一個 CPU 核心,因此哪怕你有多核的計算資源,Workstation GC 也不會去使用它們來優化分配性能,雖然 Workstation GC 同樣支持后臺回收,但即使開啟后臺回收,Workstation GC 也之多只會用一個后臺線程。這么一來其性能發揮就會受到不小的限制。面對大量分配和大量回收場景時 Workstation GC 則顯得力不從心。不過,當你的應用很輕量并且不怎么分配內存的時候,Workstation GC 將是一個很適合的選擇。
而之后誕生的 Server GC 則可以有效的利用多核計算資源,根據 CPU 核心數量來控制托管堆數量,大幅度提升了吞吐量。然而 Server GC 的缺點也很明顯——內存占用大。另外,Server GC 雖然通過并發 GC 等方式將一部分工作移動到 STW 之外,從而使得 GC 和應用程序可以同時運行,讓 STW 得到了不小的改進,然而 Server GC 的暫停時間仍然稱不上優秀,雖然在 Web 服務等應用場景下表現得不錯,然而在一些極端情況下則可能需要暫停上百毫秒。
為了進一步改善 Server GC 的綜合表現,.NET 9 引入了新的 DATAS GC,試圖在優化內存占用的同時提升暫停時間表現。這個 GC 通過引入各種啟發算法自適應應用場景來最小化內存占用的同時,也改善了暫停時間。測試表明 DATAS GC 相比 Server GC 雖然犧牲了個位數百分比的吞吐量性能,卻成功的減少了 70%~90% 的內存占用的同時,暫停時間也縮減到 Server GC 的 1/3。
然而,這仍然不能算是完美的解決方案。開發者們都是抱著既要又要還要的心理,需要的是一個既能做到大吞吐量,暫停時間又短,同時內存占用還小的 GC。
因此,.NET 全新的 GC —— 在 .NET Runtime 核心成員幾年的努力下誕生了!這就是接下來我要講的 Satori GC。
Satori GC
為了讓 GC 能夠正確追蹤對象,在不少語言中,編譯器會給存儲操作插入一個寫屏障。在寫屏障中 GC 會更新對象的引用從而確保每一個對象都能夠正確被追蹤。這么做的好處很明顯,相比讀操作而言,寫操作更少,將屏障分擔到每次的寫操作里顯然是一個更有效率的方法。然而這么做的壞處也很明顯:當 GC 需要執行壓縮操作時不得不暫停整個程序,避免代碼訪問到無效的內存地址。
而 JVM 上的一些低延時 GC 則放棄了寫屏障,轉而使用讀屏障,在每次讀取內存地址的時候通過插入屏障來確保始終拿到的是最新的內存地址,來避免無效地址訪問。然而讀操作在應用中非常頻繁,這么做雖然能夠使得 GC 執行壓縮操作時不再需要暫停整個程序,卻會不可避免地帶來性能的損失。
GC 執行壓縮操作雖然開銷很大,但相對于釋放操作而言只是少數,為了少數的操作能夠并發執行拖慢所有的讀操作顯得有些得不償失。另外,在 .NET 上,由于 .NET 支持內部指針和固定對象的內存地址,因此讀屏障在 .NET 上實現較為困難,并且會帶來吞吐量的嚴重下降,在許多場景下難以接受。
.NET 的新低延時高吞吐自適應 GC —— Satori GC 仍然采用 Dijkstra 風格的寫屏障設計,因此吞吐量性能仍然能夠匹敵已有的 Server GC。
另外,Satori GC 采用了分代、增量并發回收設計,所有與堆大小成比例的主要 GC 階段都會與應用程序線程并發執行,完全不需要暫停應用程序,除了壓縮過程之外。不過,壓縮僅僅是 GC 可以執行但不是必須執行的一個可選項。例如 C++/Rust 的內存分配器也不會進行壓縮,但仍能正常運行;Go 的 GC 也不會進行壓縮。
除了標準模式之外,Satori GC 還提供了低延時模式。在這個模式下 Satori GC 直接關閉了壓縮功能,通過犧牲少量的內存占用來獲取更低的延時。在某些情況下,因為垃圾回收發生得更快,或者壓縮本身并沒有帶來太多實際收益,內存占用反而會變得更小。例如在一些 Web 場景,大量對象只存活在單次請求期間,然后很快就會被清除。既然這些對象很快都會變成垃圾,那為什么要進行壓縮呢?
與 Go 的徹底不進行壓縮的 GC 不同,Satori GC 可以在運行時動態切換壓縮的開關狀態,以適應不同的應用場景。想要開啟低延時模式,只需要執行 GCSettings.LatencyMode = GCLatencyMode.LowLatency
即可。在需要極低延時的場景(例如高幀率游戲或金融實時交易系統)中,這一設置可以有效減少 GC 暫停時間。
Satori GC 還允許開發者根據需要關閉 Gen 0:畢竟不是所有的場景/應用都能從 Gen 0 中獲益。當應用程序并不怎么用到 Gen 0 時,為了支持 Gen 0 在寫屏障中做的額外操作反而會拖慢性能。目前可以通過設置環境變量 DOTNET_gcGen0=0
來關閉 Gen 0,不過在 Satori GC 計劃中,將會實現根據實際應用場景自動決策 Gen 0 的開啟與關閉。
性能測試
說了這么多,新的 Satori GC 到底療效如何呢?讓我們擺出來性能測試來看看。
首先要說的是,測試前需要設置 <TieredCompilation>false</TieredCompilation>
關閉分層編譯,因為 tier-0 的未優化代碼會影響對象的生命周期,從而影響 GC 行為。
測試場景 1
Unity 有一個 GC 壓力測試,游戲在每次更新都需要渲染出一幀畫面,而這個測試則模擬了游戲在每幀中分配大量的數據,但是卻不渲染任何的內容,從而通過單幀時間來反映 GC 的實際暫停。
代碼如下:
Copy
class Program
{
const int kLinkedListSize = 1000;
const int kNumLinkedLists = 10000;
const int kNumLinkedListsToChangeEachFrame = 10;
private const int kNumFrames = 100000;
private static Random r = new Random();
class ReferenceContainer
{
public ReferenceContainer rf;
}
static ReferenceContainer MakeLinkedList()
{
ReferenceContainer rf = null;
for (int i = 0; i < kLinkedListSize; i++)
{
ReferenceContainer link = new ReferenceContainer();
link.rf = rf;
rf = link;
}
return rf;
}
static ReferenceContainer[] refs = new ReferenceContainer[kNumLinkedLists];
static void UpdateLinkedLists(int numUpdated)
{
for (int i = 0; i < numUpdated; i++)
{
refs[r.Next(kNumLinkedLists)] = MakeLinkedList();
}
}
static void Main(string[] args)
{
GCSettings.LatencyMode = GCLatencyMode.LowLatency;
float maxMs = 0;
UpdateLinkedLists(kNumLinkedLists);
Stopwatch totalStopWatch = new Stopwatch();
Stopwatch frameStopWatch = new Stopwatch();
totalStopWatch.Start();
for (int i = 0; i < kNumFrames; i++)
{
frameStopWatch.Start();
UpdateLinkedLists(kNumLinkedListsToChangeEachFrame);
frameStopWatch.Stop();
if (frameStopWatch.ElapsedMilliseconds > maxMs)
maxMs = frameStopWatch.ElapsedMilliseconds;
frameStopWatch.Reset();
}
totalStopWatch.Stop();
Console.WriteLine($"Max Frame: {maxMs}, Avg Frame: {(float)totalStopWatch.ElapsedMilliseconds/kNumFrames}");
}
}
測試結果如下:
GC | 最大幀時間 | 平均幀時間 | 峰值內存占用 |
---|
Server GC | 323 ms | 0.049ms | 5071.906 MB |
DATAS GC | 139 ms | 0.146ms | 1959.301 MB |
Workstation GC | 23 ms | 0.563 ms | 563.363 MB |
Satori GC | 26 ms | 0.061 ms | 1449.582 MB |
Satori GC (低延時) | 8 ms | 0.050 ms | 1540.891 MB |
Satori GC (低延時,關 Gen 0) | 3 ms | 0.042 ms | 1566.848 MB |
可以看到 Satori GC 在擁有 Server GC 的吞吐量性能同時(平均幀時間),還擁有著優秀的最大暫停時間(最大幀時間)。
測試場景 2
在這個測試中,代碼中將產生大量的 Gen 2 -> Gen 0 的反向引用讓 GC 變得非常繁忙,然后通過大量分配生命周期很短的臨時對象觸發大量的 Gen 0 GC。
Copy
using System.Diagnostics;
using System.Runtime;
using System.Runtime.CompilerServices;
object[] a = new object[100_000_000];
var sw = Stopwatch.StartNew();
var sw2 = Stopwatch.StartNew();
var count = GC.CollectionCount(0) + GC.CollectionCount(1) + GC.CollectionCount(2);
for (var iter = 0; ; iter++)
{
object o = new object();
for (int i = 0; i < a.Length; i++)
{
a[i] = o;
}
sw.Restart();
Use(a, o);
for (int i = 0; i < 1000; i++)
{
GC.KeepAlive(new string('a', 10000));
}
var newCount = GC.CollectionCount(0) + GC.CollectionCount(1) + GC.CollectionCount(2);
if (newCount != count)
{
Console.WriteLine($"Gen0: {GC.CollectionCount(0)}, Gen1: {GC.CollectionCount(1)}, Gen2: {GC.CollectionCount(2)}, Pause on Gen0: {sw.ElapsedMilliseconds}ms, Throughput: {(iter + 1) / sw2.Elapsed.TotalSeconds} iters/sec, Max Working Set: {Process.GetCurrentProcess().PeakWorkingSet64 / 1048576.0} MB");
count = newCount;
iter = -1;
sw2.Restart();
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void Use(object[] arr, object obj) { }
由于這個測試主要就是測試 Gen 0 的回收性能,因此測試結果中將不包含關閉 Gen 0 的情況。
GC | 單次暫停 | 吞吐量 | 峰值內存占用 |
---|
Server GC | 59 ms | 7.485 iter/s | 1286.898 MB |
DATAS GC | 60 ms | 6.362 iter/s | 859.722 MB |
Workstation GC | 1081 ms | 0.804 iter/s | 805.453 MB |
Satori GC | 0 ms | 4.448 iter/s | 801.441 MB |
Satori GC (低延時) | 0 ms | 4.480 iter/s | 804.761 MB |
這個測試中 Satori GC 表現得非常亮眼:擁有不錯的吞吐量性能的同時,做到了亞毫秒級別的暫停時間:可以說在這個測試中 Satori GC 壓根就沒有暫停過我們的應用程序!
測試場景 3
這次我們使用 BinaryTree Benchmark 進行測試,這個測試由于會短時間大量分配對象,因此對于 GC 而言是一項壓力很大的測試。
Copy
using System.Diagnostics;
using System.Diagnostics.Tracing;
using System.Runtime;
using System.Runtime.CompilerServices;
using Microsoft.Diagnostics.NETCore.Client;
using Microsoft.Diagnostics.Tracing;
using Microsoft.Diagnostics.Tracing.Analysis;
using Microsoft.Diagnostics.Tracing.Parsers;
class Program
{
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
static void Main()
{
var pauses = new List<double>();
var client = new DiagnosticsClient(Environment.ProcessId);
EventPipeSession eventPipeSession = client.StartEventPipeSession([new("Microsoft-Windows-DotNETRuntime",
EventLevel.Informational, (long)ClrTraceEventParser.Keywords.GC)], false);
var source = new EventPipeEventSource(eventPipeSession.EventStream);
source.NeedLoadedDotNetRuntimes();
source.AddCallbackOnProcessStart(proc =>
{
proc.AddCallbackOnDotNetRuntimeLoad(runtime =>
{
runtime.GCEnd += (p, gc) =>
{
if (p.ProcessID == Environment.ProcessId)
{
pauses.Add(gc.PauseDurationMSec);
}
};
});
});
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
GC.WaitForPendingFinalizers();
GC.WaitForFullGCComplete();
Thread.Sleep(5000);
new Thread(() => source.Process()).Start();
pauses.Clear();
Test(22);
source.StopProcessing();
Console.WriteLine($"Max GC Pause: {pauses.Max()}ms");
Console.WriteLine($"Average GC Pause: {pauses.Average()}ms");
pauses.Sort();
Console.WriteLine($"P99.9 GC Pause: {pauses.Take((int)(pauses.Count * 0.999)).Max()}ms");
Console.WriteLine($"P99 GC Pause: {pauses.Take((int)(pauses.Count * 0.99)).Max()}ms");
Console.WriteLine($"P95 GC Pause: {pauses.Take((int)(pauses.Count * 0.95)).Max()}ms");
Console.WriteLine($"P90 GC Pause: {pauses.Take((int)(pauses.Count * 0.9)).Max()}ms");
Console.WriteLine($"P80 GC Pause: {pauses.Take((int)(pauses.Count * 0.8)).Max()}ms");
using (var process = Process.GetCurrentProcess())
{
Console.WriteLine($"Peak WorkingSet: {process.PeakWorkingSet64} bytes");
}
}
static void Test(int size)
{
var bt = new BinaryTrees.Benchmarks();
var sw = Stopwatch.StartNew();
bt.ClassBinaryTree(size);
Console.WriteLine($"Elapsed: {sw.Elapsed.TotalMilliseconds}ms");
}
}
public class BinaryTrees
{
class ClassTreeNode
{
class Next { public required ClassTreeNode left, right; }
readonly Next? next;
ClassTreeNode(ClassTreeNode left, ClassTreeNode right) =>
next = new Next { left = left, right = right };
public ClassTreeNode() { }
internal static ClassTreeNode Create(int d)
{
return d == 1 ? new ClassTreeNode(new ClassTreeNode(), new ClassTreeNode())
: new ClassTreeNode(Create(d - 1), Create(d - 1));
}
internal int Check()
{
int c = 1;
var current = next;
while (current != null)
{
c += current.right.Check() + 1;
current = current.left.next;
}
return c;
}
}
public class Benchmarks
{
const int MinDepth = 4;
public int ClassBinaryTree(int maxDepth)
{
var longLivedTree = ClassTreeNode.Create(maxDepth);
var nResults = (maxDepth - MinDepth) / 2 + 1;
for (int i = 0; i < nResults; i++)
{
var depth = i * 2 + MinDepth;
var n = 1 << maxDepth - depth + MinDepth;
var check = 0;
for (int j = 0; j < n; j++)
{
check += ClassTreeNode.Create(depth).Check();
}
}
return longLivedTree.Check();
}
}
}
這一次我們使用 Microsoft.Diagnostics.NETCore.Client
來精準的跟蹤每一次 GC 的暫停時間。
測試結果如下:
性能指標 | Workstation GC | Server GC | DATAS GC | Satori GC | Satori GC (低延時) | Satori GC (關 Gen 0) |
---|
執行所要時間 (ms) | 63,611.3954 | 22,645.3525 | 24,881.6114 | 41,515.6333 | 40,642.3008 | 13528.3383 |
峰值內存占用 (bytes) | 1,442,217,984 | 4,314,828,800 | 2,076,291,072 | 1,734,955,008 | 1,537,855,488 | 1,541,136,384 |
最大暫停時間 (ms) | 48.9107 | 259.9675 | 197.7212 | 6.5239 | 4.0979 | 1.2347 |
平均暫停時間 (ms) | 6.117282383 | 12.00785067 | 3.304014164 | 0.673435691 | 0.437758553 | 0.1391 |
P99.9 暫停時間 (ms) | 46.8537 | 243.2844 | 172.3259 | 5.8535 | 3.6835 | 0.9887 |
P99 暫停時間 (ms) | 44.0532 | 207.3627 | 57.4681 | 5.2661 | 3.2012 | 0.5814 |
P95 暫停時間 (ms) | 39.4903 | 48.7269 | 8.92 | 3.0054 | 1.3854 | 0.3536 |
P90 暫停時間 (ms) | 23.1327 | 21.4588 | 2.8013 | 1.7859 | 0.9204 | 0.2681 |
P80 暫停時間 (ms) | 8.3317 | 4.7577 | 1.7581 | 0.8009 | 0.6006 | 0.1942 |
這一次 Satori GC 的標準模式和低延時模式都做到了非常低的延時,而關閉 Gen 0 后 Satori GC 更是直接衛冕 GC 之王,不僅執行性能上跑過了 Server GC,同時還做到了接近 Workstation GC 級別的內存占用,并且還做到了亞毫秒級別的最大 STW 時間!
測試場景 4
這個場景來自社區貢獻的 GC 測試:https://github.com/alexyakunin/GCBurn
這個測試包含三個不同的重分配的測試項目,模擬三種場景:
- Cache Server:使用一半的內存(大約 16G),分配大約 1.86 億個對象
- Stateless Server:一個無狀態 Web Server
- Worker Server:一個有狀態的 Worker 始終占據 20% 內存(大約 6G),分配大概 7400 萬個對象
測試結果由其他社區成員提供。
首先看分配速率:

Server GC 是針對吞吐量進行大量優化的,因此做到最高的吞吐量性能并不意外。Satori GC 在 Cache Server 場景有所落后,但是現實中并不會有在一秒內分配超過 2 千萬個對象的場景,因此這個性能水平并不會造成實際的性能瓶頸。
然后看暫停時間:

注意時間的單位是微秒(0.001ms),并且縱坐標進行了對數縮放。Satori GC 成功地做到了亞毫秒(小于 1000 微秒)級別的暫停。
最后看峰值內存占用:

可以看到 Satori GC 相比其他 GC 而言有著出色的內存占用,在所有測試結果中幾乎都是那個內存占用最低的。
綜合以上三點,我們可以看到 Satori GC 在犧牲少量的吞吐量性能的同時,做到了亞毫秒級別的延時和低內存占用。只能說:干得漂亮!
大量分配速率測試
這同樣是來自其他社區成員貢獻的測試結果。在這個測試中,代碼使用一個循環在所有的線程上大量分配對象并立馬釋放。
測試結果如下:

可以看到 Satori GC 的默認模式在這個測試中做到了最好的分配吞吐量性能,成功做到每秒分配 20 億個對象。
總結
Satori GC 的目標是為 .NET 帶來了一種全新的低延時高吞吐自適應 GC,不僅有著優秀的分配速率,同時還能做到亞毫秒級別的暫停時間和低內存占用,與此同時做到 0 配置開箱即用。
目前 Satori GC 仍然處于實驗性階段,還有不少的課題需要解決,例如運行時自動決策 Gen 0 的開關、更好的策略以均衡吞吐量性能和內存占用以及適配更新版本的 .NET 等等,但是已經可以用于真實世界應用了,想要試用 Satori GC 的話可以參考下面的方法在自己的應用中啟用。
osu! 作為一款從引擎到游戲客戶端都是純 C# 開發的游戲,已經開始提供使用 Satori GC 的選項。在選歌列表的滾動測試中,Satori GC 成功將幀數翻了一倍,從現在的 120 fps 左右提升到接近 300 fps。
相信等 Satori GC 成熟正式作為 .NET 默認 GC 啟用后,將會為 .NET 帶來大量的性能提升,并擴展到更多的應用場景。
啟用方法
截至目前(2025/5/22),Satori GC 僅支持在 .NET 8 應用中啟用。考慮到這是目前最新的 LTS 穩定版本,因此目前來看也足夠了,另外 Satori GC 的開發人員已經在著手適配 .NET 9。
osu! 團隊配置了 CI 自動構建最新的 Satori GC,因此我們不需要手動構建 .NET Runtime 源碼得到 GC,直接下載對應的二進制即可使用。
對于 .NET 8 應用:
- 使用
dotnet publish -c Release -r <rid> --self-contained
發布一個自包含應用,例如 dotnet publish -c Release -r win-x64 --self-contained
- 從 https://github.com/ppy/Satori/releases 下載對應平臺的最新 Satori GC 構建,例如
win-x64.zip
- 解壓得到三個文件:如果是 Windows 平臺就是
coreclr.dll
、clrjit.dll
和 System.Private.CoreLib.dll
;而如果是 Linux 則是 libcoreclr.so
、libclrjit.so
和 System.Private.CoreLib.dll
- 找到第一步發布出來的應用(一般在
bin/Release/net8.0/<rid>/publish
文件夾里,例如 bin/Release/net8.0/win-x64/publish
),用第三步得到的三個文件替換掉發布目錄里面的同名文件
然后即可享受 Satori GC 帶來的低延時體驗。
反饋渠道
如果在使用過程中遇到了任何問題,可以參考:
轉自https://www.cnblogs.com/hez2010/p/18889954/
該文章在 2025/6/3 10:41:46 編輯過