Zarządzanie pamięcią i odzyskiwanie pamięci (GC) w ASP.NET Core

Autor: Sébastien Ros i Rick Anderson

Zarządzanie pamięcią jest złożone, nawet w strukturze zarządzanej, takiej jak .NET. Analizowanie i zrozumienie problemów z pamięcią może być trudne. W tym artykule:

  • Był motywowany przez wiele przecieków pamięci i GC nie działa problemy. Większość z tych problemów wynikała z braku zrozumienia, jak działa zużycie pamięci na platformie .NET Core lub nie rozumie, jak jest mierzona.
  • Demonstruje problematyczne użycie pamięci i sugeruje alternatywne podejścia.

Jak działa odzyskiwanie pamięci (GC) na platformie .NET Core

GC przydziela segmenty sterty, w których każdy segment jest ciągły zakres pamięci. Obiekty umieszczone w stercie są podzielone na jedną z 3 pokoleń: 0, 1 lub 2. Generacja określa częstotliwość, z jaką GC próbuje zwolnić pamięć na zarządzanych obiektach, do których nie odwołuje się już aplikacja. Niższe numerowane generacje to GC częściej.

Obiekty są przenoszone z jednej generacji do innej na podstawie ich okresu istnienia. W miarę dłuższego życia obiektów są przenoszone do wyższej generacji. Jak wspomniano wcześniej, generacje wyższe są GC rzadziej. Obiekty krótkotrwałe zawsze pozostają w generacji 0. Na przykład obiekty, do których odwołuje się okres życia żądania internetowego, są krótkotrwałe. Singletony na poziomie aplikacji zazwyczaj są migrowane do generacji 2.

Po uruchomieniu aplikacji ASP.NET Core GC:

  • Rezerwuje część pamięci dla początkowych segmentów stert.
  • Zatwierdza niewielką część pamięci po załadowaniu środowiska uruchomieniowego.

Powyższe alokacje pamięci są wykonywane ze względów wydajności. Zaletą wydajności są segmenty sterty w ciągłej pamięci.

GC. Zbieranie zastrzeżeń

Ogólnie rzecz biorąc, aplikacje ASP.NET Core w środowisku produkcyjnym nie powinny używać GC. Zbierz jawnie. Inducing garbage collections at sub-optimal times can decrease performance znacznie.Inducing garbage collections at sub-optimal times can decrease performance (Inducing garbage collections at sub-optimal times can decrease performance).

GC. Funkcja Collect jest przydatna podczas badania przecieków pamięci. Wywołanie GC.Collect() wyzwala blokujący cykl odzyskiwania pamięci, który próbuje odzyskać wszystkie obiekty niedostępne z kodu zarządzanego. Jest to przydatny sposób zrozumienia rozmiaru osiągalnych obiektów na żywo w stercie i śledzenia wzrostu rozmiaru pamięci w czasie.

Analizowanie użycia pamięci aplikacji

Dedykowane narzędzia mogą pomóc w analizowaniu użycia pamięci:

  • Zliczanie odwołań do obiektów
  • Mierzenie, jaki wpływ ma GC na użycie procesora CPU
  • Mierzenie przestrzeni pamięci używanej dla każdej generacji

Użyj następujących narzędzi do analizowania użycia pamięci:

Wykrywanie problemów z pamięcią

Menedżer zadań może służyć do uzyskania pojęcia, ile pamięci używa aplikacja ASP.NET. Wartość pamięci Menedżera zadań:

  • Reprezentuje ilość pamięci używanej przez proces ASP.NET.
  • Obejmuje obiekty żywe aplikacji i innych użytkowników pamięci, takich jak użycie pamięci natywnej.

Jeśli wartość pamięci Menedżera zadań zwiększa się na czas nieokreślony i nigdy nie spłaszcza się, aplikacja ma przeciek pamięci. W poniższych sekcjach przedstawiono i wyjaśniono kilka wzorców użycia pamięci.

