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

Von Sébastien Ros und Rick Anderson

Die Speicherverwaltung ist selbst in einem verwalteten Framework wie .NET komplex. Das Analysieren und Verstehen von Arbeitsspeicherproblemen kann eine Herausforderung darstellen. Inhalt dieses Artikels

  • War von vielen Arbeitsspeicherverlusten und GC-Problemen inspiriert, die nicht funktionieren. Die meisten dieser Probleme sind darauf zurückzuführen, dass sie nicht verstehen, wie der Arbeitsspeicherverbrauch in .NET Core funktioniert, oder dass sie nicht verstehen, wie sie gemessen wird.
  • Veranschaulicht die problematische Speichernutzung und schlägt alternative Ansätze vor.

Funktionsweise der Garbage Collection (GC) in .NET Core

Der GC ordnet Heapsegmente zu, wobei jedes Segment ein zusammenhängender Speicherbereich ist. Im Heap platzierte Objekte werden in eine von drei Generationen kategorisiert: 0, 1 oder 2. Die Generierung bestimmt die Häufigkeit, mit der die GC versucht, Arbeitsspeicher für verwaltete Objekte freizugeben, auf die nicht mehr von der App verwiesen wird. Niedrigere nummerierte Generationen werden häufiger von GC'd verwendet.

Objekte werden basierend auf ihrer Lebensdauer von einer Generation in eine andere verschoben. Wenn Objekte länger leben, werden sie in eine höhere Generation verschoben. Wie bereits erwähnt, werden höhere Generationen seltener gc'd genannt. 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 im Allgemeinen 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.
  • Committet einen kleinen Teil des Arbeitsspeichers, wenn die Laufzeit geladen wird.

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

Rufen Sie GC auf. Sammeln

Aufrufen von GC. Explizites Sammeln:

  • Sollte nicht von Produktions-ASP.NET Core-Apps ausgeführt werden.
  • Ist nützlich, wenn Speicherverluste untersucht werden.
  • Überprüft bei der Untersuchung, ob die GC alle verrauschten Objekte aus dem Arbeitsspeicher entfernt hat, damit der Arbeitsspeicher gemessen werden kann.

Analysieren der Speicherauslastung einer App

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

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

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

Erkennen von Arbeitsspeicherproblemen

Task-Manager können verwendet werden, um eine Vorstellung davon zu erhalten, wie viel Arbeitsspeicher eine ASP.NET App verwendet. Der Task-Manager Arbeitsspeicherwert:

  • Stellt die Arbeitsspeichermenge dar, die vom ASP.NET Prozess verwendet wird.
  • Schließt die lebenseigenen Objekte der App und andere Speicherverbraucher ein, z. B. die native Speicherauslastung.

Wenn der Task-Manager Arbeitsspeicherwert unbegrenzt zunimmt und nie abnimmt, weist die App einen Speicherverlust auf. In den folgenden Abschnitten werden verschiedene Speicherauslastungsmuster veranschaulicht und erläutert.

Beispiel für die Speicherauslastungs-App für die Anzeige

Die MemoryLeak-Beispiel-App ist auf GitHub verfügbar. Die MemoryLeak-App:

  • Enthält einen Diagnosecontroller, der Echtzeitspeicher- und GC-Daten für die App sammelt.
  • Verfügt über eine Indexseite, auf der die Speicher- und GC-Daten angezeigt werden. Die Indexseite wird jede Sekunde aktualisiert.
  • Enthält einen API-Controller, der verschiedene Speicherlademuster bereitstellt.
  • Ist kein unterstütztes Tool, kann jedoch verwendet werden, um Speicherauslastungsmuster von ASP.NET Core-Apps anzuzeigen.

Führen Sie MemoryLeak aus. Der zugeordnete Arbeitsspeicher nimmt langsam zu, bis ein GC auftritt. Der Arbeitsspeicher nimmt zu, da das Tool benutzerdefinierte Objekte zuordnet, um Daten zu erfassen. Die folgende Abbildung zeigt die Seite MemoryLeak Index, wenn eine Gen 0-GC auftritt. Das Diagramm zeigt 0 RPS (Anforderungen pro Sekunde), da keine API-Endpunkte vom API-Controller aufgerufen wurden.

