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

Von Sébastien Ros und Rick Anderson

Die Speicherverwaltung ist komplex, auch in einem verwalteten Framework wie .NET. Die Analyse und Das Verständnis von Speicherproblemen kann schwierig sein. Dieser Artikel:

  • Wurde von vielen Speicherlecks motiviert und GC funktionierte nicht . Die meisten dieser Probleme wurden verursacht, indem sie nicht verstehen, wie der Speicherverbrauch in .NET Core funktioniert oder nicht verstehen, wie sie gemessen wird.
  • Zeigt problematische Speicherverwendung und schlägt alternative Ansätze vor.

Funktionsweise der Garbage Collection (GC) in .NET Core

Der GC weist Heapsegmente zu, in denen jedes Segment ein zusammenhängender Speicherbereich ist. Objekte, die in der Heap platziert werden, werden in eine von 3 Generationen unterteilt: 0, 1 oder 2. Die Generation bestimmt die Häufigkeit, mit der der GC versucht, Speicher für verwaltete Objekte freizugeben, auf die nicht mehr von der App verwiesen wird. Weniger nummerierte Generationen sind GC'd häufiger.

Objekte werden basierend auf ihrer Lebensdauer von einer Generation in eine andere verschoben. Da Objekte länger leben, werden sie in eine höhere Generation verschoben. Wie bereits erwähnt, sind höhere Generationen GC'd weniger häufig. Kurze Laufzeitobjekte bleiben immer in Generation 0. Beispielsweise sind Objekte, auf die während der Lebensdauer einer Webanforderung verwiesen werden, kurzlebig. Singletons auf Anwendungsebene migrieren im Allgemeinen zu Generation 2.

Wenn eine ASP.NET Core-App gestartet wird, wird die GC:

  • Reserviert einige Speicher für die anfänglichen Heap-Segmente.
  • Beschreibt einen kleinen Teil des Arbeitsspeichers, wenn die Laufzeit geladen wird.

Die vorherigen Speicherzuweisungen werden aus Leistungsgründen durchgeführt. Der Leistungsvorteil stammt aus Heap-Segmenten im zusammenhängenden Speicher.

Rufen Sie GC an. Sammeln

Aufrufen von GC. Sammeln Sie explizit:

  • Sollte nicht durch die Produktion ASP.NET Core Apps erfolgen.
  • Ist nützlich beim Untersuchen von Speicherlecks.
  • Wenn Sie untersuchen, überprüft, ob der GC alle Verwängsungsobjekte aus dem Arbeitsspeicher entfernt hat, sodass der Arbeitsspeicher gemessen werden kann.

Analysieren der Speichernutzung einer App

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

  • Zählen von Objektbezügen
  • Messen, wie viel Einfluss der GC auf die CPU-Nutzung hat
  • Messspeicher für jede Generation

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

Erkennen von Speicherproblemen

Der Task-Manager kann verwendet werden, um zu erfahren, wie viel Arbeitsspeicher eine ASP.NET App verwendet. Der Arbeitsspeicherwert des Task-Managers:

  • Stellt die Menge des Arbeitsspeichers dar, der vom ASP.NET Prozess verwendet wird.
  • Enthält die lebenden Objekte der App und andere Speicherverbraucher wie die native Speichernutzung.

Wenn der Speicherwert des Task-Managers unbegrenzt erhöht wird und niemals abgeflacht wird, hat die App ein Speicherleck. In den folgenden Abschnitten werden verschiedene Speichernutzungsmuster veranschaulicht und erläutert.

Beispiel für die Speichernutzungs-App

Die Beispiel-App "MemoryLeak" 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.
  • Es ist jedoch kein unterstütztes Tool, es kann jedoch verwendet werden, um Speichernutzungsmuster von ASP.NET Core-Apps anzuzeigen.

Führen Sie MemoryLeak aus. Der zugewiesene Speicher erhöht sich langsam, bis ein GC auftritt. Der Arbeitsspeicher erhöht sich, da das Tool benutzerdefiniertes Objekt der Erfassung von Daten zuordnet. Die folgende Abbildung zeigt die Seite "MemoryLeak Index", wenn ein Gen 0 GC auftritt. Das Diagramm zeigt 0 RPS (Anforderungen pro Sekunde), da keine API-Endpunkte vom API-Controller aufgerufen wurden.

