Správa paměti a uvolňování paměti (GC) v ASP.NET Core

Autor : Sébastien Ros a Rick Anderson

Správa paměti je složitá, a to i ve spravovaném rozhraní, jako je .NET. Analýza a pochopení problémů s pamětí může být náročná. Tento článek:

  • Bylo motivováno mnoha nevracením paměti a problémy s nepracováním paměti. Většina těchto problémů byla způsobená tím, že nerozumela, jak funguje spotřeba paměti v .NET Core, nebo nerozumýšli, jak se měří.
  • Ukazuje problematické využití paměti a navrhuje alternativní přístupy.

Jak funguje uvolňování paměti (GC) v .NET Core

GC přiděluje segmenty haldy, kde každý segment je souvislý rozsah paměti. Objekty umístěné v haldě jsou kategorizovány do jedné ze 3 generací: 0, 1 nebo 2. Generování určuje frekvenci, o kterou se GC pokusí uvolnit paměť u spravovaných objektů, na které aplikace už odkazuje. Nižší počet generací je častější.

Objekty se přesunou z jedné generace do druhé na základě jejich životnosti. S tím, jak objekty žijí déle, se přesunou do vyšší generace. Jak už bylo zmíněno dříve, vyšší generace jsou méně často GC. Krátkodobé objekty zůstávají vždy ve generaci 0. Například objekty, na které se odkazuje během životnosti webového požadavku, jsou krátkodobé. Singletony na úrovni aplikace se obecně migrují na generaci 2.

Když se spustí aplikace ASP.NET Core, GC:

  • Zarezervuje paměť pro počáteční segmenty haldy.
  • Při načtení modulu runtime potvrdí malou část paměti.

Předchozí přidělení paměti se provádí z důvodů výkonu. Výhodou výkonu jsou segmenty haldy v souvislé paměti.

GC. Shromažďování upozornění

Obecně platí, že aplikace ASP.NET Core v produkčním prostředí by neměly používat GC. Explicitně shromážděte. Vynucení uvolňování paměti v suboptimální době může výrazně snížit výkon.

GC. Shromažďování je užitečné při zkoumání nevracení paměti. Volání GC.Collect() aktivuje blokující cyklus uvolňování paměti, který se pokusí uvolnit všechny objekty nepřístupné ze spravovaného kódu. Je to užitečný způsob, jak pochopit velikost dosažitelných živých objektů v haldě a sledovat růst velikosti paměti v průběhu času.

Analýza využití paměti aplikace

Vyhrazené nástroje můžou pomoct analyzovat využití paměti:

  • Počítání odkazů na objekty
  • Měření dopadu uvolňování paměti na využití procesoru
  • Měření paměťového prostoru používaného pro každou generaci

K analýze využití paměti použijte následující nástroje:

Zjišťování problémů s pamětí

Správce úloh se dá použít k získání představu o tom, kolik paměti aplikace ASP.NET používá. Hodnota paměti Správce úloh:

  • Představuje velikost paměti, kterou používá proces ASP.NET.
  • Zahrnuje živé objekty aplikace a další příjemce paměti, jako je například nativní využití paměti.

Pokud se hodnota paměti Správce úloh natrvalo zvýší a nikdy neplní, aplikace nevracení paměti. Následující části ukazují a vysvětlují několik vzorů využití paměti.

Ukázková aplikace pro zobrazení využití paměti

Ukázková aplikace MemoryLeak je dostupná na GitHubu. Aplikace MemoryLeak:

  • Obsahuje diagnostický kontroler, který shromažďuje data paměti a paměti v reálném čase pro aplikaci.
  • Obsahuje indexovou stránku, která zobrazuje data paměti a GC. Indexová stránka se aktualizuje každou sekundu.
  • Obsahuje kontroler rozhraní API, který poskytuje různé vzory zatížení paměti.
  • Podporovaný nástroj se ale nedá použít k zobrazení vzorců využití paměti ASP.NET aplikací Core.

Spusťte MemoryLeak. Přidělená paměť se pomalu zvyšuje, dokud nedojde k GC. Paměť se zvyšuje, protože nástroj přiděluje vlastní objekt pro zachytávání dat. Následující obrázek ukazuje stránku indexu MemoryLeak při výskytu GC Gen 0. Graf zobrazuje 0 RPS (požadavky za sekundu), protože se nevolali žádné koncové body rozhraní API z kontroleru rozhraní API.

