Notatka
Dostęp do tej strony wymaga autoryzacji. Może spróbować zalogować się lub zmienić katalogi.
Dostęp do tej strony wymaga autoryzacji. Możesz spróbować zmienić katalogi.
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 rozwiązywanie problemów z pamięcią może być trudne. Przecieki pamięci i problemy z odzyskiwaniem pamięci (GC) są często spowodowane brakiem zrozumienia sposobu działania zużycia pamięci w .NET lub braku zrozumienia procesu pomiaru użycia.
W tym artykule przedstawiono typowe wzorce użycia pamięci, które mogą być problematyczne i sugerują alternatywne podejścia.
Eksplorowanie odzyskiwania pamięci (GC) w .NET
Proces GC przydziela segmenty sterty, z których każdy jest ciągłym zakresem pamięci. Obiekty umieszczone w stercie są podzielone na jedną z trzech pokoleń: 0, 1 lub 2. Generowanie określa częstotliwość próby zwolnienia pamięci przez GC na zarządzanych obiektach, do których aplikacja już się nie odwołuje. GC częściej zajmuje się generacjami o niższym numerze.
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, GC działa rzadziej na wyższych generacjach. Krótkotrwałe obiekty zawsze pozostają w generacji 0. Na przykład obiekty, do których się odwołuje w trakcie trwania żądania internetowego, są krótkotrwałe. Singletony na poziomie aplikacji są zwykle migrowane do wersji Gen 2.
Gdy uruchamia się aplikacja ASP.NET Core, proces GC:
- Rezerwuje część pamięci dla początkowych segmentów stert.
- Zatwierdza niewielką część pamięci podczas ładowania środowiska uruchomieniowego.
Powyższe alokacje pamięci są wykonywane ze względów wydajności. Korzyści wydajnościowe wynikają z użycia segmentów sterty w ciągłej pamięci.
Przejrzyj uwagi podczas korzystania z GC.Collect
Ogólnie rzecz biorąc, aplikacje ASP.NET Core w środowisku produkcyjnym nie powinny jawnie używać metody GC.Collect. Wywoływanie procesów odzyskiwania pamięci w nieoptymalnych momentach może znacznie zmniejszyć wydajność.
GC.Collect jest przydatna podczas badania przecieków pamięci. Wywołanie GC.Collect() wyzwala blokujący cykl zbierania śmieci, 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 analizie użycia pamięci, w tym:
- Zliczanie odwołań do obiektów.
- Mierzenie wpływu, jaki ma GC na obciążenie CPU.
- Mierzenie przestrzeni pamięci używanej dla każdej generacji.
Użyj następujących narzędzi do analizowania użycia pamięci:
- narzędzie dotnet-trace (może być używane na maszynach produkcyjnych)
- Analizowanie użycia pamięci bez debugera programu Visual Studio
- Mierzenie użycia pamięci w programie Visual Studio
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 oraz inne elementy zużywające pamięć, takie jak pamięć natywna.
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.
Eksplorowanie przykładowej aplikacji użycia pamięci wyświetlania
Przykładowa aplikacja MemoryLeak jest dostępna w witrynie GitHub. Aplikacja MemoryLeak:
- Zawiera kontroler diagnostyczny, który w czasie rzeczywistym zbiera dane o pamięci i GC dla aplikacji.
- Zawiera stronę indeksu, która wyświetla 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.
- Może służyć do wyświetlania wzorców użycia pamięci ASP.NET Core aplikacji, ale nie jest to obsługiwane narzędzie.
Uruchom MemoryLeak. Przydzielona pamięć powoli zwiększa się do momentu wystąpienia GC. Zwiększa się ilość pamięci, 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 API z kontrolera API.
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 ma tę samą wartość, jaką pokazuje Menedżer zadań. Aby uzyskać więcej informacji, zobacz Zestaw roboczy.
Obiekty przejściowe
Poniższy interfejs API tworzy instancję ciągu znaków o rozmiarze 20 KB i zwraca ją 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 wykres jest generowany przy stosunkowo małym obciążeniu, który pokazuje, jak GC wpływa na alokacje pamięci.
Wykres ilustruje następujące szczegóły:
- 4K RPS (żądania na sekundę)
- Kolekcje GC 0. generacji występują mniej więcej co 2 sekundy
- Stały zestaw roboczy, około 500 MB
- Procesor cpu ma 12%
- Stabilne użycie pamięci i wydanie (za pośrednictwem GC)
Poniższy wykres jest pobierany z maksymalną przepływnością, którą może obsłużyć maszyna.
Wykres ilustruje następujące szczegóły:
- 22 K RPS
- Kolekcje GC 0. generacji występują kilka razy na sekundę
- Kolekcje 1. generacji są wyzwalane, ponieważ aplikacja przydziela znacznie więcej pamięci na sekundę.
- Stały zestaw roboczy, około 500 MB
- Procesor jest używany w 33%
- Stabilne użycie pamięci i wydanie (za pośrednictwem GC)
- CPU (33%) nie jest przeciążony, więc GC 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 komputera stacjonarnego.
- Server GC: Domyślna GC dla aplikacji ASP.NET Core. Zoptymalizowane pod kątem serwera.
Tryb GC można jawnie ustawić w pliku projektu lub w pliku runtimeconfig.json 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.
Note
Zbieranie nieużytków serwera nie jest dostępne na maszynach z jednym rdzeniem. Aby uzyskać więcej informacji, zobacz właściwość IsServerGC.
Na poniższej ilustracji przedstawiono profil pamięci przy 5K RPS z użyciem Workstation GC.
Różnice między tym wykresem a wersją serwera są istotne:
- Zestaw roboczy spada z 500 MB do 70 MB
- GC wykonuje kolekcje gen 0 wiele razy na sekundę zamiast co 2 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 wykorzystanie procesora jest stosunkowo niskie, Workstation GC może być bardziej wydajny. Na przykład środowisko o wysokiej gęstości, które hostuje kilka aplikacji internetowych, gdzie pamięć jest ograniczona.
GC przy użyciu platformy Docker i małych kontenerów
Jeśli na tym samym komputerze działa 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 (blog) oraz Running with Server GC in a Small Container Scenario Part 1 – Hard Limit for the GC Heap (blog).
Odwołania do obiektów persistentnych
GC nie może zwolnić obiektów, które są referencjonowane. Obiekty, na które się odwołuje, 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 wzrasta wraz z upływem czasu.
Poniższy interfejs API tworzy instancję ciągu znaków o rozmiarze 20 KB i zwraca ją do klienta. Różnica w poprzednim przykładzie polega na tym, że statyczny element członkowski odwołuje się do tego wystąpienia, co oznacza, że wystąpienie 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:
- Demonstruje typowy wyciek pamięci.
- W przypadku częstych wywołań pamięć aplikacji zwiększa się do momentu awarii procesu z
OutOfMemorywyjątkiem.
Wykres ilustruje następujące szczegóły:
- Testowanie obciążenia punktu końcowego
/api/staticstringpowoduje liniowy wzrost pamięci - GC próbuje zwolnić pamięć w miarę wzrostu ciśnienia pamięci przez wywołanie kolekcji Gen 2
- GC nie może zwolnić pamięci, która przecieka; Przydział i zestaw roboczy rosną z czasem.
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, gdy występuje presja na pamięć. Domyślna implementacja interfejsu IMemoryCache używa elementu WeakReference.
Pamięć natywna
Niektóre obiekty .NET polegają na pamięci natywnej, ale pamięć natywna nie można zbierać przez GC. Obiekt .NET używający pamięci natywnej musi go zwolnić przy użyciu kodu natywnego.
Platforma .NET udostępnia IDisposable interfejs umożliwiający deweloperom wydawanie pamięci natywnej. Nawet jeśli metoda Dispose nie jest wywoływana, poprawnie zaimplementowane klasy wywołują Dispose podczas działania finalizatora.
Spójrzmy na poniższy kod:
[HttpGet("fileprovider")]
public void GetFileProvider()
{
var fp = new PhysicalFileProvider(TempPath);
fp.Watch("*.*");
}
PhysicalFileProvider jest klasą zarządzaną, więc każde wystąpienie jest zbierane na końcu żądania.
Na poniższej ilustracji przedstawiono profil pamięci podczas ciągłego wywoływania interfejsu fileprovider API.
Powyższy wykres przedstawia oczywisty problem z implementacją tej klasy, ponieważ stale zwiększa użycie pamięci. Ten wynik jest znanym problemem śledzonym w GitHub problem dotnet/aspnetcore #844.
Ten sam wyciek występuje w kodzie użytkownika w następujących scenariuszach:
- Niewłaściwe zwalnianie klasy
- Zapominanie o wywołaniu
Disposemetody obiektów zależnych, które powinny zostać usunięte
Sterta dużych obiektów
Częste przydzielanie pamięci/wolnych cykli powoduje fragmentację pamięci, zwłaszcza 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 ją zdefragmentować. Ten proces jest nazywany zagęszczaniem. Kompaktowanie obejmuje przenoszenie obiektów. Przenoszenie dużych obiektów powoduje obniżenie wydajności. 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
- Nieskompaktowane
- Przetworzone podczas zbierania Generacji 2
Gdy LOH jest zapełniony, GC wyzwala kolekcję Gen 2.
- Kolekcje 2. generacji są z natury powolne.
- Mogą one ponieść koszt wyzwalania kolekcji we wszystkich innych generacjach.
Poniższy kod natychmiast kompaktuje LOH.
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
Aby uzyskać informacje na temat kompaktowania LOH, zobacz LargeObjectHeapCompactionMode obiekt .
W kontenerach korzystających z .NET Core 3.0 lub nowszej wersji, 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:
Na poniższym wykresie przedstawiono profil pamięci wywoływania punktu końcowego /api/loh/84976, przydzielając tylko jeszcze jeden bajt:
Note
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) są wyświetlane głównie kolekcje Gen 0
- Za pośrednictwem żądań LOH generowane są stałe kolekcje Gen 2, które są kosztowne. Wymagana jest większa liczba procesorów CPU, a przepływność spadnie ok. 50%.
Tymczasowe duże obiekty są problematyczne, ponieważ powodują kolekcje generacji 2.
Aby uzyskać maksymalną wydajność, zminimalizuj użycie dużych obiektów. Jeśli to możliwe, podziel duże obiekty. Na przykład oprogramowanie pośredniczące do buforowania odpowiedzi w ASP.NET Core dzieli wpisy w 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
Niepoprawne użycie HttpClient klasy może spowodować wyciek zasobów.
Zasoby systemowe (takie jak połączenia bazy danych, gniazda, dojścia plików itd.) stanowią dwa problemy:
- Są one rzadsze niż pamięć.
- Są bardziej problematyczne, gdy wyciekają w porównaniu z pamięcią.
Doświadczeni deweloperzy .NET wiedzą, aby wywołać metodę Dispose na obiektach implementujących interfejs IDisposable. Niedysponowanie obiektami, które implementują IDisposable, zwykle powoduje wyciek pamięci lub zasobów systemowych.
HttpClient implementuje IDisposable, ale nie powinno być likwidowane przy każdym wywołaniu. Zamiast tego należy ponownie użyć HttpClient.
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 instancje HttpClient są usuwane, system operacyjny potrzebuje trochę czasu na zwolnienie rzeczywistego połączenia sieciowego. Proces ciągłego tworzenia nowych połączeń powoduje 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 tej samej instancji HttpClient.
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;
}
Instancja HttpClient jest zwalniana po zatrzymaniu aplikacji. W tym przykładzie pokazano, że nie każdy jednorazowy zasób powinien zostać usunięty po każdym użyciu.
W poniższych artykułach opisano lepszy sposób obsługi cyklu życia wystąpienia HttpClient.
Buforowanie obiektów
W poprzednim przykładzie pokazano, w jaki sposób instancja HttpClient może być statyczna i używana ponownie przez wszystkie żądania. Ponowne użycie zapobiega wyczerpaniu zasobów.
Buforowanie obiektów jest alternatywą:
- Używa wzorca ponownego użycia.
- Projekt jest idealny dla obiektów, które są kosztowne do utworzenia.
Pula obiektów to zbiór wstępnie zainicjowanych obiektów, które można rezerwować i zwalniać w wątkach. 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, który jest wypełniany losowymi liczbami na każde żądanie.
[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:
Wykres pokazuje, że kolekcje 0. generacji mają miejsce około raz na sekundę.
Kod można zoptymalizować, używając buforów z klasy byte. Wystąpienie statyczne jest wykorzystywane wielokrotnie przy każdym żądaniu.
Co różni się od tego podejścia, jest to, że obiekt w puli jest zwracany z interfejsu API:
- Obiekt jest poza kontrolą natychmiast po powrocie z metody.
- Nie można zwolnić obiektu.
Aby skonfigurować usuwanie obiektu:
- Hermetyzowanie tablicy w puli w obiekcie jednorazowym.
- Zarejestruj obiekt w puli przy użyciu metody HttpContext.Response.RegisterForDispose .
RegisterForDispose dba o wywołanie Dispose obiektu docelowego, więc obiekt jest zwalniany dopiero 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:
Główna różnica polega na przydzielonych bajtach, a w konsekwencji mniejszej liczbie kolekcji generacji 0.