Arbeitsspeicherverwaltung und Garbage Collection (GC) in ASP.NET Core

Von Sébastien Ros und Rick Anderson

Die Arbeitsspeicherverwaltung ist auch in einem verwalteten Framework wie .NET eine komplexe Angelegenheit. Das Analysieren und Verstehen von Arbeitsspeicherproblemen kann eine Herausforderung darstellen. Dieser Artikel:

  • Beweggründe waren viele Probleme der Art Arbeitsspeicherverluste und GC funktioniert nicht. Die meisten dieser Probleme wurden dadurch verursacht, dass nicht verstanden wurde, wie der Arbeitsspeicherverbrauch in .NET Core funktioniert, oder dass nicht verstanden wurde, wie dieser gemessen wird.
  • Es werden einige problematische Arten der Speichernutzung vorgestellt und alternative Vorgehensweisen vorgeschlagen.

Funktionsweise der Garbage Collection (GC) in .NET Core

Die GC weist Heapsegmente zu, wobei jedes Segment ein zusammenhängender Arbeitsspeicherbereich ist. Objekte, die im Heap platziert werden, werden in eine von drei Generationen eingeteilt: 0, 1 oder 2. Die Generation bestimmt die Häufigkeit, mit der die GC versucht, Arbeitsspeicher für verwaltete Objekte freizugeben, auf die von der App nicht mehr verwiesen wird. Generationen mit niedrigeren Nummern werden von der GC häufiger bereinigt.

Objekte werden basierend auf ihrer Lebensdauer von einer Generation in eine andere verschoben. Wenn Objekts länger vorhanden sind, werden sie in eine höhere Generation verschoben. Wie bereits erwähnt, werden höhere Generationen seltener von der GC bereinigt. Kurzlebige Objekte verbleiben immer in Generation 0. Beispielsweise sind Objekte, auf die während der Lebensdauer einer Webanforderung verwiesen wird, kurzlebig. Singletons auf Anwendungsebene werden generell zu Generation 2 migriert.

Wenn eine ASP.NET Core-App gestartet wird, führt die GC Folgendes aus:

  • Reserviert Arbeitsspeicher für die anfänglichen Heapsegmente.
  • Weist einen kleinen Teil des Arbeitsspeichers zu, wenn die Runtime geladen wird.

Die obigen Speicherbelegungen erfolgen aus Leistungsgründen. Der Leistungsvorteil ergibt sich aus Heapsegmenten im zusammenhängenden Arbeitsspeicher.

GC.Collect-Hinweise

Im Allgemeinen sollten ASP.NET Core-Apps in der Produktion nicht ausdrücklich GC.Collect verwenden. Das Induzieren der Garbage Collection zu suboptimalen Zeiten kann die Leistung erheblich verringern.

CG.Collect ist beim Untersuchen von Arbeitsspeicherverlusten nützlich. Durch Aufrufen von GC.Collect() wird ein blockierter Garbage Collection-Zyklus ausgelöst, der versucht, alle Objekte zurückzufordern, auf die von verwaltetem Code nicht zugegriffen werden kann. Dies ist nützlich, um die Größe der erreichbaren Liveobjekte im Heap zu verstehen und das Wachstum der Speichergröße im Laufe der Zeit nachzuverfolgen.

Analysieren der Arbeitsspeicherauslastung einer App

Dedizierte Tools können bei der Analyse der Arbeitsspeicherauslastung helfen:

  • Zählen von Objektverweisen
  • Messen der Auswirkungen der GC auf die CPU-Auslastung
  • Messen des für jede Generation verwendeten Arbeitsspeichers

Verwenden Sie die folgenden Tools, um die Arbeitsspeicherauslastung zu analysieren:

Erkennen von Arbeitsspeicherproblemen

Über den Task-Manager können Sie näherungsweise erfahren, wie viel Arbeitsspeicher eine ASP.NET-App verwendet. Für den Arbeitsspeicherwert im Task-Manager gilt Folgendes:

  • Er stellt die Menge an Arbeitsspeicher dar, die vom ASP.NET-Prozess verwendet wird.
  • Er enthält die aktiven Objekte der App und andere Arbeitsspeicherconsumer wie z. B. die native Arbeitsspeicherauslastung.

