GC 的 STW 問題#
GC,垃圾回收器,本質(zhì)上是一種能夠自動管理自己分配的內(nèi)存的生命周期的內(nèi)存分配器。這種方法被大多數(shù)流行編程語言采用,然而當(dāng)你使用垃圾回收器時,你會失去對應(yīng)用程序如何管理內(nèi)存的控制。C# 允許在自動控制內(nèi)存的基礎(chǔ)之上局部對內(nèi)存進行手動控制,但是自動控制仍然是主要的場景。
然而 GC 總是需要暫停程序的運行以遍歷和識別存活的對象,從而刪除無效對象以及進行維護操作(例如通過移動對象到更緊湊的內(nèi)存區(qū)域以減少內(nèi)存碎片,這個過程也叫做壓縮)。GC 暫停整個程序的行為也叫做 STW(Stop-The-World)。這個暫停時間越長,對應(yīng)用的影響越大。
長期以來,.NET 的 GC 都一直在朝著優(yōu)化吞吐量性能和內(nèi)存占用的方向不斷優(yōu)化,這對于 Web 應(yīng)用以及跑在容器中的服務(wù)而言非常適合。而在客戶端、游戲和金融領(lǐng)域,開發(fā)人員一直都需要格外注意代碼中的分配問題,例如使用對象池、值類型以及非托管內(nèi)存等等,避免產(chǎn)生大量的垃圾和各種 GC 難以處理的反模式,以此來減少 GC 的單次暫停時間。例如在游戲中,要做到 60fps,留給每一幀的時間只有 16ms,這其中如果 GC 單次暫停時間過長,用戶就會觀察到明顯的掉幀。
Workstation GC?Server GC?DATAS GC?#
.NET 一直以來都有兩種 GC 模式 —— Workstation GC 和 Server GC。
Workstation GC 是 .NET 最古老的 GC 模式,其目標(biāo)之一是最小化內(nèi)存占用,以適配資源有限的場景。在 Workstation GC 中,它只會利用你一個 CPU 核心,因此哪怕你有多核的計算資源,Workstation GC 也不會去使用它們來優(yōu)化分配性能,雖然 Workstation GC 同樣支持后臺回收,但即使開啟后臺回收,Workstation GC 也之多只會用一個后臺線程。這么一來其性能發(fā)揮就會受到不小的限制。面對大量分配和大量回收場景時 Workstation GC 則顯得力不從心。不過,當(dāng)你的應(yīng)用很輕量并且不怎么分配內(nèi)存的時候,Workstation GC 將是一個很適合的選擇。
而之后誕生的 Server GC 則可以有效的利用多核計算資源,根據(jù) CPU 核心數(shù)量來控制托管堆數(shù)量,大幅度提升了吞吐量。然而 Server GC 的缺點也很明顯——內(nèi)存占用大。另外,Server GC 雖然通過并發(fā) GC 等方式將一部分工作移動到 STW 之外,從而使得 GC 和應(yīng)用程序可以同時運行,讓 STW 得到了不小的改進,然而 Server GC 的暫停時間仍然稱不上優(yōu)秀,雖然在 Web 服務(wù)等應(yīng)用場景下表現(xiàn)得不錯,然而在一些極端情況下則可能需要暫停上百毫秒。
為了進一步改善 Server GC 的綜合表現(xiàn),.NET 9 引入了新的 DATAS GC,試圖在優(yōu)化內(nèi)存占用的同時提升暫停時間表現(xiàn)。這個 GC 通過引入各種啟發(fā)算法自適應(yīng)應(yīng)用場景來最小化內(nèi)存占用的同時,也改善了暫停時間。測試表明 DATAS GC 相比 Server GC 雖然犧牲了個位數(shù)百分比的吞吐量性能,卻成功的減少了 70%~90% 的內(nèi)存占用的同時,暫停時間也縮減到 Server GC 的 1/3。
然而,這仍然不能算是完美的解決方案。開發(fā)者們都是抱著既要又要還要的心理,需要的是一個既能做到大吞吐量,暫停時間又短,同時內(nèi)存占用還小的 GC。
因此,.NET 全新的 GC —— 在 .NET Runtime 核心成員幾年的努力下誕生了!這就是接下來我要講的 Satori GC。
Satori GC
為了讓 GC 能夠正確追蹤對象,在不少語言中,編譯器會給存儲操作插入一個寫屏障。在寫屏障中 GC 會更新對象的引用從而確保每一個對象都能夠正確被追蹤。這么做的好處很明顯,相比讀操作而言,寫操作更少,將屏障分擔(dān)到每次的寫操作里顯然是一個更有效率的方法。然而這么做的壞處也很明顯:當(dāng) GC 需要執(zhí)行壓縮操作時不得不暫停整個程序,避免代碼訪問到無效的內(nèi)存地址。
而 JVM 上的一些低延時 GC 則放棄了寫屏障,轉(zhuǎn)而使用讀屏障,在每次讀取內(nèi)存地址的時候通過插入屏障來確保始終拿到的是最新的內(nèi)存地址,來避免無效地址訪問。然而讀操作在應(yīng)用中非常頻繁,這么做雖然能夠使得 GC 執(zhí)行壓縮操作時不再需要暫停整個程序,卻會不可避免地帶來性能的損失。
GC 執(zhí)行壓縮操作雖然開銷很大,但相對于釋放操作而言只是少數(shù),為了少數(shù)的操作能夠并發(fā)執(zhí)行拖慢所有的讀操作顯得有些得不償失。另外,在 .NET 上,由于 .NET 支持內(nèi)部指針和固定對象的內(nèi)存地址,因此讀屏障在 .NET 上實現(xiàn)較為困難,并且會帶來吞吐量的嚴(yán)重下降,在許多場景下難以接受。
.NET 的新低延時高吞吐自適應(yīng) GC —— Satori GC 仍然采用 Dijkstra 風(fēng)格的寫屏障設(shè)計,因此吞吐量性能仍然能夠匹敵已有的 Server GC。
另外,Satori GC 采用了分代、增量并發(fā)回收設(shè)計,所有與堆大小成比例的主要 GC 階段都會與應(yīng)用程序線程并發(fā)執(zhí)行,完全不需要暫停應(yīng)用程序,除了壓縮過程之外。不過,壓縮僅僅是 GC 可以執(zhí)行但不是必須執(zhí)行的一個可選項。例如 C++/Rust 的內(nèi)存分配器也不會進行壓縮,但仍能正常運行;Go 的 GC 也不會進行壓縮。
除了標(biāo)準(zhǔn)模式之外,Satori GC 還提供了低延時模式。在這個模式下 Satori GC 直接關(guān)閉了壓縮功能,通過犧牲少量的內(nèi)存占用來獲取更低的延時。在某些情況下,因為垃圾回收發(fā)生得更快,或者壓縮本身并沒有帶來太多實際收益,內(nèi)存占用反而會變得更小。例如在一些 Web 場景,大量對象只存活在單次請求期間,然后很快就會被清除。既然這些對象很快都會變成垃圾,那為什么要進行壓縮呢?
與 Go 的徹底不進行壓縮的 GC 不同,Satori GC 可以在運行時動態(tài)切換壓縮的開關(guān)狀態(tài),以適應(yīng)不同的應(yīng)用場景。想要開啟低延時模式,只需要執(zhí)行 GCSettings.LatencyMode = GCLatencyMode.LowLatency
即可。在需要極低延時的場景(例如高幀率游戲或金融實時交易系統(tǒng))中,這一設(shè)置可以有效減少 GC 暫停時間。
Satori GC 還允許開發(fā)者根據(jù)需要關(guān)閉 Gen 0:畢竟不是所有的場景/應(yīng)用都能從 Gen 0 中獲益。當(dāng)應(yīng)用程序并不怎么用到 Gen 0 時,為了支持 Gen 0 在寫屏障中做的額外操作反而會拖慢性能。目前可以通過設(shè)置環(huán)境變量 DOTNET_gcGen0=0
來關(guān)閉 Gen 0,不過在 Satori GC 計劃中,將會實現(xiàn)根據(jù)實際應(yīng)用場景自動決策 Gen 0 的開啟與關(guān)閉。
性能測試
說了這么多,新的 Satori GC 到底療效如何呢?讓我們擺出來性能測試來看看。
首先要說的是,測試前需要設(shè)置 <TieredCompilation>false</TieredCompilation>
關(guān)閉分層編譯,因為 tier-0 的未優(yōu)化代碼會影響對象的生命周期,從而影響 GC 行為。
測試場景 1
Unity 有一個 GC 壓力測試,游戲在每次更新都需要渲染出一幀畫面,而這個測試則模擬了游戲在每幀中分配大量的數(shù)據(jù),但是卻不渲染任何的內(nèi)容,從而通過單幀時間來反映 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}");
}
}
測試結(jié)果如下:
GC | 最大幀時間 | 平均幀時間 | 峰值內(nèi)存占用 |
---|
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 (低延時,關(guān) Gen 0) | 3 ms | 0.042 ms | 1566.848 MB |
可以看到 Satori GC 在擁有 Server GC 的吞吐量性能同時(平均幀時間),還擁有著優(yōu)秀的最大暫停時間(最大幀時間)。
測試場景 2
在這個測試中,代碼中將產(chǎn)生大量的 Gen 2 -> Gen 0 的反向引用讓 GC 變得非常繁忙,然后通過大量分配生命周期很短的臨時對象觸發(fā)大量的 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 的回收性能,因此測試結(jié)果中將不包含關(guān)閉 Gen 0 的情況。
GC | 單次暫停 | 吞吐量 | 峰值內(nèi)存占用 |
---|
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 表現(xiàn)得非常亮眼:擁有不錯的吞吐量性能的同時,做到了亞毫秒級別的暫停時間:可以說在這個測試中 Satori GC 壓根就沒有暫停過我們的應(yīng)用程序!
測試場景 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
來精準(zhǔn)的跟蹤每一次 GC 的暫停時間。
測試結(jié)果如下:
性能指標(biāo) | Workstation GC | Server GC | DATAS GC | Satori GC | Satori GC (低延時) | Satori GC (關(guān) Gen 0) |
---|
執(zhí)行所要時間 (ms) | 63,611.3954 | 22,645.3525 | 24,881.6114 | 41,515.6333 | 40,642.3008 | 13528.3383 |
峰值內(nèi)存占用 (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 的標(biāo)準(zhǔn)模式和低延時模式都做到了非常低的延時,而關(guān)閉 Gen 0 后 Satori GC 更是直接衛(wèi)冕 GC 之王,不僅執(zhí)行性能上跑過了 Server GC,同時還做到了接近 Workstation GC 級別的內(nèi)存占用,并且還做到了亞毫秒級別的最大 STW 時間!
測試場景 4
這個場景來自社區(qū)貢獻的 GC 測試:https://github.com/alexyakunin/GCBurn
這個測試包含三個不同的重分配的測試項目,模擬三種場景:
- Cache Server:使用一半的內(nèi)存(大約 16G),分配大約 1.86 億個對象
- Stateless Server:一個無狀態(tài) Web Server
- Worker Server:一個有狀態(tài)的 Worker 始終占據(jù) 20% 內(nèi)存(大約 6G),分配大概 7400 萬個對象
測試結(jié)果由其他社區(qū)成員提供。
首先看分配速率:

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

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

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

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