Wszystkie wpisy

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.

Span i ReadOnlySpan to dwie struktury, które powstały, by drastycznie zredukować liczbę alokacji w typowych operacjach na ciągłych obszarach pamięci. Pozwalają wycinać, parsować, kopiować i transformować dane bez tworzenia kopii. W tej części zobaczysz, jak działają jako “okna na pamięć”, jakie mają rygorystyczne ograniczenia (i czemu są konieczne) oraz jak je łączyć ze stackalloc, 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 and ReadOnlySpan are lightweight memory buffers that wrap references to managed or unmanaged memory.

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ęciJak
Sterta (managed)int[] array = ...; Span<int> span = array.AsSpan();
StosSpan<int> span = stackalloc int[10];
Pamięć niezarządzanaunsafe { 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 can be stored on the managed heap.

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 instead of Memory as a parameter if possible.

SytuacjaWybór
Synchroniczna metoda, parametr-bufferSpan<T> (lub ReadOnlySpan<T> dla read-only)
Async metoda, parametr-bufferMemory<T> (lub ReadOnlyMemory<T>)
Pole klasy reprezentujące bufferMemory<T> (Span nie może być polem)
Wewnątrz async po konkretnej operacji synchronicznejmemory.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żywaj Length ostrożnie.
  • Zawsze Return w finally - zapomniany Return to wyciek puli (buffer nie wraca, kolejne Rent muszą alokować nowe).
  • Nie przechowuj wypożyczonego buffera dłużej niż musisz - blokuje innych.
  • Po Return nie 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ęstoPremature 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ć awaitSpan nie może - użyj Memory<T> lub klasycznego T[]
Musisz przekazać buffer między metodami asyncMemory, nie Span
Czytelność kodu ma większy priorytet niż mikro-optymalizacjeSpan 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) i T[] 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ądzanej
  • Slice(start, length) - dzieli widok na mniejszy fragment, zero alokacji; modyfikacja przez slice wpływa na oryginalny bufor
  • ref struct ograniczenia - musi żyć na stosie; brak polu klasy, brak async, brak boxingu - wszystko ze względu na GC compaction
  • ReadOnlySpan<T> - widok tylko do odczytu; string.AsSpan() zwraca właśnie ten typ (bo string jest niemutowalny); idealny do parsowania tekstu - int.Parse(span) bez alokacji intermediate stringa
  • stackalloc - alokacja na stosie; piorunująco szybko, ale ograniczona do ~1 KB (większe = StackOverflowException)
  • Memory<T> - klasyczna struktura (nie ref struct); może być polem klasy, użyta w async; konwersja przez .Span do operacji synchronicznych
  • Reguła kciuka MS: sync API → Span, async API → Memory
  • ArrayPool<T> - recykling dużych buforów dla powtarzalnych operacji; Rent/Return w try/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

🔗 Linkują tu