Geheugenbeheer en vuilnisophaling in ASP.NET Core

Door Sébastien Ros en Rick Anderson

Geheugenbeheer is complex, zelfs in een beheerd framework zoals .NET. Het analyseren en oplossen van geheugenproblemen kan lastig zijn. Problemen met geheugenlekken en garbagecollection (GC) zijn meestal te wijten aan een gebrek aan inzicht in hoe geheugenverbruik werkt in .NET, of doordat men het proces voor het meten van het gebruik niet begrijpt.

In dit artikel worden veelvoorkomende patronen voor geheugengebruik gedemonstreert die problematisch kunnen zijn en alternatieve benaderingen kunnen worden voorgesteld.

Garbagecollection (GC) verkennen in .NET

Het GC-proces wijst heap-segmenten toe waarbij elk segment een aaneengesloten geheugenbereik is. Objecten die in de heap worden geplaatst, worden onderverdeeld in een van drie generaties: 0, 1 of 2. De generatie bepaalt de frequentie van de GC-pogingen om geheugen vrij te geven op beheerde objecten waarnaar de app niet meer verwijst. De GC richt zich vaker op de lagergenummerde generaties.

Objecten worden verplaatst van de ene generatie naar de andere op basis van hun levensduur. Naarmate objecten langer leven, worden ze verplaatst naar een hogere generatie. Zoals eerder vermeld, loopt GC minder vaak op hogere generaties. Objecten met korte levensduur blijven altijd in Gen 0. Objecten waarnaar wordt verwezen tijdens de levensduur van een webaanvraag, hebben bijvoorbeeld een korte levensduur. Singletons op toepassingsniveau migreren doorgaans naar Gen 2.

Wanneer een ASP.NET Core-app wordt gestart, wordt het GC-proces uitgevoerd:

  • Reserveert geheugen voor de eerste heap-segmenten.
  • Hiermee wordt een klein deel van het geheugen doorgevoerd wanneer de runtime wordt geladen.

De voorgaande geheugentoewijzingen worden uitgevoerd voor prestatieverbeteringen. Het prestatievoordeel is afkomstig van heap-segmenten in aaneengesloten geheugen.

Bekijk de kanttekeningen bij het gebruik van GC.Collect

Over het algemeen moeten ASP.NET Core-apps in productie de GC.Collect-methode niet expliciet gebruiken. Het activeren van garbagecollecties op suboptimale tijden kan de prestaties aanzienlijk verminderen.

GC.Collect is nuttig bij het onderzoeken van geheugenlekken. Het aanroepen van GC.Collect() activeert een blokkerende garbage-collectioncyclus die probeert alle objecten die ontoegankelijk zijn vanuit beheerde code terug te winnen. Het is een handige manier om inzicht te hebben in de grootte van de bereikbare live-objecten in de heap en de groei van de geheugengrootte in de loop van de tijd bij te houden.

Het geheugengebruik van een app analyseren

Toegewezen hulpprogramma's kunnen helpen bij het analyseren van geheugengebruik, waaronder:

  • Objectverwijzingen tellen.
  • Meten hoeveel effect de GC heeft op CPU-gebruik.
  • Het meten van geheugenruimte die voor elke generatie wordt gebruikt.

Gebruik de volgende hulpprogramma's om geheugengebruik te analyseren:

Geheugenproblemen detecteren

Taakbeheer kan worden gebruikt om een idee te krijgen van hoeveel geheugen een ASP.NET app gebruikt. De geheugenwaarde van Taakbeheer:

  • Vertegenwoordigt de hoeveelheid geheugen die wordt gebruikt door het ASP.NET-proces.
  • Bevat de levende objecten van de app en andere geheugengebruikers, zoals systeemeigen geheugengebruik.

Als de geheugenwaarde van Task Manager voor onbepaalde tijd toeneemt en nooit platloopt, heeft de app een geheugenlek. In de volgende secties worden verschillende patronen voor geheugengebruik gedemonstreert en uitgelegd.

