多线程与UI更新

Winform程序中所有控件的创建和访问都发生在UI线程(主线程)上,如果我们直接在按钮点击事件里执行耗时操作,UI线程就会被阻塞,消息循环也会停摆,整个界面会卡住无法响应,这是新手最常遇到的问题,解决办法是把耗时操作放到后台线程执行,但后台线程又不能直接访问控件,二者之间需要特定的通信机制。这篇笔记我们介绍Winform中多线程与UI更新的正确做法。

UI线程与跨线程访问

Winform控件不是线程安全的,除了少数例外,控件只能由创建它的UI线程访问。如果工作线程直接修改控件,.NET会抛出InvalidOperationException,即「线程间操作无效: 从不是创建控件的线程访问它」。这个异常其实是调试时默认检查生成的,发布版虽然不检查但这种行为是未定义的,可能导致偶发的崩溃。

private void badButton_Click(object sender, EventArgs e)
{
    Thread thread = new Thread(() =>
    {
        Thread.Sleep(2000);
        textBox1.Text = "完成";  // 错误!跨线程访问控件
    });
    thread.Start();
}

因此Winform多线程编程的核心规则只有一条:耗时操作在后台线程做,界面更新必须回到UI线程做。那么工作线程中如何回到UI线程呢?下面介绍几种方式。

Invoke / BeginInvoke

Winform中,控件上都有Invoke()BeginInvoke()方法,用于在工作线程中“封送”回UI线程执行,Invoke()是同步封送,即工作线程会等待UI线程执行完,BeginInvoke()则是异步封送。

private void invokeButton_Click(object sender, EventArgs e)
{
    Thread thread = new Thread(() =>
    {
        // 模拟耗时操作
        Thread.Sleep(2000);
        string result = "完成";

        // 回到UI线程更新界面
        textBox.Invoke(new Action(() =>
        {
            textBox.Text = result;
        }));
    });
    // 后台线程,主线程退出时自动结束
    thread.IsBackground = true;
    thread.Start();
}

此外,每个控件都有InvokeRequired属性,它可以用于判断当前位置是否在UI线程,为true表示当前代码不在UI线程。

BackgroundWorker

BackgroundWorker是Winform中专为后台任务封装的组件,它甚至可以直接从设计器的工具箱中拖拽出来。BackgroundWorker有3个事件:

  • DoWork:在后台线程执行耗时逻辑,这里不能碰UI控件
  • ProgressChanged:在UI线程触发,用于汇报进度,使用该事件需要开启WorkerReportsProgress(默认不开启)
  • RunWorkerCompleted:在UI线程触发,任务结束后执行收尾工作

我们这里直接将BackgroundWorker拖拽到窗体TestForm上,并设置其WorkerReportsProgress属性为True,然后我们编写如下代码。

namespace Gacfox.Demo;

public partial class TestForm : Form
{
    public TestForm()
    {
        InitializeComponent();
    }

    private void runWorkerButton_Click(object sender, EventArgs e)
    {
        backgroundWorker.RunWorkerAsync();
    }

    private void backgroundWorker_DoWork(object sender, System.ComponentModel.DoWorkEventArgs e)
    {
        for (int i = 1; i <= 100; i++)
        {
            // 模拟耗时操作
            Thread.Sleep(50);
            // 报告进度
            backgroundWorker.ReportProgress(i);
        }
        e.Result = "任务完成";
    }

    private void backgroundWorker_ProgressChanged(object sender, System.ComponentModel.ProgressChangedEventArgs e)
    {
        progressBar.Value = e.ProgressPercentage;
    }

    private void backgroundWorker_RunWorkerCompleted(object sender, System.ComponentModel.RunWorkerCompletedEventArgs e)
    {
        MessageBox.Show(e.Result?.ToString());
    }
}

启动程序后点击按钮,我们可以看到进度条不断移动,最后弹出“任务完成”的消息框。

async/await语法

现代C#提供了async/await语法,Winform现在也允许将事件处理函数标记为async函数,await之后的代码会自动回到UI线程继续执行,完全不用关心线程切换。

private async void downloadButton_Click(object sender, EventArgs e)
{
    string result = await Task.Run(() =>
    {
        // 模拟耗时操作
        Thread.Sleep(2000);
        return "完成";
    });
    MessageBox.Show(result);
}

注意:Winform的事件处理函数只能是async void(这也是async void为数不多的合法使用场景),并且耗时操作抛出的异常要用try-catch包裹,否则async void中的未处理异常会直接让程序崩溃。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。