Chart showing 0 Requests Per Second (RPS)

Graf zobrazuje dvě hodnoty pro využití paměti:

  • Přiděleno: množství paměti obsazené spravovanými objekty
  • Pracovní sada: Sada stránek ve virtuálním adresní prostoru procesu, který je aktuálně v fyzické paměti. Zobrazená pracovní sada je stejná hodnota, jakou zobrazí Správce úloh.

Přechodné objekty

Následující rozhraní API vytvoří instanci řetězce 10 KB a vrátí ji klientovi. Na každém požadavku je nový objekt přidělen v paměti a zapsán do odpovědi. Řetězce se v .NET ukládají jako znaky UTF-16, takže každý znak má v paměti 2 bajty.

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

Následující graf se generuje s relativně malým zatížením, aby ukázal, jak jsou přidělení paměti ovlivněny GC.

Graph showing memory allocations for a relatively small load

Předchozí graf ukazuje:

  • 4K RPS (požadavky za sekundu).
  • Kolekce GC generace 0 se vyskytují asi každé dvě sekundy.
  • Pracovní sada je konstantní přibližně 500 MB.
  • Procesor je 12 %.
  • Spotřeba a uvolnění paměti (prostřednictvím uvolňování paměti) je stabilní.

Následující graf se provádí s maximální propustností, kterou může počítač zpracovat.

Chart showing max throughput

Předchozí graf ukazuje:

  • 22K RPS
  • Kolekce GC generace 0 probíhají několikrát za sekundu.
  • Kolekce 1. generace se aktivují, protože aplikace přidělila výrazně více paměti za sekundu.
  • Pracovní sada je konstantní přibližně 500 MB.
  • Procesor je 33 %.
  • Spotřeba a uvolnění paměti (prostřednictvím uvolňování paměti) je stabilní.
  • Procesor (33 %) se nevyužívá, takže uvolňování paměti může držet krok s vysokým počtem přidělení.

Pracovní stanice GC vs. Server GC

Systém uvolňování paměti .NET má dva různé režimy:

  • Pracovní stanice GC: Optimalizováno pro stolní počítač.
  • Server GC. Výchozí GC pro aplikace ASP.NET Core. Optimalizováno pro server.

Režim GC lze nastavit explicitně v souboru projektu nebo v runtimeconfig.json souboru publikované aplikace. Následující kód ukazuje nastavení ServerGarbageCollection v souboru projektu:

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

Změna ServerGarbageCollection v souboru projektu vyžaduje, aby byla aplikace znovu sestavena.

Poznámka: Uvolňování paměti serveru není k dispozici na počítačích s jedním jádrem. Další informace najdete na webu IsServerGC.

Následující obrázek znázorňuje profil paměti v rámci 5K RPS pomocí GC pracovní stanice.

Chart showing memory profile for a Workstation GC

Rozdíly mezi tímto grafem a verzí serveru jsou významné:

  • Pracovní sada klesne z 500 MB na 70 MB.
  • Uvolňování paměti několikrát generuje kolekce 0krát za sekundu místo každé dvě sekundy.
  • GC klesne z 300 MB na 10 MB.

V typickém prostředí webového serveru je využití procesoru důležitější než paměť, a proto je lepší uvolňování paměti serveru. Pokud je využití paměti vysoké a využití procesoru je relativně nízké, může být uvolňování paměti výkonnější. Například vysoká hustota hostování několika webových aplikací, kde je nedostatek paměti.

GC s využitím Dockeru a malých kontejnerů

Pokud na jednom počítači běží více kontejnerizovaných aplikací, může být GC pracovní stanice výkonnější než serverový GC. Další informace naleznete v tématu Spuštění s serverem GC v malém kontejneru a spuštěn s serverem GC v malé kontejnerové scénáři část 1 – pevný limit haldy GC.

Odkazy na trvalý objekt

GC nemůže uvolnit objekty, které jsou odkazovány. Objekty, na které se odkazuje, ale které už nejsou potřeba, způsobí nevrácení paměti. Pokud aplikace často přiděluje objekty a po tom, co je už nepotřebujete, nepovede je uvolnit, využití paměti se v průběhu času zvýší.

