如何使用控件处理跨线程作

多线程可以提高 Windows 窗体应用的性能,但对 Windows 窗体控件的访问本质上不是线程安全的。 多线程可能使您的代码暴露于严重且复杂的错误之中。 有两个或两个以上线程操作控件可能会迫使该控件处于不一致状态并导致争用条件、死锁和冻结或挂起。 如果在应用中实现多线程处理,请务必以线程安全的方式调用跨线程控件。 有关详细信息,请参阅 管理线程最佳实践

有两种方法可以从没有创建该控件的线程中安全地调用 Windows 窗体控件。 System.Windows.Forms.Control.Invoke使用该方法调用在主线程中创建的委托,后者又调用控件。 或者,实现一个 System.ComponentModel.BackgroundWorker,该模型使用事件驱动模型将后台线程中完成的工作与报告结果分开。

不安全的跨线程调用

直接从未创建控件的线程调用该控件是不安全的。 以下代码片段演示了对 System.Windows.Forms.TextBox 控件的不安全调用。 Button1_Click 事件处理程序创建一个新的 WriteTextUnsafe 线程,该线程直接设置主线程的 TextBox.Text 属性。

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

Visual Studio 调试器通过引发 InvalidOperationException 带有消息的跨线程作无效来检测这些不安全 的线程调用。从创建线程以外的线程进行访问的控制。InvalidOperationException Visual Studio 调试期间,始终发生不安全的跨线程调用,并且可能在应用运行时发生。 应解决此问题,但可以通过将 Control.CheckForIllegalCrossThreadCalls 属性设置为 false来禁用异常。

安全的跨线程调用

Windows 窗体应用程序遵循严格的类似协定的框架,类似于所有其他 Windows UI 框架:必须从同一线程创建和访问所有控件。 这一点很重要,因为 Windows 要求应用程序提供单个专用线程才能将系统消息传送到。 每当 Windows 窗口管理器检测到与应用程序窗口(例如按键、鼠标单击或调整窗口大小)的交互时,它将该信息路由到创建和管理 UI 的线程,并将其转换为可作的事件。 此线程称为 UI 线程

由于在另一个线程上运行的代码无法访问由 UI 线程创建和管理的控件,因此 Windows 窗体提供了从另一个线程安全地处理这些控件的方法,如以下代码示例所示:

示例:使用 Control.InvokeAsync (.NET 9 及更高版本)

从 .NET 9 开始,Windows 窗体包括 InvokeAsync 此方法,该方法提供对 UI 线程的异步友好封送处理。 此方法对异步事件处理程序非常有用,并消除了许多常见的死锁方案。

注释

Control.InvokeAsync 仅在 .NET 9 及更高版本中可用。 .NET Framework 不支持它。

了解区别:调用与 InvokeAsync

Control.Invoke (发送 - 阻止):

  • 同步将委托发送到 UI 线程的消息队列。
  • 调用线程会等待,直到 UI 线程处理委托。
  • 当封送给消息队列的委托正在等待消息到达(死锁)时,可能会导致 UI 冻结。
  • 当你准备好在 UI 线程上显示结果时非常有用,例如:禁用按钮或设置控件的文本。

Control.InvokeAsync (发布 - 非阻止):

  • 异步将委托发布到 UI 线程的消息队列,而不是等待调用完成。
  • 立即返回,而不会阻止调用线程。
  • 返回一个 Task 可以等待完成的项。
  • 非常适合异步方案并防止 UI 线程瓶颈。

InvokeAsync 的优点

Control.InvokeAsync 与较旧的 Control.Invoke 方法有一些优势。 它返回一个 Task 可以等待的项,使其适用于异步和 await 代码。 它还可防止将异步代码与同步调用混合时可能发生的常见死锁问题。 与此方法不同 Control.InvokeInvokeAsync 该方法不会阻止调用线程,这会使应用保持响应。

