Una tabella decisionale pratica per C# async / await - Task.Run e ConfigureAwait
· Aggiornato il: · Go Komura · 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-forgete perdita di traccia delle eccezioni e dei tempi di spegnimento - Spruzzare
ConfigureAwait(false)ovunque indiscriminatamente - Scegliendo
ValueTasksemplicemente 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
- Prima la conclusione (in una riga)
- Termini utilizzati in questo articolo
- 2.1. Termini da distinguere per primi
- 2.2. Termini che appaiono frequentemente
- 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
- 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
- Anti-pattern comuni
- Una lista di controllo per la revisione del codice
- Una guida approssimativa alla scelta
- Conclusione
- 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.Runpuò aiutare nel codice dell’UI, ma nell’elaborazione delle richieste ASP.NET Core, avvolgere il lavoro inTask.Rune attenderlo immediatamente dovrebbe generalmente essere evitato - Per più operazioni indipendenti, considerare
Task.WhenAllprima di attenderle in serie - Con molti oggetti, non attivare tutto in una volta tramite
Task.WhenAll- decidi un limite al parallelismo fire-and-forgetsembra 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>. ScegliereValueTasksolo dopo che la misurazione ne dimostra la necessità ConfigureAwait(false)è un’opzione efficace nel codice di libreria per uso generale, ma il sempliceawaitva bene nell’UI e nel codice lato applicazioneasync voidnon 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:
- Cosa aspetta effettivamente questa operazione?
- Chi possiede la durata di questa operazione?
- 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 |
flowchart TD
start["Il lavoro che vuoi fare"] --> q1{"In attesa di I / O esterno?"}
q1 -- "SÌ" --> p1["attendere direttamente l'API asincrona"]
q1 -- "NO" --> q2{"Calcoli pesanti della CPU?"}
q2 -- "SÌ" --> q3{"Dove corre?"}
q3 -- "Evento / desktop dell'UI" --> p2["Considera Task.Run"]
q3 -- "ASP.NET Core richiesta" --> p3["Non eseguire il wrapper in Task.Run<br/>Se necessario, sposta su un lavoratore o una coda"]
q3 -- "Lavoratore / fondo" --> p4["Corri sul posto o<br/>rendi esplicito il parallelismo"]
q2 -- "NO" --> q4{"Gestire più lavori?"}
q4 -- "Aspetta che finisca tutto" --> p5["Task.WhenAll"]
q4 -- "Usa quello che finisce per primo" --> p6["Task.WhenAny"]
q4 -- "Molti articoli" --> p7["Parallel.ForEachAsync<br/>o SemaphoreSlim"]
q4 -- "Processo in ordine" --> p8["Channel<T>"]
q4 -- "Intervallo fisso" --> p9["PeriodicTimer"]
q4 -- "Flusso sequenziale" --> p10["IAsyncEnumerable<T>"]
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.Runnon è 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.Runaiuta - Elaborazione della richiesta ASP.NET Core: generalmente evitare
Task.Runseguito immediatamente daawait - 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.
sequenceDiagram
participant Caller as Caller
participant T1 as Task 1
participant T2 as Task 2
participant T3 as Task 3
Caller->>T1: Start
Caller->>T2: Start
Caller->>T3: Start
Caller->>Caller: await Task.WhenAll(...)
T1-->>Caller: Done
T2-->>Caller: Done
T3-->>Caller: Done
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.WhenAnyrestituisce 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 nelfinallyaspettiamo 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
cancellationTokensi innesca, tutti i task terminano conOperationCanceledException; se le accumuli infailures, finiresti con unAggregateExceptione l’interruzione/timeout dell’utente verrebbe registrato/ritentato come “tutti i mirror sono falliti”. ChiamareThrowIfCancellationRequested()all’inizio delcatchfa sì che la cancellazione venga rilanciata comeOperationCanceledException.
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.ForEachAsyncoSemaphoreSlim
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.
flowchart LR
p["produttore"] --> w["WriteAsync"]
w --> q{"C'è spazio in coda?"}
q -- "SÌ" --> c["Entra nel canale"]
q -- "NO" --> b["Aspetta finché c'è spazio"]
c --> d["consumatore ReadAsync"]
d --> e["attendere 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 CancellationTokenfunziona 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, utilizzareawait 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
Releaseinfinally
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.
flowchart LR
a["Codice dell'UI / app"] --> b["await someAsync()"]
b --> c["Riprendere nel contesto originale"]
d["Biblioteca di uso generale"] --> e["await someAsync().ConfigureAwait(false)"]
e --> f["Nessuna ipotesi di ritorno a un contesto specifico"]
- UI / codice app
- Il semplice
awaitva 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)
- Il semplice
- ASP.NET Core codice app
- Normalmente
awaitè solitamente sufficiente - Non è necessario forzare
ConfigureAwait(false)come regola generale
- Normalmente
- Codice libreria per uso generale
- Se non dipende dall’UI o dai modelli di app,
ConfigureAwait(false)è un’opzione valida
- Se non dipende dall’UI o dai modelli di app,
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.CancelAfterpiù 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:
Task.Runattorno all’I / O- Attesa seriale di operazioni veramente indipendenti
- 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
WhenAllillimitati su grandi volumi? - Se un
CancellationTokenviene accettato, viene passato correttamente a valle? - Eventuali
async voidgestori 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 difinally? - Se si utilizza
ValueTaskc’è 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)
- Codice UI / app: semplice
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.
- Attese I / O separate dal calcolo della CPU
- Per I / O,
awaitdirettamente l’API asincrona - Per il lavoro della CPU, decidere dove dovrebbe essere eseguito
- Per operazioni multiple, scegli
WhenAll/WhenAny/ parallelismo limitato - Per staccarsi dalla durata della richiesta, metterla in coda invece di fare fuoco e dimenticare
- 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
- Codice di esempio completo per questo articolo (libreria, demo, test unitari) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/csharp-async-await-best-practices
- Scenari di programmazione asincrona - C#
- Programmazione asincrona con async e wait
- Modello asincrono basato su attività (TAP) in .NET
- ConfigureAwait FAQ
- Parallel.ForEachAsync Metodo
- Task.WaitAsync Metodo
- System.Threading.Channels libreria
- Creare un servizio di coda
- Attività in background con servizi ospitati in ASP.NET Core
- Genera e consuma flussi asincroni
- Implementare un metodo DisposeAsync
- ValueTask Struttura
- CA2012: usa ValueTasks correttamente
Articoli correlati
Articoli recenti con gli stessi tag per approfondire argomenti vicini.
Qual è il .NET Generic Host? - La base per DI, configurazione e registrazione
Il .NET Generic Host in parole povere: un posto per DI, configurazione, registrazione, BackgroundService e arresto regolare. Esempio di c...
Scelta tra i tre timer di .NET - PeriodicTimer / Timer / DispatcherTimer
Quale timer .NET dovresti usare? PeriodicTimer per loop asincroni, timer per callback ThreadPool, DispatcherTimer per WPF UI, oltre a una...
Perché utilizzare .NET Generic Host e BackgroundService nelle app desktop
Come utilizzare Generic Host e BackgroundService per organizzare l'avvio, l'elaborazione periodica, l'arresto, la registrazione, la confi...
WPF / WinForms asincrono e il thread dell'UI su un foglio
Dopo l'attesa in WPF o WinForms, quale thread esegue il tuo codice? Copre Dispatcher.Invoke, ConfigureAwait(false) e perché .Result e .Wa...
Una guida pratica a FileSystemWatcher: gestire gli eventi persi e duplicati
Organizziamo come utilizzare FileSystemWatcher e le sue insidie: eventi persi, notifiche duplicate, trappole di rilevamento del completam...
Argomenti correlati
Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.
Argomenti tecnici Windows
Portale su sviluppo Windows, analisi dei problemi e valorizzazione delle risorse esistenti.
Thread UI e timer
Thread UI WPF / WinForms, flussi asincroni, Dispatcher e progettazione dei timer.
Servizi collegati all’argomento
L’articolo è direttamente collegato ai servizi seguenti.
Sviluppo di applicazioni Windows
Nelle app Windows che coinvolgono UI, elaborazione in background e I / O, sapere quando utilizzare il modello asincrono / attendere si traduce direttamente nella qualità dell'implementazione.
Consulenza tecnica e revisione del progetto
Se vuoi risolvere le decisioni Task.Run e ConfigureAwait insieme alla ripartizione delle responsabilità, ciò porta alla consulenza tecnica e alla revisione del progetto.
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.