Una tabella decisionale pratica per C# async / await - Task.Run e ConfigureAwait

· Aggiornato il: · · C#, async/await, .NET, Progettazione

Usiamo async / await di C# ogni giorno, ma ciò che infastidisce le persone nella pratica non è la sintassi stessa: è quale modello scegliere in quale situazione. Le domande che le persone cercano maggiormente sono domande decisionali: quando utilizzare Task.Run, dove inserire ConfigureAwait(false) e se fire-and-forget dovrebbe essere consentito.

  • Avvolgimento di un’attesa I / O in Task.Run
  • await-eseguire operazioni indipendenti una alla volta, in serie
  • Aggiunta casuale di fire-and-forget e perdita di traccia delle eccezioni e dei tempi di spegnimento
  • Spruzzare ConfigureAwait(false) ovunque indiscriminatamente
  • Scegliendo ValueTask semplicemente perché “sembra leggero”

Invece di memorizzare ciascuno di questi individualmente, andrai fuori strada meno se inizi a identificare che tipo di lavoro è.

In questo articolo, presupponendo principalmente lo sviluppo generale di applicazioni C# / .NET su .NET 6 e versioni successive, presentiamo i modelli async / await nell’ordine che rende le decisioni più facili.

I tipi di sviluppo che abbiamo in mente includono:

  • App desktop come WinForms / WPF
  • ASP.NET Core applicazioni web / API
  • Lavoratori / servizi di fondo
  • App della console
  • Librerie di classi riutilizzabili

Il codice in questo articolo è pubblicato su GitHub come set di esempi completo compilabile ed eseguibile (una libreria, una demo della console e test unitari che verificano ogni modello nella tabella decisionale).

csharp-async-attende-le-migliori-pratiche - komurasoft-blog-samples (GitHub)

Sommario

  1. Prima la conclusione (in una riga)
  2. Termini utilizzati in questo articolo
    • 2.1. Termini da distinguere per primi
    • 2.2. Termini che appaiono frequentemente
  3. La tabella decisionale da considerare per prima
    • 3.1. Il quadro generale
    • 3.2. Per le attese I / O, attendere direttamente l’API asincrona
    • 3.3. Per lavori pesanti sulla CPU, scegli dove utilizzare Task.Run
    • 3.4. Per più operazioni indipendenti, Task.WhenAll
    • 3.5. Per utilizzare per prima la finitura, Task.WhenAny
    • 3.6. Per molti articoli con parallelismo limitato, Parallel.ForEachAsync o SemaphoreSlim
    • 3.7. Per elaborare in ordine, Channel<T>
    • 3.8. Per correre a intervalli fissi, PeriodicTimer
    • 3.9. Per i dati che arrivano in modo incrementale, IAsyncEnumerable<T>
    • 3.10. Per lo smaltimento asincrono, attendere l’utilizzo
    • 3.11. Per la reciproca esclusione in attesa, SemaphoreSlim
    • 3.12. Scrivi wait Differently in UI / Codice app / Librerie
  4. Regole fondamentali di scrittura
    • 4.1. Restituisci attività/attività<T> Primo
    • 4.2. async void Solo per gestori eventi
    • 4.3. Accetta un CancellationToken e passalo a valle
    • 4.4. Mantieni asincrono API asincrono fino in fondo
    • 4.5. Quando crei attività con LINQ, materializza con ToArray / ToList
  5. Anti-pattern comuni
  6. Una lista di controllo per la revisione del codice
  7. Una guida approssimativa alla scelta
  8. Conclusione
  9. Riferimenti

1. Prima la conclusione (in una riga)

  • async / await è un modo di scrivere codice in modo che i thread non vengano bloccati durante l’attesa - non un meccanismo che accelera automaticamente tutto o sposta magicamente il lavoro su un altro thread
  • Determinare innanzitutto se il lavoro è un attesa I / O o un calcolo CPU
  • Per le attese I / O, la mossa di base è attendere direttamente l’API asincrona
  • Per il lavoro della CPU, pensa a dove dovrebbe essere eseguito il calcolo. Task.Run può aiutare nel codice dell’UI, ma nell’elaborazione delle richieste ASP.NET Core, avvolgere il lavoro in Task.Run e attenderlo immediatamente dovrebbe generalmente essere evitato
  • Per più operazioni indipendenti, considerare Task.WhenAll prima di attenderle in serie
  • Con molti oggetti, non attivare tutto in una volta tramite Task.WhenAll - decidi un limite al parallelismo
  • fire-and-forget sembra facile ma è difficile da gestire. Se hai davvero bisogno di separare la vita di un lavoro dal chiamante, consegnalo a un luogo gestito come un canale o un HostedService: è più stabile
  • Per i tipi di reso, inizia con Task / Task<T>. Scegliere ValueTask solo dopo che la misurazione ne dimostra la necessità
  • ConfigureAwait(false) è un’opzione efficace nel codice di libreria per uso generale, ma il semplice await va bene nell’UI e nel codice lato applicazione
  • async void non viene mai utilizzato al di fuori dei gestori di eventi

