Span, Memory e gestione efficiente della memoria in C#
Apriamo la quarta serie, dedicata a prestazioni e produzione, affrontando la gestione efficiente della memoria. Fino a questo punto abbiamo dato per scontato il garbage collector, lasciando che si occupasse di allocare e liberare la memoria. Nelle applicazioni ad alte prestazioni, però, le allocazioni eccessive diventano un collo di bottiglia. I tipi Span<T> e Memory<T> permettono di lavorare su porzioni di memoria senza copiarle né allocarne di nuova, riducendo la pressione sul garbage collector. In questo articolo capiamo come funzionano e quando usarli.
Stack, heap e allocazioni
Come abbiamo visto nella prima serie, i tipi valore risiedono tipicamente nello stack, mentre gli oggetti dei tipi riferimento vivono nell'heap, gestito dal garbage collector. Ogni allocazione sull'heap ha un costo, e la successiva raccolta della memoria non più usata introduce pause che, sommate, incidono sulle prestazioni. Molte di queste allocazioni derivano da operazioni apparentemente innocue, come estrarre una sottostringa o creare un array temporaneo.
string data = "nome=Gabriele;eta=40";
// Ogni Substring alloca una nuova stringa sull'heap
string[] parts = data.Split(';');
string namePart = parts[0].Substring(5); // ulteriore allocazione
Il tipo Span
Uno Span<T> rappresenta una vista su una regione contigua di memoria, senza possederla né copiarla. Si può pensare a uno span come a una finestra su dati esistenti, che siano un array, una stringa o memoria allocata sullo stack. Poiché non alloca, permette di lavorare su porzioni di dati a costo quasi nullo.
int[] numbers = { 10, 20, 30, 40, 50 };
// Uno span sull'intero array, senza copie
Span<int> span = numbers;
// Una porzione dell'array: nessuna allocazione
Span<int> slice = span.Slice(1, 3); // elementi 20, 30, 40
// Le modifiche si riflettono sull'array originale
slice[0] = 99;
Console.WriteLine(numbers[1]); // Stampa 99
Lo slicing è l'operazione chiave: Slice produce un nuovo span che punta a una sotto-regione degli stessi dati, senza copiare nulla. Questo è profondamente diverso da Substring, che invece alloca una nuova stringa.
ReadOnlySpan e le stringhe
Per i dati che non devono essere modificati esiste ReadOnlySpan<T>. È particolarmente utile con le stringhe, che sono immutabili: ReadOnlySpan<char> consente di analizzare il testo estraendo porzioni senza allocare nuove stringhe, un guadagno significativo nei parser e nell'elaborazione testuale intensiva.
ReadOnlySpan<char> text = "nome=Gabriele";
int separator = text.IndexOf('=');
// Le due porzioni sono viste sulla stringa originale, senza allocazioni
ReadOnlySpan<char> key = text.Slice(0, separator);
ReadOnlySpan<char> value = text.Slice(separator + 1);
Console.WriteLine(value.ToString()); // "Gabriele"
Molti metodi della libreria standard, come int.Parse, accettano ormai un ReadOnlySpan<char>, permettendo di convertire una porzione di testo direttamente, senza passare per una stringa intermedia.
Allocare sullo stack con stackalloc
Per buffer piccoli e di breve durata, stackalloc alloca memoria direttamente sullo stack, evitando del tutto l'heap e il garbage collector. Combinato con Span<T>, offre un modo sicuro di usare questa memoria temporanea. Va riservato a dimensioni contenute, perché lo stack è limitato.
// Buffer temporaneo sullo stack, nessuna allocazione sull'heap
Span<int> buffer = stackalloc int[10];
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = i * i;
}
I vincoli dello Span
Lo Span<T> è un tipo speciale, un ref struct, soggetto a vincoli precisi che ne garantiscono la sicurezza. Poiché può puntare a memoria sullo stack, non può essere memorizzato sull'heap: non può essere un campo di una classe, non può essere catturato da una lambda e non può essere usato attraverso i confini di un metodo asincrono. Queste restrizioni sono ciò che rende lo span sicuro e a costo zero.
Il tipo Memory
Proprio perché lo Span<T> non può attraversare i confini asincroni, esiste Memory<T>. A differenza dello span, può risiedere sull'heap ed essere memorizzato in campi o usato in metodi async. Rappresenta anch'esso una vista su una regione di memoria, ma senza i vincoli dello stack, al prezzo di una minore efficienza. Da un Memory<T> si ottiene uno Span<T> tramite la proprietà Span quando serve elaborarlo.
static async Task ProcessAsync(Memory<byte> buffer)
{
// Memory puo essere usato attraverso await, a differenza di Span
await SomeAsyncOperation();
// Si ottiene lo Span solo quando serve elaborare i dati
Span<byte> span = buffer.Span;
span[0] = 1;
}
Un esempio pratico
Mettiamo insieme i concetti in un piccolo parser che somma numeri separati da virgola, senza allocare stringhe intermedie. Rispetto all'approccio con Split e Substring, questa versione non produce alcuna allocazione durante l'analisi.
static int SumCsv(ReadOnlySpan<char> input)
{
int total = 0;
while (!input.IsEmpty)
{
int comma = input.IndexOf(',');
// Determina la porzione corrente senza allocare
ReadOnlySpan<char> part = comma == -1 ? input : input.Slice(0, comma);
total += int.Parse(part);
// Avanza oltre la virgola, o termina
input = comma == -1 ? ReadOnlySpan<char>.Empty : input.Slice(comma + 1);
}
return total;
}
Console.WriteLine(SumCsv("10,20,30,40")); // Stampa 100
Quando usare questi tipi
Span e Memory non vanno usati ovunque: introducono complessità e vincoli che nella maggior parte del codice non ripagano. Sono giustificati nei punti caldi delle prestazioni, dove il profiling ha rivelato che le allocazioni sono un problema: parser, serializzatori, elaborazione di grandi volumi di dati, codice di rete a basso livello. Nel codice applicativo ordinario, la chiarezza resta prioritaria rispetto a queste micro-ottimizzazioni.
Conclusione
Abbiamo esplorato Span<T> e ReadOnlySpan<T> come viste a costo zero su regioni di memoria, capaci di eseguire slicing senza allocazioni, insieme a stackalloc per i buffer temporanei sullo stack e a Memory<T> per gli scenari asincroni. Abbiamo compreso i vincoli che rendono lo span sicuro e in quali situazioni queste tecniche portano benefici concreti. Nel prossimo articolo torneremo alle funzionalità del linguaggio con il pattern matching avanzato, che rende il codice più espressivo e dichiarativo.