Partager via


Comment gérer les opérations entre threads avec des contrôles

Le multithreading peut améliorer les performances des applications Windows Forms, mais l’accès aux contrôles Windows Forms n’est pas intrinsèquement thread-safe. Le multithreading peut exposer votre code à des bogues sérieux et complexes. Deux threads ou plus qui manipulent un contrôle peuvent forcer le contrôle à entrer dans un état incohérent et entraîner des conditions de concurrence, des interblocages et des blocages. Si vous implémentez le multithreading dans votre application, veillez à appeler les contrôles entre threads de manière sûre. Pour plus d’informations, consultez Meilleures pratiques pour le threading managé.

Il existe deux façons d’appeler en toute sécurité un contrôle Windows Forms à partir d’un thread qui n’a pas créé ce contrôle. Utilisez la méthode System.Windows.Forms.Control.Invoke pour appeler un délégué créé dans le thread principal, qui appelle à son tour le contrôle. Ou, implémentez un System.ComponentModel.BackgroundWorker, qui utilise un modèle piloté par les événements pour séparer le travail effectué dans le thread d’arrière-plan de la création de rapports sur les résultats.

Appels interthread non sécurisés

Il est dangereux d’appeler un contrôle directement à partir d’un thread qui ne l’a pas créé. L’extrait de code suivant illustre un appel non sécurisé au contrôle System.Windows.Forms.TextBox. Le gestionnaire d’événements Button1_Click crée un thread WriteTextUnsafe, qui définit directement la propriété TextBox.Text du thread principal.

private void button2_Click(object sender, EventArgs e)
{
    WriteTextUnsafe("Writing message #1 (UI THREAD)");
    _ = Task.Run(() => WriteTextUnsafe("Writing message #2 (OTHER THREAD)"));
}

private void WriteTextUnsafe(string text) =>
    textBox1.Text += $"{Environment.NewLine}{text}";
Private Sub Button2_Click(sender As Object, e As EventArgs) Handles Button2.Click
    WriteTextUnsafe("Writing message #1 (UI THREAD)")
    Task.Run(Sub() WriteTextUnsafe("Writing message #2 (OTHER THREAD)"))
End Sub

Private Sub WriteTextUnsafe(text As String)
    TextBox1.Text += $"{Environment.NewLine}{text}"
End Sub

Le débogueur Visual Studio détecte ces appels de thread non sécurisés en générant un InvalidOperationException message, l’opération interthread n’est pas valide. Contrôle accessible à partir d’un thread autre que le thread sur lequel il a été créé. Le InvalidOperationException problème se produit toujours pour les appels interthread non sécurisés pendant le débogage de Visual Studio et peut se produire au moment de l’exécution de l’application. Vous devez résoudre le problème, mais vous pouvez désactiver l’exception en définissant la propriété Control.CheckForIllegalCrossThreadCalls sur false.

Appels inter-threads sécurisés

Les applications Windows Forms suivent une infrastructure stricte de type contrat, similaire à toutes les autres infrastructures d’interface utilisateur Windows : tous les contrôles doivent être créés et accessibles à partir du même thread. Cela est important, car Windows exige que les applications fournissent un thread dédié unique pour transmettre des messages système. Chaque fois que le Gestionnaire de fenêtres Windows détecte une interaction avec une fenêtre d’application, tel qu’une touche, un clic de souris ou un redimensionnement de la fenêtre, il route ces informations vers le thread qui a créé et gère l’interface utilisateur, et le transforme en événements actionnables. Ce thread est appelé thread d’interface utilisateur.

Étant donné que le code s’exécutant sur un autre thread ne peut pas accéder aux contrôles créés et gérés par le thread d’interface utilisateur, Windows Forms fournit des moyens de travailler en toute sécurité avec ces contrôles à partir d’un autre thread, comme illustré dans les exemples de code suivants :

Exemple : Utiliser Control.InvokeAsync (.NET 9 et versions ultérieures)

À compter de .NET 9, Windows Forms inclut la InvokeAsync méthode, qui fournit un marshaling asynchrone vers le thread d’interface utilisateur. Cette méthode est utile pour les gestionnaires d’événements asynchrones et élimine de nombreux scénarios d’interblocage courants.

Note

Control.InvokeAsync est disponible uniquement dans .NET 9 et versions ultérieures. Il n’est pas pris en charge dans .NET Framework.

Compréhension de la différence : Invoke vs InvokeAsync

