Delen via


Geheugenbeheer en geheugenopruiming (GC) in ASP.NET Core

Door Sébastien Ros en Rick Anderson

Geheugenbeheer is complex, zelfs in een beheerd framework zoals .NET. Het analyseren en begrijpen van geheugenproblemen kan lastig zijn. Dit artikel:

  • Was gemotiveerd door veel geheugenlekken en problemen met de GC. De meeste van deze problemen zijn veroorzaakt doordat u niet begrijpt hoe geheugenverbruik werkt in .NET Core of niet begrijpt hoe het wordt gemeten.
  • Demonstreert problematisch geheugengebruik en stelt alternatieve benaderingen voor.

Hoe Garbage Collection (GC) werkt in .NET Core

De GC wijst heap-segmenten toe waarbij elk segment een aaneengesloten geheugenbereik is. Objecten die in de heap worden geplaatst, worden onderverdeeld in een van 3 generaties: 0, 1 of 2. De generatie bepaalt de frequentie waarmee de GC probeert geheugen vrij te geven voor beheerde objecten waarnaar niet meer wordt verwezen door de app. Lagere genummerde generaties zijn vaker GC's.

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, zijn hogere generaties minder vaak GC's. Objecten met korte levensduur blijven altijd in generatie 0. Objecten waarnaar wordt verwezen tijdens de levensduur van een webaanvraag, hebben bijvoorbeeld een korte levensduur. Singletons op toepassingsniveau worden over het algemeen gemigreerd naar generatie 2.

Wanneer een ASP.NET Core-app wordt gestart, wordt de GC:

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

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

GC. Kanttekeningen verzamelen

Over het algemeen zouden ASP.NET Core-apps in productie geen expliciet gebruik moeten maken van GC.Collect. Het induceren van garbage collections op suboptimale momenten kan de prestaties aanzienlijk verminderen.

GC.Collect is handig 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:

  • Objectverwijzingen tellen
  • Meten hoeveel impact de GC heeft op HET CPU-gebruik
  • Geheugenruimte meten 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.

Voorbeeld van een app voor weergave van geheugengebruik

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.
  • Is echter geen ondersteund hulpprogramma, maar kan worden gebruikt om geheugengebruikspatronen van ASP.NET Core-apps weer te geven.

Voer MemoryLeak uit. Toegewezen geheugen neemt langzaam toe totdat een GC plaatsvindt. Geheugen neemt toe omdat het hulpprogramma 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)

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 het fysieke geheugen bevindt. De werkset die wordt weergegeven is dezelfde waarde als die door Taakbeheer wordt weergegeven.

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 om te laten zien hoe geheugentoewijzingen worden beïnvloed door de GC.

Grafiek met geheugentoewijzingen voor een relatief kleine belasting

In de voorgaande grafiek ziet u:

  • 4K RPS (Aanvragen per seconde).
  • Generatie 0 GC-verzamelingen vinden ongeveer elke twee seconden plaats.
  • De werkset is constant op ongeveer 500 MB.
  • De processor is 12%.
  • Het geheugenverbruik en de release (via GC) is stabiel.

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

Grafiek met maximale doorvoer

In de voorgaande grafiek ziet u:

  • 22 duizend verzoeken per seconde
  • Generatie 0 GC-verzamelingen vinden meerdere keren per seconde plaats.
  • Verzamelingen van de 1e generatie worden geactiveerd omdat de app aanzienlijk meer geheugen per seconde heeft toegewezen.
  • De werkset is constant op ongeveer 500 MB.
  • CPU is 33%.
  • Het geheugenverbruik en de release (via GC) is stabiel.
  • De CPU (33%) is niet overbelast, daarom kan de garbage collection een groot aantal toewijzingen 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.

Notitie: De garbagecollection van de server is niet beschikbaar op computers met één kern. Zie IsServerGC voor meer informatie.

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:

  • De werkset daalt van 500 MB tot 70 MB.
  • De GC voert generatie 0-verzamelingen meerdere keren per seconde uit in plaats van elke twee 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 een container worden uitgevoerd op één computer, kan Workstation GC beter presteren dan Server GC. Zie Uitvoeren met Server GC in een kleine container en Uitvoeren met Server GC in een kleine container scenario deel 1 – Vaste limiet voor de GC-heap voor meer informatie.

Permanente objectverwijzingen