Vorangehendes Diagramm

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

  • Zugeordnet: die Menge an Arbeitsspeicher, die von verwalteten Objekten belegt wird.
  • Arbeitssatz:Die Gruppe von Seiten im virtuellen Adressraum des Prozesses, die sich derzeit im physischen Speicher befinden. Der angezeigte Arbeitssatz ist derselbe Wert, 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 als UTF-16-Zeichen in .NET gespeichert, sodass jedes Zeichen 2 Bytes im Arbeitsspeicher belegt.

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

Das folgende Diagramm wird mit einer relativ geringen Auslastung in generiert, um zu zeigen, wie sich die Speicherbelegungen durch die GC auswirken.

Vorangehendes Diagramm

Das obige Diagramm zeigt Folgendes:

  • 4.000 RPS (Anforderungen pro Sekunde).
  • Gc-Auflistungen der Generation 0 erfolgen etwa alle zwei Sekunden.
  • Der Arbeitssatz ist konstant bei ca. 500 MB.
  • Die CPU-Auslastung beträgt 12 %.
  • Die Arbeitsspeichernutzung und -freigabe (über GC) ist stabil.

Das folgende Diagramm wird mit dem maximalen Durchsatz dargestellt, der vom Computer verarbeitet werden kann.

Vorangehendes Diagramm

Das obige Diagramm zeigt Folgendes:

  • 22.000 RPS
  • Gc-Auflistungen der Generation 0 treten mehrmals pro Sekunde auf.
  • Sammlungen der Generation 1 werden ausgelöst, da der App pro Sekunde deutlich mehr Arbeitsspeicher zugewiesen wird.
  • Der Arbeitssatz ist konstant bei ca. 500 MB.
  • Die CPU-Auslastung beträgt 33 %.
  • Die Arbeitsspeichernutzung und -freigabe (über GC) ist stabil.
  • Die CPU (33 %) ist nicht überlastet, daher kann die Garbage Collection mit einer hohen Anzahl von Zuordnungen schritthalten.

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 explizit in der Projektdatei oder in der runtimeconfig.js in der Datei der veröffentlichten App festgelegt werden. Das folgende Markup zeigt die Einstellung ServerGarbageCollection in der Projektdatei:

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

Wenn Sie ServerGarbageCollection die Projektdatei ändern, muss die App neu erstellt werden.

Hinweis: Die Garbage Collection des Servers ist auf Computern mit einem einzelnen Kern nicht verfügbar. Weitere Informationen finden Sie unter IsServerGC.

Die folgende Abbildung zeigt das Speicherprofil unter einem 5K RPS mithilfe der Arbeitsstations-GC.

Vorangehendes Diagramm

Die Unterschiede zwischen diesem Diagramm und der Serverversion sind erheblich:

  • Der Arbeitssatz fällt von 500 MB auf 70 MB.
  • Der GC führt Auflistungen der Generation 0 mehrmals pro Sekunde statt alle zwei Sekunden durch.
  • GC fällt 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 gering ist, ist die Arbeitsstations-GC möglicherweise leistungsfähiger. Beispielsweise das Hosten mehrerer Web-Apps mit hoher Dichte, bei denen der Arbeitsspeicher knapp wird.

GC mittels Docker und kleiner Containers

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

Persistente Objektverweise

Die GC kann keine Objekte freigeben, auf die verwiesen wird. Objekte, auf die verwiesen wird, aber nicht mehr benötigt werden, führen zu einem Speicherverlust. Wenn die App häufig Objekte zuordnet und sie nicht mehr freigibt, nachdem sie nicht mehr benötigt werden, erhöht sich die Arbeitsspeicherauslastung im Laufe der Zeit.

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 Sammlung 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:

  • Ein Beispiel für einen typischen Speicherverlust.
  • Bei häufigen Aufrufen erhöht sich der App-Arbeitsspeicher, bis der Prozess mit einer OutOfMemory Ausnahme abstürzt.

Vorheriges Diagramm