In breve, la cosa più importante intorno a async / await è non rientrare in “Task.Run per impostazione predefinita”, “spara e dimentica per impostazione predefinita” o “ValueTask per impostazione predefinita.”

Inizia con:

  1. Cosa aspetta effettivamente questa operazione?
  2. Chi possiede la durata di questa operazione?
  3. Dove viene controllata la concorrenza?

Guardare questi tre riduce notevolmente l’esitazione.

2. Termini utilizzati in questo articolo

2.1. Termini da distinguere per primi

Separare questi due all’inizio elimina gran parte della confusione.

Termine Significato qui
Destinato a I / O Lavoro incentrato su in attesa di completamento esterno - HTTP, DB, file, socket
Vincolato alla CPU Il lavoro era incentrato sul calcolo della CPU stessa: compressione, elaborazione delle immagini, hashing, trasformazioni pesanti

async / await brilla per le attese I / O, dove il thread può essere restituito ad altro lavoro durante l’attesa. Il calcolo della CPU, al contrario, è tempo effettivamente impiegato nell’elaborazione anziché nell’attesa, quindi le domande diventano su quale thread viene eseguito e come viene deciso il grado di parallelismo.

2.2. Termini che appaiono frequentemente

Termine Significato qui
Blocco Continuo ad occupare un thread in attesa del completamento
fire-and-forget Inizio del lavoro senza che il chiamante ne attenda il completamento
SynchronizationContext Il meccanismo per “ritornare alla posizione di esecuzione originale”, ad es. nell’UI
Contropressione Un meccanismo che fa attendere lo scrittore quando l’input arriva troppo velocemente, impedendo una crescita illimitata

Il punto particolarmente importante è che asincronia e parallelismo sono cose diverse.

  • Asincronia: su come aspetti
  • Parallelismo: fare le cose allo stesso tempo

Quando questi due si confondono, inizi a volere Task.Run ovunque. Questo è il primo bivio sulla strada.

3. La tabella decisionale da considerare per prima

3.1. Il quadro generale

Inizia con questa tabella e la direzione generale di solito andrà a posto.

Situazione Raggiungi per primo Cosa guardare
In attesa di HTTP / DB / files await l’API asincrona direttamente Non avvolgere in Task.Run
Calcoli pesanti che non devono congelare l’UI Task.Run Spostare il lavoro della CPU dal thread dell’UI
ASP.NET Core elaborazione richiesta Semplice await Non Task.Run e attendi immediatamente
Alcune operazioni asincrone indipendenti Task.WhenAll Iniziamo prima tutto, poi aspettiamo insieme
Utilizzare solo quello che termina per primo Task.WhenAny Pensa a cancellare il resto e raccogliere eccezioni
Molti articoli, hanno bisogno di un berretto Parallel.ForEachAsync / SemaphoreSlim Rendi esplicito il parallelismo
Lavoro in background elaborato in ordine Channel<T> Pensa alle code delimitate e alla contropressione
Lavoro asincrono a intervalli fissi PeriodicTimer Mantieni un timer, un consumatore
Risultati del processo poco a poco IAsyncEnumerable<T> / await foreach Procedi senza aspettare tutto
Necessario lo smaltimento asincrono await using Utilizzare IAsyncDisposable
La reciproca esclusione attende SemaphoreSlim.WaitAsync Sempre Release in try / finally
Codice libreria per uso generale Consideriamo ConfigureAwait(false) Evitare di dipendere dall’UI / dai contesti specifici dell’app
NOEvento / desktop dell'UIASP.NET Core richiestaLavoratore / fondoNOAspetta che finisca tuttoUsa quello che finisce per primoMolti articoliProcesso in ordineIntervallo fissoFlusso sequenzialeIl lavoro che vuoi fareIn attesa di I / O esterno?attendere direttamente l'API asincronaCalcoli pesanti della CPU?Dove corre?Considera Task.RunNon eseguire il wrapper in Task.RunSe necessario, sposta su un lavoratore o una codaCorri sul posto orendi esplicito il parallelismoGestire più lavori?Task.WhenAllTask.WhenAnyParallel.ForEachAsynco SemaphoreSlimChannel&lt;T&gt;PeriodicTimerIAsyncEnumerable&lt;T&gt;

Di seguito, esaminiamo ciascun modello a turno.

3.2. Per le attese I / O, attendere direttamente l’API asincrona

Questo è il modello più fondamentale.

Per HTTP, DB, letture / scritture di file e simili, controlla innanzitutto se esiste una versione asincrona dell’API. Se lo fa, la mossa base è await direttamente.

