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 #
| API | Status |
|---|---|
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 stron | Zależ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:
| Implementacja | Czas | Alokacje |
|---|---|---|
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 -
TryParsezwracający kilkaout 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 -
IndexOfmoż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
stringjest 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
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#.
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#.
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#.
🔗 Linkują tu