Control.Invoke (Envoi - Blocage) :

  • Envoie de façon synchrone le délégué à la file d’attente de messages du thread d’interface utilisateur.
  • Le thread appelant attend que le thread d’interface utilisateur traite le délégué.
  • Peut entraîner le blocage de l’interface utilisateur lorsque le délégué marshalé vers la file d’attente de messages attend qu’un message arrive (blocage).
  • Utile lorsque vous disposez de résultats prêts à s’afficher sur le thread d’interface utilisateur, par exemple : désactivation d’un bouton ou définition du texte d’un contrôle.

Control.InvokeAsync (publication - Non bloquant) :

  • Publie de façon asynchrone le délégué dans la file d’attente de messages du thread d’interface utilisateur au lieu d’attendre la fin de l’appel.
  • Retourne immédiatement sans bloquer le thread appelant.
  • Retourne une Task valeur qui peut être attendue pour l’achèvement.
  • Idéal pour les scénarios asynchrones et empêche les goulots d’étranglement des threads d’interface utilisateur.

Avantages d’InvokeAsync

Control.InvokeAsync présente plusieurs avantages par rapport à l’ancienne Control.Invoke méthode. Elle retourne une Task valeur que vous pouvez attendre, ce qui le rend efficace avec le code asynchrone et await. Il empêche également les problèmes courants de blocage qui peuvent se produire lors du mélange de code asynchrone avec des appels d’appel synchrones. Contrairement Control.Invokeà , la InvokeAsync méthode ne bloque pas le thread appelant, ce qui maintient vos applications réactives.

La méthode prend en charge l’annulation via CancellationToken, de sorte que vous pouvez annuler les opérations si nécessaire. Il gère également les exceptions correctement, en les renvoyant à votre code afin de pouvoir traiter les erreurs de manière appropriée. .NET 9 inclut des avertissements du compilateur (WFO2001) qui vous aident à utiliser la méthode correctement.

Pour obtenir des conseils complets sur les gestionnaires d’événements asynchrones et les meilleures pratiques, consultez vue d’ensemble des événements.

Choix de la surcharge InvokeAsync appropriée

Control.InvokeAsync fournit quatre surcharges pour différents scénarios :

Surcharger Cas d’usage Example
InvokeAsync(Action) Opération de synchronisation, aucune valeur de retour. Mettez à jour les propriétés du contrôle.
InvokeAsync<T>(Func<T>) Opération de synchronisation, avec valeur de retour. Obtenir l’état du contrôle.
InvokeAsync(Func<CancellationToken, ValueTask>) Opération asynchrone, aucune valeur de retour.* Mises à jour de l’interface utilisateur de longue durée.
InvokeAsync<T>(Func<CancellationToken, ValueTask<T>>) Opération asynchrone, avec valeur de retour.* Extraction de données asynchrones avec résultat.

*Visual Basic ne prend pas en charge l’attente d’un ValueTask.

L’exemple suivant montre comment mettre InvokeAsync à jour les contrôles en toute sécurité à partir d’un thread d’arrière-plan :

private async void button1_Click(object sender, EventArgs e)
{
    button1.Enabled = false;
    
    try
    {
        // Perform background work
        await Task.Run(async () =>
        {
            for (int i = 0; i <= 100; i += 10)
            {
                // Simulate work
                await Task.Delay(100);
                
                // Create local variable to avoid closure issues
                int currentProgress = i;
                
                // Update UI safely from background thread
                await loggingTextBox.InvokeAsync(() =>
                {
                    loggingTextBox.Text = $"Progress: {currentProgress}%";
                });
            }
        });

        loggingTextBox.Text = "Operation completed!";
    }
    finally
    {
        button1.Enabled = true;
    }
}
Private Async Sub Button1_Click(sender As Object, e As EventArgs) Handles button1.Click
    button1.Enabled = False

    Try
        ' Perform background work
        Await Task.Run(Async Function()
                           For i As Integer = 0 To 100 Step 10
                               ' Simulate work
                               Await Task.Delay(100)

                               ' Create local variable to avoid closure issues
                               Dim currentProgress As Integer = i

                               ' Update UI safely from background thread
                               Await loggingTextBox.InvokeAsync(Sub()
                                                                    loggingTextBox.Text = $"Progress: {currentProgress}%"
                                                                End Sub)
                           Next
                       End Function)

        ' Update UI after completion
        Await loggingTextBox.InvokeAsync(Sub()
                                             loggingTextBox.Text = "Operation completed!"
                                         End Sub)
    Finally
        button1.Enabled = True
    End Try
End Sub

Pour les opérations asynchrones qui doivent s’exécuter sur le thread d’interface utilisateur, utilisez la surcharge asynchrone :

