Wszystkie 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#.


Przez ostatnich pięć wpisów - od Span’a, przez Memory, aż do ref struct - poznawałeś narzędzia do operowania na pamięci bez alokacji. Teraz zastosujemy to wszystko w jednym, kanonicznym scenariuszu produkcyjnym: parser logów serwera czytający miliony linii bez ani jednego zbędnego obiektu na stercie.

Parsowanie tekstu to jedna z najdroższych operacji w klasycznym C# - każda linia logu, każdy CSV, każdy JSON, każdy header HTTP. Pomnóż przez skalę nowoczesnych systemów (10k req/s, miliony zdarzeń dziennie) i zwykłe Split/Substring zaczynają zapychać GC. W tej części pokażę Ci, jak ten problem rozwiązać systemowo - z gotowym wzorcem, który możesz przenieść do własnych projektów.

1. Problem: czemu klasyczny string-based parsing dławi serwery #

Weźmy typową linię logu:

[2026-06-19 10:00:00] [ERROR] Database connection failed

Klasyczne podejście do wyciągnięcia daty/poziomu/wiadomości:

// ❌ PODEJŚCIE TRADYCYJNE - zabójca pamięci
string log = "[2026-06-19 10:00:00] [ERROR] Database connection failed";

string[] parts = log.Split(']');
// ↑ alokacje:
//   - 1 × tablica string[] (3 elementy)
//   - 3 × nowy string (po jednym dla każdego fragmentu)
// = 4 obiekty na stercie tylko za Split

string date = parts[0].Substring(1).Trim();   // kolejny nowy string
string level = parts[1].Substring(2).Trim();  // i kolejny
string message = parts[2].Trim();             // i kolejny
// ↑ kolejne 3 alokacje przez Substring + 3 przez Trim

Łącznie ~10 obiektów na stercie za jedną linię logu. Pomnóż przez milion linii dziennie:

1_000_000 linii × 10 obiektów = 10_000_000 obiektów dziennie

GC musi je wszystkie skatalogować i zwolnić. Każdy przebieg GC to mikro-pauza aplikacji. Pod obciążeniem pauzy się sumują - aplikacja zaczyna “przycinać”, percentyl P99 latencji wzrasta, użytkownicy widzą “spinner”.

To realny problem w systemach typu:

  • Brokerzy wiadomości (Kafka consumers, RabbitMQ)
  • Web serwery o dużej intensywności (gateway API)
  • Analizatory logów (parsowanie produkcyjnych logów w czasie rzeczywistym)
  • Parsery JSON/CSV/XML w pipeline’ach ETL

2. Narzędzia zbrodni: ReadOnlySpan<char> API #

Zamiast kopiować kawałki tekstu do nowych stringów, “przesuwamy okno” po oryginalnym buforze. Pięć metod, które trzeba znać:

text.AsSpan() - darmowe okno na string #

string log = "[2026-06-19 10:00:00] [ERROR] ...";
ReadOnlySpan<char> span = log.AsSpan();   // zero alokacji

To literalnie darmowe - ustawienie pointera + length na stosie. Span wskazuje na ten sam bufor co log.

span.IndexOf(char) - szukanie pozycji #

int closingBracket = span.IndexOf(']');   // 19 dla naszego logu
// -1 jeśli znak nie został znaleziony!

Pod spodem zoptymalizowane przez SIMD (Single Instruction Multiple Data) dla większych Span’ów - znacznie szybsze niż klasyczna pętla po string.

span.Slice(start, length) - zawężanie okna #

ReadOnlySpan<char> date = span.Slice(1, closingBracket - 1);
// "2026-06-19 10:00:00"
// Zero alokacji - to ten sam bufor, tylko z innymi pointerami

Slice to operacja matematyczna na wskaźnikach - kilka instrukcji CPU, koszt bliski zeru. Można robić łańcuchy slice’ów bez żadnych kosztów (span.Slice(0, 100).Slice(10, 20)).

span.SequenceEqual("LITERAL") - porównanie bez alokacji #

if (level.SequenceEqual("ERROR"))
{
    // wykonaj alert
}

Klasyczne level.ToString() == "ERROR" alokuje nowy string (pokonując całą oszczędność Span’a). SequenceEqual porównuje char po char bezpośrednio na buforze, bez konwersji.

span.StartsWith(...) / span.EndsWith(...) #

if (line.StartsWith("[ERROR]"))    // szybkie filtrowanie
{
    Process(line);
}

Również bez alokacji - przydatne dla wczesnego odrzucenia linii w hot path.

3. Implementacja: zero-allocation log parser #

Składamy wszystko w jedną metodę:

public static bool TryParseLog(
    ReadOnlySpan<char> line,
    out ReadOnlySpan<char> date,
    out ReadOnlySpan<char> level,
    out ReadOnlySpan<char> message)
{
    // Format: [DATE] [LEVEL] MESSAGE

    // KROK 1: znajdź zamykający ']' daty
    int dateEnd = line.IndexOf(']');
    if (dateEnd < 0)   // KLUCZOWE: ZAWSZE sprawdź -1
    {
        date = level = message = default;
        return false;
    }

    // Data: pomiń początkowy '[', do indeksu zamykającego
    date = line.Slice(1, dateEnd - 1);

    // KROK 2: przesuń okno za "] ["
    ReadOnlySpan<char> remaining = line.Slice(dateEnd + 3);

    // KROK 3: znajdź zamykający ']' poziomu
    int levelEnd = remaining.IndexOf(']');
    if (levelEnd < 0)
    {
        level = message = default;
        return false;
    }

    level = remaining.Slice(0, levelEnd);

    // KROK 4: wiadomość to wszystko po "] " (3 znaki: '] ' to 2, ale `Slice` chce next index)
    message = remaining.Slice(levelEnd + 2);

    return true;
}

Co kosztowało: literalnie zero alokacji na stercie. Wszystkie operacje to:

  • 2 wywołania IndexOf (pętle CPU, brak alokacji)
  • 3 wywołania Slice (manipulacja pointerów na stosie)

Dla miliona linii: zero obiektów dla GC. Cała praca zostaje w obrębie L1/L2 cache CPU - błyskawicznie.

Użycie #

string log = "[2026-06-19 10:00:00] [ERROR] Database connection failed";

if (TryParseLog(log.AsSpan(), out var date, out var level, out var message))
{
    if (level.SequenceEqual("ERROR"))
    {
        // Cała ścieżka filtrowania ERRORs - bez alokacji
        AlertCriticalEvent(date, message);   // przekazujemy Span dalej
    }
}

AlertCriticalEvent może być metodą synchroniczną przyjmującą ReadOnlySpan<char> - kontynuując ścieżkę bez alokacji. Dopiero na granicy z legacy API (logger, baza, console) zrobiloby się .ToString() - kontrolowana alokacja w jednym miejscu.

4. Ekosystem .NET gotowy na Span #

Przez pierwsze lata istnienia Span używanie go było czasem toporne - stare API przyjmowało tylko string. Od .NET Core 2.1 w górę ekosystem został przebudowany. Najwięcej zoptymalizowanych metod:

Parsowanie typów #

ReadOnlySpan<char> yearSpan = "2026".AsSpan();
ReadOnlySpan<char> dateSpan = "2026-06-19 10:00:00".AsSpan();
ReadOnlySpan<char> guidSpan = "550e8400-e29b-41d4-a716-446655440000".AsSpan();

int year = int.Parse(yearSpan);                  // zero alokacji
double pi = double.Parse("3.14159".AsSpan());    // zero alokacji
DateTime date = DateTime.Parse(dateSpan);        // zero alokacji
Guid id = Guid.Parse(guidSpan);                  // zero alokacji

Wszystkie Parse / TryParse typów numerycznych, daty, GUID-a mają przeciążenia dla Span.

Konwersje binarne #

ReadOnlySpan<byte> base64Bytes = ...;
string base64 = Convert.ToBase64String(base64Bytes);   // zero pośrednich alokacji

ReadOnlySpan<byte> utf8Bytes = ...;
string utf8Text = Encoding.UTF8.GetString(utf8Bytes);  // już zoptymalizowane pod Span

JSON parsing #

ReadOnlySpan<byte> jsonBytes = ...;
using JsonDocument doc = JsonDocument.Parse(jsonBytes);
// Cały Utf8JsonReader pod spodem to ref struct + Span - zero alokacji w hot path

Co jeszcze nie ma pełnego wsparcia Span #

APIStatus
int.Parse, DateTime.Parse itp.✅ Span przeciążenia
Convert.*, Encoding.*✅ Span przeciążenia
JsonDocument.Parse (System.Text.Json)✅ od .NET Core 3.0
Regex.Match⚠️ częściowe od .NET 7 (Regex.EnumerateMatches)
XmlReader⚠️ klasyczne API operuje na string
ConfigurationBuilder❌ klasyczne
Biblioteki NuGet trzecich stronZależnie od projektu

Dla maksymalnej wydajności w hot path pisz własny parser zamiast Regex - jest dramatycznie szybszy i nie alokuje.

5. Bonus: porównanie wydajności #

Klasyczny benchmark dla parsowania 1 miliona linii logów:

ImplementacjaCzasAlokacje
string.Split + Substring~850 ms~120 MB
string.IndexOf + Substring~520 ms~80 MB
ReadOnlySpan + Slice + SequenceEqual~120 ms~0 MB
ReadOnlySpan + dedykowane Parse(Span) API~110 ms~0 MB

Span-based parser jest ~7x szybszy i alokuje ~0 MB dla GC. To realna różnica - dla aplikacji obsługującej ciągły strumień logów oszczędność CPU i pamięci RAM idzie w dziesiątki procent ogólnego obciążenia serwera.

6. Pułapki produkcyjne #

Pułapka A: zapomnienie sprawdzenia IndexOf == -1 #