Chart showing 0 Requests Per Second (RPS)

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

  • Zugewiesen: Die Menge des Arbeitsspeichers, der von verwalteten Objekten belegt wird
  • Arbeitssatz: Der Satz von Seiten im virtuellen Adressraum des Prozesses, der derzeit im physischen Arbeitsspeicher ansässig ist. Der angezeigte Arbeitssatz ist der gleiche Wert wie der Task-Manager.

Vorübergehende Objekte

Die folgende API erstellt eine 10-KB-Zeichenfolgeninstanz und gibt ihn an den Client zurück. Bei jeder Anforderung wird ein neues Objekt im Arbeitsspeicher zugewiesen und an die Antwort geschrieben. Zeichenfolgen werden als UTF-16 Zeichen in .NET 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 wird mit einer relativ kleinen Last generiert, um anzuzeigen, wie Speicherzuweisungen von der GC beeinflusst werden.

Graph showing memory allocations for a relatively small load

Das vorherige Diagramm zeigt:

  • 4K RPS (Anforderungen pro Sekunde).
  • Generation 0 GC-Sammlungen treten etwa zwei Sekunden auf.
  • Der Arbeitssatz ist bei ca. 500 MB konstant.
  • CPU ist 12%.
  • Der Speicherverbrauch und die Veröffentlichung (über GC) ist stabil.

Das folgende Diagramm wird am maximalen Durchsatz durchgeführt, der vom Computer behandelt werden kann.

Chart showing max throughput

Das vorherige Diagramm zeigt:

  • 22K RPS
  • Generation 0 GC-Sammlungen treten mehrmals pro Sekunde auf.
  • Generation 1-Auflistungen werden ausgelöst, da die App deutlich mehr Arbeitsspeicher pro Sekunde zugewiesen hat.
  • Der Arbeitssatz ist bei ca. 500 MB konstant.
  • CPU ist 33%.
  • Der Speicherverbrauch und die Veröffentlichung (über GC) ist stabil.
  • Die CPU (33 %) ist nicht überlastet, daher kann die Garbage Collection mit einer hohen Anzahl von Zuordnungen aufhalten.

Workstation GC vs. Server GC

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

  • Workstation GC: Optimiert für den Desktop.
  • Server GC. Der 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.json Datei der veröffentlichten App festgelegt werden. Das folgende Markup zeigt die Einstellung ServerGarbageCollection in der Projektdatei:

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

Das Ändern ServerGarbageCollection in der Projektdatei erfordert, dass die App neu erstellt werden soll.

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

Das folgende Bild zeigt das Speicherprofil unter einem 5K RPS mit dem Workstation GC.

Chart showing memory profile for a Workstation GC

Die Unterschiede zwischen diesem Diagramm und der Serverversion sind erheblich:

  • Der Arbeitssatz fällt von 500 MB auf 70 MB.
  • Die GC führt 0 Sammlungen mehrmals pro Sekunde statt alle zwei Sekunden durch.
  • GC bricht von 300 MB auf 10 MB ab.

Bei einer typischen Webserverumgebung ist die CPU-Auslastung wichtiger als arbeitsspeicher, daher ist der Server GC besser. Wenn die Arbeitsspeicherauslastung hoch ist und die CPU-Auslastung relativ niedrig ist, ist die Workstation GC möglicherweise leistungsfähiger. Beispiel: Hohe Dichte, die mehrere Web-Apps hosten, in denen Arbeitsspeicher knapp ist.

GC mittels Docker und kleiner Containers

Wenn mehrere containerisierte Apps auf einem Computer ausgeführt werden, ist Workstation GC möglicherweise mehr Preformant als Server GC. Weitere Informationen finden Sie unter Ausführen mit Server GC in einem kleinen Container und ausführen mit Server GC in einem Kleinen Containerszenario Teil 1 – Harte Grenze für den GC Heap.

Verweise auf persistente Objekte

