Garbage Collection in ASP.NET: Speicherverwaltung für Hochleistungs-APIs

Der Garbage Collector (GC) der .NET-Laufzeitumgebung übernimmt die automatische Bereinigung des verwalteten Speichers. In durchsatzstarken ASP.NET Core-Webanwendungen bestimmt das Allokationsverhalten des Codes jedoch direkt, wie oft der GC Ausführungsstopps (Stop-the-World-Pausen) einlegt und wie stabil die p99-Latenz bleibt.

In diesem Artikel behandeln wir praxiserprobte Strategien, um die Allokationsrate zu senken und die Effizienz des GC gezielt zu unterstützen.

Das Generationenmodell des .NET GC

Der GC verwaltet Objekte anhand ihrer Lebensdauer:

  • Gen 0: Neu allokierte, kurzlebige Objekte wie Methodenvariablen. Die Bereinigung ist sehr schnell.
  • Gen 1: Pufferbereich für Objekte, die einen Gen-0-Durchlauf überstehen.
  • Gen 2: Langlebige Objekte wie Singletons oder In-Memory-Caches. Bereinigungen hier sind teuer und beanspruchen mehr CPU-Zyklen.
  • Large Object Heap (LOH): Objekte ab 85.000 Bytes werden direkt hier zugewiesen und selten kompaktiert, was zu Fragmentierung führen kann.

Praktische Maßnahmen zur Entlastung des GC

1. Wertetypen nutzen und Boxing verhindern

Wird ein Wertetyp (struct, int, etc.) einer object-Referenz oder einer nicht-generischen Schnittstelle zugewiesen, entsteht Boxing, was eine Allokation auf dem Heap erzwingt:

int num = 10;
object obj = num; // Boxing: Unnötige Heap-Allokation

// Bessere Alternative: Generische Signaturen ohne Boxing
void LogValue<T>(T value) => Console.WriteLine(value);

2. Puffer wiederverwenden mit ArrayPool und ObjectPool

Bei wiederkehrenden I/O-Operationen wie dem Lesen von Requests sollte auf die Erzeugung neuer Byte-Arrays verzichtet werden:

using System.Buffers;

// Puffer aus dem Pool leihen
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
    // Stream-Operationen durchführen
}
finally
{
    ArrayPool<byte>.Shared.Return(buffer);
}

Für eigene komplexe Instanzen empfiehlt sich ein strukturierter Objekt-Pool:

public class ObjectPool<T> where T : class, new()
{
    private readonly Stack<T> _pool = new();

    public T GetObject()
    {
        return _pool.Count > 0 ? _pool.Pop() : new T();
    }

    public void ReleaseObject(T obj)
    {
        _pool.Push(obj);
    }
}

3. Allokationsfreies Slicing mit Span<T>

Anstatt Strings bei Parsing-Vorgängen mit Substring mehrfach zu kopieren, erlaubt ReadOnlySpan<char> das direkte Arbeiten auf dem bestehenden Speicherbereich ohne neue Zuweisungen.

4. Server-GC für Web-Szenarien konfigurieren

Für ASP.NET Core-APIs im Mehrkernbetrieb ist die Aktivierung des Server-GCs ratsam, da dieser pro logischem CPU-Kern einen dedizierten Heap bereitstellt:

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
  <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>

Überwachung und Diagnose

Mit Kommandozeilentools wie dotnet-counters lässt sich das Allokationsverhalten im laufenden Betrieb präzise nachvollziehen:

dotnet-counters monitor --process-id <PID> --counters System.Runtime

Fazit

Ein performanter Garbage Collector ersetzt keine vorausschauende Programmierung. Wer unnötige Heap-Zuweisungen durch Pooling, Wertetypen und moderne Konstrukte wie Span<T> vermeidet, minimiert Latenzspitzen und sorgt für stabile Durchsatzraten unter Last.