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:
| Generacja | Co tam trafia | Częstotliwość GC |
|---|---|---|
| Gen 0 | Nowo zaalokowane małe obiekty | Bardzo często (milisekundy) |
| Gen 1 | Obiekty, które przetrwały Gen 0 | Często (sekundy) |
| Gen 2 | Długo żyjące obiekty | Rzadko (minuty) |
| LOH (Large Object Heap) | Każdy obiekt >85 000 bajtów | Bardzo 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.Lengthto 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ść | Zachowanie | Kiedy |
|---|---|---|
clearArray: false (domyślne) | Zostawia “brudne” bajty - max wydajność | Standardowe użycie |
clearArray: true | Zeruje przed zwrotem | Dane 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:
usingautomatycznie zwraca - nie da się zapomnieć (jak w klasycznymtry/finally)Memory<T>zamiastT[]- kompatybilność z async API (Stream.ReadAsync(Memory))Memory.Spandla synchronicznych operacji wewnątrz
Kiedy ArrayPool, kiedy MemoryPool:
| Sytuacja | Wybó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/finally | ArrayPool wystarcza |
| Nowy kod, masz wybór | MemoryPool 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:
- Czy bufor jest duży? (>~4KB, a zwłaszcza >80KB do LOH)
- Nie → zwykłe
new byte[N]lubstackalloc byte[N](dla <1KB) - Tak → krok 2
- Nie → zwykłe
- Czy operacja jest częsta? (tysiące razy na sekundę, w pętli, hot path)
- Nie → zwykłe
newjest OK - Tak → krok 3
- Nie → zwykłe
- Czy używasz
Memory<T>/async?- Tak →
MemoryPool<T>.Shared.Rentzusing - Nie →
ArrayPool<T>.Shared.Rentztry/finally
- Tak →
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 →
OutOfMemoryExceptionmimo “wolnej” pamięci ArrayPool<T>.Shared- recykling tablic;Rent(N)zwraca co najmniej N (zwykle potęga 2: 1024); zwrot przezReturn(buffer)wtry/finally- Pułapka rozmiaru -
buffer.Lengthto NIE żądany rozmiar; zawężaj Span’em:buffer.AsSpan(0, requestedSize) clearArraywReturn-false(domyślne, wydajność),truedla danych wrażliwychMemoryPool<T>.Shared- nowoczesny odpowiednik zIMemoryOwner<T>iusing; 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
Parsowanie tekstu bez alokacji - Zero-Allocation Log Parser w C#
Praktyczne zastosowanie Span<char>, ReadOnlySpan<char> i ref struct - parsowanie linii logu serwera bez ani jednej alokacji na stercie. IndexOf, Slice, SequenceEqual, int.Parse i DateTime.Parse z przeciążeniami dla Span. Dwudziesta część kursu C#.
ref struct w C# - własne typy stack-only, mechanika zakazów CLR
Mit struct=stos rozbity, ref struct jako żelazna obietnica nieopuszczania stosu, tworzenie własnych typów stack-only (parsery, tokenizery), pełna lista zakazów CLR z mechaniką (async, pole klasy, iteratory, lambdy, boxing). Dziewiętnasta część kursu C#.
Memory i ReadOnlyMemory w C# - asynchroniczny krewniak Spana
Memory<T> jako obietnica Span<T> przeżywająca await, ReadAsync z buforem, ReadOnlyMemory<char> dla tekstu w async, IMemoryOwner i MemoryPool dla recyklingu, lifetime rules - reguły 3-8 z dokumentacji MS o czasie życia bufora. Osiemnasta część kursu C#.
🔗 Linkują tu