De weergave van geheugengebruik in voorbeeld-app verkennen

De MemoryLeak-voorbeeld-app is beschikbaar op GitHub. De MemoryLeak-app:

  • Bevat een diagnostische controller die realtime geheugen en GC-gegevens voor de app verzamelt.
  • Bevat een indexpagina waarop het geheugen en de GC-gegevens worden weergegeven. De pagina Index wordt elke seconde vernieuwd.
  • Bevat een API-controller die verschillende patronen voor geheugenbelasting biedt.
  • Kan worden gebruikt om geheugengebruikspatronen van ASP.NET Core apps weer te geven, maar het is geen ondersteund hulpprogramma.

Voer MemoryLeak uit. Toegewezen geheugen neemt langzaam toe totdat een GC plaatsvindt. Geheugen neemt toe omdat het hulpprogramma een aangepast object toewijst om gegevens vast te leggen. In de volgende afbeelding ziet u de pagina MemoryLeak Index wanneer een Gen 0 GC plaatsvindt. In de grafiek ziet u 0 RPS (Aanvragen per seconde), omdat er geen API-eindpunten van de API-controller zijn aangeroepen.

Grafiek met 0 aanvragen per seconde (RPS) nadat MemoryLeak en Gen 0 GC zijn uitgevoerd.

In de grafiek worden twee waarden weergegeven voor het geheugengebruik:

  • Toegewezen: De hoeveelheid geheugen die wordt bezet door beheerde objecten.
  • Werkset: De set pagina's in de virtuele adresruimte van het proces dat zich momenteel in fysiek geheugen bevindt. De werkset die wordt weergegeven is dezelfde waarde als die door Taakbeheer wordt weergegeven. Zie Working Set voor meer informatie.

Tijdelijke objecten

De volgende API maakt een tekenreeksinstantie van 20 kB en retourneert deze aan de client. Bij elke aanvraag wordt een nieuw object toegewezen in het geheugen en naar het antwoord geschreven. Tekenreeksen worden opgeslagen als UTF-16 tekens in .NET, zodat elk teken 2 bytes in het geheugen nodig heeft.

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

De volgende grafiek wordt gegenereerd met een relatief kleine belasting die laat zien hoe GC van invloed is op geheugentoewijzingen.

Grafiek met geheugentoewijzingen voor een relatief kleine belasting.

De grafiek illustreert de volgende details:

  • 4K RPS (aanvragen per seconde)
  • Gen 0 GC-verzamelingen vinden ongeveer elke 2 seconden plaats
  • Constante werkset, ongeveer 500 MB
  • De processor is 12%
  • Stabiel geheugenverbruik en geheugen vrijgeven (via GC)

De volgende grafiek wordt genomen bij de maximale doorvoer die door de machine kan worden verwerkt.

Grafiek met maximale doorvoer.

De grafiek illustreert de volgende details:

  • 22 K RPS
  • Gen 0 GC-verzamelingen vinden meerdere keren per seconde plaats
  • Gen 1-verzamelingen worden geactiveerd aangezien de app aanzienlijk meer geheugen per seconde toewijst
  • Constante werkset, ongeveer 500 MB
  • De CPU is 33%
  • Stabiel geheugenverbruik en geheugen vrijgeven (via GC)
  • CPU (33%) is niet te veel gebruikt, zodat GC een groot aantal toewijzingen kan bijhouden

Werkstation GC versus Server GC

De .NET Garbage Collector heeft twee verschillende modi:

  • Werkstation GC: geoptimaliseerd voor het bureaublad.
  • Server GC: de standaard-GC voor ASP.NET Core-apps. Geoptimaliseerd voor de server.

De GC-modus kan expliciet worden ingesteld in het projectbestand of in het runtimeconfig.json-bestand van de gepubliceerde app. In de volgende markeringen ziet u de instelling ServerGarbageCollection in het projectbestand:

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

