Quando si utilizza async / await in WPF / WinForms, le cose più facili in cui perdersi sono a quale thread ritorna l’esecuzione dopo await e quando è sicuro toccare l’UI.
Soprattutto una volta che Dispatcher, BeginInvoke, ConfigureAwait(false) e .Result / .Wait() vengono mescolati insieme, le cause delle finestre bloccate e delle eccezioni cross-thread diventano difficili da vedere.
Questo articolo si concentra esclusivamente sulla relazione tra il thread dell’UI WPF / WinForms e async / await.
Per il quadro decisionale complessivo per async / await, vedere il pezzo complementare Best practice per C# asincrono / in attesa: una tabella decisionale per Task.Run e ConfigureAwait.
I luoghi in cui viene versato vero sangue, in pratica, sono più o meno questi.
- Non sai dove prosegue la continuazione dopo
await - Non sai se potrai toccare l’UI dopo aver eseguito
Task.Run - Non sei sicuro di dove mettere
ConfigureAwait(false) - La finestra si blocca su
.Result/.Wait()/.GetAwaiter().GetResult() Dispatchere WinForms’Invoke/BeginInvoke/InvokeAsyncdi WPF si confondono nella tua testa
WPF e WinForms sono entrambi modelli incentrati sul thread dell’UI.
Quindi il modo più efficace per risolvere async / await non è parlare filosoficamente su “cos’è l’asincronia”, ma rendere esplicito cosa stai facendo al thread dell’UI e al loop di messaggi.
Questo articolo presuppone principalmente WPF / WinForms app su .NET 6 o versioni successive e illustra, in un ordine utile nella pratica, dove l’esecuzione ritorna dopo await, Dispatcher, ConfigureAwait(false) e perché .Result / .Wait() si blocca.
Tieni presente che WinForms’ Control.InvokeAsync è .NET 9 o successivo.
Nel precedente WinForms, le basi sono BeginInvoke / Invoke.
Inoltre, il codice visualizzato in questo articolo è pubblicato su GitHub come set di esempi completo compilabile ed eseguibile (una libreria indipendente dall’UI, esempi WPF / WinForms e test unitari che riproducono gli obiettivi di continuazione in attesa e il deadlock).
wpf-winforms-ui-thread-async-await-one-sheet - komurasoft-blog-samples (GitHub)
Sommario
- Prima la conclusione (in una riga)
- La panoramica in un foglio
- 2.1. Il quadro generale
- 2.2. La tabella decisionale di primo passaggio
- Termini utilizzati in questo articolo
- 3.1. Il thread dell’UI e il ciclo di messaggi
- 3.2.
SynchronizationContext/Dispatcher/Invoke
- Modelli tipici
- 4.1. Plain
awaitin un gestore eventi dell’UI - 4.2.
Task.RunSolo per lavoro pesante della CPU - 4.3.
ConfigureAwait(false)È “Non impone un reso”, non “Garantisce il mancato reso” - 4.4. Perché
.Result/.Wait()/.GetAwaiter().GetResult()Rimangono bloccati
- 4.1. Plain
- Quando utilizzare
Dispatcher/Invoke - Anti-pattern comuni
- Lista di controllo per la revisione del codice
- Una guida decisionale approssimativa
- Riepilogo
- Riferimenti
1. Prima la conclusione (in una riga)
- Con un semplice
awaitin un WPF / WinForms gestore di eventi dell’UI, puoi presumere che la continuazione dopoawaitessenzialmente ritorni al thread dell’UI Task.Runserve per spostare il lavoro della CPU dal thread dell’UI, non è uno strumento per avvolgere le attese di I / O- Anche con
await Task.Run(...)all’interno di un gestore dell’UI, seawaitè un sempliceawait, la continuazione normalmente torna al thread dell’UI ConfigureAwait(false)significa cheawaitnon forza il ritorno al contesto dell’UI acquisito. Toccare l’UI direttamente nel seguito è pericoloso.Result/.Wait()/.GetAwaiter().GetResult()blocca il thread dell’UI. Se la continuazione diawaitdeve tornare all’UI, si blocca abbastanza regolarmente- Per tornare esplicitamente all’UI in WPF, utilizzare
Dispatcher.InvokeAsync - Per tornare esplicitamente all’UI in WinForms, il modo tradizionale è
BeginInvoke; su .NET 9 o versione successiva,InvokeAsyncsi adatta bene al flusso asincrono - La policy del primo passaggio: semplice
awaital livello più esterno dell’UI, considerareConfigureAwait(false)nelle librerie di uso generale ed eseguire il backup esplicito dell’UI solo dove necessario
In breve, in WPF / WinForms, se tieni traccia di:
- Su quale thread sei attualmente in esecuzione
- Dove ritorna il seguito del
await - Chi ha la responsabilità del rientro nell’IU
Queste tre cose, la visibilità migliora notevolmente.
2. Panoramica in un unico foglio
2.1. Il quadro generale
Afferrare prima il quadro generale da questo diagramma è il percorso più veloce.
flowchart LR
A["Gestore eventi dell'interfaccia utente<br/>(WPF / WinForms)"] --> B["semplice await<br/>API I / O"]
B --> C["Cattura l'UI SynchronizationContext"]
C --> D["Riprende nel thread dell'UI dopo l'attesa"]
D --> E["Può scrivere gli aggiornamenti dell'UI così come sono"]
A --> F["await Task.Run(...)<br/>lavoro pesante della CPU"]
F --> G["Il calcolo stesso viene eseguito su ThreadPool"]
G --> H["Riprende nel thread dell'UI dopo l'attesa"]
H --> E
A --> I["await SomeAsync().ConfigureAwait(false)"]
I --> J["Non forza il ritorno all'UI"]
J --> K["Continuazione su un thread arbitrario"]
K --> L["Gli aggiornamenti diretti dell'UI sono pericolosi<br/>Dispatcher / Invoke richiesti"]
A --> M["SomeAsync().Result / .Wait()<br/>GetAwaiter().GetResult()"]
M --> N["Blocca il thread dell'UI"]
N --> O["La continuazione non può tornare all'UI"]
O --> P["Blocco / stallo / come minimo congelamento"]
Ciò che vedi in pratica sono più o meno questi 4 modelli.
- Semplice
awaitin un gestore eventi dell’UI - Utilizzo di
Task.Runin un gestore eventi dell’UI per scaricare il lavoro della CPU - Rimozione della destinazione del reso con
ConfigureAwait(false) - Blocco del thread dell’UI con
.Result/.Wait()
2.2. La tabella decisionale di primo passaggio
| Situazione | Ciò che corre nell’attesa | Continuazione dopo await |
OK per toccare direttamente l’UI? | Prima scelta |
|---|---|---|---|---|
await SomeIoAsync() in un gestore dell’UI |
In attesa del completamento dell’I / O. Il thread dell’UI stesso può tornare al ciclo di messaggi | Essenzialmente il thread dell’UI | Sì | semplice await |
await Task.Run(...) in un gestore dell’UI |
Lavoro pesante della CPU sul ThreadPool | Essenzialmente il thread dell’UI | Sì | Task.Run solo per CPU |
await x.ConfigureAwait(false) in un gestore dell’UI |
La destinazione restituita non è bloccata nell’UI | Un thread arbitrario | No | Generalmente evitare nel codice dell’UI |
x.Result / x.Wait() nel thread dell’UI |
Il thread dell’UI è bloccato in attesa | La continuazione riesce a malapena a funzionare in primo luogo | No | Non utilizzare |
Desideri aggiornare l’UI dopo un thread in background o ConfigureAwait(false) |
In esecuzione su un thread diverso dall’UI | Non l’UI così com’è | No | Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync |
Il punto importante in questa tabella è che plain await è in realtà il tuo alleato nel codice dell’UI.
Il nemico non è await stesso, ma blocca in modo sincrono il thread dell’UI.
3. Termini utilizzati in questo articolo
3.1. Il thread dell’UI e il ciclo di messaggi
L’UI in WPF / WinForms funziona fondamentalmente come un thread dell’UI che guida l’input, il rendering e l’elaborazione degli eventi.
Il ruolo del thread dell’UI è più o meno questo.
- Elabora messaggi come la pressione di pulsanti, l’immissione di tasti e le ridisegnazioni
- Sii l’unico thread che può toccare in sicurezza i controlli e gli oggetti dell’UI
- Se ci metti troppo lavoro, gli aggiornamenti dello schermo e la reattività dell’input si bloccano
Il punto cruciale qui è che il compito del thread dell’UI è quello di scorrere rapidamente. Bloccalo a lungo e il mouse, la tastiera e la riverniciatura si intasano: dal punto di vista dell’utente, l’app si “blocca”.
Mantenere questa immagine nella tua testa come un diagramma aiuta a evitare confusione.
flowchart LR
A["Richieste di input / ridisegno dell'utente"] --> B["Ciclo di messaggi del thread dell'UI"]
B --> C["Esegui il gestore eventi"]
C --> D["Aggiorna lo schermo"]
D --> B
C --> E["Lavoro sincrono lungo"]
E --> F["Il ciclo di messaggi non può scorrere"]
F --> G["Lo schermo appare bloccato"]
3.2. SynchronizationContext / Dispatcher / Invoke
Ciò si ottiene ordinando i termini che appaiono frequentemente per uso pratico.
| Termine | Significato qui |
|---|---|
| Discussione dell’UI | Il thread che ha creato gli oggetti dell’UI. Essenzialmente l’unico che può toccare in sicurezza l’UI |
| Ciclo di messaggi | Il meccanismo mediante il quale il thread dell’UI elabora i messaggi in ordine |
SynchronizationContext |
Un’astrazione per “restituire il lavoro a quella posizione di esecuzione” |
Dispatcher |
La coda di WPF per il thread dell’UI |
Invoke / BeginInvoke / InvokeAsync |
API per pubblicare lavoro nel thread dell’UI |
A rigor di termini, quando await decide dove va la continuazione, dà la priorità all’attuale SynchronizationContext e, in caso contrario, considera anche un non predefinito TaskScheduler.
Ma nella pratica del WPF / WinForms, è sufficiente pensare innanzitutto che l’UI SynchronizationContext è in vigore.
La mappatura per framework è più chiara come una tabella.
| Quadro | Contesto lato UI | Rappresentante API per il ritorno esplicito all’UI |
|---|---|---|
| WPF | DispatcherSynchronizationContext |
Dispatcher.InvokeAsync / Dispatcher.BeginInvoke / Dispatcher.Invoke |
| WinForms | WindowsFormsSynchronizationContext |
Control.BeginInvoke / Control.Invoke / .NET 9+ Control.InvokeAsync |
WPF è incentrato su Dispatcher.
WinForms è incentrato sugli handle di controllo e sul ciclo di messaggi, con BeginInvoke / Invoke in primo piano.
In pratica, ricordare il rapporto tra l’astrazione e i pezzi concreti a questo livello impedisce loro di confondersi.
flowchart TD
A["Codice attuale"] --> B["SynchronizationContext"]
B --> C["WPF: DispatcherSynchronizationContext"]
B --> D["WinForms: WindowsFormsSynchronizationContext"]
C --> E["Dispatcher.InvokeAsync / BeginInvoke / Invoke"]
D --> F["Control.BeginInvoke / Invoke / InvokeAsync(.NET 9+)"]
4. Modelli tipici
4.1. Plain await in un gestore eventi dell’UI
Questa è la forma più semplice.
private async void LoadButton_Click(object sender, RoutedEventArgs e)
{
LoadButton.IsEnabled = false;
StatusText.Text = "Loading...";
try
{
string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
PreviewTextBox.Text = text;
StatusText.Text = "Done";
}
catch (Exception ex)
{
StatusText.Text = ex.Message;
}
finally
{
LoadButton.IsEnabled = true;
}
}
In questo codice, LoadButton_Click inizia nel thread dell’UI.
E poiché await File.ReadAllTextAsync(...) è un semplice await, normalmente cattura il contesto dell’UI in quel punto.
Di conseguenza:
- Il thread dell’UI non è occupato durante l’attesa dell’I / O del file
- La continuazione al termine della lettura ritorna essenzialmente al thread dell’UI
- Puoi scrivere
PreviewTextBox.Text = text;così com’è
Non è necessario alcun Dispatcher aggiuntivo qui.
Se hai semplicemente eseguito un semplice await all’interno di un gestore dell’UI, normalmente puoi toccare l’UI così com’è.
La vista è la stessa in WinForms.
Finché esegui un semplice await all’interno di un gestore Click, la continuazione ritorna essenzialmente al lato dell’UI.
Come diagramma, il flusso appare così.
sequenceDiagram
participant UI as UI thread
participant IO as Async I/O
participant Ctx as UI SynchronizationContext
UI->>UI: Click handler starts
UI->>IO: await ReadAllTextAsync
UI-->>Ctx: Schedule continuation back to the UI
Note over UI: Returns to the message loop while waiting
IO-->>Ctx: I/O completes
Ctx-->>UI: Resume the continuation on the UI thread
UI->>UI: Update TextBox / Label
4.2. Task.Run Solo per lavoro pesante della CPU
Task.Run ripaga quando vuoi spostare i pesanti calcoli della CPU dal thread dell’UI.
private async void HashButton_Click(object sender, RoutedEventArgs e)
{
HashButton.IsEnabled = false;
ResultText.Text = "Computing...";
try
{
byte[] data = await File.ReadAllBytesAsync(InputPathTextBox.Text);
string hash = await Task.Run(() =>
{
using SHA256 sha256 = SHA256.Create();
byte[] digest = sha256.ComputeHash(data);
return Convert.ToHexString(digest);
});
ResultText.Text = hash;
}
catch (Exception ex)
{
ResultText.Text = ex.Message;
}
finally
{
HashButton.IsEnabled = true;
}
}
Ciò che accade in questo codice è più o meno questo.
- Il gestore eventi viene avviato nel thread dell’UI
- L’attesa I / O di
File.ReadAllBytesAsyncscorre in modo asincrono - Solo il calcolo dell’hash pesante viene inviato a ThreadPool con
Task.Run - La continuazione di
await Task.Run(...)è un sempliceawait, quindi ritorna al thread dell’UI - Puoi scrivere
ResultText.Text = hash;così com’è
In altre parole, solo la parte interna di Task.Run è su un altro thread.
Non ti sposti permanentemente in “un luogo che non è più l’UI” oltre await.
Vederlo su un foglio rende difficile la lettura errata.
sequenceDiagram
participant UI as UI thread
participant IO as Async I/O
participant Pool as ThreadPool
UI->>IO: await ReadAllBytesAsync
IO-->>UI: Plain await, so resumes on the UI
UI->>Pool: Push heavy CPU work via Task.Run
Pool-->>UI: Return the computed result
Note over UI: The continuation of await Task.Run(...) resumes on the UI
UI->>UI: Reflect the result on screen
Ci sono due precauzioni qui.
- Non racchiudere le attese I / O in
Task.Run - Pensa a
Task.Runnon come a “rendere le cose asincrone” ma come a creare “un posto dove scaricare il lavoro della CPU”
Scrivere qualcosa come Task.Run(async () => await File.ReadAllTextAsync(...)) ripubblica inutilmente un’attesa I / O su ThreadPool e ti fa guadagnare poco.
4.3. ConfigureAwait(false) È “Non forza un reso”, non “Garantisce il mancato reso”
Questa è la parte più comunemente fraintesa.
Innanzitutto, a cui appartiene ConfigureAwait(false) è il codice di libreria per uso generale che non dipende dall’UI o da qualsiasi modello di applicazione specifico.
public sealed class DocumentRepository
{
public async Task<string> LoadNormalizedTextAsync(string path, CancellationToken cancellationToken)
{
string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
return text.Replace("\r\n", "\n", StringComparison.Ordinal);
}
}
Questo metodo non tocca l’UI.
Funziona in WPF, WinForms, ASP.NET Core o in un lavoratore simile.
Per un codice come questo, aggiungere ConfigureAwait(false) è naturale.
E il sito di chiamata lato UI può utilizzare un semplice await.
private readonly DocumentRepository _repository = new();
private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
OpenButton.IsEnabled = false;
StatusText.Text = "Loading...";
try
{
string text = await _repository.LoadNormalizedTextAsync(
PathTextBox.Text,
CancellationToken.None);
PreviewTextBox.Text = text;
StatusText.Text = "Done";
}
catch (Exception ex)
{
StatusText.Text = ex.Message;
}
finally
{
OpenButton.IsEnabled = true;
}
}
Il punto importante qui è che ConfigureAwait(false) all’interno della libreria non forza il await del chiamante a diventare anche false.
In altre parole, ottieni questa separazione:
- All’interno della libreria, l’esecuzione non ritorna all’UI
- Quando il gestore dell’UI plain-
awaits, la continuazione del chiamante ritorna all’UI
Al contrario, scriverlo nel gestore dell’UI stesso è pericoloso.
private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
string text = await _repository.LoadNormalizedTextAsync(
PathTextBox.Text,
CancellationToken.None).ConfigureAwait(false);
PreviewTextBox.Text = text;
}
In questo caso, la continuazione di await in OpenButton_Click non è forzata a tornare all’UI.
Quindi PreviewTextBox.Text = text; può diventare un accesso multithread.
C’è un altro punto silenziosamente importante.
L’aggiunta di ConfigureAwait(false) non garantisce il passaggio a ThreadPool: se l’attesa viene completata in modo sincrono senza attendere, la continuazione potrebbe semplicemente continuare a scorrere sul thread corrente.
Leggerlo come “va sempre a un altro thread” o “da qui in poi non è mai l’UI” è una ricetta per gli incidenti. Il significato è sempre e solo questo: la continuazione di quel await non è forzata a tornare al contesto dell’UI originale — niente di più.
Come diagramma:
flowchart LR
A["await in un gestore dell'UI"] --> B{"Aggiungere ConfigureAwait(false)?"}
B -- No --> C["Continuazione essenzialmente sul thread dell'UI"]
C --> D["Facile aggiornare l'UI così com'è"]
B -- Yes --> E["Continuazione non bloccata nell'UI"]
E --> F["Può riprendere su un thread arbitrario"]
F --> G["Gli aggiornamenti dell'UI richiedono Dispatcher / Invoke"]
4.4. Perché .Result / .Wait() / .GetAwaiter().GetResult() rimangono bloccati
Questo è l’incidente che vedi più spesso.
private void LoadButton_Click(object sender, RoutedEventArgs e)
{
string text = LoadTextAsync().Result;
PreviewTextBox.Text = text;
}
private async Task<string> LoadTextAsync()
{
string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
return text.ToUpperInvariant();
}
A prima vista sembra semplicemente recuperare un risultato in modo sincrono, ma farlo sul thread dell’UI è pericoloso.
Il flusso come diagramma:
sequenceDiagram
participant UI as UI thread
participant IO as Async I/O
participant Ctx as UI SynchronizationContext
UI->>UI: LoadButton_Click starts
UI->>IO: Call LoadTextAsync()
IO-->>UI: Returns an incomplete Task
UI->>UI: Blocks waiting on .Result
IO-->>Ctx: I/O completes, wants to return the continuation to the UI
Ctx-->>UI: Wants to run the continuation
Note over UI: But the UI is blocked on .Result
Note over UI, Ctx: The continuation cannot run, so it can never complete
Esprimere ciò che accade in parole:
- Il thread dell’UI chiama
LoadTextAsync() awaitall’interno diLoadTextAsync()cattura il contesto dell’UI- Il thread dell’UI è in attesa su
.Result - L’I / O termina
- La continuazione di
LoadTextAsync()vuole tornare al thread dell’UI - Ma il thread dell’UI è bloccato su
.Result - La continuazione non può essere eseguita, quindi
LoadTextAsync()non viene mai completata .Resultnon finisce mai
In altre parole, l’UI dice “Aspetterò finché non finisci” e il lato asincrono dice “posso finire quando potrò tornare all’UI”: si aspettano a vicenda. Davvero spiacevole.
Un malinteso comune qui è pensare che GetAwaiter().GetResult() sia sicuro.
Ma la sostanza, ovvero bloccare il thread dell’UI, è la stessa. Ciò che differisce è principalmente il modo in cui vengono racchiuse le eccezioni.
Pertanto, nel codice dell’UI, è più sicuro considerare questi tre come portatori dello stesso odore.
.Result.Wait().GetAwaiter().GetResult()
Tieni presente che anche chiamare Task.Wait() sul Task restituito dal Dispatcher.InvokeAsync(...) di WPF è pericoloso.
La documentazione di WPF afferma inoltre che la chiamata di Task.Wait su Task restituita da un DispatcherOperation si blocca.
In un contesto di UI, l’intera direzione dell’“attesa sincrona di qualcosa che hai pubblicato” tende a rimanere bloccata.
È “sempre in stallo”? Non necessariamente. Se il codice presenta continuazioni che non ritornano all’UI, potrebbe semplicemente bloccare l’UI senza bloccarsi. Ma questo è già abbastanza doloroso, quindi, di regola, non farlo nel codice dell’UI.
5. Quando utilizzare Dispatcher / Invoke
Considerato tutto finora: in un gestore dell’UI con await semplice, normalmente non è necessario Dispatcher / Invoke esplicito.
Diventa necessario, ad esempio, quando:
- Vuoi toccare l’UI nel seguito di un
ConfigureAwait(false) - Sei all’interno di
Task.Run, o altrimenti strutturato in modo che anche il codice esterno non ritorni all’UI - Le notifiche arrivano inizialmente sui thread non dell’UI: ricezioni socket, timer, callback di eventi
- In un livello che separa intenzionalmente l’UI da quella non UI, è necessario rendere esplicito solo l’aggiornamento finale dell’UI
In WPF, l’API rappresentativa è Dispatcher.InvokeAsync.
private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
await Dispatcher.InvokeAsync(() =>
{
PreviewTextBox.Text = text;
StatusText.Text = "Done";
});
}
In WinForms su .NET 9 o versioni successive, InvokeAsync si integra naturalmente con il flusso asincrono.
private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
await previewTextBox.InvokeAsync(() =>
{
previewTextBox.Text = text;
statusLabel.Text = "Done";
});
}
Nel modello tradizionale WinForms, utilizza BeginInvoke.
Invoke è un invio sincrono e fa attendere il chiamante. BeginInvoke pubblica e ritorna immediatamente.
In un flusso asincrono, il lato non bloccante generalmente si ingrana meglio.
Tuttavia, Control.BeginInvoke restituisce IAsyncResult e quindi non può essere await direttamente. Se vuoi usare Control.InvokeAsync in ambienti privi di esso (.NET Framework 4.8, .NET 6 / 8, ecc.), il modo naturale è incapsularlo con TaskCompletionSource per ottenere un Task.
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
public static class ControlUiExtensions
{
// Per funzionare anche su .NET Framework 4.8 usiamo la versione generica
// di TaskCompletionSource. A partire da .NET 5 si può usare anche quella non generica.
//
// cancellationToken non è opzionale: se il controllo viene distrutto dopo che
// BeginInvoke ha accettato il delegato, quest'ultimo può essere scartato senza
// eseguire e TaskCompletionSource non riceverà né risultato né eccezione.
// Senza una via di uscita, chi attende resterebbe in attesa per sempre.
public static Task InvokeOnUiAsync(
this Control control, Action action, CancellationToken cancellationToken)
{
if (control is null)
{
throw new ArgumentNullException(nameof(control));
}
if (action is null)
{
throw new ArgumentNullException(nameof(action));
}
if (!control.IsHandleCreated)
{
throw new InvalidOperationException("L'handle della finestra non è ancora stato creato.");
}
if (!control.InvokeRequired)
{
action();
return Task.CompletedTask;
}
// Per evitare che la continuazione di chi attende prosegua direttamente sul thread
// dell'UI, chiediamo esplicitamente di eseguirla in modo asincrono.
var tcs = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
// La cancellazione e l'esecuzione si contendono lo stesso "diritto di una volta".
// Con Interlocked.Exchange, solo chi scrive per primo 1 procede.
// Se invece si controllasse un flag e poi si chiamasse action(), subito dopo
// il controllo potrebbe arrivare la cancellazione: il chiamante ha già ricevuto
// la cancellazione e inizia l'operazione successiva, mentre il vecchio delegato
// ancora in coda riscrive la schermata - la nuova visualizzazione viene sovrascritta
// da quella vecchia, un guasto difficile da riprodurre.
int claimed = 0; // 0 = non determinato / 1 = uno dei due ha preso il diritto
// Se viene cancellato, il Task viene chiuso anche se il delegato non gira.
// La registrazione deve essere rimossa al completamento del Task, altrimenti
// il token trattiene tcs per tutta la sua vita. Dispose di CancellationTokenRegistration
// è thread-safe, quindi può essere chiamata da qualsiasi thread.
CancellationTokenRegistration registration = cancellationToken.Register(() =>
{
if (Interlocked.Exchange(ref claimed, 1) == 0)
{
tcs.TrySetCanceled(cancellationToken);
}
});
tcs.Task.ContinueWith(
_ => registration.Dispose(),
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
try
{
control.BeginInvoke(new Action(() =>
{
// Tra l'invio e l'esecuzione sul thread UI potrebbe arrivare la cancellazione.
// Se non riusciamo a prendere il diritto, significa che la cancellazione
// è arrivata per prima: non tocchiamo la UI e usciamo.
if (Interlocked.Exchange(ref claimed, 1) != 0)
{
return;
}
try
{
action();
tcs.TrySetResult(true);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
}));
}
catch (Exception ex)
{
// BeginInvoke stesso può lanciare (ad esempio handle già assente).
// Se non chiudiamo il Task qui, chi attende rimane in attesa.
// Il delegato non è partito, quindi anche qui prendiamo il diritto.
if (Interlocked.Exchange(ref claimed, 1) == 0)
{
tcs.TrySetException(ex);
}
}
return tcs.Task;
}
}
Il chiamante assume quasi la stessa forma dell’esempio con InvokeAsync. Il token va legato alla vita del form.
// Campo del form. Annulla quando si chiude.
private readonly CancellationTokenSource _formClosing = new();
protected override void OnFormClosed(FormClosedEventArgs e)
{
// Se un delegato già inviato viene scartato senza eseguire perché il controllo
// è stato distrutto, qui chiudiamo comunque il lato che attende.
_formClosing.Cancel();
base.OnFormClosed(e);
}
private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, _formClosing.Token);
string text = await File.ReadAllTextAsync(path, linked.Token).ConfigureAwait(false);
await previewTextBox.InvokeOnUiAsync(() =>
{
previewTextBox.Text = text;
statusLabel.Text = "Done";
}, linked.Token);
}
Punti da tenere a mente:
- Controllare un flag prima di eseguire
action()non basta. Subito dopo il controllo può arrivare la cancellazione: in quell’istantetcsè già segnato come cancellato, il chiamante ha ricevuto la cancellazione e inizia l’operazione successiva, ma il vecchio delegato ancora in coda riscrive la schermata - la nuova visualizzazione viene sovrascritta da quella vecchia, un guasto difficile da riprodurre. L’uso diInterlocked.Exchangeper farsi contendere “un diritto di una volta” serve proprio a questo; chi non riesce a prenderlo se ne va senza fare nulla. - L’invocazione prima della creazione dell’handle (
Load) o dopo la chiusura del form solleva eccezioni. Chi chiama deve essere consapevole del ciclo di vita del controllo. - Dopo aver invocato, se il controllo viene distrutto, il delegato può essere scartato senza eseguire. In tal caso
TaskCompletionSourcenon riceve né risultato né eccezione e chi attende rimarrebbe in attesa per sempre. Passa sempre un token legato alla vita del form, come nell’esempio sopra. Il risultato chiuso saràOperationCanceledException. File.ReadAllTextAsyncè un’API .NET Core 2.0+. Per ottenere la stessa forma in .NET Framework 4.8, sostituiscila conStreamReader.ReadToEndAsynco simili.
Per distinguerli, questo livello di distinzione è sufficiente.
| Quello che vuoi | WPF | WinForms |
|---|---|---|
| Esegui sull’UI in modo sincrono | Dispatcher.Invoke |
Control.Invoke |
| Pubblica nell’UI in modo asincrono | Dispatcher.InvokeAsync / Dispatcher.BeginInvoke |
Control.BeginInvoke / .NET 9+ Control.InvokeAsync |
| Adatta in modo naturale con asincrono / attendi | Dispatcher.InvokeAsync |
.NET 9+ Control.InvokeAsync, altrimenti BeginInvoke |
Gli istinti pratici:
- Non necessario se stai semplicemente utilizzando
awaitin un gestore dell’UI - Utilizzalo quando vuoi toccare l’UI da un punto diverso dall’UI
- Non proliferare
Invokesincrono all’interno dei flussi asincroni
Questo da solo riduce notevolmente gli incidenti.
In caso di dubbio, è sufficiente un diagramma decisionale a questo livello.
flowchart TD
A["È il luogo in cui questa continuazione esegue il thread dell'UI?"] --> B{"SÌ?"}
B -- Yes --> C["Mantieni la pianura in attesa e aggiorna l'UI"]
B -- No --> D{"Hai bisogno di toccare l'UI?"}
D -- No --> E["Continua l'elaborazione così com'è"]
D -- Yes --> F["WPF: Dispatcher.InvokeAsync"]
D -- Yes --> G["WinForms: BeginInvoke / InvokeAsync"]
6. Anti-pattern comuni
| Anti-modello | Perché fa male | Prima sostituzione |
|---|---|---|
LoadAsync().Result in un gestore dell’UI |
Blocca il thread dell’UI. Incline allo stallo | await LoadAsync() |
LoadAsync().Wait() in un gestore dell’UI |
Stesso. Il ciclo di messaggi interrompe | await LoadAsync() |
LoadAsync().GetAwaiter().GetResult() in un gestore dell’UI |
Differisce solo la presentazione delle eccezioni; il blocco è lo stesso | await LoadAsync() |
Aggiunta meccanica di ConfigureAwait(false) al codice dell’UI |
Gli aggiornamenti dell’UI dopo await si interrompono facilmente |
Plain await nel livello più esterno dell’UI |
Task.Run(async () => await IoAsync()) |
Ripubblicare inutilmente I / O | await IoAsync() |
Codice libreria che contiene Dispatcher o Control direttamente |
Approfondisce la dipendenza dall’UI. Difficile da riutilizzare | La libreria restituisce solo dati; il lato dell’UI effettua il marshalling |
Uso intenso di Dispatcher.Invoke / Control.Invoke nei flussi asincroni |
Forma facilmente anelli di bloccaggio | Considera Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync |
| Sincronizzazione asincrona nei costruttori o nei getter di proprietà | Si blocca un terreno fertile per l’avvio | Passa a Loaded / Shown / InitializeAsync |
Tra questi, tre hanno tassi di incontro particolarmente alti.
.Result/.Wait()nel thread dell’UI- Aggiunta meccanica di
ConfigureAwait(false)al codice dell’UI - Le responsabilità della libreria e dell’UI si fondono in modo che
Dispatchersi infiltri negli strati più profondi
La semplice eliminazione di questi tre calma già notevolmente il codice.
7. Lista di controllo per la revisione del codice
Quando rivedi async / await in WPF / WinForms, procedi dall’alto verso il basso.
- Sono presenti
.Result/.Wait()/.GetAwaiter().GetResult()rimanenti nei gestori eventi dell’UI o nei percorsi di inizializzazione dell’UI? Task.Runviene utilizzato solo per il calcolo della CPU? Sta avvolgendo l’I / O?ConfigureAwait(false)si è insinuato meccanicamente nel codice dell’UI?- Al contrario, il codice della libreria di uso generale trascina con sé una dipendenza dal contesto dell’UI?
- Per ogni posizione che tocca l’UI direttamente dopo un
await, puoi effettivamente sostenere che il punto riguardi il contesto dell’UI? - Laddove è richiesto un ritorno esplicito all’UI, vengono utilizzati
Dispatcher.InvokeAsync/BeginInvoke/InvokeAsync? - I marescialli sincroni come
Dispatcher.Invoke/Control.Invokesi stanno moltiplicando inutilmente? - La funzione asincrona viene sincronizzata forzatamente da costruttori, proprietà sincrone o eventi sincroni?
- Il livello della libreria fa riferimento direttamente a
Window/Control/Dispatcher?
Questa lista di controllo è utile anche per allineare un team su “ciò che rientra nelle responsabilità dell’UI”.
8. Una guida decisionale approssimativa
| Quello che vuoi | Prima scelta |
|---|---|
| Attendi HTTP / DB / I / O del file in un gestore dell’UI | semplice await |
| Lavoro pesante della CPU che non deve bloccare l’UI | await a Task.Run |
Aggiorna l’UI dopo ConfigureAwait(false) o da un thread in background |
WPF: Dispatcher.InvokeAsync / WinForms: BeginInvoke o .NET 9+ InvokeAsync |
| Scrivere una libreria di uso generale | Considera ConfigureAwait(false) |
| Sincronizza asincrono nell’UI | Fondamentalmente no. Estendi la catena dei chiamanti a async |
| Inizializza all’avvio | Loaded / Shown / un esplicito InitializeAsync |
Tocca l’UI direttamente dopo await |
Mantieni await semplice nel livello dell’UI più esterno |
9. Riepilogo
Ciò che conta davvero con async / await in WPF / WinForms non è la vaga sensazione che “l’asincronia è difficile”, ma pensare separatamente a:
- Dove sono iniziate le cose
- Dove ritorna il seguito del
await - Chi è responsabile del ritorno all’UI
Come regole di primo passaggio, attenersi solo a queste è sufficiente per resistere.
- Semplice
awaitnel livello più esterno dell’UI Task.Runsolo per lavoro pesante della CPU- Considera
ConfigureAwait(false)nelle librerie di uso generale Dispatcher/BeginInvoke/InvokeAsyncsolo quando è necessario tornare all’UI- Non utilizzare mai
.Result/.Wait()/.GetAwaiter().GetResult()nel thread dell’UI
async / await in sé non è un meccanismo così capriccioso.
Ma usalo senza mantenere il thread dell’UI al centro della tua visuale, e all’improvviso si trasformerà in un pantano.
Mettiamola al contrario:
- Separa l’esterno dell’UI dall’interno
- Stai attento a dove ritornano le continuazioni
- Non introdurre blocchi
Attenersi solo a questi tre e il codice asincrono in WPF / WinForms diventa molto più silenzioso. Il codice che blocca lo schermo di solito non è un caso di “asincronismo difettoso”: è semplicemente sciatto nel modo in cui prende in prestito dal thread dell’UI.
10. Riferimenti
- Codice di esempio completo per questo articolo (libreria indipendente dall’UI, esempi WPF / WinForms, test unitari) - komurasoft-blog-samples (GitHub)
- Articolo correlato: Migliori pratiche asincrone / in attesa di C#: una tabella decisionale per Task.Run e ConfigureAwait
- Modello con filettatura - WPF
- DispatcherSynchronizationContext Classe
- Come gestire le operazioni multithread con i controlli - Windows Forms
- WindowsFormsSynchronizationContext Classe
- Panoramica eventi - Windows Forms
- TaskScheduler.FromCurrentSynchronizationContext Metodo
- ConfigureAwait FAQ
- Come funziona davvero Async / Await in C#
- Aspetta, UI e deadlock! Oh mio Dio!
- Modello di threading per le app WebView2
Articoli correlati
Articoli recenti con gli stessi tag per approfondire argomenti vicini.
Icone nella system tray e notifiche toast nelle app Windows — le insidie di NotifyIcon e come scegliere l'AppNotification giusta
Una guida pratica per mantenere un'applicazione Windows aziendale residente nella system tray (area di notifica) e avvisare l'utente tram...
Internazionalizzazione delle app WinForms/WPF — la pratica di resx, satellite assembly e cambio cultura
Analizziamo dal punto di vista pratico l'internazionalizzazione delle applicazioni desktop Windows: la differenza tra CurrentCulture e Cu...
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...
Stampa e output PDF nelle applicazioni aziendali Windows — come scegliere tra System.Drawing.Printing, WPF e librerie di report
Organizza in una tabella decisionale la stampa WinForms con PrintDocument, la stampa WPF con FlowDocument/FixedDocument e le alternative ...
Outsourcing e sviluppo su commissione di app Windows: cosa chiarire prima di affidare l'incarico
Prima di affidare in outsourcing o su commissione lo sviluppo di un'app Windows, ecco i punti da chiarire: revisione del software esisten...
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
Il thread dell'UI e async / await in WPF / WinForms sono tra i punti in cui le implementazioni di sviluppo di applicazioni Windows rimangono molto spesso bloccate.
Consulenza tecnica e revisione del progetto
Se sei nella fase di risolvere le responsabilità dell'UI rispetto al lavoro in background e quando utilizzare il Dispatcher, questo può essere rivisitato come un impegno di consulenza tecnica e revisione del progetto.
Domande frequenti
Domande che ricorrono nelle consulenze sull’argomento dell’articolo.
- Dopo l'attesa in WPF o WinForms, su quale thread viene eseguito il mio codice?
- Con una semplice attesa in un gestore eventi dell'UI, la continuazione ritorna essenzialmente al thread dell'UI. L'attesa cattura l'UI SynchronizationContext a quel punto - DispatcherSynchronizationContext in WPF, WindowsFormsSynchronizationContext in WinForms - e riprende la continuazione lì una volta completato il lavoro atteso. Ciò significa che puoi aggiornare i controlli direttamente dopo l'attesa senza alcuna chiamata esplicita di Dispatcher o Invoke. Questo vale anche per `await Task.Run(...)`: solo l'interno di `Task.Run` gira su ThreadPool.
- Perché .Result o .Wait() si bloccano sul thread dell'UI?
- La chiamata a .Result o .Wait() blocca il thread dell'UI mentre attende l'attività. Ma l'attesa all'interno di quel metodo asincrono ha catturato il contesto dell'UI, quindi la sua continuazione deve essere eseguita sul thread dell'UI per essere completata, cosa che non può, perché il thread dell'UI è bloccato. Ciascuna parte attende l'altra, quindi l'attività non viene mai completata e .Result non viene mai restituito. GetAwaiter().GetResult() ha lo stesso problema; differisce solo il confezionamento delle eccezioni. Nel codice dell'UI, estendi la catena di chiamate su asincrona anziché su blocco.
- Dovrei usare ConfigureAwait(false) nel codice WPF o WinForms?
- Non nei gestori dell'UI: dopo ConfigureAwait(false), la continuazione non viene forzata al contesto dell'UI, quindi toccare direttamente i controlli può diventare un accesso multithread. La sua sede corretta è il codice di libreria di uso generale che non dipende dall'UI e il suo utilizzo non influisce sul chiamante: un gestore dell'UI che attende semplicemente un metodo di libreria di questo tipo riprende comunque sul thread dell'UI. Tieni inoltre presente che non garantisce il cambio di thread: se l'attesa viene completata in modo sincrono, l'esecuzione potrebbe semplicemente continuare sul thread corrente.
- Quando ho bisogno di Dispatcher.Invoke o Control.BeginInvoke?
- Non ne hai bisogno quando sei semplicemente in attesa all'interno di un gestore dell'UI. Sono necessari quando si desidera toccare l'UI da un punto diverso dal thread dell'UI: in una continuazione dopo ConfigureAwait(false), all'interno di Task.Run o nei callback che arrivano su thread non dell'UI come ricezioni socket e timer. Nei flussi asincroni, preferisci i moduli non bloccanti - Dispatcher.InvokeAsync in WPF e BeginInvoke o .NET 9+ Control.InvokeAsync in WinForms - perché l'Invoke sincrono all'interno dei flussi asincroni forma facilmente anelli di blocco.
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.