Následující rozhraní API vytvoří instanci řetězce 10 KB a vrátí ji klientovi. Rozdíl oproti předchozímu příkladu spočívá v tom, že na tuto instanci odkazuje statický člen, což znamená, že pro kolekci není nikdy k dispozici.

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

Předchozí kód:

  • Je příkladem typického nevrácení paměti.
  • Při častých voláních dojde k nárůstu paměti aplikace, dokud se proces nespadne s OutOfMemory výjimkou.

Chart showing a memory leak

Na předchozím obrázku:

  • Zátěžové testování koncového /api/staticstring bodu způsobuje lineární zvýšení paměti.
  • Uvolňování paměti se snaží uvolnit paměť s rostoucím zatížením paměti voláním kolekce generace 2.
  • GC nemůže uvolnit nevracenou paměť. Přidělená a pracovní sada se s časem zvýší.

Některé scénáře, jako je ukládání do mezipaměti, vyžadují uložení odkazů na objekty, dokud je tlak na paměť nenutí uvolnění. Třídu WeakReference lze použít pro tento typ kódu ukládání do mezipaměti. Objekt WeakReference se shromažďuje pod tlakem paměti. Výchozí implementace IMemoryCache použití WeakReference.

Nativní paměť

Některé objekty .NET Core spoléhají na nativní paměť. Nativní paměť nelze shromažďovat GC. Objekt .NET, který používá nativní paměť, musí ho uvolnit pomocí nativního kódu.

.NET poskytuje IDisposable rozhraní, které vývojářům umožní uvolnit nativní paměť. I když Dispose není volána, správně implementované třídy volání Dispose při finalizačním spuštění .

Vezměte v úvahu následující kód:

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

PhysicalFileProvider je spravovaná třída, takže na konci požadavku se bude shromažďovat jakákoli instance.

Následující obrázek znázorňuje profil paměti při průběžném vyvolání fileprovider rozhraní API.

Chart showing a native memory leak

Předchozí graf ukazuje jasný problém s implementací této třídy, protože stále zvyšuje využití paměti. Jedná se o známý problém, který se v tomto problému sleduje.

Ke stejnému úniku může dojít v uživatelském kódu jedním z následujících způsobů:

  • Třída není správně uvolněna.
  • Zapomněli jsme vyvolat metodu Dispose závislých objektů, které by měly být uvolněny.

Halda velkého objektu

Časté cykly přidělení paměti nebo volné cykly mohou fragmentovat paměť, zejména při přidělování velkých bloků paměti. Objekty jsou přiděleny v souvislých blocích paměti. Chcete-li zmírnit fragmentaci, když GC uvolní paměť, pokusí se ji defragmentovat. Tento proces se nazývá komprimace. Komprimace zahrnuje přesouvání objektů. Přesunutí velkých objektů představuje snížení výkonu. Z tohoto důvodu GC vytvoří speciální paměťovou zónu pro velké objekty, označované jako velká halda objektu (LOH). Objekty větší než 85 000 bajtů (přibližně 83 kB) jsou:

  • Umístěn na LOH.
  • Není komprimovaný.
  • Shromažďuje se během generace 2 GCS.

Když je LOH plný, GC aktivuje kolekci generace 2. Kolekce generace 2:

  • Jsou ze své podstaty pomalé.
  • Navíc se účtují náklady na aktivaci kolekce ve všech ostatních generacích.

Následující kód zkomprimuje LOH okamžitě:

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

Informace LargeObjectHeapCompactionMode o komprimování LOH.

V kontejnerech využívajících .NET Core 3.0 a novějších je LOH automaticky komprimován.

Toto chování ilustruje následující rozhraní API:

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

Následující graf ukazuje profil paměti pro volání /api/loh/84975 koncového bodu v rámci maximálního zatížení:

Chart showing memory profile of allocating bytes

Následující graf ukazuje profil paměti volání koncového /api/loh/84976 bodu a přidělení pouze jednoho dalšího bajtu:

Chart showing memory profile of allocating one more byte

Poznámka: Struktura byte[] má režijní bajty. Proto 84 976 bajtů aktivuje limit 85 000.

