Wszystkie wpisy

ArrayPool i MemoryPool w C# - wypożyczalnia tablic kontra LOH

Recykling dużych buforów dla hot path - LOH (Large Object Heap) i jego pułapki, ArrayPool<T>.Shared z Rent/Return, pułapka nadmiarowego rozmiaru, MemoryPool<T> z IMemoryOwner i using dla async. Dwudziesta pierwsza część kursu C#.


W poprzednich wpisach o pamięci - od Span’a przez Memory po parser logów - widziałeś, jak unikać alokacji. Ale czasem alokacji nie da się uniknąć: pobierasz wielki plik, buforując 100KB-fragmenty. Każda taka operacja to nowa tablica byte[100_000] na stercie - w hot path z dużą intensywnością serwer może paść z OutOfMemoryException nawet gdy łączna pamięć stanowi ułamek dostępnego RAM-u.

Powodem jest Large Object Heap (LOH) - specjalna część zarządzanej sterty z własnymi pułapkami fragmentacji. W tej części zobaczysz dlaczego LOH zabija serwery, jak ArrayPool<T> i MemoryPool<T> rozwiązują problem przez recykling buforów, oraz kanoniczne wzorce użycia obu (z naciskiem na klasyczną pułapkę nadmiarowego rozmiaru po Rent).

1. Problem: zmora Large Object Heap (LOH) #

GC w .NET dzieli stertę na generacje:

GeneracjaCo tam trafiaCzęstotliwość GC
Gen 0Nowo zaalokowane małe obiektyBardzo często (milisekundy)
Gen 1Obiekty, które przetrwały Gen 0Często (sekundy)
Gen 2Długo żyjące obiektyRzadko (minuty)
LOH (Large Object Heap)Każdy obiekt >85 000 bajtówBardzo rzadko (z Gen 2)

Próg 85KB to próg historyczny dobrany empirycznie - obiekty powyżej tego rozmiaru są “duże” w sensie obciążenia GC.

Trzy pułapki LOH #

Pułapka 1: rzadkie GC. GC pełnowymiarowy (Gen 2 + LOH) jest drogi - kompletne skanowanie wszystkich obiektów żyjących długoterminowo. .NET odkłada to jak najdłużej. Konsekwencja: duże obiekty siedzą w pamięci dłużej, niż realnie potrzebne.

Pułapka 2: brak domyślnej defragmentacji. Mała sterta (Gen 0/1/2) jest okresowo kompaktowana - GC przesuwa obiekty, by zlikwidować luki. LOH domyślnie nie - kompaktowanie wielkich obiektów byłoby zbyt kosztowne. Każde zwolnienie zostawia “dziurę” w pamięci.

Pułapka 3: fragmentacja LOH. Po godzinach pracy serwer wygląda tak:

LOH: [obj1 90KB] [DZIURA 90KB] [obj2 100KB] [DZIURA 95KB] [obj3 200KB] [DZIURA 80KB] ...

Kolejna alokacja 100KB trafi do tych dziur, ale tylko jeśli któraś dziura ma >=100KB. Jeśli nie - alokacja idzie na koniec, mimo że pamięć “wewnątrz” LOH jest wolna. Pamięć rośnie. Serwer pada na OutOfMemory mimo że łącznie ma kilkadziesiąt GB wolnego RAM-u.

Diagnostyka tego problemu bez wiedzy o LOH zajmuje tygodnie zgadywania. Z wiedzą - od razu wiesz: użyj puli.

2. Rozwiązanie: wypożyczalnia tablic #

Klasyczna analogia: zamiast produkować samochody i wyrzucać je na złom po jednej jeździe, wypożyczalnia trzyma flotę pojazdów, którą klienci biorą na chwilę i oddają.