In der obigen Abbildung:

  • Auslastungstests des /api/staticstring Endpunkts verursachen eine lineare Erhöhung des Arbeitsspeichers.
  • Die Garbage Collection versucht, Arbeitsspeicher freizugeben, wenn die Arbeitsspeicherauslastung zunimmt, indem eine Auflistung der Generation 2 aufgerufen wird.
  • Der GC kann den verlorenen Speicher nicht freigeben. Zuordnung und Arbeitssatz erhöhen sich mit der Zeit.

Einige Szenarien, z. B. das Zwischenspeichern, erfordern, dass Objektverweise gehalten werden, bis eine Auslastung des Arbeitsspeichers die Freigabe erzwingt. Die WeakReference -Klasse kann für diese Art von Zwischenspeicherungscode verwendet werden. Ein WeakReference -Objekt wird bei Auslastung des Arbeitsspeichers erfasst. Die Standardimplementierungen von IMemoryCache verwenden WeakReference .

Nativer Arbeitsspeicher

Einige .NET Core-Objekte basieren auf nativem Arbeitsspeicher. Der systemeigene Speicher kann nicht vom GC gesammelt werden. Das .NET-Objekt, das nativen Arbeitsspeicher verwendet, muss es mit nativem Code freigeben.

.NET stellt die IDisposable Schnittstelle bereit, mit der Entwickler nativen Speicher freigeben können. Auch wenn Dispose nicht aufgerufen wird, rufen ordnungsgemäß 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 jede Instanz am Ende der Anforderung erfasst wird.

Die folgende Abbildung zeigt das Speicherprofil beim kontinuierlichen Aufrufen der fileprovider API.

Vorheriges Diagramm

Das obige Diagramm zeigt ein offensichtliches Problem mit der Implementierung dieser Klasse, da die Speicherauslastung weiter steigt. Dies ist ein bekanntes Problem, das in diesem Problemnachverfolgt wird.

Der gleiche Verlust kann im Benutzercode auftreten, indem eine der folgenden Möglichkeiten gilt:

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

Heap für große Objekte

Häufige Speicherbelegungs-/freie Zyklen können Arbeitsspeicher fragmentieren, insbesondere bei der Zuweisung großer Speicherblöcke. Objekte werden in zusammenhängenden Speicherblöcken zugeordnet. Wenn der GC Arbeitsspeicher frei gibt, wird versucht, ihn zu defragmentieren, um die Fragmentierung zu verringern. Dieser Prozess wird als Komprimierung bezeichnet. Komprimierung umfasst das Verschieben von Objekten. Das Verschieben großer Objekte führt zu Leistungseinbußen. Aus diesem Grund erstellt der GC eine spezielle Speicherzone für große Objekte, die als großer Objektheap (Large Object Heap, LOH) bezeichnet wird. Objekte, die größer als 85.000 Bytes (ca. 83 KB) sind:

  • Wird auf dem LOH platziert.
  • Nicht komprimiert.
  • Gesammelt während GCs der Generation 2.

Wenn der LOH voll ist, löst die Garbage Collection eine Sammlung der Generation 2 aus. Sammlungen der Generation 2:

  • Sind grundsätzlich langsam.
  • Darüber hinaus fallen die Kosten für das Auslösen einer Sammlung für alle anderen Generationen an.

Der folgende Code komprimiert den LOH sofort:

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

Informationen LargeObjectHeapCompactionMode zum Komprimieren des LOH finden Sie unter .

In Containern mit .NET Core 3.0 und höher 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 Speicherprofil des Aufrufs des /api/loh/84975 Endpunkts unter maximaler Auslastung:

Vorheriges Diagramm

Das folgende Diagramm zeigt das Speicherprofil des Aufrufs des /api/loh/84976 Endpunkts, das nur ein weiteres Byte zuweise:

Vorheriges Diagramm

Hinweis: Die byte[] -Struktur weist Mehraufwandbytes auf. Deshalb löst 84.976 Bytes den Grenzwert von 85.000 aus.

Vergleich der beiden vorherigen Diagramme:

  • Die Arbeitsmenge ist für beide Szenarien ähnlich, etwa 450 MB.
  • Die unter LOH-Anforderungen (84.975 Bytes) zeigt größtenteils Sammlungen der Generation 0 an.
  • Die Über-LOH-Anforderungen generieren konstante Sammlungen der Generation 2. Sammlungen der Generation 2 sind teuer. Es ist mehr CPU erforderlich, und der Durchsatz sinkt um fast 50 %.