private async void button2_Click(object sender, EventArgs e)
{
    button2.Enabled = false;
    try
    {
        loggingTextBox.Text = "Starting operation...";

        // Dispatch and run on a new thread, but wait for tasks to finish
        // Exceptions are rethrown here, because await is used
        await Task.WhenAll(Task.Run(SomeApiCallAsync),
                           Task.Run(SomeApiCallAsync),
                           Task.Run(SomeApiCallAsync));

        // Dispatch and run on a new thread, but don't wait for task to finish
        // Exceptions are not rethrown here, because await is not used
        _ = Task.Run(SomeApiCallAsync);
    }
    catch (OperationCanceledException)
    {
        loggingTextBox.Text += "Operation canceled.";
    }
    catch (Exception ex)
    {
        loggingTextBox.Text += $"Error: {ex.Message}";
    }
    finally
    {
        button2.Enabled = true;
    }
}

private async Task SomeApiCallAsync()
{
    using var client = new HttpClient();
    
    // Simulate random network delay
    await Task.Delay(Random.Shared.Next(500, 2500));

    // Do I/O asynchronously
    string result = await client.GetStringAsync("https://github.com/dotnet/docs/raw/refs/heads/main/README.md");

    // Marshal back to UI thread
    await this.InvokeAsync(async (cancelToken) =>
    {
        loggingTextBox.Text += $"{Environment.NewLine}Operation finished at: {DateTime.Now:HH:mm:ss.fff}";
    });

    // Do more async I/O ...
}
Private Async Sub Button2_Click(sender As Object, e As EventArgs) Handles button2.Click
    button2.Enabled = False
    Try
        loggingTextBox.Text = "Starting operation..."

        ' Dispatch and run on a new thread, but wait for tasks to finish
        ' Exceptions are rethrown here, because await is used
        Await Task.WhenAll(Task.Run(AddressOf SomeApiCallAsync),
                           Task.Run(AddressOf SomeApiCallAsync),
                           Task.Run(AddressOf SomeApiCallAsync))

        ' Dispatch and run on a new thread, but don't wait for task to finish
        ' Exceptions are not rethrown here, because await is not used
        Call Task.Run(AddressOf SomeApiCallAsync)

    Catch ex As OperationCanceledException
        loggingTextBox.Text += "Operation canceled."
    Catch ex As Exception
        loggingTextBox.Text += $"Error: {ex.Message}"
    Finally
        button2.Enabled = True
    End Try
End Sub

Private Async Function SomeApiCallAsync() As Task
    Using client As New HttpClient()

        ' Simulate random network delay
        Await Task.Delay(Random.Shared.Next(500, 2500))

        ' Do I/O asynchronously
        Dim result As String = Await client.GetStringAsync("https://github.com/dotnet/docs/raw/refs/heads/main/README.md")

        ' Marshal back to UI thread
        ' Extra work here in VB to handle ValueTask conversion
        Await Me.InvokeAsync(DirectCast(
                Async Function(cancelToken As CancellationToken) As Task
                    loggingTextBox.Text &= $"{Environment.NewLine}Operation finished at: {DateTime.Now:HH:mm:ss.fff}"
                End Function,
            Func(Of CancellationToken, Task)).AsValueTask() 'Extension method to convert Task
        )

        ' Do more Async I/O ...
    End Using
End Function

Note

Si vous utilisez Visual Basic, l’extrait de code précédent a utilisé une méthode d’extension pour convertir un ValueTaskTasken . Le code de méthode d’extension est disponible sur GitHub.

Exemple : Utiliser la méthode Control.Invoke

L’exemple suivant illustre un modèle permettant de garantir des appels thread-safe vers un contrôle Windows Forms. Il interroge la propriété System.Windows.Forms.Control.InvokeRequired, qui compare l’ID de thread de création du contrôle à l’ID de thread appelant. S’ils sont différents, vous devez appeler la méthode Control.Invoke.

La WriteTextSafe permet de définir la propriété TextBox du contrôle Text à une nouvelle valeur. La méthode interroge InvokeRequired. Si InvokeRequired retourne true, WriteTextSafe s’appelle de manière récursive, en passant la méthode en tant que délégué à la méthode Invoke. Si InvokeRequired retourne false, WriteTextSafe définit le TextBox.Text directement. Le gestionnaire d’événements Button1_Click crée le nouveau thread et exécute la méthode WriteTextSafe.

private void button1_Click(object sender, EventArgs e)
{
    WriteTextSafe("Writing message #1");
    _ = Task.Run(() => WriteTextSafe("Writing message #2"));
}

