Wszystkie wpisy

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


W poprzednim wpisie Span i ReadOnlySpan w C# poznałeś Span<T> jako “okno na pamięć” bez alokacji. Wymieniłem też jedno krytyczne ograniczenie: Span jest ref struct - nie może być używany w metodach async. To problem, bo nowoczesny C# w dużej części opiera się o async/await - cała praca z plikami, sieciami, bazami danych, mikroserwisami.

Dlatego Memory<T> i ReadOnlyMemory<T>. To asynchroniczny krewniak Span’a, który zachowuje większość zalet (zero kopiowania, slicing, uniwersalność), tracąc trochę wydajności w zamian za zdolność do przeżycia await. W tej części zobaczysz, kiedy używać Memory zamiast Span, kanoniczny wzorzec Memory na boundary metody, Span do operacji, oraz lifetime rules z dokumentacji Microsoftu, które oddzielają kod działający od kodu z subtelnymi błędami współbieżności.

1. Dlaczego Memory w ogóle istnieje #

Przypomnijmy reguły dla Span:

OgraniczenieKonsekwencja
ref struct - musi żyć na stosieBrak pól klasy, brak boxingu
Maszyna stanów async boxuje lokalne na stertęBrak Span w async

Druga linia to showstopper. Cała współczesna biblioteka standardowa jest async: Stream.ReadAsync, HttpClient.GetAsync, DbContext.SaveChangesAsync. Bez asynchronicznego buforowania zostajemy z byte[] i wszystkich jego alokacji.

Memory<T> to rozwiązanie problemu. Cytat z dokumentacji MS: Memory can be stored on the managed heap. To klasyczna struktura (struct, bez ref przed), więc:

  • ✅ Może być polem klasy
  • ✅ Może być lokalną zmienną w async metodzie
  • ✅ Może przekraczać await
  • ✅ Może być przekazana między wątkami

Cena: trochę większy rozmiar struktury (3 pola - reference, offset, length zamiast 2 pól w Span) i marginalnie wolniejszy dostęp do danych (przez extra wskazanie). Dla większości zastosowań nieistotne.

2. Memory jako obietnica Span #

Mentalna metafora: Memory<T> to obietnica dostarczenia Span<T>, gdy będzie się go dało użyć.

public class PipelineComponent
{
    private Memory<byte> _buffer;          // OK - Memory może być polem klasy

    public async Task ProcessAsync(Memory<byte> input)   // OK - parametr async
    {
        await Task.Delay(100);             // Memory przeżywa await

        // KLUCZOWE: nie ZAPISZ Span'a do lokalnej zmiennej w async metodzie!
        // Wydziel synchroniczną metodę i przekaż .Span jako parametr.
        ProcessSync(input.Span);
    }

    private void ProcessSync(Span<byte> data)   // SYNC - tu Span działa swobodnie
    {
        data[0] = 0xFF;
        Span<byte> slice = data.Slice(10, 20);
        // ... synchroniczne operacje ...
    }
}

Wzorzec: na granicach (sygnatury async metod, parametry, pola klas) używaj Memory<T>. Operacje na Span’ie wydzielaj do synchronicznych metod pomocniczych, którym przekazujesz memory.Span jako parametr.

Krytyczna pułapka: Span<T>/ReadOnlySpan<T> jako lokalna zmienna w metodzie async powoduje błąd kompilacji CS4012:

public async Task BadAsync(ReadOnlyMemory<char> text)
{
    await Task.Delay(10);
    ReadOnlySpan<char> span = text.Span;   // ❌ CS4012: cannot be declared in async
    foreach (char c in span) { ... }
}

Powód: maszyna stanów async pakuje wszystkie zmienne lokalne na stertę (jako pola struktury). Span<T> jako ref struct nie może tam trafić. Kompilator wymusi wydzielenie synchronicznej metody pomocniczej.

ReadOnlyMemory<T> - read-only krewniak #