De GC kan geen objecten vrij maken waarnaar wordt verwezen. Objecten waarnaar wordt verwezen, maar die niet meer nodig zijn, leiden tot een geheugenlek. Als de app vaak objecten toewijst en ze 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 naar dit exemplaar wordt verwezen door een statisch lid, wat betekent dat het 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:

  • Is een voorbeeld van 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

In de voorgaande afbeelding:

  • Belastingtesten van het /api/staticstring eindpunt zorgen voor een lineaire toename van het geheugen.
  • De GC probeert geheugen vrij te maken naarmate de geheugendruk toeneemt, door een verzameling van de tweede generatie aan te roepen.
  • De GC kan het gelekte geheugen niet vrij maken. Toegewezen en werkset nemen toe in de loop van de 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 IMemoryCache gebruik WeakReference.

Systeemeigen geheugen

Sommige .NET Core-objecten zijn afhankelijk van systeemeigen geheugen. Systeemeigen geheugen kan niet worden verzameld door de GC. Het .NET-object dat systeemeigen geheugen gebruikt, moet het vrij maken met systeemeigen code.

.NET biedt de IDisposable interface waarmee ontwikkelaars systeemeigen geheugen kunnen vrijgeven. Zelfs als Dispose niet wordt aangeroepen, zullen klassen die correct geïmplementeerd zijn Dispose aanroepen 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 is een bekend probleem dat in dit issue wordt bijgehouden.

Hetzelfde lek kan zich voordoen in de gebruikerscode, op een van de volgende manieren:

  • De klasse wordt niet correct vrijgegeven.
  • Vergeet de Dispose methode aan te roepen van de afhankelijke objecten die moeten worden verwijderd.

Grote object stapel-geheugen

Frequente geheugentoewijzing/vrije cycli kunnen geheugen fragmenteren, 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.
  • Verzameld tijdens generatie 2 GCs.

Wanneer de LOH vol is, activeert de GC een verzameling van de tweede generatie. Verzamelingen van de tweede generatie:

  • Zijn inherent traag.
  • Daarnaast worden er kosten in rekening 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 LargeObjectHeapCompactionMode voor meer informatie over het comprimeren van de LOH.

In containers met .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

Let op: de byte[]-structuur heeft overhead-bytes. Daarom activeert 84.976 bytes de limiet van 85.000.

De twee voorgaande grafieken vergelijken:

  • De werkset is vergelijkbaar voor beide scenario's, ongeveer 450 MB.
  • De onderstaande LOH-aanvragen (84.975 bytes) tonen voornamelijk generatie 0-verzamelingen.
  • De over LOH-aanvragen genereren constante verzamelingen van generatie 2. Verzamelingen van de tweede generatie zijn duur. Er is meer CPU vereist en de doorvoer daalt bijna 50%.

Tijdelijke grote objecten zijn bijzonder problematisch omdat ze gen2 GCs veroorzaken.

Voor maximale prestaties moet het gebruik van grote objecten worden geminimaliseerd. Splits indien mogelijk grote objecten op. Antwoordcaching-middleware in ASP.NET Core splitst de cachevermeldingen bijvoorbeeld 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 HttpClient kan leiden tot een resourcelek. Systeembronnen, zoals databaseverbindingen, sockets, bestandsingangen, enzovoort:

  • Zijn schaarser dan geheugen.
  • Zijn problematischer wanneer ze uitlekken dan geheugen.

Ervaren .NET-ontwikkelaars weten om Dispose aan te roepen op objecten die IDisposable 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 de werkelijke netwerkverbinding door het besturingssysteem wordt vrijgegeven. Door continu nieuwe verbindingen te maken, treden poortenuitputting op. 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.

Zie het volgende voor een betere manier om de levensduur van een HttpClient exemplaar te beheren:

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:

  • Maakt gebruik van het hergebruikspatroon.
  • Is ontworpen voor objecten die duur zijn om te maken.

Een pool is een verzameling vooraf geïnitialiseerde 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 voorgaande grafiek worden verzamelingen van generatie 0 ongeveer één keer per seconde uitgevoerd.

De voorgaande code kan worden geoptimaliseerd door de byte buffer te groeperen met behulp van 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. Dat betekent:

  • 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 Dispose op het doelobject wordt aangeroepen, zodat dit alleen wordt vrijgegeven wanneer 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-poolversie resulteert in de volgende grafiek:

Grafiek met minder toewijzingen

Het belangrijkste verschil is toegewezen bytes en als gevolg hiervan zijn er veel minder verzamelingen van generatie 0.

Aanvullende bronnen