Die GC kann keine Objekte freigeben, auf die verwiesen wird. Objekte, auf die verwiesen, aber nicht mehr benötigt werden, führen zu einem Speicherverlust. Wenn die App objekte häufig zuweist und sie nicht mehr freigeben kann, nachdem sie nicht mehr benötigt werden, erhöht sich die Speicherauslastung 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 diese Instanz von einem statischen Element referenziert wird, was bedeutet, dass sie nie für die Auflistung 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:

  • Ist ein Beispiel für ein typisches Speicherleck.
  • Bei häufigen Aufrufen wird der App-Speicher erhöht, bis der Prozess mit einer OutOfMemory Ausnahme abstürzt.

Chart showing a memory leak

In der vorherigen Abbildung:

  • Beim Laden des Endpunkts wird /api/staticstring ein linearer Anstieg des Arbeitsspeichers verursacht.
  • Die GC versucht, Arbeitsspeicher freizugeben, da der Speicherdruck wächst, indem eine Generation 2-Sammlung aufgerufen wird.
  • Der GC kann den durchleckten Speicher nicht freigeben. Zugewiesene und arbeitssets erhöhen sich mit Zeit.

Einige Szenarien, z. B. zwischenspeichern, erfordern, dass Objektverweise gehalten werden, bis der Arbeitsspeicherdruck sie losgelassen wird. Die WeakReference Klasse kann für diesen Typ von Cachecode verwendet werden. Ein WeakReference Objekt wird unter Speicherdruck gesammelt. Die Standardimplementierung von IMemoryCache Verwendungen WeakReference.

Systemeigener Arbeitsspeicher

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

.NET stellt die Schnittstelle bereit, mit der IDisposable Entwickler nativen Arbeitsspeicher freigeben können. Auch wenn Dispose sie nicht aufgerufen wird, rufen die ordnungsgemäß implementierten Klassen auf Dispose , 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 gesammelt wird.

Die folgende Abbildung zeigt das Speicherprofil, während sie die fileprovider API kontinuierlich aufrufen.

Chart showing a native memory leak

Das vorherige Diagramm zeigt ein offensichtliches Problem mit der Implementierung dieser Klasse, da sie die Speicherauslastung erhöht. Dies ist ein bekanntes Problem, das in diesem Problem nachverfolgt wird.

Dasselbe Leck kann im Benutzercode erfolgen, indem einer der folgenden Aktionen ausgeführt wird:

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

Große Objekte heap

Häufige Speicherzuweisungen/freie Zyklen können speicherfragmentieren, insbesondere wenn große Speicherblöcke zugewiesen werden. Objekte werden in zusammenhängenden Speicherblöcken zugeordnet. Um die Fragmentierung zu verringern, versucht der GC Speicher freizugeben, es zu defragmentieren. Dieser Prozess wird als Komprimierung bezeichnet. Die Komprimierung umfasst das Verschieben von Objekten. Durch das Verschieben großer Objekte wird eine Leistungsstrafe verhängt. Aus diesem Grund erstellt der GC eine spezielle Speicherzone für große Objekte, die als großes Objekt heap (LOH) bezeichnet wird. Objekte, die größer als 85.000 Bytes sind (ca. 83 KB) sind:

  • Platziert am LOH.
  • Nicht komprimiert.
  • Gesammelt während der Generation 2 GCs.

Wenn der LOH voll ist, löst der GC eine Generation 2-Sammlung aus. Sammlung der Generation 2:

  • Sind inhärent langsam.
  • Darüber hinaus entstehen die Kosten für die Auslösung einer Sammlung aller anderen Generationen.

Der folgende Code komprimiert den LOH sofort:

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

Informationen zum Komprimieren des LOH finden Sie unter LargeObjectHeapCompactionMode

In Containern mit .NET Core 3.0 und höher wird der LOH automatisch komprimiert.

Die folgende API, die dieses Verhalten veranschaulicht:

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

Das folgende Diagramm zeigt das Speicherprofil des Aufrufens des /api/loh/84975 Endpunkts unter maximaler Auslastung:

Chart showing memory profile of allocating bytes

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

Chart showing memory profile of allocating one more byte

Hinweis: Die byte[] Struktur verfügt über Overheadbytes. Deshalb löst 84.976 Bytes das Limit von 85.000 aus.