public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await File.ReadAllTextAsync(path, cancellationToken);
}

Ciò che vuoi evitare qui è avvolgere l’I / O già asincrono in Task.Run.

// Bad example
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await Task.Run(() => File.ReadAllTextAsync(path, cancellationToken), cancellationToken);
}

Questo semplicemente reinvia un’attesa I / O su un altro thread: confonde il codice senza alcun vantaggio.

  • Per le attese I / O, Task.Run non è necessario
  • Cerca prima un’API asincrona
  • Se ricevi un token, passalo direttamente a valle

Questo è un terreno ben battuto.

3.3. Per lavori pesanti sulla CPU, scegli dove utilizzare Task.Run

Task.Run ripaga quando vuoi spostare il calcolo della CPU dal thread corrente.

Ad esempio, l’esecuzione di un calcolo pesante direttamente in un gestore di eventi dell’UI blocca lo schermo. In tal caso, Task.Run è la soluzione naturale.

public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken cancellationToken)
{
    return Task.Run(() =>
    {
        cancellationToken.ThrowIfCancellationRequested();

        using var sha256 = System.Security.Cryptography.SHA256.Create();
        byte[] current = data;

        for (int i = 0; i < repeat; i++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            current = sha256.ComputeHash(current);
        }

        return current;
    }, cancellationToken);
}

La domanda importante, però, è da dove chiami.

  • UI come WinForms / WPF: ci sono situazioni in cui Task.Run aiuta
  • Elaborazione della richiesta ASP.NET Core: generalmente evitare Task.Run seguito immediatamente da await
  • Lavoratore / elaborazione in background: esegui sul posto o progetta il grado di parallelismo

L’elaborazione delle richieste ASP.NET Core è già in esecuzione su ThreadPool, quindi l’inserimento di un livello di Task.Run e l’attesa immediata tende ad aggiungere nient’altro che una pianificazione aggiuntiva.

Quindi in ASP.NET Core, questo modo di pensare funziona meglio:

  • Per le attese I / O, semplice await
  • Per brevi interventi sulla CPU, eseguilo sul posto
  • Per un lavoro lungo o da disaccoppiare dalla durata della richiesta, consegnalo a una coda o HostedService

Tieni presente che quando chiami un’API che dispone solo di una versione di sincronizzazione dall’UI, puoi utilizzare Task.Run per la reattività dell’UI. Ma questo non è “I / O asincrono”: si tratta semplicemente di evitare il problema occupando un thread. Sul lato server, come in ASP.NET Core, questa via di fuga fondamentalmente non è scalabile.

3.4. Per più operazioni indipendenti, Task.WhenAll

Il codice che attende più operazioni asincrone indipendenti una alla volta viene visualizzato continuamente.

// Independent operations made serial
string a = await _httpClient.GetStringAsync(urlA, cancellationToken);
string b = await _httpClient.GetStringAsync(urlB, cancellationToken);
string c = await _httpClient.GetStringAsync(urlC, cancellationToken);

Se non dipendono l’uno dall’altro, è più naturale iniziarli tutti prima e aspettare insieme la fine.

public async Task<string[]> DownloadAllAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    Task<string>[] tasks = urls
        .Select(url => _httpClient.GetStringAsync(url, cancellationToken))
        .ToArray();

    return await Task.WhenAll(tasks);
}

La chiave è ToArray(). LINQ viene valutato pigramente, quindi dopo solo Select, potrebbe non essere stato ancora enumerato nulla. La materializzazione con ToArray() o ToList() garantisce che tutte le attività siano iniziate in quel momento.

Task 3Task 2Task 1CallerTask 3Task 2Task 1CallerStartStartStartawait Task.WhenAll(...)DoneDoneDone

Questo modello è adatto ai casi in cui:

  • Il numero di articoli è piccolo o moderato
  • Vuoi aspettarli tutti insieme
  • È accettabile eseguirli tutti contemporaneamente senza limite

Con molti elementi, è più sicuro mettere un limite al parallelismo, come nel punto 3.6 di seguito.

3.5. Per utilizzare per prima la finitura, Task.WhenAny

Ad esempio, quando si desidera utilizzare quello tra diversi mirror che risponde per primo, Task.WhenAny è la scelta chiara.

