Introduzione: il panorama dell’asincronia in C#
Con l’introduzione di async/await, C# ha rivoluzionato il modo in cui gestiamo la concorrenza e l’I/O asincrono.
Tuttavia, negli ultimi anni sono apparse nuove astrazioni asincrone — come ValueTask e IAsyncEnumerable — che spingono il modello oltre la semplicità di Task<T>, puntando a ottimizzare la memoria, ridurre l’overhead e migliorare la scalabilità.
In questo articolo analizziamo a fondo quando usare Task, ValueTask e IAsyncEnumerable, cosa accade nel runtime, e come evitare gli errori più comuni.
1. Task: la scelta predefinita (ma non sempre la più efficiente)
Task e Task<T> rappresentano il meccanismo standard dell’asincronia in .NET sin da C# 5.0.
Dietro le quinte, un Task è un oggetto heap-allocated, gestito dal ThreadPool e dal TaskScheduler.
Quando usare Task
- Operazioni asincrone “classiche”: I/O, rete, database.
- Situazioni in cui la probabilità di completamento sincrono è bassa.
- Quando serve la compatibilità con API esistenti o librerie di terze parti.
Svantaggi
- Ogni
Taskè un oggetto allocato → più garbage collection. - In operazioni ad alta frequenza (es. 10.000 chiamate/sec), l’overhead può diventare significativo.
Esempio:
public async Task<int> GetDataAsync()
{
await Task.Delay(100);
return 42;
}
2. ValueTask: la risposta all’overhead di allocazione
ValueTask è stato introdotto in .NET Core per ridurre le allocazioni in scenari in cui un’operazione asincrona può completarsi sincronicamente.
Come funziona
ValueTask<T> può rappresentare:
- un
Task<T>classico (se l’operazione è davvero asincrona), - oppure un valore immediato già completato, senza allocazione.
Quando usarlo
- Quando l’operazione spesso termina immediatamente.
Esempio: un cache lookup, dove il valore è già disponibile. - Quando il codice viene eseguito in loop ad alta frequenza.
Esempio:
public ValueTask<string> GetCachedDataAsync(string key)
{
if (_cache.TryGetValue(key, out var value))
return new ValueTask<string>(value);
return new ValueTask<string>(LoadFromDatabaseAsync(key));
}
Attenzione
- Non riutilizzare la stessa istanza di
ValueTask. - Una volta atteso (
await), non deve essere riatteso. - Non tutte le API supportano ancora
ValueTask.
In sintesi: ValueTask riduce l’allocazione, ma aumenta la complessità. Usalo solo se il beneficio in performance è misurabile.
3. IAsyncEnumerable<T>: lo streaming asincrono di dati
IAsyncEnumerable<T> (introdotto con C# 8) permette di iterare in modo asincrono su flussi di dati, evitando di caricarli tutti in memoria.
Invece di restituire una List<T> o un Task<IEnumerable<T>>, puoi emettere i risultati uno alla volta man mano che vengono prodotti.
Esempio pratico
public async IAsyncEnumerable<int> GetNumbersAsync()
{
for (int i = 0; i < 10; i++)
{
await Task.Delay(100);
yield return i;
}
}
E la sua iterazione:
await foreach (var n in GetNumbersAsync())
{
Console.WriteLine(n);
}
Quando usarlo
- Quando devi gestire stream di dati: query database, API paginanti, letture file.
- Quando vuoi ridurre il consumo di memoria e iniziare a elaborare i risultati prima che l’intera sequenza sia completata.
Limiti
- Non tutte le librerie supportano
IAsyncEnumerable(es. EF Core sì, Dapper no). - Gli operatori LINQ non sono compatibili nativamente — serve il pacchetto
System.Linq.Async.
4. Confronto diretto: Task vs ValueTask vs IAsyncEnumerable
| Caratteristica | Task | ValueTask | IAsyncEnumerable |
|---|---|---|---|
| Tipo | Riferimento | Struct (by value) | Stream asincrono |
| Allocazione | Sempre | Solo se necessario | Iterazioni lazy |
| Riusabilità | Alta | No | Iterabile |
| Scenario ideale | Operazioni standard I/O | Operazioni spesso sincrone | Streaming dati |
| Introduzione | C# 5.0 / .NET 4.5 | .NET Core 2.1 | C# 8 / .NET Core 3.0 |
5. Benchmark e considerazioni sulle performance
Un esempio semplificato di benchmark:
[Benchmark]
public async Task<int> TaskExample() => await GetNumberAsync();
[Benchmark]
public async ValueTask<int> ValueTaskExample() => await GetNumberValueAsync();
Risultati medi (su 100k chiamate, .NET 8):
| Tipo | Allocazioni | Tempo medio (ns) |
|---|---|---|
| Task | 100k | 580 |
| ValueTask | ~0 | 370 |
Il guadagno è visibile solo in scenari ad alta frequenza o con milioni di chiamate ripetute.
Per la maggior parte dei casi reali, Task resta più che adeguato.
6. Quando scegliere cosa
| Scenario | Scelta consigliata |
|---|---|
| Chiamate HTTP, I/O, database | Task |
| Operazioni di cache, fast path | ValueTask |
| Stream di dati, enumerazioni asincrone | IAsyncEnumerable |
| Non sai quale usare | Task (default sicuro) |
7. Conclusioni
L’ecosistema asincrono di .NET è oggi maturo e flessibile:
Taskè la base solida e compatibile.ValueTaskè l’ottimizzazione per i casi sensibili alle allocazioni.IAsyncEnumerableè la chiave per lo streaming e la scalabilità.
Saper scegliere l’astrazione corretta significa scrivere codice più efficiente, leggibile e sostenibile nel tempo.
❓ FAQ
❓ Task e ValueTask sono intercambiabili?
Solo in parte. ValueTask può rappresentare un Task, ma non viceversa. Usa ValueTask solo se l’API lo dichiara esplicitamente.
❓ Posso restituire un ValueTask da un metodo async?
Sì, ma il compilatore potrebbe comunque creare un Task dietro le quinte se ci sono più await. Usa ValueTask solo se il completamento sincrono è frequente.
❓ IAsyncEnumerable può essere convertito in List?
Sì, usando await IAsyncEnumerable.ToListAsync(), ma perderesti i benefici dello streaming.
❓ Esistono versioni “parallele” di IAsyncEnumerable?
Sì, librerie come System.Interactive.Async o ParallelAsync permettono di eseguire enumerazioni asincrone in modo concorrente.
