In .NET il garbage collector (GC) gestisce la memoria in modo automatico, evitando memory leak e semplificando la vita agli sviluppatori. Tuttavia, in applicazioni ad alte prestazioni (real-time, networking, finanza, AI, elaborazione di dati massivi), il GC può diventare un collo di bottiglia.
Per rispondere a questa esigenza, Microsoft ha introdotto strumenti come Span<T>, Memory<T> e stackalloc: scrivere C# ad alte prestazioni senza GC overhead non è più un sogno, ma una pratica concreta.
In questo articolo vedremo:
- differenze tra tipi di dati che creano copie in memoria e tipi che lavorano su riferimenti,
- esempi reali con confronto versione tradizionale vs versione ottimizzata,
- scenari applicativi concreti,
- best practice per usare questi strumenti senza rischi.
Tipi di dati e copie in memoria
Per capire il valore di Span<T>
, Memory<T>
e stackalloc
è fondamentale distinguere tra tipi che creano copie e tipi che lavorano per riferimento.
- Value type (int, double, struct senza reference): copiati per valore. Ogni assegnazione crea una nuova copia in memoria.
- Reference type (class, array, string, object): copiati per riferimento, ma con semantica diversa.
Il caso particolare sono le stringhe:
- sono reference type, ma immutabili.
- ogni operazione di slicing, concatenazione o
Split()
crea nuove stringhe in memoria. - con grandi quantità di dati, questo porta a un overhead enorme sul GC.
Ed è qui che entra in gioco Span<T>
, che permette di lavorare su segmenti di memoria esistenti senza creare copie.
Span<T>
: parsing di file di log
Versione tradizionale
string line = "2025-09-17 INFO User logged in";
// Estrazione con Split()
string[] parts = line.Split(' ');
string timestamp = parts[0]; // nuova stringa allocata
string level = parts[1]; // nuova stringa allocata
- Allocazioni: array + 2 nuove stringhe.
- Problema: in un file da milioni di righe, il GC passa più tempo a liberare memoria che a lasciare CPU libera per il parsing.
Versione ottimizzata con Span<T>
ReadOnlySpan<char> line = "2025-09-17 INFO User logged in".AsSpan();
// Nessuna nuova stringa
var timestamp = line[..10]; // slice su memoria esistente
var level = line.Slice(11, 4); // slice su memoria esistente
- Allocazioni: zero.
- I dati non vengono copiati, ma solo referenziati con un “puntatore sicuro”.
Risultato: parsing più veloce, riduzione drastica della pressione sul GC.
Memory<T>
: networking asincrono
Versione tradizionale
var buffer = new byte[4096]; // nuova allocazione ogni volta
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
ProcessBuffer(buffer.Take(bytesRead).ToArray()); // altra allocazione
- Allocazioni: un array a ogni ciclo + ToArray().
- Problema: in server che gestiscono migliaia di connessioni, questo porta a consumo di memoria e pause GC.
Versione ottimizzata con Memory<T>
Memory<byte> buffer = new byte[4096];
int bytesRead = await stream.ReadAsync(buffer);
ProcessBuffer(buffer.Span[..bytesRead]);
- Allocazioni: una sola, il buffer viene riutilizzato.
- Vantaggio:
Memory<T>
può essere passato ad API asincrone e usato in oggetti persistenti, a differenza diSpan<T>
.
Risultato: riduzione delle allocazioni, throughput maggiore, GC meno stressato.
stackalloc
: crittografia e hashing
Versione tradizionale
byte[] temp = new byte[64]; // heap allocation
using var sha256 = SHA256.Create();
byte[] hash = sha256.ComputeHash(data.Concat(temp).ToArray());
- Allocazioni: array temporanei.
- Problema: in loop di calcolo intensivo (es. hashing multiplo) il GC accumula overhead inutile.
Versione ottimizzata con stackalloc
Span<byte> temp = stackalloc byte[64];
using var sha256 = SHA256.Create();
sha256.TryComputeHash(data, temp, out int written);
- Allocazioni: nessuna (memoria sullo stack).
- Lo stack viene liberato automaticamente a fine metodo.
Risultato: performance estreme, ideale per scenari critici come sicurezza, dispositivi embedded e real-time.
Scenari reali dove fanno la differenza
Ecco una serie di scenari dove avere un codice C# ad alte prestazioni senza GC overhead fa la differenza:
- Parsing massivo di dati testuali
- Differenza tra
Split()
(allocazioni multiple) eSpan<T>
(slicing senza copie).
- Differenza tra
- Networking ad alta frequenza
- Usare
Memory<T>
al posto di array sempre nuovi: meno frammentazione, prestazioni costanti.
- Usare
- Elaborazione immagini/audio
- Operare su buffer binari senza duplicare dati temporanei.
- Finanza e trading real-time
- Eliminare pause GC in applicazioni che richiedono latenze prevedibili.
- Machine learning
Span<float>
+ SIMD per manipolare array numerici senza copie intermedie.
Best practice
- Usa
Span<T>
per slicing e operazioni veloci in metodi sincroni. - Usa
Memory<T>
per buffer asincroni e scenari a lungo termine. - Usa
stackalloc
solo per buffer piccoli (<1KB) per non saturare lo stack. - Combina con
ArrayPool<T>
oMemoryPool<T>
per riutilizzare buffer e ridurre allocazioni. - Non sacrificare la leggibilità: questi strumenti vanno usati dove servono, non ovunque.
FAQ
1. Qual è la differenza tra tipi che creano copie e tipi che non lo fanno?
Le stringhe, essendo immutabili, creano sempre nuove istanze. Span invece lavora come vista sulla memoria già esistente, evitando copie.
2. Perché Span<T>
è più veloce di Split()
?
Perché non crea array o stringhe nuove: fa solo slicing su memoria esistente.
3. Posso sostituire sempre array e stringhe con Span<T>?
No, Span non possiede i dati. È un wrapper temporaneo: utile per performance, non per sostituire strutture dati permanenti.
4. stackalloc è sicuro?
Sì, se usato con buffer piccoli. Allocare grandi quantità sullo stack può causare stack overflow.
5. In quali scenari Memory<T> è obbligatorio?
In tutti i contesti asincroni o quando serve conservare un riferimento a lungo termine a una porzione di memoria.
Link esterni di approfondimento
- Microsoft Docs – Span<T>
- Microsoft Docs – Memory<T>
- High-performance .NET: Memory and spans
- System.Buffers: ArrayPool e MemoryPool
- stackalloc keyword (C# Reference)
Hai bisogno di una consulenza sullo sviluppo di applicazioni in C# contattami a questo link.