Temporäre große Objekte sind besonders problematisch, da sie Gen2-GCs verursachen.

Um eine maximale Leistung zu erzielen, sollte die Verwendung großer Objekte minimiert werden. Teilen Sie nach Möglichkeit große Objekte auf. Beispiel: Middleware für die Zwischenspeicherung von Antworten in ASP.NET Core die Cacheeinträge in Blöcke unter 85.000 Byte aufteilen.

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

Weitere Informationen finden Sie unter:

HttpClient

Eine falsche Verwendung HttpClient von kann zu einem Ressourcenverlust führen. Systemressourcen wie Datenbankverbindungen, Sockets, Dateihandles usw.:

  • Sind weniger auslastungsbehinderung als Arbeitsspeicher.
  • Sind problematischer, wenn ein Speicherverlust als arbeitsspeicherbehaftet ist.

Erfahrene .NET-Entwickler wissen, dass sie für Objekte aufrufen Dispose können, die IDisposable implementieren. Das Nichtversetzen von Objekten, die implementieren, IDisposable führt in der Regel zu Speicherverlusten oder kompensierten Systemressourcen.

HttpClient implementiert IDisposable , sollte aber nicht bei jedem Aufruf verworfen werden. Stattdessen HttpClient sollte 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;
    }
}

Beim Laden 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)

Obwohl die HttpClient Instanzen verworfen werden, dauert es einige Zeit, bis die tatsächliche Netzwerkverbindung vom Betriebssystem freigegeben wird. Durch das kontinuierliche Erstellen neuer Verbindungen kommt es zu einer Porterschöpfung. Jede Clientverbindung erfordert einen eigenen Clientport.

Eine Möglichkeit, die Porterschöpfung zu verhindern, besteht in der 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 verwerfende Ressource nach jeder Verwendung verworfen werden sollte.

Im Folgenden finden Sie eine bessere Möglichkeit, die Lebensdauer einer Instanz zu HttpClient verarbeiten:

Objektpooling

Im vorherigen Beispiel wurde gezeigt, wie HttpClient die Instanz statisch gemacht und von allen Anforderungen wiederverwendet werden kann. Durch die Wiederverwendung wird verhindert, dass nicht mehr alle Ressourcen verfügbar sind.

Objektpooling:

  • Verwendet das Wiederverwendungsmuster.
  • Ist für Objekte konzipiert, deren Erstellung teuer ist.

Ein Pool ist eine Auflistung vorab initialisierter Objekte, die reserviert und threadübergreifend freigegeben werden können. Pools können Zuordnungsregeln wie Grenzwerte, vordefinierte Größen oder Zuwachsraten definieren.

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

Der folgende API-Endpunkt instanziiert einen Puffer, der bei jeder Anforderung mit byte Zufallszahlen 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;
        }

Im folgenden Diagramm wird der Aufruf der vorherigen API mit mittlerer Auslastung angezeigt:

vorangehendes Diagramm

Im obigen Diagramm werden Sammlungen der Generation 0 ungefähr einmal pro Sekunde angezeigt.

Der vorangehende Code kann optimiert werden, indem der Puffer mithilfe von ArrayPool in byte einem Pool gepoolt wird. <T> Eine statische Instanz wird anforderungsübergreifend wiederverwendet.

Der Andere bei diesem Ansatz ist, dass ein Poolobjekt von der API zurückgegeben wird. Dies bedeutet:

  • Das -Objekt befindet sich nicht mehr in Ihrer Kontrolle, sobald Sie von der -Methode zurückkehren.
  • Sie können das -Objekt nicht frei geben.

So richten Sie die Beseitigung des Objekts ein:

RegisterForDispose ruft für das Zielobjekt auf, sodass es nur freigegeben Dispose 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;
}

Das Anwenden der gleichen Last wie die Nicht-Poolversion führt zu folgendem Diagramm:

vorangehendes Diagramm

Der Hauptunterschied besteht in den zugeordneten Bytes und folglich deutlich weniger Auflistungen der Generation 0.

Zusätzliche Ressourcen