Przykładowa aplikacja użycia pamięci wyświetlania

Przykładowa aplikacja MemoryLeak jest dostępna w witrynie GitHub. Aplikacja MemoryLeak:

  • Zawiera kontroler diagnostyczny, który zbiera pamięć w czasie rzeczywistym i dane GC dla aplikacji.
  • Zawiera stronę Indeks, na którą są wyświetlane dane pamięci i GC. Strona Indeks jest odświeżona co sekundę.
  • Zawiera kontroler interfejsu API, który zapewnia różne wzorce obciążenia pamięci.
  • Nie jest to jednak obsługiwane narzędzie, którego można użyć do wyświetlania wzorców użycia pamięci ASP.NET Core.

Uruchom polecenie MemoryLeak. Przydzielona pamięć powoli zwiększa się do momentu wystąpienia GC. Pamięć zwiększa się, ponieważ narzędzie przydziela obiekt niestandardowy do przechwytywania danych. Na poniższej ilustracji przedstawiono stronę indeksu MemoryLeak, gdy wystąpi GC 0. generacji. Wykres przedstawia 0 RPS (żądania na sekundę), ponieważ nie wywołano żadnych punktów końcowych interfejsu API z kontrolera interfejsu API.

Chart showing 0 Requests Per Second (RPS)

Wykres przedstawia dwie wartości użycia pamięci:

  • Przydzielone: ilość pamięci zajmowanej przez obiekty zarządzane
  • Zestaw roboczy: zestaw stron w wirtualnej przestrzeni adresowej procesu, który jest obecnie rezydentem pamięci fizycznej. Wyświetlany zestaw roboczy jest tą samą wartością, która jest wyświetlana w Menedżerze zadań.

Obiekty przejściowe

Poniższy interfejs API tworzy wystąpienie ciągu 10 KB i zwraca je do klienta. W każdym żądaniu nowy obiekt jest przydzielany w pamięci i zapisywany w odpowiedzi. Ciągi są przechowywane jako znaki UTF-16 na platformie .NET, więc każdy znak przyjmuje 2 bajty w pamięci.

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

Poniższy graf jest generowany przy stosunkowo małym obciążeniu, aby pokazać, w jaki sposób alokacje pamięci mają wpływ na GC.

Graph showing memory allocations for a relatively small load

Powyższy wykres przedstawia następujące elementy:

  • 4K RPS (żądania na sekundę).
  • Kolekcje GC generacji 0 są wykonywane co około dwie sekundy.
  • Zestaw roboczy jest stały na poziomie około 500 MB.
  • Procesor CPU wynosi 12%.
  • Zużycie pamięci i wydanie (za pośrednictwem GC) jest stabilne.

Poniższy wykres jest pobierany z maksymalną przepływnością, którą można obsłużyć przez maszynę.

Chart showing max throughput

Powyższy wykres przedstawia następujące elementy:

  • 22K RPS
  • Kolekcje GC generacji 0 występują kilka razy na sekundę.
  • Kolekcje generacji 1 są wyzwalane, ponieważ aplikacja przydzielała znacznie więcej pamięci na sekundę.
  • Zestaw roboczy jest stały na poziomie około 500 MB.
  • Procesor CPU wynosi 33%.
  • Zużycie pamięci i wydanie (za pośrednictwem GC) jest stabilne.
  • Procesor CPU (33%) nie jest nadmiernie wykorzystywany, dlatego odzyskiwanie pamięci może nadążyć za dużą liczbą alokacji.

Stacja robocza GC a GC serwera

Moduł zbierający elementy bezużyteczne platformy .NET ma dwa różne tryby:

  • Stacja robocza GC: zoptymalizowana pod kątem pulpitu.
  • GC serwera. Domyślna GC dla aplikacji ASP.NET Core. Zoptymalizowane pod kątem serwera.

Tryb GC można jawnie ustawić w pliku projektu lub w runtimeconfig.json pliku opublikowanej aplikacji. Następujące znaczniki pokazują ustawienie ServerGarbageCollection w pliku projektu:

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