Dokładnie tak działa ArrayPool<T> z System.Buffers:

  • Klasa utrzymuje pulę gotowych tablic różnych rozmiarów
  • Klient wypożycza (Rent(N)) - dostaje tablicę o co najmniej N elementach
  • Po użyciu klient zwraca (Return(buffer)) - tablica wraca do puli
  • Dla GC: zero nowej alokacji w hot path

Wbudowana, globalna pula: ArrayPool<T>.Shared. Jedna instancja na cały proces, thread-safe.

Klasyczny przykład (zły) #

public void ProcessData(int dataSize)
{
    // ❌ KATASTROFA przy intensywnym hot path:
    // Każde wywołanie alokuje nowy 100KB-bufor → LOH
    byte[] buffer = new byte[100_000];
    // ... praca ...
}   // buffer staje się śmieciem dla GC, LOH stopniowo się fragmentuje

W aplikacji obsługującej 100 req/s przez godzinę: 360_000 alokacji LOH = ciągła fragmentacja.

Przykład z ArrayPool #

using System.Buffers;

public void ProcessData(int dataSize)
{
    // 1. Wypożyczamy bufor z co najmniej dataSize bajtów
    byte[] buffer = ArrayPool<byte>.Shared.Rent(dataSize);
    try
    {
        // ... praca na buffer ...
    }
    finally
    {
        // 2. ZAWSZE oddajemy w finally
        ArrayPool<byte>.Shared.Return(buffer);
    }
}

W hot path: ta sama, jedna fizyczna tablica jest re-używana przez wszystkie wywołania. Zero alokacji LOH.

3. Krytyczna pułapka: nadmiarowy rozmiar #

Najczęstszy błąd początkujących z ArrayPool. ArrayPool trzyma tablice w rozmiarach potęg dwójki (16, 32, 64, …, 1024, 2048, 4096, …) - dla optymalizacji.

byte[] buffer = ArrayPool<byte>.Shared.Rent(1000);

Console.WriteLine(buffer.Length);   // 1024 (NIE 1000!)

Konsekwencje:

  • buffer.Length to fizyczny rozmiar tablicy, nie żądany
  • Bajty na pozycjach 1000-1023 zawierają śmieci z poprzednich użyć
  • Iteracja for (int i = 0; i < buffer.Length; i++) operuje na śmieciach

Anti-pattern #

byte[] buffer = ArrayPool<byte>.Shared.Rent(1000);
try
{
    // ❌ ŹLE - iteruje przez WSZYSTKIE bajty (włącznie z 1024 - tymi nadmiarowymi)
    for (int i = 0; i < buffer.Length; i++)
    {
        buffer[i] = ProcessByte(buffer[i]);
    }
}
finally
{
    ArrayPool<byte>.Shared.Return(buffer);
}

Powyższy kod operuje na 24 dodatkowych bajtach zawierających dane z poprzedniego wypożyczenia - może to być zaszyfrowane hasło z innego użytkownika, JSON z poprzedniego requesta, etc. Bug bezpieczeństwa + niepoprawne dane.

Rozwiązanie: Span z dokładnym rozmiarem #

Łączy się to pięknie z poprzednim wpisem o Span’ie:

byte[] buffer = ArrayPool<byte>.Shared.Rent(1000);
try
{
    // ✅ Span ograniczony DOKŁADNIE do żądanego rozmiaru
    Span<byte> workingArea = buffer.AsSpan(0, 1000);

    // Cała praca przez Span - nadmiarowe bajty są niewidoczne
    for (int i = 0; i < workingArea.Length; i++)
    {
        workingArea[i] = ProcessByte(workingArea[i]);
    }
}
finally
{
    ArrayPool<byte>.Shared.Return(buffer);
}

Wzorzec produkcyjny: zawsze trzymaj żądany rozmiar w osobnej zmiennej. Span jako interfejs do “okrojonego” bufora ignoruje nadmiar.