Porovnání dvou předchozích grafů:

  • Pracovní sada je podobná pro oba scénáře, přibližně 450 MB.
  • V rámci požadavků LOH (84 975 bajtů) se zobrazuje převážně kolekce 0 generace.
  • Více než LOH požadavky generují kolekce konstantní generace 2. Kolekce generace 2 jsou nákladné. Vyžaduje se více procesoru a propustnost klesne téměř o 50 %.

Dočasné velké objekty jsou obzvláště problematické, protože způsobují GCS gen2.

Pro dosažení maximálního výkonu by se mělo minimalizovat použití velkých objektů. Pokud je to možné, rozdělte velké objekty. Například middleware odpovědi Ukládání do mezipaměti v ASP.NET Core rozdělí položky mezipaměti na bloky menší než 85 000 bajtů.

Následující odkazy ukazují přístup ASP.NET Core k zachování objektů pod limitem LOH:

Další informace naleznete v tématu:

HttpClient

Nesprávné použití HttpClient může vést k úniku prostředků. Systémové prostředky, jako jsou připojení k databázi, sokety, popisovače souborů atd.:

  • Je jich málo než paměť.
  • Jsou problematická, pokud nevracená než paměť.

Zkušení vývojáři .NET vědí, že volají Dispose objekty, které implementují IDisposable. Neuskutečení objektů, které implementují IDisposable , obvykle vede k nevracení paměti nebo nevraceným systémovým prostředkům.

HttpClientimplementuje IDisposable, ale nemělo by být uvolněno při každém vyvolání. HttpClient Místo toho byste měli znovu použít.

Následující koncový bod vytvoří a v každém požadavku vyřadí novou HttpClient instanci:

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

Při načítání se protokolují následující chybové zprávy:

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)

I když HttpClient jsou instance uvolněny, skutečné síťové připojení nějakou dobu trvá uvolnění operačního systému. Při průběžném vytváření nových připojení dochází k vyčerpání portů. Každé připojení klienta vyžaduje vlastní port klienta.

Jedním ze způsobů, jak zabránit vyčerpání portů, je opakované použití stejné HttpClient instance:

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

Instance HttpClient se uvolní, když se aplikace zastaví. Tento příklad ukazuje, že po každém použití by neměl být odstraněn každý uvolnitelný prostředek.

Lepší způsob, jak zvládnout životnost instance, najdete v následujících tématech HttpClient :

Sdružování objektů

Předchozí příklad ukázal, jak HttpClient může být instance statická a opakovaně použita všemi požadavky. Opakované použití zabraňuje výpadku prostředků.

Sdružování objektů:

  • Používá vzor opakovaného použití.
  • Je určen pro objekty, které jsou nákladné k vytvoření.

Fond je kolekce předem inicializovaných objektů, které je možné rezervovat a uvolnit napříč vlákny. Fondy můžou definovat pravidla přidělování, jako jsou limity, předdefinované velikosti nebo míra růstu.

Balíček NuGet Microsoft.Extensions.ObjectPool obsahuje třídy, které pomáhají spravovat tyto fondy.

Následující koncový bod rozhraní API vytvoří byte instanci vyrovnávací paměti vyplněné náhodnými čísly na každém požadavku:

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

            return array;
        }

Následující graf zobrazuje volání předchozího rozhraní API se střední zátěží:

Chart showing calls to API with moderate load

V předchozím grafu dochází k kolekcím 0 generace přibližně jednou za sekundu.

Předchozí kód lze optimalizovat sdružováním byte vyrovnávací paměti pomocí ArrayPool<T>. Statická instance se opakovaně používá napříč požadavky.

Čím se tento přístup liší, je to, že z rozhraní API se vrátí objekt ve fondu. To znamená:

  • Objekt je mimo váš ovládací prvek, jakmile se vrátíte z metody.
  • Objekt nelze uvolnit.

Nastavení odstranění objektu:

RegisterForDispose se postará o volání Dispose cílového objektu, aby byl uvolněn pouze po dokončení požadavku HTTP.

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

Pokud použijete stejné zatížení jako verze, která není ve fondu, bude výsledkem následující graf:

Chart showing fewer allocations

Hlavní rozdíl je přidělen bajty a v důsledku toho je mnohem méně kolekcí generace 0.

Další prostředky