int idx = span.IndexOf(']');
ReadOnlySpan<char> date = span.Slice(1, idx - 1);   // CRASH gdy idx == -1

Slice(1, -2) rzuca ArgumentOutOfRangeException. Zawsze sprawdzaj idx < 0 przed użyciem do Slice.

Pułapka B: .ToString() w środku hot path #

foreach (var line in millionLines)
{
    if (LogParser.TryParse(line.AsSpan(), out var date, out var level, out var message))
    {
        string levelStr = level.ToString();   // ❌ alokacja na każdej linii!
        if (levelStr == "ERROR") { ... }
    }
}

.ToString() na każdej linii niweluje całą oszczędność Span’a. Używaj SequenceEqual:

if (level.SequenceEqual("ERROR")) { ... }   // ✅ zero alokacji

Pułapka C: Span jako parametr async metody #

Już omawiane w poprzednich wpisach, ale warto powtórzyć w kontekście parsera:

public async Task<bool> ProcessLineAsync(ReadOnlySpan<char> line)   // ❌ BŁĄD KOMPILACJI
{
    await Task.Delay(10);
    return LogParser.TryParse(line, ...);
}

Span nie może w async. Rozwiązanie: parsowanie w synchronicznej metodzie pomocniczej, async metoda przekazuje wynik dalej:

public async Task<bool> ProcessLineAsync(ReadOnlyMemory<char> line)   // Memory dla async
{
    await Task.Delay(10);
    return LogParser.TryParse(line.Span, out _, out _, out _);
}

Pułapka D: span przeżyje wywołujący #

ReadOnlySpan<char> DangerousReturn()
{
    string local = GetLog();              // local żyje tylko w tej metodzie
    return local.AsSpan().Slice(0, 10);   // ❌ NIEBEZPIECZNE!
}

Span wskazuje na local, który może zostać zwolniony po wyjściu z metody. Dla string (klasy referencyjnej) GC potrafi to ogarnąć, ale dla stackalloc to natychmiastowy crash. Reguła: Span nie powinien przeżyć źródła, na które wskazuje.

7. Strategia produkcyjna #

Zasada nadrzędna: mierz przed optymalizacją. Span ma realny sens tylko tam, gdzie alokacje są wykryte jako bottleneck przez profiler. W 95% kodu klasyczny string/Substring jest:

  • Czytelniejszy
  • Łatwiejszy do utrzymania
  • Wystarczająco szybki

Optymalizacja przez Span to świadoma decyzja oparta na danych z profilera (dotMemory, PerfView, BenchmarkDotNet) wskazujących, że konkretny kawałek kodu generuje nadmierną GC pressure.

Kiedy świadomie sięgać po Span od początku:

  • Pisanie biblioteki/parsera, który będzie wywoływany w hot path konsumentów
  • Krytyczna ścieżka serwera produkcyjnego (po profilowaniu)
  • Tooling przetwarzający gigabajty plików tekstowych
  • Engine bazy danych, cache, broker wiadomości

W typowej aplikacji webowej / desktop / mobile: standardowe string API są właściwym wyborem.

Podsumowanie tematu #

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

  • Problem alokacji w klasycznym Split/Substring - milion linii × ~10 obiektów na linię = miliony obiektów dla GC
  • Pięć narzędzi Span’a do parsowania: AsSpan(), IndexOf(), Slice(), SequenceEqual(), StartsWith/EndsWith
  • Kanoniczny wzorzec parsowania logu - TryParse zwracający kilka out ReadOnlySpan<char> jednocześnie
  • Ekosystem .NET gotowy na Span - int.Parse, DateTime.Parse, Guid.Parse, JsonDocument.Parse, Convert.ToBase64String, Encoding.UTF8.GetString - wszystkie z przeciążeniami dla Span
  • Co jeszcze nie ma pełnego wsparcia - Regex (częściowe od .NET 7), XML, niektóre biblioteki trzecich stron
  • Benchmark - Span-based parser ~7x szybszy, ~0 MB alokacji vs ~120 MB klasycznym Split
  • Pułapki - IndexOf może zwrócić -1 (zawsze sprawdzaj), .ToString() w hot path niweluje oszczędność, Span zabroniony w async (Memory!), Span nie powinien przeżyć źródła
  • Strategia produkcyjna - mierz przed optymalizacją; Span to świadoma decyzja oparta na profilerze; w 95% kodu string jest właściwym wyborem

W następnym wpisie ArrayPool i MemoryPool w C# zajmiemy się drugą stroną zarządzania pamięcią - kiedy musisz alokować dużą tablicę, ale chcesz uniknąć fragmentacji LOH (Large Object Heap). Wypożyczalnia tablic, klasyczna pułapka nadmiarowego rozmiaru po Rent, oraz MemoryPool z using dla async.

Quiz i zadanie poniżej. Zadanie demonstruje dokładny wzorzec produkcyjny parsera logów - TryParse zwracający trzy out ReadOnlySpan<char> z pełnym zachowaniem zera alokacji w trakcie parsowania.

Podobne wpisy

🔗 Linkują tu