public async Task<byte[]> DownloadFromFirstMirrorAsync(
    IReadOnlyList<string> urls,
    CancellationToken cancellationToken)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    List<Task<byte[]>> pending = urls
        .Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
        .ToList();

    var failures = new List<Exception>();

    try
    {
        while (pending.Count > 0)
        {
            Task<byte[]> finished = await Task.WhenAny(pending);
            pending.Remove(finished);

            try
            {
                byte[] data = await finished;   // solo il successo arriva qui
                cts.Cancel();                   // dopo aver scelto il vincitore, ferma gli altri
                return data;
            }
            catch (Exception ex)
            {
                // Se è il chiamante ad aver annullato, non contarlo come fallimento del mirror.
                // Se passa qui, tutti i task terminano con OperationCanceledException;
                // metterli in failures darebbe un AggregateException, e l'interruzione/timeout
                // dell'utente verrebbe registrato/ritentato come "tutti i mirror sono falliti".
                cancellationToken.ThrowIfCancellationRequested();

                // Questo mirror è fallito, ma gli altri potrebbero ancora riuscire.
                failures.Add(ex);
            }
        }
    }
    finally
    {
        cts.Cancel();   // ferma eventuali download rimasti anche quando si esce per eccezione

        try
        {
            await Task.WhenAll(pending);
        }
        catch
        {
            // raccogli eccezioni di cancellazione o fallimento dei non-vincitori
        }
    }

    throw new AggregateException("All mirrors failed.", failures);
}

L’ordine qui è importante: il segnale di annullamento viene emesso solo “dopo che il vincitore è deciso”.

  • Task.WhenAny restituisce il primo task completato, non necessariamente il primo che ha avuto successo. Anche il mirror più veloce, se risponde 404 o con una connessione interrotta, viene restituito come “vincitore”.
  • Se cancelli prima di guardare il risultato, ti ritrovi ad aver fermato da solo gli altri mirror ancora vivi e a rialanciare l’eccezione del vincitore fallito. Questa è la modalità di guasto più grave, perché annulla tutto il motivo per cui avevi preparato più mirror.
  • Quindi estrai i task completati uno per uno e awaitali; solo dopo un successo cancelli gli altri. Se fallisce, rimuovi quel task dai candidati e aspetta il prossimo completamento.
  • Cancel() è solo una richiesta; non aspetta che l’altro si fermi. Per questo nel finally aspettiamo i restanti e osserviamo le eccezioni di cancellazione o fallimento. Senza questo, eccezioni non osservate rimarrebbero nei task.
  • Quando tutti falliscono, raccogli le singole eccezioni e lanci un AggregateException. Se lanci solo la prima, perdi “quali mirror sono falliti e come”.
  • La cancellazione da parte del chiamante non deve essere contata come fallimento. Quando cancellationToken si innesca, tutti i task terminano con OperationCanceledException; se le accumuli in failures, finiresti con un AggregateException e l’interruzione/timeout dell’utente verrebbe registrato/ritentato come “tutti i mirror sono falliti”. Chiamare ThrowIfCancellationRequested() all’inizio del catch fa sì che la cancellazione venga rilanciata come OperationCanceledException.

Task.WhenAny è utile, ma richiede un po’ più di progettazione rispetto a WhenAll. Resta chiaro se lo scegli solo quando “il primo è tutto ciò di cui ho bisogno”.

3.6. Per molti articoli con parallelismo limitato, Parallel.ForEachAsync o SemaphoreSlim

Task.WhenAll esegue tutte le attività che hai creato contemporaneamente. Pertanto, con un numero elevato di elementi, le connessioni HTTP, le connessioni DB, l’utilizzo della memoria e il carico sui servizi esterni si sovrappongono.

In questi casi, è più stabile decidere quanti corrono contemporaneamente.

Parallel.ForEachAsync rende questo intento molto leggibile.

public async Task DownloadAndSaveAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = 8,
        CancellationToken = cancellationToken
    };

    await Parallel.ForEachAsync(
        urls.Select((url, index) => (url, index)),
        options,
        async (item, token) =>
        {
            string html = await _httpClient.GetStringAsync(item.url, token);
            string path = Path.Combine("cache", $"{item.index}.html");
            await File.WriteAllTextAsync(path, html, token);
        });
}

Questo modello è adatto ai casi in cui:

  • Ci sono molti articoli
  • La lavorazione di ogni articolo è indipendente
  • Ma bisogna evitare di licenziarli tutti insieme

Se desideri un controllo più preciso, esiste anche l’approccio SemaphoreSlim - ad esempio, “al massimo 4 chiamate simultanee a questa particolare API esterna”.

Quindi:

  • Una manciata di oggetti → Task.WhenAll
  • Grandi volumi → Parallel.ForEachAsync o SemaphoreSlim

Con questa divisione, raramente si sbaglia molto.

3.7. Per elaborare in ordine, Channel<T>

A volte vuoi staccare il lavoro dal chiamante, lavoro che “non deve finire adesso, ma deve assolutamente essere elaborato”. Invio di e-mail, log di inoltro, post-elaborazione del webhook, conversione di file e così via.