Wenn der Arbeitsspeicherwert im Task-Manager unbegrenzt zunimmt und nie abflacht, tritt bei der App ein Arbeitsspeicherverlust auf. In den folgenden Abschnitten werden verschiedene Muster der Arbeitsspeicherauslastung veranschaulicht und erläutert.

Beispiel-App für die Auslastung des Anzeigespeichers

Die Beispiel-App „MemoryLeak“ ist auf GitHub verfügbar. Für die MemoryLeak-App gilt Folgendes:

  • Sie enthält einen Diagnosecontroller, der Echtzeitdaten für Arbeitsspeicher und GC für die App sammelt.
  • Sie verfügt über eine Indexseite, auf der die Daten für Arbeitsspeicher und GC angezeigt werden. Die Indexseite wird jede Sekunde aktualisiert.
  • Sie enthält einen API-Controller, der verschiedene Auslastungsmuster für den Arbeitsspeicher bereitstellt.
  • Sie ist kein unterstütztes Tool, kann jedoch verwendet werden, um Auslastungsmuster von ASP.NET Core-Apps für den Arbeitsspeicher anzuzeigen.

Führen Sie MemoryLeak aus. Der zugeordnete Arbeitsspeicher nimmt langsam zu, bis eine GC erfolgt. Der Arbeitsspeicher nimmt zu, da das Tool benutzerdefinierte Objekte zum Erfassen von Daten zuordnet. Die folgende Abbildung zeigt die Seite für den MemoryLeak-Index, wenn eine GC für Generation 0 ausgeführt wird. Das Diagramm zeigt 0 RPS (Requests Per Second, Anforderungen pro Sekunde), da vom API-Controller keine API-Endpunkte aufgerufen wurden.

Chart showing 0 Requests Per Second (RPS)

Das Diagramm zeigt zwei Werte für die Arbeitsspeicherauslastung an:

  • Zugeordnet: Die Menge des von verwalteten Objekten belegten Arbeitsspeichers
  • Arbeitssatz: Der Satz von Seiten im virtuellen Adressbereich des Prozesses, die derzeit im physischen Arbeitsspeicher vorhanden sind. Der angezeigte Arbeitssatz ist der gleiche Wert, der auch im Task-Manager angezeigt wird.

Vorübergehende Objekte

Die folgende API erstellt eine 10-KB-Zeichenfolgeninstanz und gibt sie an den Client zurück. Bei jeder Anforderung wird ein neues Objekt im Arbeitsspeicher zugeordnet und in die Antwort geschrieben. Zeichenfolgen werden in .NET als UTF-16-Zeichen gespeichert, sodass jedes Zeichen 2 Bytes im Arbeitsspeicher benötigt.

[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
    return new String('x', 10 * 1024);
}

Das folgende Diagramm wurde mit einer relativ geringen Last generiert, um zu zeigen, wie die Arbeitsspeicherbelegungen von der GC beeinflusst werden.

Graph showing memory allocations for a relatively small load

Dieses Diagramm zeigt Folgendes:

  • 4.000 RPS (Requests Per Second, Anforderungen pro Sekunde).
  • Garbage Collections für Generation 0 erfolgen etwa alle zwei Sekunden.
  • Der Arbeitssatz liegt konstant bei ca. 500 MB.
  • Der CPU-Wert beträgt 12 %.
  • Verbrauch und Freigabe (durch die GC) des Arbeitsspeichers sind stabil.

Das folgende Diagramm resultiert aus dem maximalen Durchsatz, der vom Computer verarbeitet werden kann.

Chart showing max throughput