Zmiana ServerGarbageCollection w pliku projektu wymaga ponownego skompilowania aplikacji.

Uwaga: Odzyskiwanie pamięci serwera nie jest dostępne na maszynach z jednym rdzeniem. W celu uzyskania więcej informacji, zobacz następujący temat: IsServerGC.

Na poniższej ilustracji przedstawiono profil pamięci w ramach 5K RPS przy użyciu kontrolera GC stacji roboczej.

Chart showing memory profile for a Workstation GC

Różnice między tym wykresem a wersją serwera są istotne:

  • Zestaw roboczy spada z 500 MB do 70 MB.
  • GC generacji 0 kolekcji wiele razy na sekundę zamiast co dwie sekundy.
  • GC spada z 300 MB do 10 MB.

W typowym środowisku serwera internetowego użycie procesora CPU jest ważniejsze niż pamięć, dlatego serwer GC jest lepszy. Jeśli wykorzystanie pamięci jest wysokie, a użycie procesora CPU jest stosunkowo niskie, GC stacji roboczej może być bardziej wydajne. Na przykład wysoka gęstość hostująca kilka aplikacji internetowych, w których pamięć jest niedostępna.

GC przy użyciu platformy Docker i małych kontenerów

Jeśli na jednej maszynie jest uruchomionych wiele konteneryzowanych aplikacji, GC stacji roboczej może być bardziej wydajne niż GC serwera. Aby uzyskać więcej informacji, zobacz Running with Server GC in a Small Container and Running with Server GC in a Small Container Scenario Part 1 – Hard Limit for the GC Heap (Uruchamianie z serwerem GC w małym kontenerze) i Running with Server GC in a Small Container Scenario Part 1 – Hard Limit for the GC Heap (Uruchamianie z klastrem GC w małym scenariuszu kontenera — część 1 — twardy limit sterty GC).

Odwołania do obiektów trwałych

GC nie może zwolnić obiektów, do których odwołuje się odwołanie. Obiekty, do których odwołuje się odwołanie, ale nie są już potrzebne, powodują wyciek pamięci. Jeśli aplikacja często przydziela obiekty i nie zwalnia ich po tym, jak nie są już potrzebne, użycie pamięci zwiększy się wraz z upływem czasu.

Poniższy interfejs API tworzy wystąpienie ciągu 10 KB i zwraca je do klienta. Różnica w poprzednim przykładzie polega na tym, że to wystąpienie jest przywoływało statyczny element członkowski, co oznacza, że nigdy nie jest dostępne dla kolekcji.

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

Powyższy kod:

  • Jest przykładem typowego przecieku pamięci.
  • W przypadku częstych wywołań pamięć aplikacji zwiększa się do momentu awarii procesu z OutOfMemory wyjątkiem.

Chart showing a memory leak

Na powyższym obrazie:

  • Testowanie obciążenia punktu końcowego /api/staticstring powoduje liniowy wzrost pamięci.
  • GC próbuje zwolnić pamięć w miarę wzrostu ciśnienia pamięci przez wywołanie kolekcji generacji 2.
  • GC nie może zwolnić wycieku pamięci. Przydzielony zestaw roboczy zwiększa się wraz z upływem czasu.

Niektóre scenariusze, takie jak buforowanie, wymagają, aby odwołania do obiektów były przechowywane, dopóki ciśnienie pamięci nie wymusza ich zwolnienia. Klasa WeakReference może być używana dla tego typu kodu buforowania. Obiekt WeakReference jest zbierany pod ciśnieniem pamięci. Domyślna implementacja IMemoryCache funkcji używa WeakReferencemetody .

Pamięć natywna

Niektóre obiekty platformy .NET Core bazują na pamięci natywnej. Pamięci natywnej nie można zbierać przez GC. Obiekt .NET używający pamięci natywnej musi zwolnić go przy użyciu kodu natywnego.