Het wijzigen van ServerGarbageCollection in het projectbestand vereist dat de app opnieuw wordt opgebouwd.

Note

De garbagecollection van de server is niet beschikbaar op computers met één kern. Voor meer informatie, zie de eigenschap IsServerGC.

In de volgende afbeelding ziet u het geheugenprofiel onder een 5K RPS met behulp van de Workstation GC.

Grafiek met geheugenprofiel voor een werkstation-GC.

De verschillen tussen deze grafiek en de serverversie zijn aanzienlijk:

  • Werkset daalt van 500 MB tot 70 MB
  • GC voert gen 0 verzamelingen meerdere keren per seconde in plaats van elke 2 seconden
  • GC daalt van 300 MB tot 10 MB

In een typische webserveromgeving is het CPU-gebruik belangrijker dan geheugen, dus de Server GC is beter. Als het geheugengebruik hoog is en het CPU-gebruik relatief laag is, kan de GC van het werkstation beter presteren. High-density host bijvoorbeeld verschillende web-apps waarbij geheugen schaars is.

GC met Docker en kleine containers

Wanneer meerdere apps in containers op dezelfde computer worden uitgevoerd, is Workstation GC mogelijk beter presterend dan Server GC. Zie Uitvoeren met Server GC in een kleine container (blog) en Uitvoeren met Server GC in een scenario met kleine containers deel 1 : vaste limiet voor de GC Heap (blog) voor meer informatie.

Permanente objectverwijzingen

De GC kan objecten waarnaar wordt verwezen niet vrijgeven. Objecten waarnaar wordt verwezen, maar die niet meer nodig zijn, leiden tot een geheugenlek. Als de app vaak objecten toewijst en deze niet meer vrijgeeft nadat ze niet meer nodig zijn, neemt het geheugengebruik na verloop van tijd toe.

De volgende API maakt een tekenreeksinstantie van 20 kB en retourneert deze aan de client. Het verschil met het vorige voorbeeld is dat een statisch lid verwijst naar dit exemplaar, wat betekent dat het exemplaar nooit beschikbaar is voor verzameling.

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;
}

De voorgaande code:

  • Demonstreert een typisch geheugenlek.
  • Bij frequente aanroepen neemt het geheugen van de app toe totdat het proces vastloopt met een OutOfMemory exception.

Grafiek met een geheugenlek als gevolg van permanente ongebruikte verwijzingen.

De grafiek illustreert de volgende details:

  • Belastingstests van het /api/staticstring eindpunt veroorzaken een lineaire toename van het geheugen
  • GC probeert geheugen vrij te maken naarmate geheugendruk toeneemt door een Gen 2-verzameling aan te roepen
  • GC (Garbage Collector) kan het gelekte geheugen niet vrijmaken; Toegewezen geheugen en werktijd nemen toe na verloop van tijd

Voor sommige scenario's, zoals caching, moeten objectverwijzingen worden bewaard totdat geheugendruk ervoor zorgt dat ze worden vrijgegeven. De WeakReference klasse kan worden gebruikt voor dit type cachecode. Een WeakReference object wordt verzameld onder geheugendruk. De standaard implementatie van de IMemoryCache interface maakt gebruik van WeakReference.

Systeemeigen geheugen

Sommige .NET-objecten zijn afhankelijk van systeemeigen geheugen, maar systeemeigen geheugen is niet verzamelbaar door de GC. Het .NET-object dat gebruikmaakt van systeemeigen geheugen, moet het vrij maken met behulp van systeemeigen code.

.NET biedt de IDisposable interface waarmee ontwikkelaars systeemeigen geheugen kunnen vrijgeven. Zelfs als de Dispose methode niet wordt aangeroepen, worden Dispose klassen correct geïmplementeerd wanneer de finalizer wordt uitgevoerd.

Houd rekening met de volgende code:

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