Parametr clearArray w Return #

ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
//                                    ^^^^^^^^^^^^^^^^^
//                                    zeruje bajty przed zwrotem
WartośćZachowanieKiedy
clearArray: false (domyślne)Zostawia “brudne” bajty - max wydajnośćStandardowe użycie
clearArray: trueZeruje przed zwrotemDane wrażliwe (hasła, klucze, PII) - bezpieczeństwo

Dla bezpieczeństwa: jeśli wypożyczony bufor zawierał klucze kryptograficzne, hasła użytkowników, PII - zawsze clearArray: true. Inaczej kolejny Rent może dostać tablicę ze śladami wrażliwych danych.

4. MemoryPool<T> - wersja z using dla async #

ArrayPool daje klasyczne T[] i wymaga ręcznego try/finally. Nowoczesny odpowiednik - MemoryPool<T> - zwraca IMemoryOwner<T> (IDisposable) i pasuje idealnie do using:

using System.Buffers;

public async Task ProcessFileAsync(string path)
{
    using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(50_000);
    //    ^^^^^^^^^^^^^^^^^^^^                                  ^^^^^^
    //    automatyczny Dispose                                  co najmniej 50KB
    Memory<byte> buffer = owner.Memory;

    using FileStream file = File.OpenRead(path);
    int bytesRead = await file.ReadAsync(buffer);

    Console.WriteLine($"Read {bytesRead} bytes");
}   // <--- tu owner.Dispose() automatycznie zwraca do puli

Zalety MemoryPool:

  • using automatycznie zwraca - nie da się zapomnieć (jak w klasycznym try/finally)
  • Memory<T> zamiast T[] - kompatybilność z async API (Stream.ReadAsync(Memory))
  • Memory.Span dla synchronicznych operacji wewnątrz

Kiedy ArrayPool, kiedy MemoryPool:

SytuacjaWybór
Klasyczne API przyjmujące T[] (zewnętrzne biblioteki)ArrayPool
Nowoczesne async API z Memory<T>MemoryPool
Chcesz using (automatyczny zwrot)MemoryPool
Synchroniczny kod z try/finallyArrayPool wystarcza
Nowy kod, masz wybórMemoryPool dla ergonomii

5. Pułapki produkcyjne #

Pułapka A: zapomniany Return #

public void Bad()
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(10_000);
    DoSomething(buffer);
    // ❌ Brak Return - bufor wyciekł z puli
}

Po wielu takich wyciekach pula stopniowo alokuje nowe tablice (bo poprzednie “zniknęły”). Cały zysk z używania puli znika. Diagnostyka: profiler pokaże rosnącą liczbę alokacji byte[] mimo użycia ArrayPool - to znak wycieków.

Pułapka B: użycie po Return #

byte[] buffer = ArrayPool<byte>.Shared.Rent(1000);
ArrayPool<byte>.Shared.Return(buffer);

buffer[0] = 42;   // ❌ KATASTROFA - inny komponent może już mieć ten bufor

Po Return tablica może być natychmiast wypożyczona przez inny wątek. Modyfikacja niszczy dane drugiego użytkownika. Konwencja: po Return traktuj zmienną jakby już nie istniała (najlepiej buffer = null).

Pułapka C: zwrot tablicy nie z puli #

byte[] external = new byte[1000];
ArrayPool<byte>.Shared.Return(external);   // ❌ NIE rób tego

ArrayPool przyjmie tę tablicę do puli, ale to niezamierzone - nie wiesz, czy zewnętrzna tablica jest bezpieczna do recyklingu. Zwracaj tylko to, co wcześniej wypożyczyłeś.

Pułapka D: pula dla małych buforów #

byte[] buffer = ArrayPool<byte>.Shared.Rent(8);   // 8 bajtów!

Dla bufora 8 bajtów narzut Rent/Return (synchronizacja wątków, lookup w słowniku rozmiarów) jest większy niż koszt new byte[8]. Reguła kciuka: pula opłaca się dla buforów >= 4KB (a najbardziej dla >= 80KB - LOH).

Dla małych buforów - stackalloc (do ~1KB) lub zwykłe new (>1KB i <4KB).