Analogicznie do Span’a - dla tekstów (string jest niemutowalny) i czystych odczytów:

public async Task<int> CountWordsAsync(ReadOnlyMemory<char> text)
{
    await Task.Delay(10);
    ReadOnlySpan<char> span = text.Span;   // ReadOnlyMemory.Span → ReadOnlySpan
    // ... liczenie słów ...
}

// Konsument:
string content = "Lorem ipsum dolor sit amet";
ReadOnlyMemory<char> memory = content.AsMemory();   // string → ReadOnlyMemory<char>
int wordCount = await service.CountWordsAsync(memory);

string.AsMemory() zwraca ReadOnlyMemory<char> (analogicznie do AsSpan() zwracającego ReadOnlySpan<char>).

3. Kanoniczny wzorzec - Stream.ReadAsync(Memory<byte>) #

Najpopularniejsze użycie Memory w realnym kodzie. Nowoczesne Stream.ReadAsync (od .NET Core 2.1+) ma przeciążenie przyjmujące Memory<byte> i zwracające ValueTask<int>:

public async Task ProcessFileInChunksAsync(string path)
{
    // Jeden bufor 1024 bajty - re-używany przez całą pętlę
    byte[] buffer = new byte[1024];
    Memory<byte> bufferMemory = buffer.AsMemory();

    using FileStream stream = File.OpenRead(path);

    int bytesRead;
    while ((bytesRead = await stream.ReadAsync(bufferMemory)) > 0)
    {
        // Po await mamy nowe dane w buforze. Konwersja na Span do operacji.
        Span<byte> chunk = bufferMemory.Span.Slice(0, bytesRead);
        ProcessChunk(chunk);
    }
}

private void ProcessChunk(Span<byte> data)
{
    // Synchroniczna, bezalokacyjna obróbka
    for (int i = 0; i < data.Length; i++)
        data[i] = (byte)(data[i] ^ 0xFF);
}

Co tu się zoptymalizowało:

Klasyczny byte[] w pętliMemory<byte> z Span
Bufornowy byte[1024] co iteracjęjeden bufor re-używany
Alokacje na stercietysiące małych1 (na początku)
ReadAsync zwracaTask<int> (alokacja Task)ValueTask<int> (zero gdy sync hit)
GC pressureWysokaNiska

Dla streamingu wielkich plików, połączeń HTTP, czytania z socketów - to fundamentalna optymalizacja w nowoczesnym .NET.

Dlaczego ValueTask<int>? #

Stream.ReadAsync(Memory<byte>) zwraca ValueTask<int>, nie Task<int>. Pamiętasz z wpisu o ValueTask? Gdy czytanie kończy się synchronicznie (dane już są w buforze internym streamu) - ValueTask nie alokuje. Dla pliku odczytywanego w pętli to często - dziesiątki tysięcy razy zero alokacji.

4. IMemoryOwner<T> i ownership #

Dla bardziej zaawansowanych scenariuszy gdzie potrzeba przekazywać własność bufora między komponentami, .NET wprowadziło interfejs IMemoryOwner<T> z namespace System.Buffers:

public interface IMemoryOwner<T> : IDisposable
{
    Memory<T> Memory { get; }
}

Implementuje IDisposable - trzymający referencję do IMemoryOwner<T> jest właścicielem bufora i musi go zwolnić.

MemoryPool<T>.Shared.Rent #

using System.Buffers;

public async Task DownloadAsync(Uri url)
{
    // Wypożycz buffer z globalnej puli
    using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(8192);
    //    ^^^^^                                                ^^^^
    //    IDisposable                                          co najmniej 8KB

    Memory<byte> buffer = owner.Memory;

    using HttpClient client = new HttpClient();
    using Stream stream = await client.GetStreamAsync(url);

    int bytesRead;
    while ((bytesRead = await stream.ReadAsync(buffer)) > 0)
    {
        ProcessChunk(buffer.Span.Slice(0, bytesRead));
    }

    // Tu `using` wywoła owner.Dispose() - buffer wraca do puli
}