Dieses Diagramm zeigt Folgendes:

  • 22.000 RPS
  • Garbage Collections für Generation 0 erfolgen mehrmals pro Sekunde.
  • Garbage Collections für Generation 1 werden ausgelöst, weil der App deutlich mehr Arbeitsspeicher pro Sekunde zugewiesen wurde.
  • Der Arbeitssatz liegt konstant bei ca. 500 MB.
  • Der CPU-Wert beträgt 33 %.
  • Verbrauch und Freigabe (durch die GC) des Arbeitsspeichers sind stabil.
  • Die CPU (33 %) ist nicht überlastet, daher kann die Garbage Collection mit einer hohen Anzahl von Speicherbelegungen Schritt halten.

Arbeitsstations-GC im Vergleich zu Server-GC

Der .NET Garbage Collector verfügt über zwei verschiedene Modi:

  • Arbeitsstations-GC: Optimiert für den Desktop.
  • Server-GC: Die Standard-GC für ASP.NET Core-Apps. Optimiert für den Server.

Der GC-Modus kann in der Projektdatei oder der Datei runtimeconfig.json der veröffentlichten App explizit festgelegt werden. Das folgende Markup zeigt die Festlegung von ServerGarbageCollection in der Projektdatei:

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

Wenn ServerGarbageCollection in der Projektdatei geändert wird, muss die App neu kompiliert werden.

Hinweis: Die Server-Garbage Collection ist auf Computern mit nur einem Kern nicht verfügbar. Weitere Informationen finden Sie unter IsServerGC.

Die folgende Abbildung zeigt das Arbeitsspeicherprofil mit 5.000 RPS unter Verwendung der Arbeitsstations-GC.

Chart showing memory profile for a Workstation GC

Die Unterschiede zwischen diesem Diagramm und der Serverversion sind erheblich:

  • Der Arbeitssatz sinkt von 500 MB auf 70 MB.
  • Die GC wird für Generation 0 mehrmals pro Sekunde statt alle zwei Sekunden durchgeführt.
  • Der GC-Wert sinkt von 300 MB auf 10 MB.

In einer typischen Webserverumgebung ist die CPU-Auslastung wichtiger als der Arbeitsspeicher, daher ist die Server-GC besser. Wenn die Arbeitsspeicherauslastung hoch und die CPU-Auslastung relativ niedrig ist, ist die Arbeitsstations-GC möglicherweise leistungsfähiger. Ein Beispiel ist das Hosten mehrerer Web-Apps mit hoher Dichte, wenn der Arbeitsspeicher knapp ist.

GC mittels Docker und kleiner Containers

Wenn mehrere containerisierte Apps auf einem Computer ausgeführt werden, ist die Arbeitsstations-GC möglicherweise leistungsintensiver als die Server-GC. Weitere Informationen finden Sie unter Running with Server GC in a Small Container Scenario Part 0 (Szenario: Ausführen mit Server-GC in einem kleinen Container, Teil 0) und Running with Server GC in a Small Container Scenario Part 1 – Hard Limit for the GC Heap (Szenario: Ausführen mit Server-GC in einem kleinen Container, Teil 1: Harte Grenze für den GC-Heap).

Persistente Objektverweise

Die GC kann keine Objekte freigeben, auf die verwiesen wird. Objekte, auf die verwiesen wird, die aber nicht mehr benötigt werden, führen zu einem Arbeitsspeicherverlust. Wenn die App häufig Objekte zuordnet und diese nicht mehr freigeben kann, nachdem sie nicht mehr benötigt werden, nimmt die Speicherauslastung im Laufe der Zeit zu.

Die folgende API erstellt eine 10-KB-Zeichenfolgeninstanz und gibt sie an den Client zurück. Der Unterschied zum vorherigen Beispiel besteht darin, dass auf diese Instanz von einem statischen Member verwiesen wird, was bedeutet, dass sie nie für die Garbage Collection verfügbar ist.

private static ConcurrentBag<string> _staticStrings = new ConcurrentBag<string>();

[HttpGet("staticstring")]
public ActionResult<string> GetStaticString()
{
    var bigString = new String('x', 10 * 1024);
    _staticStrings.Add(bigString);
    return bigString;
}

Der vorangehende Code:

  • Dies ist ein Beispiel für einen typischen Arbeitsspeicherverlust.
  • Bei häufigen Aufrufen nimmt der App-Arbeitsspeicher zu, bis der Prozess mit einer OutOfMemory-Ausnahme abstürzt.

