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.