MemoryPool<T> to odpowiednik ArrayPool<T> dla Memory - recykling buforów dla powtarzalnych operacji. Globalna pula (Shared) wystarcza w 99% przypadków.

Zasady używania:

  • Zawsze using albo jawny Dispose na zwróconym IMemoryOwner<T>. Brak = wyciek bufora z puli.
  • Rent(N) może zwrócić bufor większy niż N (np. 16384 dla Rent(8192)). Używaj Memory.Length świadomie albo własnego counter’a.
  • Po Dispose nie używaj bufora - inny komponent może go już mieć.

5. Lifetime rules - kontrakty z dokumentacji MS #

Dokumentacja MS definiuje 10 reguł użycia Memory. Cztery najważniejsze do zapamiętania:

Rule #3: void-returning + Memory parameter #

If your method accepts Memory<T> and returns void, you must not use the Memory<T> instance after your method returns.

Bufor jest wypożyczony metodzie tylko na czas jej wykonania (“lease”). Po return masz zakaz dotykać go - np. w Task.Run w tle. Owner może w międzyczasie modyfikować bufor; concurrent reads/writes = race condition + uszkodzone dane.

Anty-przykład:

static void Log(ReadOnlyMemory<char> message)   // void!
{
    Task.Run(() =>                              // ❌ ŁAMIE REGUŁĘ #3
    {
        StreamWriter sw = File.AppendText("log.txt");
        sw.WriteLine(message);                  // używa Memory po wyjściu z metody
    });
}

Poprawnie - zrób defensive copy albo zwracaj Task:

static Task Log(ReadOnlyMemory<char> message)   // Task!
{
    return Task.Run(() =>
    {
        StreamWriter sw = File.AppendText("log.txt");
        sw.WriteLine(message);   // OK - Memory żyje aż Task się zakończy
    });
}

Rule #4: Task-returning + Memory parameter #

If your method accepts a Memory<T> and returns a Task, you must not use the Memory<T> instance after the Task transitions to a terminal state.

Async wersja Reguły #3. Dopóki Task żyje, masz prawo do bufora. Po zakończeniu Task (Completed, Faulted, Canceled) - zakaz. Caller może już używać bufora do innych celów.

Rule #5: konstruktor + Memory parameter #

If your constructor accepts Memory<T> as a parameter, instance methods on the constructed object are assumed to be consumers of the Memory<T> instance.

Klasa, której konstruktor przyjmuje Memory, trzyma ten bufor przez całe życie instancji. Wywołujący nie może modyfikować go w międzyczasie.

public class TokenReader
{
    private readonly ReadOnlyMemory<char> _source;

    public TokenReader(ReadOnlyMemory<char> source)
    {
        _source = source;   // klasa konsumuje Memory przez całe życie
    }

    public bool TryReadNext(out ReadOnlyMemory<char> token) { /* ... */ }
}

// Wywołujący:
var memory = bigText.AsMemory();
var reader = new TokenReader(memory);
// memory NIE MOŻE być modyfikowane dopóki reader żyje
while (reader.TryReadNext(out var token)) { /* ... */ }
// Teraz reader jest skończony, memory można zwolnić/przepiąć

Rule #7: IMemoryOwner ownership #

If you have an IMemoryOwner<T> reference, you must at some point dispose of it or transfer its ownership (but not both).

Trzymasz referencję do IMemoryOwner<T> → masz obowiązek zwolnić (Dispose) albo przekazać innej komponencie. Nigdy oba - po przekazaniu nie jesteś już właścicielem, nie wolno Dispose.

6. Memory<T>.Pin() - dla P/Invoke w async #

Klasyczny C# pozwala na fixed keyword do tymczasowego “przypięcia” buforów w pamięci - GC nie przesunie ich, więc native code może bezpiecznie operować na adresach. Ale fixed jest synchroniczny - kompilator wymaga, by cały fixed block był synchroniczny.