Se li butti via con un Task.Run nudo, quanto segue diventa vago:

  • Dove vengono osservate le eccezioni?
  • Lo aspettiamo allo spegnimento?
  • Quanto accettiamo quando il volume cresce?

Questo tipo di lavoro è più semplice da gestire mettendolo in coda e facendolo elaborare in ordine da un consumatore dedicato.

NOproduttoreWriteAsyncC'è spazio in coda?Entra nel canaleAspetta finché c'è spazioconsumatore ReadAsyncattendere ed elaborare in ordine

Channel<T> ti consente di scrivere la forma produttore / consumatore in modo molto semplice.

public sealed class BackgroundTaskQueue
{
    private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
        Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
            new BoundedChannelOptions(100)
            {
                FullMode = BoundedChannelFullMode.Wait
            });

    public ValueTask EnqueueAsync(
        Func<CancellationToken, ValueTask> workItem,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(workItem);
        return _queue.Writer.WriteAsync(workItem, cancellationToken);
    }

    public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
        => _queue.Reader.ReadAsync(cancellationToken);
}

BoundedChannelFullMode.Wait in questo esempio significa fai attendere lo scrittore quando la coda è piena. Questa è la contropressione.

In ASP.NET Core, consumare tale coda in combinazione con BackgroundService è una forma chiara. Rispetto al “vero fuoco e dimentica”, questo gestisce le eccezioni, lo spegnimento, il parallelismo e i limiti in modo molto più elegante.

3.8. Per correre a intervalli fissi, PeriodicTimer

Per il lavoro asincrono a intervalli fissi, PeriodicTimer è altamente leggibile.

public async Task RunPeriodicAsync(CancellationToken cancellationToken)
{
    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));

    while (await timer.WaitForNextTickAsync(cancellationToken))
    {
        await RefreshCacheAsync(cancellationToken);
    }
}

Cosa c’è di buono in questo stile:

  • Il flusso è più facile da seguire rispetto ai timer in stile callback
  • Può essere scritto await-prima
  • CancellationToken funziona naturalmente allo spegnimento

Come avvertenza: PeriodicTimer viene utilizzato presupponendo che non si emettano più chiamate WaitForNextTickAsync simultanee contro un timer. E se il lavoro richiede più tempo del periodo, quel ritardo deve essere gestito come una questione di progettazione. Il timer non si parallelizzerà da solo per recuperare il ritardo.

3.9. Per i dati che arrivano in modo incrementale, IAsyncEnumerable<T>

A volte è preferibile elaborare gli articoli non appena arrivano anziché accumulare prima tutto in un List<T>.

  • Lettura di un’API impaginata in sequenza
  • Lettura delle righe del file poco a poco
  • Passaggio diretto dei risultati dello streaming

Per questi, IAsyncEnumerable<T> e await foreach sono la soluzione naturale.

public async Task ProcessUsersAsync(CancellationToken cancellationToken)
{
    await foreach (User user in _userRepository.StreamUsersAsync(cancellationToken))
    {
        await ProcessUserAsync(user, cancellationToken);
    }
}

Questa forma è adatta ai casi in cui:

  • Non vuoi aspettare che tutto sia pronto
  • Desideri elaborare un elemento alla volta
  • Non vuoi tenere tutto in memoria

È più semplice decidere se restituire Task<List<T>> o IAsyncEnumerable<T> chiedendo: i risultati verranno utilizzati solo una volta completati o in ordine di arrivo?

3.10. Per lo smaltimento asincrono, attendere l’utilizzo

I tipi che necessitano di lavoro asincrono a disposizione - flushing, chiusura di connessioni - implementano IAsyncDisposable. In tal caso utilizzare await using invece di using.

public async Task WriteFileAsync(string path, byte[] data, CancellationToken cancellationToken)
{
    await using var stream = new FileStream(
        path,
        FileMode.Create,
        FileAccess.Write,
        FileShare.None,
        bufferSize: 81920,
        useAsync: true);

    await stream.WriteAsync(data, cancellationToken);
}

I punti sono:

  • Per IAsyncDisposable, utilizzare await using
  • L’“apertura” è sincrona mentre la “chiusura” è asincrona è del tutto normale

Ciò è utile quando si desidera evitare la mancata corrispondenza di “le scritture erano asincrone, ma l’eliminazione finale era sincrona”.

3.11. Per la reciproca esclusione in attesa, SemaphoreSlim

Nel codice che si estende su await, ci sono situazioni in cui SemaphoreSlim sostituisce lock.

public sealed class CacheRefresher
{
    private readonly SemaphoreSlim _gate = new(1, 1);

    public async Task RefreshAsync(CancellationToken cancellationToken)
    {
        await _gate.WaitAsync(cancellationToken);
        try
        {
            await RefreshCoreAsync(cancellationToken);
        }
        finally
        {
            _gate.Release();
        }
    }