PhysicalFileProvider is een beheerde klasse, dus elk exemplaar wordt verzameld aan het einde van de aanvraag.

In de volgende afbeelding ziet u het geheugenprofiel terwijl u de fileprovider API continu aanroept.

Grafiek met een systeemeigen geheugenlek

In de voorgaande grafiek ziet u een duidelijk probleem met de implementatie van deze klasse, omdat het geheugengebruik blijft toenemen. Dit resultaat is een bekend probleem dat is bijgehouden in GitHub dotnet/aspnetcore-probleem #844.

Hetzelfde lek treedt op in de gebruikerscode in de volgende scenario's:

  • De klasse niet op de juiste manier vrijgegeven
  • Vergeet de Dispose methode aan te roepen van de afhankelijke objecten die moeten worden verwijderd

Grote object stapel-geheugen

Frequente geheugentoewijzing/vrije cycli resulteren in gefragmenteerd geheugen, met name bij het toewijzen van grote segmenten geheugen. Objecten worden toegewezen in aaneengesloten blokken van geheugen. Om fragmentatie te beperken, probeert de GC het geheugen te defragmenteren wanneer de GC geheugen vrijgeeft. Dit proces wordt compressie genoemd. Compressie omvat het verplaatsen van objecten. Bij het verplaatsen van grote objecten wordt een prestatiestraf opgelegd. Daarom maakt de GC een speciale geheugenzone voor grote objecten, de zogenaamde grote object heap (LOH). Objecten die groter zijn dan 85.000 bytes (ongeveer 83 kB) zijn:

  • Geplaatst op de LOH
  • Niet gecomprimeerd
  • Verwerkt tijdens de verzameling Gen 2

Wanneer de LOH vol is, activeert de GC een generatie 2-verzameling (Gen 2-collection).

  • Gen 2-verzamelingen zijn inherent traag.
  • Ze kunnen kosten in rekening worden gebracht voor het activeren van een verzameling voor alle andere generaties.

Met de volgende code wordt de LOH onmiddellijk gecomprimeerd:

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

Zie de LargeObjectHeapCompactionMode eigenschap voor meer informatie over het comprimeren van de LOH.

In containers die gebruikmaken van .NET Core 3.0 of hoger, wordt de LOH automatisch gecomprimeerd.

De volgende API die dit gedrag illustreert:

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

In de volgende grafiek ziet u het geheugenprofiel voor het aanroepen van het /api/loh/84975 eindpunt, onder maximale belasting:

Grafiek met het geheugenprofiel van het toewijzen van bytes

In de volgende grafiek ziet u het geheugenprofiel voor het aanroepen van het /api/loh/84976 eindpunt, waarbij slechts één byte wordt toegewezen:

Grafiek met geheugenprofiel voor het toewijzen van nog een byte

Note

De byte[] structuur heeft overheadbytes. Daarom activeert 84.976 bytes de limiet van 85.000.

De twee voorgaande grafieken vergelijken:

  • Werkset is vergelijkbaar voor beide scenario's, ongeveer 450 MB
  • Onder LOH-aanvragen (84.975 bytes) worden voornamelijk Gen 0-verzamelingen weergegeven
  • Bij LOH-aanvragen worden constante Gen 2-verzamelingen gegenereerd, die duur zijn. Er is meer CPU vereist en doorvoer daalt ~50%.

Tijdelijke grote objecten zijn problematisch omdat ze Gen 2-verzamelingen veroorzaken.

Voor maximale prestaties minimaliseert u het gebruik van grote objecten. Splits indien mogelijk grote objecten op. Zo splitst bijvoorbeeld middleware voor responsecaching in ASP.NET Core de cache-items op in blokken van minder dan 85.000 bytes.

In de volgende koppelingen ziet u de ASP.NET Kernbenadering om objecten onder de limiet van LOH te houden:

Voor meer informatie, zie:

HttpClient

Onjuist gebruik van de HttpClient klasse kan leiden tot een resourcelek.

Systeembronnen (zoals databaseverbindingen, sockets, bestandsingangen, enzovoort) hebben twee problemen:

  • Ze zijn schaarser dan geheugen.
  • Ze veroorzaken meer problemen bij lekken dan geheugen.

Ervaren .NET ontwikkelaars weten de methode Dispose aan te roepen op objecten die de IDisposable-interface implementeren. Het niet verwijderen van objecten die worden geïmplementeerd door IDisposable , resulteert doorgaans in lekkend geheugen of lekkende systeembronnen.

HttpClient implementeert IDisposable, maar mag niet worden verwijderd bij elke aanroep. In plaats daarvan moet HttpClient opnieuw worden gebruikt.

Het volgende eindpunt maakt en verwijdert een nieuw HttpClient exemplaar voor elke aanvraag:

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

Onder laden worden de volgende foutberichten vastgelegd:

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)

Hoewel de HttpClient exemplaren worden verwijderd, duurt het enige tijd voordat het besturingssysteem de werkelijke netwerkverbinding vrijgeeft. Het proces van het continu maken van nieuwe verbindingen leidt tot uitputting van poorten. Elke clientverbinding vereist een eigen clientpoort.

Een manier om poortuitputting te voorkomen, is door hetzelfde HttpClient exemplaar opnieuw te gebruiken.

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;
}

Het HttpClient exemplaar wordt vrijgegeven wanneer de app stopt. In dit voorbeeld ziet u dat niet elke wegwerpresource na elk gebruik moet worden verwijderd.

In de volgende artikelen wordt een betere manier beschreven om te gaan met de levensduur van een HttpClient object.

Objectgroepering

In het vorige voorbeeld is getoond hoe het HttpClient exemplaar statisch kan worden gemaakt en opnieuw kan worden gebruikt door alle aanvragen. Hergebruik voorkomt dat de bronnen opraken.

Objectpooling is een alternatief:

  • Er wordt gebruik gemaakt van het hergebruikpatroon.
  • Het ontwerp is ideaal voor objecten die duur zijn om te maken.

Een pool is een verzameling vooraf gedefinieerde objecten die kunnen worden gereserveerd en vrijgegeven in threads. Pools kunnen toewijzingsregels definiëren, zoals limieten, vooraf gedefinieerde grootten of groeisnelheid.

Het NuGet-pakket Microsoft.Extensions.ObjectPool bevat klassen die helpen bij het beheren van dergelijke pools.

Met het volgende API-eindpunt wordt een byte buffer geïnstitueerde die wordt gevuld met willekeurige getallen voor elke aanvraag:

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

            return array;
        }

In de volgende grafiek ziet u hoe u de voorgaande API aanroept met gemiddelde belasting:

Grafiek met aanroepen naar API met gemiddelde belasting.

In de grafiek ziet u dat Gen 0-verzamelingen ongeveer één keer per seconde plaatsvinden.

De code kan worden geoptimaliseerd door de byte buffer te groeperen met behulp van de klasse ArrayPool<T> . Een statisch exemplaar wordt herhaaldelijk gebruikt voor elke aanvraag.

Wat er anders is met deze benadering is dat een gegroepeerd object wordt geretourneerd vanuit de API:

  • Het object valt buiten uw beheer zodra u terugkeert vanuit de methode.
  • U kunt het object niet vrijgeven.

Om de afvoer van het object in te stellen:

RegisterForDispose zorgt ervoor dat het doelobject wordt aangeroepen Dispose , dus het object wordt pas vrijgegeven nadat de HTTP-aanvraag is voltooid.

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;
}

Het toepassen van dezelfde belasting als de niet-gepoolde versie resulteert in de volgende grafiek:

Grafiek met minder toewijzingen.

Het belangrijkste verschil is het aantal toegewezen bytes en daarom minder Gen 0-verzamelingen.