Vergleich der beiden vorherigen Diagramme:

  • Der Arbeitssatz ist für beide Szenarien ähnlich, etwa 450 MB.
  • Die unter LOH-Anforderungen (84.975 Bytes) zeigen meist 0-Auflistungen der Generation an.
  • Die über LOH-Anforderungen generieren konstanten Generation 2-Sammlungen. Generation 2-Sammlungen sind teuer. Mehr CPU ist erforderlich, und der Durchsatz sinkt fast 50%.

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

Für die maximale Leistung sollte die Verwendung großer Objekte minimiert werden. Falls möglich, teilen Sie große Objekte auf. So teilen Sie beispielsweise die Middleware für die Reaktionsspeicherung in ASP.NET Core die Cacheeinträge in Blöcke unter 85.000 Bytes auf.

Die folgenden Links zeigen den ASP.NET Core Ansatz zur Beibehaltung von Objekten unter dem LOH-Grenzwert:

Weitere Informationen finden Sie unter

HttpClient

Falsche Verwendung HttpClient kann zu einem Ressourcenleck führen. Systemressourcen, z. B. Datenbankverbindungen, Sockets, Dateihandles usw.:

  • Sind knapper als Arbeitsspeicher.
  • Sind problematischer, wenn der Speicher durchleckt wird.

Erfahrene .NET-Entwickler wissen, objekte aufzurufen Dispose , die implementiert werden IDisposable. Nicht verworrene Objekte, die in der Regel implementiert IDisposable werden, führen zu durchleckten Speicher- oder durchleckten Systemressourcen.

HttpClientIDisposableimplementiert , sollte jedoch nicht für jeden Aufruf entsorgt werden. HttpClient Stattdessen sollte wiederverwendet werden.

Der folgende Endpunkt erstellt und entfernt eine neue HttpClient Instanz für jede Anforderung:

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

Nach dem 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 Instanzen verworfen werden, dauert die HttpClient tatsächliche Netzwerkverbindung einige Zeit, um vom Betriebssystem freigegeben zu werden. Durch kontinuierliches Erstellen neuer Verbindungen tritt die Entlastung von Ports auf. Jede Clientverbindung erfordert einen eigenen Clientport.

Eine Möglichkeit, die Portausschöpfung zu verhindern, besteht darin, dieselbe HttpClient Instanz wiederzuverwenden:

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 veröffentlicht, wenn die App beendet wird. In diesem Beispiel wird gezeigt, dass nach jeder Verwendung nicht jede einwegbare Ressource entsorgt werden sollte.

Sehen Sie sich folgendes an, um die Lebensdauer einer HttpClient Instanz besser zu behandeln:

Objektpooling

Im vorherigen Beispiel wurde gezeigt, wie die HttpClient Instanz statische und wiederverwendet werden kann, die von allen Anforderungen verwendet werden kann. Wiederverwenden verhindert, dass Ressourcen nicht mehr ausgeführt werden.

Objektpooling:

  • Verwendet das Wiederverwendungsmuster.
  • Dient zum Erstellen von Objekten, die teuer sind.

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

Das NuGet Paket Microsoft.Extensions.ObjectPool enthält Klassen, die dazu beitragen, solche Pools zu verwalten.

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

Im folgenden Diagramm wird die vorherige API mit moderater Last aufgerufen:

Chart showing calls to API with moderate load

Im vorhergehenden Diagramm erfolgen Die Generation 0-Auflistungen ungefähr einmal pro Sekunde.

Der vorherige Code kann durch Pooling des byte Puffers mithilfe von ArrayPoolT<> optimiert werden. Eine statische Instanz wird über Anforderungen hinweg wiederverwendet.

Was mit diesem Ansatz unterscheidet, ist, dass ein pooliertes Objekt aus der API zurückgegeben wird. Dies bedeutet:

  • Das Objekt ist nicht im Steuerelement, sobald Sie aus der Methode zurückkehren.
  • Sie können das Objekt nicht freigeben.

So richten Sie die Entsorgung des Objekts ein:

RegisterForDispose dient dem Aufrufen Disposedes Zielobjekts, sodass es nur veröffentlicht 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 poolierte Version führt zu dem folgenden Diagramm:

Chart showing fewer allocations

Der Hauptunterschied wird Bytes zugewiesen, und daher werden viel weniger Sammlungen der Generation 0 zugeordnet.

Zusätzliche Ressourcen