多线程与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中的未处理异常会直接让程序崩溃。