task vs value task

Task vs ValueTask vs IAsyncEnumerable: scegliere il giusto strumento per la concorrenza asincrona

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

CaratteristicaTaskValueTaskIAsyncEnumerable
TipoRiferimentoStruct (by value)Stream asincrono
AllocazioneSempreSolo se necessarioIterazioni lazy
RiusabilitàAltaNoIterabile
Scenario idealeOperazioni standard I/OOperazioni spesso sincroneStreaming dati
IntroduzioneC# 5.0 / .NET 4.5.NET Core 2.1C# 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):

TipoAllocazioniTempo medio (ns)
Task100k580
ValueTask~0370

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

ScenarioScelta consigliata
Chiamate HTTP, I/O, databaseTask
Operazioni di cache, fast pathValueTask
Stream di dati, enumerazioni asincroneIAsyncEnumerable
Non sai quale usareTask (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.


Approfondimenti esterni


Pubblicato

in

da

Tag: