Span i ReadOnlySpan w C# - okna na pamięć bez alokacji
Eliminacja alokacji w pętlach przetwarzania - Span jako widok na ciągły obszar pamięci, ReadOnlySpan do parsowania tekstu bez Substring, ref struct i jego ograniczenia, stackalloc, ArrayPool, Memory dla async. Siedemnasta część kursu C#.
W ostatnich wpisach poświęconych asynchronicznośći - od maszyny stanów przez ValueTask po wycieki pamięci - przewija się jeden wątek: alokacje na stercie obciążają Garbage Collector. Każdy Task<T>, każdy string z Substring, każde new ArrayList() to praca dla GC. W gorącej ścieżce (parsowanie milionów logów, obsługa 10k żądań HTTP/s) ta praca staje się wąskim gardłem.
Spanstackalloc, ArrayPool i Memory<T> w produkcyjnym kodzie.
1. Problem: koszt kopiowania w klasycznym C# #
Wyobraź sobie typowe parsowanie daty:
string raw = "2026-06-18";
string yearStr = raw.Substring(0, 4); // alokacja: nowy string "2026"
string monthStr = raw.Substring(5, 2); // alokacja: nowy string "06"
string dayStr = raw.Substring(8, 2); // alokacja: nowy string "18"
int year = int.Parse(yearStr);
int month = int.Parse(monthStr);
int day = int.Parse(dayStr);
Trzy alokacje na jedną datę. Aplikacja parsująca CSV z milionem wierszy:
1_000_000 wierszy × 3 stringi = 3_000_000 nowych obiektów
Każdy musi przejść przez GC. Mikro-przycięcia, obciążenie CPU, wzrost pamięci RAM przed kolejnym przebiegiem GC. To realny problem produkcyjny w systemach przetwarzających logi, JSON-y, dane sensorów albo zapytania HTTP.
string jest niemutowalny w C# - każda operacja “zmiany” tworzy nowy obiekt. Tego nie da się obejść bez sięgnięcia po niższe narzędzie.
2. Czym jest Span<T> - okno na pamięć #
Cytat z dokumentacji Microsoftu: Span
Span<T> to struktura (typ wartościowy), która działa jak widok lub okno na istniejący ciągły obszar pamięci. Nie tworzy kopii - jedynie wskazuje na fragment bufora i zapamiętuje długość.
Co najważniejsze, Span jest uniwersalny. Może wskazywać na:
| Źródło pamięci | Jak |
|---|---|
| Sterta (managed) | int[] array = ...; Span<int> span = array.AsSpan(); |
| Stos | Span<int> span = stackalloc int[10]; |
| Pamięć niezarządzana | unsafe { byte* ptr = ...; Span<byte> span = new Span<byte>(ptr, length); } |
Ten sam typ Span działa identycznie dla wszystkich trzech - kod operujący na Span nie musi wiedzieć, skąd pamięć pochodzi. To fundamentalny zysk projektowy: jedna abstrakcja zamiast trzech.
Slicing - dzielenie bez alokacji #
int[] numbers = { 10, 20, 30, 40, 50 };
Span<int> span = numbers.AsSpan(); // widok na całą tablicę
Span<int> middle = span.Slice(1, 3); // widok: 3 elementy od indeksu 1
// ZERO alokacji - to ten sam buffor!
middle[0] = 99; // modyfikacja przez widok...
Console.WriteLine(numbers[1]); // → 99 - oryginalna tablica też zmieniona!
Slice(start, length) zwraca nowy Span wskazujący na ten sam bufor, ale z innym offsetem i długością. Modyfikacja przez wąski widok wpływa na oryginalny obszar pamięci - bo to jest ten sam obszar.
3. Ograniczenia: ref struct #
Aby Span był bezpieczny i bezalokacyjny, kompilator nakłada na niego rygorystyczne zasady. Span jest zdefiniowany jako ref struct - specjalny rodzaj struktury, która musi żyć na stosie. Nigdy nie może trafić na stertę.
Co z tego wynika - żelazne zakazy:
Zakaz 1: Span nie może być polem klasy #
public class Container
{
public Span<int> data; // ❌ BŁĄD KOMPILACJI - klasa żyje na stercie
}
Klasy żyją na stercie. Pole klasy też. Span nie może być na stercie. Sprzeczność - kompilator odrzuca.
Zakaz 2: Span nie może być w metodzie async #
public async Task ProcessAsync(Span<int> data) // ❌ BŁĄD KOMPILACJI
{
int first = data[0];
await Task.Delay(100);
int second = data[0];
}
Wracając do maszyny stanów async: kompilator zamienia metodę async na strukturę, która przy faktycznym await jest boxowana na stertę. Lokalne zmienne stają się polami tej struktury. Span nie może być polem - więc nie może być w metodzie async.
Zakaz 3: Span nie może być zboxowany #
Span<int> span = numbers.AsSpan();
object boxed = span; // ❌ BŁĄD KOMPILACJI
Boxing przeniósłby strukturę na stertę. Sprzeczność z definicją.
Czemu te ograniczenia są konieczne #
Span trzyma odniesienie do wnętrza tablicy. Garbage Collector od czasu do czasu kompaktuje stertę - przesuwa obiekty, by zlikwidować fragmentację. GC potrafi śledzić referencje do obiektów i je zaktualizować. Ale nie referencje do wnętrza obiektu (np. do trzeciego elementu tablicy).
Gdyby Span mógł żyć na stercie i przetrwać GC compaction, wskazywałby na nieaktualny adres - katastrofa. Stack natomiast nie jest kompaktowany - referencje wewnątrz są stabilne. Stąd zakaz trafiania na stertę.
4. ReadOnlySpan<T> - widok tylko do odczytu #
string w C# jest niemutowalny. Gdyby string.AsSpan() zwracał zwykły Span<char>, można by edytować znaki wewnątrz stringa - łamiąc fundamentalną gwarancję języka. Dlatego strings dają ReadOnlySpan<char> - struktura identyczna z Span<T>, ale kompilator zabrania modyfikacji.
string raw = "2026-06-18";
ReadOnlySpan<char> span = raw.AsSpan();
ReadOnlySpan<char> yearSpan = span.Slice(0, 4); // zero alokacji
ReadOnlySpan<char> monthSpan = span.Slice(5, 2);
ReadOnlySpan<char> daySpan = span.Slice(8, 2);
// .NET Core 2.1+ ma przeciążenia int.Parse przyjmujące ReadOnlySpan<char>
int year = int.Parse(yearSpan); // bez alokacji intermediate string
int month = int.Parse(monthSpan);
int day = int.Parse(daySpan);
Console.WriteLine($"{year}-{month}-{day}");
Zero nowych obiektów typu string przy parsowaniu daty. Cała operacja korzysta z fragmentów oryginalnego bufora raw.
Inne API zoptymalizowane pod Span #
Nowoczesne .NET API masowo akceptują ReadOnlySpan<char>:
double.Parse(span);
DateTime.Parse(span);
Guid.Parse(span);
Convert.ToBase64String(byteSpan);
JsonDocument.Parse(byteSpan);
Encoding.UTF8.GetString(byteSpan);
Wszędzie, gdzie spotkasz przeciążenie z ReadOnlySpan<...> - jest to szybsza ścieżka unikająca alokacji.
5. stackalloc - alokacja na stosie #
Dla krótko żyjących, małych buforów obliczeniowych - stackalloc alokuje na stosie:
Span<int> buffer = stackalloc int[100]; // 100 intów = 400 bajtów na stosie
for (int i = 0; i < buffer.Length; i++)
buffer[i] = i * i;
int sum = 0;
foreach (int val in buffer)
sum += val;
// Tutaj buffer "ginie" wraz z wyjściem z metody - zero śladu w GC
Korzyść: alokacja na stosie to dosłownie zwiększenie wskaźnika stosu o N bajtów - piorunująco szybko, nawet w pętlach. Brak interakcji z GC.
Ostrzeżenie: stos jest mały (zwykle 1 MB per wątek w .NET). stackalloc int[1_000_000] to StackOverflowException - aplikacja pada bez ratunku. Pisz stackalloc tylko dla buforów, które wiesz, że są małe (do ~1 KB jest bezpieczne).
6. Memory<T> - Span dla async #
Span nie może być w async. Ale w realnych systemach często właśnie chcemy operować na buforze w async metodzie (pobieramy strumień przez HTTP, kawałkami z dysku, partycjami z bazy). Rozwiązanie - Memory<T> (i ReadOnlyMemory<T>).
Cytat MS: Unlike Span
Memory<T> to klasyczna struktura (nie ref struct) - może być polem klasy, użyta w async, przekazana między wątkami. Wewnętrznie trzyma:
- Referencję do bufora (
T[],MemoryManager<T>) - Offset i długość
Konwersja: memory.Span daje Span’a (do użycia w synchronicznych operacjach), ale Span’a nie da się rzutować na Memory (brak informacji o właścicielu bufora).
public async Task ProcessAsync(ReadOnlyMemory<byte> data)
{
// Memory można trzymać przez await
await Task.Delay(100);
// Wewnątrz synchronicznego fragmentu konwertujemy na Span
ReadOnlySpan<byte> span = data.Span;
byte first = span[0];
}
Reguła kciuka MS #
Cytat z dokumentacji: For a synchronous API, use Span
| Sytuacja | Wybór |
|---|---|
| Synchroniczna metoda, parametr-buffer | Span<T> (lub ReadOnlySpan<T> dla read-only) |
| Async metoda, parametr-buffer | Memory<T> (lub ReadOnlyMemory<T>) |
| Pole klasy reprezentujące buffer | Memory<T> (Span nie może być polem) |
| Wewnątrz async po konkretnej operacji synchronicznej | memory.Span → operuj na Span’ie |
7. ArrayPool<T> - recykling buforów #
Dla powtarzalnych operacji wymagających dużych buforów (większych niż się opłaca stackalloc), istnieje ArrayPool<T>:
using System.Buffers;
byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); // wypożyczamy buffer >= 8192 bajtów
try
{
Span<byte> span = buffer.AsSpan(0, 8192); // owijamy w Span do operacji
await stream.ReadAsync(span);
Process(span);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer); // zwracamy do puli
}
Korzyść: zamiast tysięcy alokacji new byte[8192] w pętli (każda dla GC), masz dziesiątki wypożyczeń z puli, każde z bufferem z poprzedniego użycia.
Pamiętaj:
Rent(N)zwraca buffer co najmniej N - może być większy (np. 8192 zamiast 7500). UżywajLengthostrożnie.- Zawsze
Returnwfinally- zapomniany Return to wyciek puli (buffer nie wraca, kolejneRentmuszą alokować nowe). - Nie przechowuj wypożyczonego buffera dłużej niż musisz - blokuje innych.
- Po
Returnnie używaj buffera - inny komponent może go już mieć.
8. Kiedy NIE używać Span #
Span jest optymalizacją. Wprowadzasz go, gdy profiler udowodnia problem z alokacjami w gorącej ścieżce. Bez profilera - zostań przy klasycznych typach.
Powód, by zostać przy string/T[] | Wyjaśnienie |
|---|---|
| Kod się nie wykonuje często | Premature optimization is the root of all evil |
Potrzebujesz LINQ (Where, Select, OrderBy) | Span nie ma operatorów LINQ - tylko podstawowe metody (Slice, IndexOf, CopyTo) |
Buffer musi być polem klasy lub przekraczać await | Span nie może - użyj Memory<T> lub klasycznego T[] |
| Musisz przekazać buffer między metodami async | Memory, nie Span |
| Czytelność kodu ma większy priorytet niż mikro-optymalizacje | Span jest niskopoziomowy; klasyczny Substring jest dla początkujących bardziej czytelny |
Podsumowanie tematu #
W tej części kursu poznałeś:
- Problem - klasyczne operacje na
string(Substring) iT[]często alokują nowe obiekty - w hot path to gigantyczna presja na GC Span<T>- struktura (ref struct), widok na ciągły obszar pamięci bez kopiowania; działa uniwersalnie dla sterty, stosu i pamięci niezarządzanejSlice(start, length)- dzieli widok na mniejszy fragment, zero alokacji; modyfikacja przez slice wpływa na oryginalny buforref structograniczenia - musi żyć na stosie; brak polu klasy, brakasync, brak boxingu - wszystko ze względu na GC compactionReadOnlySpan<T>- widok tylko do odczytu;string.AsSpan()zwraca właśnie ten typ (bostringjest niemutowalny); idealny do parsowania tekstu -int.Parse(span)bez alokacji intermediate stringastackalloc- alokacja na stosie; piorunująco szybko, ale ograniczona do ~1 KB (większe = StackOverflowException)Memory<T>- klasyczna struktura (nieref struct); może być polem klasy, użyta w async; konwersja przez.Spando operacji synchronicznych- Reguła kciuka MS: sync API → Span, async API → Memory
ArrayPool<T>- recykling dużych buforów dla powtarzalnych operacji;Rent/Returnwtry/finally- Kiedy NIE używać - rzadko wykonywany kod, gdy potrzeba LINQ, gdy potrzeba pola klasy, gdy czytelność > mikro-optymalizacje
To koniec sekcji o pamięci i wydajności. W następnym wpisie zajmiemy się testami jednostkowymi w C# - xUnit, atrybuty [Fact] i [Theory], wzorzec AAA (Arrange-Act-Assert), asercje przez Assert.* oraz Moq jako biblioteka do mockowania zależności. Testowanie zamknie fundamentalną część kursu poświęconą jakości kodu.
Quiz i zadanie poniżej. Zadanie demonstruje kanoniczny wzorzec użycia ReadOnlySpan<char> - parsowanie daty na trzy liczby bez ani jednej alokacji pośredniej. To dokładnie ta technika, którą stosuje się przy parsowaniu plików CSV/JSON/logów w aplikacjach wysokiej wydajności.
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