Platforma .NET udostępnia IDisposable interfejs umożliwiający deweloperom wydawanie pamięci natywnej. Nawet jeśli Dispose nie jest wywoływana, poprawnie zaimplementowano wywołanie Dispose klas po uruchomieniu finalizatora.

Rozważ następujący kod:

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

PhysicalFileProvider jest klasą zarządzaną, więc każde wystąpienie zostanie zebrane na końcu żądania.

Na poniższej ilustracji przedstawiono profil pamięci podczas ciągłego wywoływania interfejsu fileprovider API.

Chart showing a native memory leak

Powyższy wykres przedstawia oczywisty problem z implementacją tej klasy, ponieważ stale zwiększa użycie pamięci. Jest to znany problem, który jest śledzony w tym problemie.

Ten sam wyciek może wystąpić w kodzie użytkownika, wykonując jedną z następujących czynności:

  • Nieprawidłowe zwalnianie klasy.
  • Zapominając, aby wywołać metodę Dispose obiektów zależnych, które powinny być usuwane.

Sterta dużych obiektów

Częste przydzielanie pamięci/wolne cykle mogą fragmentować pamięć, szczególnie w przypadku przydzielania dużych fragmentów pamięci. Obiekty są przydzielane w ciągłych blokach pamięci. Aby wyeliminować fragmentację, gdy GC zwalnia pamięć, próbuje go zdefragmentować. Ten proces jest nazywany kompaktowaniem. Kompaktowanie obejmuje przenoszenie obiektów. Przeniesienie dużych obiektów nakłada karę za wydajność. Z tego powodu GC tworzy specjalną strefę pamięci dla dużych obiektów, nazywanych stertą dużych obiektów (LOH). Obiekty, które są większe niż 85 000 bajtów (około 83 KB), to:

  • Umieszczone na LOH.
  • Niezwarte.
  • Zbierane podczas generowania 2 GCs.

Gdy LOH jest pełna, GC wyzwoli kolekcję generacji 2. Kolekcje generacji 2:

  • Są z natury powolne.
  • Ponadto poniesie koszt wyzwalania kolekcji we wszystkich innych generacjach.

Poniższy kod kompaktuje natychmiast LOH:

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

Zobacz LargeObjectHeapCompactionMode , aby uzyskać informacje na temat kompaktowania LOH.

W kontenerach korzystających z platformy .NET Core 3.0 lub nowszej LOH jest automatycznie kompaktowana.

Następujący interfejs API, który ilustruje to zachowanie:

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

Na poniższym wykresie przedstawiono profil pamięci wywoływania punktu końcowego /api/loh/84975 pod maksymalnym obciążeniem:

Chart showing memory profile of allocating bytes

Na poniższym wykresie przedstawiono profil pamięci wywoływania punktu końcowego /api/loh/84976 , przydzielając tylko jeden bajt:

Chart showing memory profile of allocating one more byte

Uwaga: struktura byte[] ma bajty narzutu. Dlatego 84 976 bajtów wyzwala limit 85 000.

Porównanie dwóch poprzednich wykresów:

  • Zestaw roboczy jest podobny dla obu scenariuszy, około 450 MB.
  • W obszarze żądania LOH (84 975 bajtów) przedstawiono głównie kolekcje generacji 0.
  • Żądania over LOH generują kolekcje 2 generacji stałej. Kolekcje generacji 2 są kosztowne. Wymagana jest większa liczba procesorów CPU, a przepływność spada prawie 50%.

Tymczasowe duże obiekty są szczególnie problematyczne, ponieważ powodują gen2 GCs.

Aby uzyskać maksymalną wydajność, należy zminimalizować użycie dużych obiektów. Jeśli to możliwe, podziel duże obiekty. Na przykład oprogramowanie pośredniczące Buforowanie odpowiedzi w ASP.NET Core podzieli wpisy pamięci podręcznej na bloki mniejsze niż 85 000 bajtów.

Poniższe linki pokazują podejście ASP.NET Core do utrzymania obiektów w ramach limitu LOH:

Aby uzyskać więcej informacji, zobacz:

HttpClient

Nieprawidłowe użycie HttpClient może spowodować wyciek zasobów. Zasoby systemowe, takie jak połączenia bazy danych, gniazda, dojścia plików itp.:

  • Są bardziej ograniczone niż pamięć.
  • Są bardziej problematyczne w przypadku wycieku niż pamięć.

Doświadczeni deweloperzy platformy .NET wiedzą, że wywołają Dispose obiekty implementujące IDisposableelement . Nie dysponowanie obiektów, które implementują IDisposable , zwykle powoduje wyciek pamięci lub wycieku zasobów systemowych.

HttpClientimplementuje metodę IDisposable, ale nie należy ich usuwać przy każdym wywołaniu. HttpClient Zamiast tego należy ponownie użyć.

Następujący punkt końcowy tworzy i usuwa nowe HttpClient wystąpienie na każdym żądaniu:

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

Podczas ładowania rejestrowane są następujące komunikaty o błędach:

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)

Mimo że HttpClient wystąpienia są usuwane, rzeczywiste połączenie sieciowe może z czasem zostać zwolnione przez system operacyjny. Stale tworząc nowe połączenia, występuje wyczerpanie portów. Każde połączenie klienta wymaga własnego portu klienta.

Jednym ze sposobów zapobiegania wyczerpaniu portów jest ponowne użycie tego samego HttpClient wystąpienia:

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

Wystąpienie HttpClient jest zwalniane po zatrzymaniu aplikacji. W tym przykładzie pokazano, że nie każdy jednorazowy zasób powinien zostać usunięty po każdym użyciu.

Aby uzyskać lepszy sposób obsługi okresu istnienia HttpClient wystąpienia, zobacz następujące informacje:

Buforowanie obiektów

W poprzednim przykładzie pokazano, w jaki sposób HttpClient wystąpienie może być statyczne i ponownie używane przez wszystkie żądania. Ponowne użycie zapobiega wyczerpaniu zasobów.

Buforowanie obiektów:

  • Używa wzorca ponownego użycia.
  • Jest przeznaczony dla obiektów, które są kosztowne do utworzenia.

Pula to kolekcja wstępnie zainicjowanych obiektów, które można rezerwować i zwalniać między wątkami. Pule mogą definiować reguły alokacji, takie jak limity, wstępnie zdefiniowane rozmiary lub szybkość wzrostu.

Pakiet NuGet Microsoft.Extensions.ObjectPool zawiera klasy, które ułatwiają zarządzanie takimi pulami.

Następujący punkt końcowy interfejsu API tworzy wystąpienie buforu byte wypełnionego losowymi liczbami w każdym żądaniu:

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

            return array;
        }

Na poniższym wykresie przedstawiono wywołanie powyższego interfejsu API z umiarkowanym obciążeniem:

Chart showing calls to API with moderate load

Na poprzednim wykresie kolekcje generacji 0 odbywają się mniej więcej raz na sekundę.

Powyższy kod można zoptymalizować, tworząc pulę buforu przy użyciu biblioteki byte ArrayPool<T>. Wystąpienie statyczne jest ponownie używane w żądaniach.

Czym różni się to od tego podejścia, jest to, że obiekt w puli jest zwracany z interfejsu API. Oznacza to:

  • Obiekt jest poza kontrolką natychmiast po powrocie z metody .
  • Nie można zwolnić obiektu.

Aby skonfigurować usuwanie obiektu:

RegisterForDispose Program zajmie się wywołaniem Dispose obiektu docelowego, tak aby został wydany tylko po zakończeniu żądania 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;
}

Zastosowanie tego samego obciążenia co wersja niepulowana powoduje wyświetlenie następującego wykresu:

Chart showing fewer allocations

Główna różnica polega na przydzielaniu bajtów i w konsekwencji znacznie mniejszej liczbie kolekcji generacji 0.

Dodatkowe zasoby