Pułapka E: pula dla rzadkich operacji #

public void RareOperation()
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(100_000);
    // wywołane raz na godzinę
}

Dla rzadkich operacji jedna alokacja new byte[100_000] na godzinę jest niezauważalna. Pula opłaca się dla częstych operacji (sekundowych, milisekundowych). Premature optimization jak zwykle.

6. Kiedy używać puli - decyzja #

Algorytm decyzyjny:

  1. Czy bufor jest duży? (>~4KB, a zwłaszcza >80KB do LOH)
    • Nie → zwykłe new byte[N] lub stackalloc byte[N] (dla <1KB)
    • Tak → krok 2
  2. Czy operacja jest częsta? (tysiące razy na sekundę, w pętli, hot path)
    • Nie → zwykłe new jest OK
    • Tak → krok 3
  3. Czy używasz Memory<T>/async?
    • Tak → MemoryPool<T>.Shared.Rent z using
    • Nie → ArrayPool<T>.Shared.Rent z try/finally

Klasyczne przypadki użycia puli w produkcyjnym kodzie:

  • Bufory streamingowe (Stream.ReadAsync wielokrotnie z tym samym buforem)
  • Parsery binarne (JSON, MessagePack, Protocol Buffers)
  • Serializatory dla dużych obiektów
  • Network I/O (gniazda TCP, klienci HTTP z dużymi pobraniami)
  • Image/video processing (duże bufory pikseli)
  • Cache warstw pośrednich

Podsumowanie tematu #

W tej części kursu poznałeś:

  • LOH (Large Object Heap) - obiekty >85KB; GC chodzi rzadko, domyślnie brak defragmentacji; fragmentacja → wzrost RAM-u → OutOfMemoryException mimo “wolnej” pamięci
  • ArrayPool<T>.Shared - recykling tablic; Rent(N) zwraca co najmniej N (zwykle potęga 2: 1024); zwrot przez Return(buffer) w try/finally
  • Pułapka rozmiaru - buffer.Length to NIE żądany rozmiar; zawężaj Span’em: buffer.AsSpan(0, requestedSize)
  • clearArray w Return - false (domyślne, wydajność), true dla danych wrażliwych
  • MemoryPool<T>.Shared - nowoczesny odpowiednik z IMemoryOwner<T> i using; idealny dla async + Memory<T>
  • Pułapki - zapomniany Return (wyciek), użycie po Return (race condition z innym wątkiem), zwrot zewnętrznej tablicy (bug bezpieczeństwa), pula dla małych/rzadkich operacji (overkill)
  • Algorytm decyzyjny - duży bufor + częsta operacja → pula; reszta → zwykłe new/stackalloc

Tym wpisem finalizujemy sekcję o pamięci i wydajności. Masz teraz w arsenale: Span<T>, Memory<T>, ref struct, parser bez alokacji, ArrayPool/MemoryPool - narzędzia, które wielu programistów z wieloletnim stażem (zostających przy old-school .NET Framework) nie zna.

W następnym wpisie Relacyjne bazy danych w C# zaczynamy nową sekcję kursu - trwałe przechowywanie danych. Architektura silnika RDBMS (Query Optimizer, Storage Engine ze stronami 8KB, Transaction Log gwarantujący ACID), projektowanie tabel z PK/FK, mapowanie typów SQL Server na C# (krytyczne decyzje: NVARCHAR vs VARCHAR, DECIMAL vs FLOAT dla walut, DATETIME2 vs stary DATETIME).

Quiz i zadanie poniżej. Zadanie demonstruje kanoniczny wzorzec produkcyjny: Rent z ArrayPool, ograniczenie żądanego rozmiaru przez Span, try/finally z Return. To dokładnie ta technika, którą stosują wysokowydajne biblioteki .NET (Kestrel, EF Core, System.Text.Json) w hot path.

Podobne wpisy

🔗 Linkują tu