Chart showing a memory leak

In der obigen Abbildung:

  • Auslastungstests für den /api/staticstring-Endpunkt verursachen einen linearen Anstieg beim Arbeitsspeicher.
  • Wenn die Arbeitsspeicherauslastung zunimmt, wird versucht, durch eine Garbage Collection für Generation 2 Arbeitsspeicher freizugeben.
  • Die GC kann den verlorenen Arbeitsspeicher nicht freigeben. Der belegte Arbeitsspeicher und der Arbeitssatz nehmen im Lauf der Zeit zu.

In einigen Szenarien – beispielsweise bei der Zwischenspeicherung – müssen Objektverweise so lange aufbewahrt werden, bis sie aufgrund der Arbeitsspeicherauslastung freigegeben werden müssen. Für diese Art von Code für die Zwischenspeicherung kann die WeakReference-Klasse verwendet werden. Ein WeakReference-Objekt wird aufgrund der Arbeitsspeicherauslastung bereinigt. Die Standardimplementierung von IMemoryCache verwendet WeakReference.

Nativer Arbeitsspeicher

Einige .NET Core-Objekte nutzen nativen Arbeitsspeicher. Nativer Arbeitsspeicher kann nicht durch die GC bereinigt werden. Das .NET-Objekt, das nativen Arbeitsspeicher verwendet, muss diesen mithilfe von nativem Code freigeben.

.NET stellt die Schnittstelle IDisposable bereit, damit Entwickler*innen nativen Arbeitsspeicher freigeben können. Auch wenn Dispose nicht aufgerufen wird, rufen korrekt implementierte Klassen Dispose auf, wenn der Finalizer ausgeführt wird.

Betrachten Sie folgenden Code:

[HttpGet("fileprovider")]
public void GetFileProvider()
{
    var fp = new PhysicalFileProvider(TempPath);
    fp.Watch("*.*");
}

PhysicalFileProvider ist eine verwaltete Klasse, sodass alle Instanzen nach Abschluss der Anforderung bereinigt werden.

Die folgende Abbildung zeigt das Arbeitsspeicherprofil während eines kontinuierlichen Aufrufs der fileprovider-API.

Chart showing a native memory leak

Das obige Diagramm zeigt ein offensichtliches Problem mit der Implementierung dieser Klasse, da die Arbeitsspeicherauslastung ständig erhöht wird. Dies ist ein bekanntes Problem, das in diesem Issue nachverfolgen wird.

Derselbe Verlust kann in Benutzercode aus einem der folgenden Gründe auftreten:

  • Die Klasse wird nicht ordnungsgemäß freigegeben.
  • Es wird vergessen, die Dispose-Methode der abhängigen Objekte aufzurufen, die verworfen werden sollen.

Heap für große Objekte

Durch häufige Zyklen der Arbeitsspeicherbelegung/-freigabe kann der Arbeitsspeicher fragmentiert werden, insbesondere bei der Zuordnung großer Speicherblöcke. Objekte werden in zusammenhängenden Speicherblöcken zugeordnet. Um die Fragmentierung zu minimieren, versucht die GC bei der Freigabe von Arbeitsspeicher diesen zu defragmentieren. Dieser Prozess wird als Komprimierung bezeichnet. Die Komprimierung beinhaltet das Verschieben von Objekten. Das Verschieben großer Objekte führt zu Leistungseinbußen. Aus diesem Grund erstellt die GC eine spezielle Arbeitsspeicherzone für große Objekte, den so genannten Heap für große Objekte Llarge Object Heap, LOH). Für Objekte, die größer als 85.000 Bytes (ca. 83 KB) sind, gilt Folgendes:

  • Sie werden im LOH platziert.
  • Sie werden nicht komprimiert.
  • Sie werden während einer GC für Generation 2 bereinigt.

Wenn der LOH voll ist, löst die GC eine Bereinigung für Generation 2 aus. Für Garbage Collections für Generation 2 gilt Folgendes:

  • Sie sind von Natur aus langsam.
  • Es fallen zusätzliche Kosten für das Auslösen einer Garbage Collection für alle anderen Generationen an.