该方法支持取消, CancellationToken因此可以根据需要取消作。 它还会正确处理异常,将其传递回代码,以便可以适当地处理错误。 .NET 9 包括编译器警告(WFO2001),可帮助你正确使用该方法。

有关异步事件处理程序和最佳做法的综合指南,请参阅 事件概述

选择正确的 InvokeAsync 重载

Control.InvokeAsync 为不同的方案提供四个重载:

超载 用例 Example
InvokeAsync(Action) 同步作,无返回值。 更新控件属性。
InvokeAsync<T>(Func<T>) 同步作,返回值。 获取控制状态。
InvokeAsync(Func<CancellationToken, ValueTask>) 异步作,无返回值。* 长时间运行的 UI 更新。
InvokeAsync<T>(Func<CancellationToken, ValueTask<T>>) 具有返回值的异步作。* 使用结果提取异步数据。

*Visual Basic 不支持等待 。ValueTask

以下示例演示如何使用 InvokeAsync 后台线程安全地更新控件:

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

对于需要在 UI 线程上运行的异步作,请使用异步重载:

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

注释

如果使用 Visual Basic,则前面的代码片段使用扩展方法将 a ValueTask 转换为 a Task. GitHub 上提供了扩展方法代码

示例:使用 Control.Invoke 方法

以下示例演示了一种模式,用于确保对 Windows 窗体控件的线程安全调用。 它查询 System.Windows.Forms.Control.InvokeRequired 属性,该属性将控件的创建线程 ID 与调用线程 ID 进行比较。 如果它们不同,则应调用该方法 Control.Invoke

启用 WriteTextSafeTextBox 控件 Text 的属性设置为新值。 方法查询 InvokeRequired。 如果 InvokeRequired 返回 trueWriteTextSafe 则以递归方式调用自身,将该方法作为委托传递给 Invoke 该方法。 如果 InvokeRequired 返回 falseWriteTextSafe 直接设置 TextBox.TextButton1_Click 事件处理程序将创建新线程并运行 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

有关不同之处的详细信息InvokeInvokeAsync,请参阅了解差异:Invoke 与 InvokeAsync

示例:使用 BackgroundWorker

实现多线程方案的一种简单方法,同时保证仅对主线程(UI 线程)执行对控件或窗体的访问,该 System.ComponentModel.BackgroundWorker 组件使用事件驱动模型。 后台线程引发 BackgroundWorker.DoWork 事件,该事件不会与主线程交互。 主线程运行 BackgroundWorker.ProgressChangedBackgroundWorker.RunWorkerCompleted 事件处理程序,后者可以调用主线程的控件。

重要

对于 Windows 窗体应用程序中的异步方案,该 BackgroundWorker 组件不再是推荐的方法。 虽然我们继续支持此组件以实现向后兼容性,但它只处理将处理器工作负载从 UI 线程卸载到另一个线程。 它不会处理其他异步方案,例如文件 I/O 或网络作,其中处理器可能未主动工作。

对于新式异步编程,请改用 async 方法 await 。 如果需要显式卸载处理器密集型工作,请使用 Task.Run 创建和启动新任务,然后可以像执行任何其他异步作一样等待。 有关详细信息,请参阅 示例:使用 Control.InvokeAsync(.NET 9 及更高版本)跨线程作和事件

若要通过使用 BackgroundWorker 进行线程安全的调用,请处理 DoWork 事件。 后台处理程序用于报告状态的事件有两个: ProgressChangedRunWorkerCompleted。 该 ProgressChanged 事件用于将状态更新传达给主线程,该 RunWorkerCompleted 事件用于指示后台辅助角色已完成。 若要启动后台线程,请调用 BackgroundWorker.RunWorkerAsync

该示例在 DoWork 事件中从 0 计数到 10,在每次计数之间暂停一秒。 它使用 ProgressChanged 事件处理程序将数字报告回主线程,并设置 TextBox 控件 Text 的属性。 若要使 ProgressChanged 事件正常工作,属性 BackgroundWorker.WorkerReportsProgress 必须设置为 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