WPF / WinForms asincrono e il thread dell'UI su un foglio

· Aggiornato il: · · C#, async/await, .NET, WPF, WinForms, UI, Threading

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()
  • Dispatcher e WinForms’ Invoke / BeginInvoke / InvokeAsync di 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

  1. Prima la conclusione (in una riga)
  2. La panoramica in un foglio
    • 2.1. Il quadro generale
    • 2.2. La tabella decisionale di primo passaggio
  3. Termini utilizzati in questo articolo
    • 3.1. Il thread dell’UI e il ciclo di messaggi
    • 3.2. SynchronizationContext / Dispatcher / Invoke
  4. Modelli tipici
    • 4.1. Plain await in un gestore eventi dell’UI
    • 4.2. Task.Run Solo 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
  5. Quando utilizzare Dispatcher / Invoke
  6. Anti-pattern comuni
  7. Lista di controllo per la revisione del codice
  8. Una guida decisionale approssimativa
  9. Riepilogo
  10. Riferimenti

1. Prima la conclusione (in una riga)

  • Con un semplice await in un WPF / WinForms gestore di eventi dell’UI, puoi presumere che la continuazione dopo await essenzialmente ritorni al thread dell’UI
  • Task.Run serve 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, se await è un semplice await, la continuazione normalmente torna al thread dell’UI
  • ConfigureAwait(false) significa che await non 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 di await deve 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, InvokeAsync si adatta bene al flusso asincrono
  • La policy del primo passaggio: semplice await al livello più esterno dell’UI, considerare ConfigureAwait(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:

  1. Su quale thread sei attualmente in esecuzione
  2. Dove ritorna il seguito del await
  3. 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.

Gestore eventi dell'interfaccia utente(WPF / WinForms)semplice awaitAPI I / OCattura l'UI SynchronizationContextRiprende nel thread dell'UI dopo l'attesaPuò scrivere gli aggiornamenti dell'UI così come sonoawait Task.Run(...)lavoro pesante della CPUIl calcolo stesso viene eseguito su ThreadPoolRiprende nel thread dell'UI dopo l'attesaawait SomeAsync().ConfigureAwait(false)Non forza il ritorno all'UIContinuazione su un thread arbitrarioGli aggiornamenti diretti dell'UI sono pericolosiDispatcher / Invoke richiestiSomeAsync().Result / .Wait()GetAwaiter().GetResult()Blocca il thread dell'UILa continuazione non può tornare all'UIBlocco / stallo / come minimo congelamento

Ciò che vedi in pratica sono più o meno questi 4 modelli.

  1. Semplice await in un gestore eventi dell’UI
  2. Utilizzo di Task.Run in un gestore eventi dell’UI per scaricare il lavoro della CPU
  3. Rimozione della destinazione del reso con ConfigureAwait(false)
  4. 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 semplice await
await Task.Run(...) in un gestore dell’UI Lavoro pesante della CPU sul ThreadPool Essenzialmente il thread dell’UI 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.

Richieste di input / ridisegno dell'utenteCiclo di messaggi del thread dell'UIEsegui il gestore eventiAggiorna lo schermoLavoro sincrono lungoIl ciclo di messaggi non può scorrereLo 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.

Codice attualeSynchronizationContextWPF: DispatcherSynchronizationContextWinForms: WindowsFormsSynchronizationContextDispatcher.InvokeAsync / BeginInvoke / InvokeControl.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ì.

UI SynchronizationContextAsync I/OUI threadUI SynchronizationContextAsync I/OUI threadReturns to the message loop while waitingClick handler startsawait ReadAllTextAsyncSchedule continuation back to the UII/O completesResume the continuation on the UI threadUpdate 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.

  1. Il gestore eventi viene avviato nel thread dell’UI
  2. L’attesa I / O di File.ReadAllBytesAsync scorre in modo asincrono
  3. Solo il calcolo dell’hash pesante viene inviato a ThreadPool con Task.Run
  4. La continuazione di await Task.Run(...) è un semplice await, quindi ritorna al thread dell’UI
  5. 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.

ThreadPoolAsync I/OUI threadThreadPoolAsync I/OUI threadThe continuation of await Task.Run(...) resumes on the UIawait ReadAllBytesAsyncPlain await, so resumes on the UIPush heavy CPU work via Task.RunReturn the computed resultReflect the result on screen

Ci sono due precauzioni qui.

  • Non racchiudere le attese I / O in Task.Run
  • Pensa a Task.Run non 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:

NoYesawait in un gestore dell'UIAggiungere ConfigureAwait(false)?Continuazione essenzialmente sul thread dell'UIFacile aggiornare l'UI così com'èContinuazione non bloccata nell'UIPuò riprendere su un thread arbitrarioGli 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:

UI SynchronizationContextAsync I/OUI threadUI SynchronizationContextAsync I/OUI threadBut the UI is blocked on .ResultThe continuation cannot run, so it can never completeLoadButton_Click startsCall LoadTextAsync()Returns an incomplete TaskBlocks waiting on .ResultI/O completes, wants to return the continuation to the UIWants to run the continuation

Esprimere ciò che accade in parole:

  1. Il thread dell’UI chiama LoadTextAsync()
  2. await all’interno di LoadTextAsync() cattura il contesto dell’UI
  3. Il thread dell’UI è in attesa su .Result
  4. L’I / O termina
  5. La continuazione di LoadTextAsync() vuole tornare al thread dell’UI
  6. Ma il thread dell’UI è bloccato su .Result
  7. La continuazione non può essere eseguita, quindi LoadTextAsync() non viene mai completata
  8. .Result non 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’istante tcs è 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 di Interlocked.Exchange per 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 TaskCompletionSource non 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 con StreamReader.ReadToEndAsync o 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 await in un gestore dell’UI
  • Utilizzalo quando vuoi toccare l’UI da un punto diverso dall’UI
  • Non proliferare Invoke sincrono all’interno dei flussi asincroni

Questo da solo riduce notevolmente gli incidenti.

In caso di dubbio, è sufficiente un diagramma decisionale a questo livello.

YesNoNoYesYesÈ il luogo in cui questa continuazione esegue il thread dell'UI?SÌ?Mantieni la pianura in attesa e aggiorna l'UIHai bisogno di toccare l'UI?Continua l'elaborazione così com'èWPF: Dispatcher.InvokeAsyncWinForms: 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.

  1. .Result / .Wait() nel thread dell’UI
  2. Aggiunta meccanica di ConfigureAwait(false) al codice dell’UI
  3. Le responsabilità della libreria e dell’UI si fondono in modo che Dispatcher si 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.Run viene 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.Invoke si 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

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.

  1. Semplice await nel livello più esterno dell’UI
  2. Task.Run solo per lavoro pesante della CPU
  3. Considera ConfigureAwait(false) nelle librerie di uso generale
  4. Dispatcher / BeginInvoke / InvokeAsync solo quando è necessario tornare all’UI
  5. 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

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.

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.

Torna al blog