    private static Task RefreshCoreAsync(CancellationToken cancellationToken)
        => Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}

Ciò che conta sono due cose:

  • Entra con WaitAsync
  • Chiama sempre Release in finally

Per “solo una alla volta” o “al massimo 3 chiamate simultanee a questa API esterna”, SemaphoreSlim è assolutamente pratico.

3.12. Tratta await in modo diverso in UI / codice app / librerie

ConfigureAwait(false) non è qualcosa da aggiungere quando e dove.

La spaccatura ampia è questa.

Codice dell'UI / appawait someAsync()Riprendere nel contesto originaleBiblioteca di uso generaleawait someAsync().ConfigureAwait(false)Nessuna ipotesi di ritorno a un contesto specifico
  • UI / codice app
    • Il semplice await va bene per cominciare
    • Se gli aggiornamenti dell’UI o il lavoro dipendente dal contesto dell’app seguono l’attesa, è più naturale non aggiungere ConfigureAwait(false)
  • ASP.NET Core codice app
    • Normalmente await è solitamente sufficiente
    • Non è necessario forzare ConfigureAwait(false) come regola generale
  • Codice libreria per uso generale
    • Se non dipende dall’UI o dai modelli di app, ConfigureAwait(false) è un’opzione valida

Quindi ricorda:

  • Codice lato app: semplice await
  • Librerie di uso generale: considerare ConfigureAwait(false)

e raramente ti troverai nei guai nella pratica.

4. Regole di scrittura di base

4.1. Restituisci Task/Task<T> Primo

Per i tipi restituiti dal metodo asincrono, pensare prima in questo ordine.

Tipo restituito Primo istinto
Task L’impostazione predefinita per i metodi asincroni che non restituiscono nulla
Task<T> L’impostazione predefinita per i metodi asincroni che restituiscono un valore
ValueTask / ValueTask<T> Scegliere solo dopo che la misurazione ne abbia evidenziato la necessità

ValueTask sembra attraente, ma non è sempre migliore di Task. È una struttura, quindi ha costi di copia e il suo utilizzo comporta dei vincoli.

Il punto particolarmente importante: un ValueTask fondamentalmente deve essere atteso esattamente una volta. Non è adatto per essere riposto con disinvoltura in un locale e atteso ripetutamente.

Quindi, per il codice delle applicazioni quotidiane, Task / Task<T> è sufficiente per iniziare.

Inoltre, aggiungere il suffisso Async ai nomi dei metodi mantiene le cose più chiare.

public Task SaveAsync(CancellationToken cancellationToken)
{
    return Task.CompletedTask;
}

public Task<int> CountAsync(CancellationToken cancellationToken)
{
    return Task.FromResult(_count);
}

Come sopra, se non c’è nulla in await, è più naturale restituire Task.CompletedTask o Task.FromResult piuttosto che forzare async nel metodo.

4.2. async void Solo per gestori eventi

La regola di base è evitare async void gestori di eventi esterni.

Il motivo è semplice:

  • Il chiamante non può aspettarlo
  • Non è possibile attendere il completamento
  • La gestione delle eccezioni diventa difficile
  • È difficile da testare

I gestori eventi sono l’unico posto che richiede void, quindi usalo solo lì.

private async void SaveButton_Click(object? sender, EventArgs e)
{
    try
    {
        await SaveAsync(_saveCancellation.Token);
        _statusLabel.Text = "Saved.";
    }
    catch (OperationCanceledException)
    {
        _statusLabel.Text = "Cancelled.";
    }
    catch (Exception ex)
    {
        MessageBox.Show(this, ex.Message, "Save error");
    }
}

Nei gestori di eventi, la mentalità che conta è: scrivi tu la parte che rileva le eccezioni all’interno e le riporta tu stesso all’UI.

4.3. Accetta un CancellationToken e passalo a valle

Per le operazioni annullabili, accetta un CancellationToken e passalo direttamente a valle.

public async Task<string> DownloadTextAsync(string url, CancellationToken cancellationToken)
{
    using HttpResponseMessage response = await _httpClient.GetAsync(url, cancellationToken);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync(cancellationToken);
}

Il fallimento comune qui è accettare un token al livello più alto ma non trasmetterlo a valle. Ciò tende a produrre codice che “sembra cancellabile, ma in realtà non si ferma a metà strada”.

Inoltre, i timeout significano cose diverse a seconda che tu voglia “limitare solo l’attesa” o “interrompere anche il lavoro effettivo”.

  • Un limite solo per l’attesa: WaitAsync
  • Interruzione anche del lavoro vero e proprio: CancellationTokenSource.CancelAfter più propagazione del token

Questa distinzione è una fonte frequente di bug successivi, quindi deciderla in anticipo mantiene le cose stabili.

4.4. Mantieni asincrono API asincrono fino in fondo