Der folgende Code komprimiert den LOH sofort:

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

Informationen zum Komprimieren des LOH finden Sie unter LargeObjectHeapCompactionMode.

In Containern, die .NET Core 3.0 und höher verwenden, wird der LOH automatisch komprimiert.

Die folgende API veranschaulicht dieses Verhalten:

[HttpGet("loh/{size=85000}")]
public int GetLOH1(int size)
{
   return new byte[size].Length;
}

Das folgende Diagramm zeigt das Arbeitsspeicherprofil für das Aufrufen des /api/loh/84975-Endpunkts unter maximaler Last:

Chart showing memory profile of allocating bytes

Das folgende Diagramm zeigt das Arbeitsspeicherprofil für das Aufrufen des /api/loh/84976-Endpunkts, wobei nur noch ein weiteres Byte zugeordnet wird:

Chart showing memory profile of allocating one more byte

Hinweis: Die byte[]-Struktur enthält Overheadbytes. Aus diesem Grund wird bei einem Wert von 84.976 Bytes der Grenzwert von 85.000 ausgelöst.

Vergleich der beiden vorherigen Diagramme:

  • Der Arbeitssatz ist für beide Szenarien ähnlich, etwa 450 MB.
  • Bei den LOH-Anforderungen unterhalb des Grenzwerts (84.975 Bytes) werden hauptsächlich Garbage Collections für Generation 0 angezeigt.
  • Die LOH-Anforderungen oberhalb des Grenzwerts generieren konstante Garbage Collections für Generation 2. Garbage Collections für Generation 2 sind teuer. Mehr CPU ist erforderlich, und der Durchsatz sinkt um fast 50 %.

Temporäre große Objekte sind besonders problematisch, da sie GCs für Generation 2 verursachen.

Um eine maximale Leistung zu erzielen, sollten möglichst wenig große Objekte verwendet werden. Teilen Sie große Objekte nach Möglichkeit auf. Beispielsweise teilt die Middleware zum Zwischenspeichern von Antworten in ASP.NET Core die Cacheeinträge in Blöcke von jeweils weniger als 85.000 Bytes auf.

Die folgenden Links zeigen den Ansatz von ASP.NET Core, Objekte unter dem LOH-Grenzwert zu halten:

Weitere Informationen finden Sie unter:

HttpClient

Eine falsche Verwendung von HttpClient kann zu Ressourcenverlusten führen. Für Systemressourcen wie Datenbankverbindungen, Sockets, Dateihandles usw. gilt Folgendes:

  • Sie sind knapper als Arbeitsspeicher.
  • Sie sind bei Verlust problematischer als Arbeitsspeicher.

Erfahrene .NET-Entwickler*innen wissen, wie Dispose für Objekte aufgerufen wird, die IDisposable implementieren. Wenn Objekte, die IDisposable implementieren, nicht verworfen werden, führt dies in der Regel zu Verlusten bei Arbeitsspeicher oder Systemressourcen.

HttpClient implementiert IDisposable, sollte aber nicht bei jedem Aufruf verworfen werden. Stattdessen sollte HttpClient wiederverwendet werden.

Der folgende Endpunkt erstellt und verwirft bei jeder Anforderung eine neue HttpClient-Instanz:

[HttpGet("httpclient1")]
public async Task<int> GetHttpClient1(string url)
{
    using (var httpClient = new HttpClient())
    {
        var result = await httpClient.GetAsync(url);
        return (int)result.StatusCode;
    }
}

Unter Last werden die folgenden Fehlermeldungen protokolliert:

fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Connection id "0HLG70PBE1CR1", Request id "0HLG70PBE1CR1:00000031":
      An unhandled exception was thrown by the application.
System.Net.Http.HttpRequestException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted --->
    System.Net.Sockets.SocketException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted
   at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port,
    CancellationToken cancellationToken)