public void WriteTextSafe(string text)
{
    if (textBox1.InvokeRequired)
        textBox1.Invoke(() => WriteTextSafe($"{text} (NON-UI THREAD)"));

    else
        textBox1.Text += $"{Environment.NewLine}{text}";
}
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click

    WriteTextSafe("Writing message #1")
    Task.Run(Sub() WriteTextSafe("Writing message #2"))

End Sub

Private Sub WriteTextSafe(text As String)

    If (TextBox1.InvokeRequired) Then

        TextBox1.Invoke(Sub()
                            WriteTextSafe($"{text} (NON-UI THREAD)")
                        End Sub)

    Else
        TextBox1.Text += $"{Environment.NewLine}{text}"
    End If

End Sub

Pour plus d’informations sur la façon dont Invoke diffère la différence, consultez Présentation de InvokeAsyncla différence : Invoke vs InvokeAsync.

Exemple : Utiliser un BackgroundWorker

Un moyen simple d’implémenter des scénarios de multithreading tout en garantissant que l’accès à un contrôle ou un formulaire est effectué uniquement sur le thread principal (thread d’interface utilisateur), est avec le System.ComponentModel.BackgroundWorker composant, qui utilise un modèle piloté par les événements. Le thread d’arrière-plan déclenche l’événement BackgroundWorker.DoWork, qui n’interagit pas avec le thread principal. Le thread principal exécute les gestionnaires d’événements BackgroundWorker.ProgressChanged et BackgroundWorker.RunWorkerCompleted, qui peuvent appeler les contrôles du thread principal.

Important

Le BackgroundWorker composant n’est plus l’approche recommandée pour les scénarios asynchrones dans les applications Windows Forms. Bien que nous continuions à prendre en charge ce composant pour la compatibilité descendante, il traite uniquement du déchargement de la charge de travail du processeur du thread d’interface utilisateur vers un autre thread. Il ne gère pas d’autres scénarios asynchrones tels que les E/S de fichier ou les opérations réseau où le processeur peut ne pas fonctionner activement.

Pour la programmation asynchrone moderne, utilisez async plutôt des méthodes await . Si vous devez décharger explicitement le travail gourmand en charge du processeur, utilisez Task.Run pour créer et démarrer une tâche, que vous pouvez alors attendre comme toute autre opération asynchrone. Pour plus d’informations, consultez Exemple : Utiliser Control.InvokeAsync (.NET 9 et versions ultérieures) et les opérations et événements entre threads.

Pour effectuer un appel thread-safe à l’aide de BackgroundWorker, gérez l’événement DoWork. Il existe deux événements que le collaborateur en arrière-plan utilise pour signaler l’état : ProgressChanged et RunWorkerCompleted. L’événement ProgressChanged est utilisé pour communiquer les mises à jour d’état au thread principal, et l’événement RunWorkerCompleted est utilisé pour signaler que le worker en arrière-plan est terminé. Pour démarrer le thread d’arrière-plan, appelez BackgroundWorker.RunWorkerAsync.

L’exemple compte de 0 à 10 dans l’événement DoWork, en s'arrêtant pendant une seconde entre chaque nombre. Il utilise le gestionnaire d’événements ProgressChanged pour signaler le nombre au thread principal et définir la propriété TextBox du contrôle Text. Pour que l’événement ProgressChanged fonctionne, la propriété BackgroundWorker.WorkerReportsProgress doit être définie sur true.

private void button1_Click(object sender, EventArgs e)
{
    if (!backgroundWorker1.IsBusy)
        backgroundWorker1.RunWorkerAsync(); // Not awaitable
}

private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e)
{
    int counter = 0;
    int max = 10;

    while (counter <= max)
    {
        backgroundWorker1.ReportProgress(0, counter.ToString());
        System.Threading.Thread.Sleep(1000);
        counter++;
    }
}

private void backgroundWorker1_ProgressChanged(object sender, ProgressChangedEventArgs e) =>
    textBox1.Text = (string)e.UserState;
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click

    If (Not BackgroundWorker1.IsBusy) Then
        BackgroundWorker1.RunWorkerAsync() ' Not awaitable
    End If

End Sub

Private Sub BackgroundWorker1_DoWork(sender As Object, e As ComponentModel.DoWorkEventArgs) Handles BackgroundWorker1.DoWork

    Dim counter = 0
    Dim max = 10

    While counter <= max

        BackgroundWorker1.ReportProgress(0, counter.ToString())
        System.Threading.Thread.Sleep(1000)

        counter += 1

    End While

End Sub

Private Sub BackgroundWorker1_ProgressChanged(sender As Object, e As ComponentModel.ProgressChangedEventArgs) Handles BackgroundWorker1.ProgressChanged
    TextBox1.Text = e.UserState
End Sub