Se utilizzi async / await, è più naturale rimanere asincrono fino in fondo, ove possibile.

Guida approssimativa alla sostituzione:

Modello allettante Sostituisci con
Task.Result / Task.Wait() await
Task.WaitAll() await Task.WhenAll(...)
Task.WaitAny() await Task.WhenAny(...)
Thread.Sleep(...) await Task.Delay(...)

Soprattutto nell’UI e in ASP.NET Core, il mixaggio in attesa sincrona rende difficile ragionare sugli stalli.

Il moderno C# supporta async Task Main(), quindi anche nelle app per console non c’è motivo di forzare le cose in sincronia.

4.5. Quando crei attività con LINQ, materializza con ToArray / ToList

Quando si combina Task.WhenAll o Task.WhenAny con LINQ, è più sicuro materializzarsi una volta con ToArray() o ToList().

Task<User>[] tasks = userIds
    .Select(id => _userRepository.GetAsync(id, cancellationToken))
    .ToArray();

User[] users = await Task.WhenAll(tasks);

Il motivo è la pigra valutazione di LINQ. Leggere il codice partendo dal presupposto “tutto è già iniziato” quando non è stato ancora enumerato nulla è una trappola silenziosamente pericolosa.

  • Aspettando tutto insieme → ToArray()
  • Rimozione / sostituzione lungo il percorso → ToList()

Questa è una regola pratica facile da mantenere.

5. Anti-pattern comuni

Anti-modello Perché fa male Prima sostituzione
Task.Run(async () => await IoAsync()) Reinvia inutilmente un’attesa I / O await IoAsync()
Task.Result / Wait() Blocca il thread; incline alle bancarelle await
Miscelazione di Thread.Sleep() in un flusso asincrono Occupa il thread anche durante l’attesa Task.Delay()
async void sui metodi ordinari Non può essere atteso; eccezioni difficili da gestire Task / Task<T>
Attendere seriale a cui appartiene Task.WhenAll Inutilmente lento Avvia tutto, quindi WhenAll
Spara grandi volumi tramite WhenAll contemporaneamente Picchi di carico Parallel.ForEachAsync / SemaphoreSlim
Cercando di prolungare un’attesa con lock Non adatto allo scopo SemaphoreSlim.WaitAsync
fire-and-forget tramite nudo Task.Run Eccezioni, chiusura, limiti vaghi Channel<T> / BackgroundService
Aggiunta meccanica di ConfigureAwait(false) al codice dell’UI Aggiornamenti dell’UI dopo l’attesa si fermano facilmente Semplice await
Rendere ValueTask il valore predefinito La complessità raramente ripaga Task primo

Di questa tabella, le tre viste più spesso nel lavoro reale sono:

  1. Task.Run attorno all’I / O
  2. Attesa seriale di operazioni veramente indipendenti
  3. Spara e dimentica senza gestione della durata

Correggere solo questi tre migliora già notevolmente la chiarezza del codice.

6. Una lista di controllo per la revisione del codice

Nelle revisioni del codice async / await, procedi lungo questo elenco dall’alto.

  • Si può dire, a parole, se il lavoro è legato all’I / O o legato alla CPU?
  • Restano Task.Result / Task.Wait() / Thread.Sleep()?
  • Eventuali attese I / O racchiuse in Task.Run?
  • Ci sono operazioni indipendenti inutilmente attese in serie?
  • Al contrario, eventuali WhenAll illimitati su grandi volumi?
  • Se un CancellationToken viene accettato, viene passato correttamente a valle?
  • Eventuali async void gestori di eventi esterni?
  • Se esiste il “fire-and-forget”, viene deciso chi gestisce le eccezioni, l’arresto e i limiti?
  • Se viene utilizzato SemaphoreSlim, Release è all’interno di finally?
  • Se si utilizza ValueTask c’è una motivazione misurata e viene atteso una sola volta?
  • La presenza / assenza di ConfigureAwait(false) corrisponde al tipo di codice?
    • Codice UI / app: semplice await
    • Biblioteche di uso generale: considerare ConfigureAwait(false)

Questa lista di controllo è utile anche per allineare i criteri di revisione all’interno di un team.

7. Una guida approssimativa alla scelta

Cosa vuoi fare Raggiungi per primo
Un HTTP / DB / I / O file await l’API asincrona direttamente
Calcoli pesanti senza bloccare l’UI Task.Run
Alcune operazioni asincrone indipendenti Task.WhenAll
È necessario solo il primo risultato Task.WhenAny
Grandi volumi con tappo Parallel.ForEachAsync / SemaphoreSlim
Elaborazione in background ordinata Channel<T>
Esegui a intervalli fissi PeriodicTimer
Elabora un flusso sequenziale IAsyncEnumerable<T> / await foreach
La reciproca esclusione attende SemaphoreSlim
Biblioteca per uso generale Considera ConfigureAwait(false)
Incerto sul tipo di reso Task / Task<T> primo