Auch wenn die HttpClient-Instanzen verworfen werden, dauert es einige Zeit, bis die tatsächliche Netzwerkverbindung vom Betriebssystem freigegeben wird. Durch kontinuierliches Erstellen neuer Verbindungen tritt eine Portauslastung auf. Jede Clientverbindung erfordert einen eigenen Clientport.

Eine Möglichkeit, eine Portauslastung zu vermeiden, ist die Wiederverwendung derselben HttpClient-Instanz:

private static readonly HttpClient _httpClient = new HttpClient();

[HttpGet("httpclient2")]
public async Task<int> GetHttpClient2(string url)
{
    var result = await _httpClient.GetAsync(url);
    return (int)result.StatusCode;
}

Die HttpClient-Instanz wird freigegeben, wenn die App beendet wird. Dieses Beispiel zeigt, dass nicht jede Ressource, die verworfen werden kann, auch nach jeder Verwendung verworfen werden sollte.

Hier lernen Sie eine bessere Möglichkeit kennen, die Lebensdauer einer HttpClient-Instanz zu verarbeiten:

Objektpooling

Das obige Beispiel zeigt, wie die HttpClient-Instanz als statisch festgelegt und von allen Anforderungen wiederverwendet werden kann. Die Wiederverwendung verhindert, dass Ressourcen knapp werden.

Für Objektpooling gilt Folgendes:

  • Es verwendet das Wiederverwendungsmuster.
  • Es ist für Objekte konzipiert, deren Erstellung teuer ist.

Ein Pool ist eine Sammlung von vorab initialisierten Objekten, die über threadübergreifend reserviert und freigegeben werden können. Pools können Zuordnungsregeln wie Grenzwerte, vordefinierte Größen oder Wachstumsraten definieren.

Das NuGet-Paket Microsoft.Extensions.ObjectPool enthält Klassen, die bei der Verwaltung solcher Pools helfen.

Der folgende API-Endpunkt initiiert einen byte-Puffer, der mit zufallsbasierten Zahlen für jede Anforderung gefüllt ist:

        [HttpGet("array/{size}")]
        public byte[] GetArray(int size)
        {
            var random = new Random();
            var array = new byte[size];
            random.NextBytes(array);

            return array;
        }

Das folgende Diagramm zeigt den Aufruf der vorherigen API mit moderater Last:

Chart showing calls to API with moderate load

Im obigen Diagramm erfolgt ungefähr einmal pro Sekunde eine Garbage Collection für Generation 0.

Der obige Code kann optimiert werden, indem der byte-Puffer mithilfe von ArrayPool<T> gepoolt wird. Eine statische Instanz wird von mehreren Anforderungen wiederverwendet.

Der Unterschied bei diesem Ansatz besteht darin, dass von der API ein Objekt im Pool zurückgegeben wird. Dies bedeutet:

  • Das Objekt befindet sich außerhalb Ihrer Kontrolle, sobald die Methode abgeschlossen wurde.
  • Sie können das Objekt nicht freigeben.

So richten Sie das Verwerfen des Objekts ein:

RegisterForDispose ruft Dispose im Zielobjekt auf, sodass es erst freigegeben wird, wenn die HTTP-Anforderung abgeschlossen ist.

private static ArrayPool<byte> _arrayPool = ArrayPool<byte>.Create();

private class PooledArray : IDisposable
{
    public byte[] Array { get; private set; }

    public PooledArray(int size)
    {
        Array = _arrayPool.Rent(size);
    }

    public void Dispose()
    {
        _arrayPool.Return(Array);
    }
}

[HttpGet("pooledarray/{size}")]
public byte[] GetPooledArray(int size)
{
    var pooledArray = new PooledArray(size);

    var random = new Random();
    random.NextBytes(pooledArray.Array);

    HttpContext.Response.RegisterForDispose(pooledArray);

    return pooledArray.Array;
}

Die Anwendung derselben Last wie für die Version ohne Pool führt zu folgendem Diagramm:

Chart showing fewer allocations

Der Hauptunterschied besteht in der Anzahl der zugeordneten Bytes zugeordnet und folglich einer geringeren Anzahl von Garbage Collections für Generation 0.

Zusätzliche Ressourcen