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:
| Ograniczenie | Konsekwencja |
|---|---|
ref struct - musi żyć na stosie | Brak 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: Memorystruct, bez ref przed), więc:
- ✅ Może być polem klasy
- ✅ Może być lokalną zmienną w
asyncmetodzie - ✅ 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ętli | Memory<byte> z Span | |
|---|---|---|
| Bufor | nowy byte[1024] co iterację | jeden bufor re-używany |
| Alokacje na stercie | tysiące małych | 1 (na początku) |
ReadAsync zwraca | Task<int> (alokacja Task) | ValueTask<int> (zero gdy sync hit) |
| GC pressure | Wysoka | Niska |
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
usingalbo jawnyDisposena zwróconymIMemoryOwner<T>. Brak = wyciek bufora z puli. Rent(N)może zwrócić bufor większy niż N (np. 16384 dlaRent(8192)). UżywajMemory.Lengthświadomie albo własnego counter’a.- Po
Disposenie 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 returnsvoid, you must not use theMemory<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 aTask, you must not use theMemory<T>instance after theTasktransitions 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 theMemory<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 #
| Typ | Gdzie | Read-only? |
|---|---|---|
Span<T> | sync API, lokalne zmienne, parametry sync metod | nie |
ReadOnlySpan<T> | sync API, string.AsSpan(), immutable bufory | tak |
Memory<T> | async API, pola klas, długoterminowe bufory | nie |
ReadOnlyMemory<T> | async API, string.AsMemory(), immutable async | tak |
Decyzja w trzech krokach:
- Read-only? → dodaj
ReadOnlyprzed nazwą - W async, polu klasy, długim cyklu życia? →
Memory<T> - Synchronicznie, lokalnie, gorąca ścieżka? →
Span<T>
Konwersje:
Memory<T>→Span<T>:.Spanproperty (właściwość)Span<T>→Memory<T>: niemożliwe (brak informacji o ownership)T[]→Memory<T>:.AsMemory()T[]→Span<T>:.AsSpan()string→ReadOnlyMemory<char>:.AsMemory()string→ReadOnlySpan<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żywaawait - Wzorzec Memory + .Span - na granicach metod async Memory, wewnątrz synchronicznego fragmentu .Span dla wydajności
Stream.ReadAsync(Memory<byte>)- kanoniczny przykład; zwracaValueTask<int>dla bezalokacyjnego hot path; re-używany bufor w pętli czytaniaReadOnlyMemory<char>dla tekstów -string.AsMemory(); analogicznie doReadOnlySpan<char>IMemoryOwner<T>zSystem.Buffers- reprezentuje ownership bufora; implementujeIDisposableMemoryPool<T>.Shared.Rent- recykling buforów dla powtarzalnych operacji w async;usingna zwróconymIMemoryOwner- 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 odpowiednikfixeddla 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
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#.
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#.
Task kontra ValueTask w C# - kiedy struct zamiast referencji oszczędza GC
Optymalizacja asynchroniczności dla hot path - ValueTask jako struct unika alokacji Task na stercie, gdy operacja kończy się synchronicznie. Cztery żelazne zasady bezpieczeństwa, trade-offy, kiedy używać a kiedy stać przy zwykłym Task. Czternasta część kursu C#.
🔗 Linkują tu