W async wywołaniach do natywnego kodu zamiast fixed używasz Memory<T>.Pin() - zwraca MemoryHandle (IDisposable):

public async Task SendBytesAsync(Memory<byte> data)
{
    using MemoryHandle handle = data.Pin();
    unsafe
    {
        NativeAsyncSend((byte*)handle.Pointer, data.Length, ...);
    }
    await Task.Yield();   // dalej można `await`
    // Memory pozostaje przypięte do `using` Dispose
}

Mocno zaawansowany kod - przy P/Invoke do bibliotek C w async wywołaniach.

7. Reguła kciuka - 4 typy do zapamiętania #

TypGdzieRead-only?
Span<T>sync API, lokalne zmienne, parametry sync metodnie
ReadOnlySpan<T>sync API, string.AsSpan(), immutable buforytak
Memory<T>async API, pola klas, długoterminowe buforynie
ReadOnlyMemory<T>async API, string.AsMemory(), immutable asynctak

Decyzja w trzech krokach:

  1. Read-only? → dodaj ReadOnly przed nazwą
  2. W async, polu klasy, długim cyklu życia?Memory<T>
  3. Synchronicznie, lokalnie, gorąca ścieżka?Span<T>

Konwersje:

  • Memory<T>Span<T>: .Span property (właściwość)
  • Span<T>Memory<T>: niemożliwe (brak informacji o ownership)
  • T[]Memory<T>: .AsMemory()
  • T[]Span<T>: .AsSpan()
  • stringReadOnlyMemory<char>: .AsMemory()
  • stringReadOnlySpan<char>: .AsSpan()

Podsumowanie tematu #

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

  • Czemu Memory - Span nie może być w async (jest ref struct); Memory to klasyczna struktura, może żyć na stercie, przeżywa await
  • Wzorzec Memory + .Span - na granicach metod async Memory, wewnątrz synchronicznego fragmentu .Span dla wydajności
  • Stream.ReadAsync(Memory<byte>) - kanoniczny przykład; zwraca ValueTask<int> dla bezalokacyjnego hot path; re-używany bufor w pętli czytania
  • ReadOnlyMemory<char> dla tekstów - string.AsMemory(); analogicznie do ReadOnlySpan<char>
  • IMemoryOwner<T> z System.Buffers - reprezentuje ownership bufora; implementuje IDisposable
  • MemoryPool<T>.Shared.Rent - recykling buforów dla powtarzalnych operacji w async; using na zwróconym IMemoryOwner
  • Lifetime rules MS - Rule #3 (void: lease kończy się przy return), Rule #4 (Task: lease do terminal state), Rule #5 (konstruktor: cała instancja konsumuje), Rule #7 (IMemoryOwner: dispose albo transfer ownership)
  • Memory<T>.Pin() - asynchroniczny odpowiednik fixed dla P/Invoke
  • Reguła kciuka - 4 typy (Span/ReadOnlySpan/Memory/ReadOnlyMemory), 3 pytania decyzyjne (read-only? async? sync?)

W następnym wpisie ref struct w C# zejdziemy o poziom niżej - zobaczysz, jak tworzyć własne typy stack-only (jak Span), pełną mechanikę zakazów CLR (czemu nie w async, czemu nie pole klasy, czemu nie w iteratorach, czemu nie w lambdach) oraz dlaczego wszystkie cztery zakazy mają jedną wspólną przyczynę.

Quiz i zadanie poniżej. Zadanie demonstruje kanoniczny wzorzec Memory na boundary async metody → .Span wewnątrz synchronicznego fragmentu do liczenia znaków. To dokładnie ta technika, którą stosuje się przy parsowaniu HTTP query strings, plików JSON, logów w wysokowydajnych systemach.

Podobne wpisy

🔗 Linkują tu