8. Conclusione

La migliore pratica per async / await non riguarda tanto la memorizzazione di molte piccole tecniche e altro sul principio organizzativo di scegliere il modello in base al tipo di lavoro: questo è ciò che ripaga nella pratica.

L’ordine dell’esame è più o meno questo.

  1. Attese I / O separate dal calcolo della CPU
  2. Per I / O, await direttamente l’API asincrona
  3. Per il lavoro della CPU, decidere dove dovrebbe essere eseguito
  4. Per operazioni multiple, scegli WhenAll / WhenAny / parallelismo limitato
  5. Per staccarsi dalla durata della richiesta, metterla in coda invece di fare fuoco e dimenticare
  6. Allineare la gestione di tipi di reso, annullamento, eccezioni, esclusione e contesti

Poiché la sintassi async / await stessa è così concisa, un uso sciatto ne oscura l’intento. Al contrario:

  • Tratta I / O come I / O
  • Tratta la CPU come CPU
  • Trattare il lavoro in background come lavoro in background, con durate gestite

La semplice separazione di questi tre rende il codice notevolmente più leggibile.

9. Riferimenti

Articoli recenti con gli stessi tag per approfondire argomenti vicini.

Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.

L’articolo è direttamente collegato ai servizi seguenti.

Domande frequenti

Domande che ricorrono nelle consulenze sull’argomento dell’articolo.

Quando dovrei utilizzare Task.Run in C#?
Utilizza Task.Run quando desideri spostare i calcoli pesanti della CPU dal thread corrente, ad esempio eseguendo un calcolo pesante da un gestore eventi dell'UI in WinForms o WPF in modo che lo schermo non si blocchi. Non racchiudere le attese di I/O in Task.Run: per HTTP, operazioni su database o file, usa semplicemente `await` sull'API asincrona. Nell'elaborazione delle richieste ASP.NET Core, evita `Task.Run` seguito immediatamente da `await`, perché le richieste sono già in esecuzione su ThreadPool e l'invio extra non aggiunge altro che un sovraccarico di pianificazione.
Dove dovrei usare ConfigureAwait(false)?
ConfigureAwait(false) è un'opzione efficace nel codice della libreria per uso generico che non dipende dall'UI o dai contesti specifici dell'app. Nell'UI e nel codice lato applicazione, l'attesa semplice va bene: se gli aggiornamenti dell'UI o il lavoro dipendente dal contesto seguono l'attesa, è più naturale non aggiungerla. Nel codice dell'app ASP.NET Core, l'attesa semplice è in genere sufficiente e non è necessario forzare ConfigureAwait(false) come regola generale.
Il fuoco e dimentica con Task.Run è accettabile?
Sembra facile ma è difficile da gestire: dove vengono osservate le eccezioni, se si attende il lavoro alla chiusura e quanto si accetta quando il volume aumenta, tutto diventa vago. Se hai veramente bisogno di separare la vita di un lavoro dal chiamante, consegnalo a un luogo gestito come un Channel<T> limitato utilizzato da un lavoratore dedicato o un BackgroundService in ASP.NET Core. Ciò gestisce le eccezioni, l'arresto, il parallelismo e i limiti in modo molto più elegante di un semplice Task.Run.
Devo restituire Task o ValueTask dai metodi asincroni?
Inizia con Task e Task<T>. ValueTask non è sempre migliore: è una struttura con costi di copia, il suo utilizzo comporta dei vincoli ed è fondamentalmente pensato per essere atteso esattamente una volta, quindi non è adatto ad essere archiviato e atteso ripetutamente. Scegli ValueTask solo dopo che la misurazione ne dimostra la necessità. Se un metodo non ha nulla da attendere, restituire Task.CompletedTask o Task.FromResult è più naturale che forzare l'asincrono su di esso.
Come posso limitare il parallelismo durante l'elaborazione di molte operazioni asincrone?
Task.WhenAll avvia tutte le attività contemporaneamente, quindi con molti elementi le connessioni HTTP, le connessioni DB, l'utilizzo della memoria e il carico sui servizi esterni saltano tutti insieme. Per volumi di grandi dimensioni, utilizza Parallel.ForEachAsync con MaxDegreeOfParallelism per rendere esplicito il limite oppure SemaphoreSlim per un controllo più preciso, ad esempio limitando le chiamate simultanee a un'API esterna specifica. La regola pratica: una manciata di elementi, Task.WhenAll; grandi volumi, parallelismo limitato.

Profilo dell’autore

Pagina di presentazione dell’autore dell’articolo.

Go Komura

Rappresentante di KomuraSoft LLC

Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.

Torna al blog