再次探讨 WinForms 多线程开发
再次探讨 WinForms 多线程开发
WinForms 已经开源,您现在可以在 GitHub 上查看 WinForm 源代码。
正好有人又讨论到在 WinFroms 环境下的多线程开发,这里就再整理一下涉及到的技术点。
从官方文档可以知道,Windows Forms 是 Windows 界面库,例如 User32 和 GDI+ 的 .NET 封装,WinForms 中的控件背后实际上是 Windows 的 GDI 控件实现。
考虑在窗体上执行一个长时间执行的任务
LongTimeWork 代表一个需要长时间操作才能完成的任务。这里通过 Sleep() 来模拟长事件的操作。
主要特性:
- 通过事件 ValueChanged 反馈任务进度
- 通过事件 Finished 报告任务已经完成
- 通过参数 CancellationTokenSource 提供对中途取消任务的支持
代码如下:
using System;
using System.Collections.Generic;
using System.Text;
namespace LongTime.Business
{
// 定义事件的参数类
public class ValueEventArgs: EventArgs
{
public int Value { set; get; }
}
// 定义事件使用的委托
public delegate void ValueChangedEventHandler(object sender, ValueEventArgs e);
public class LongTimeWork
{
// 定义一个事件来提示界面工作的进度
public event ValueChangedEventHandler ValueChanged;
// 报告任务被取消
public event EventHandler Cancelled;
public event EventHandler Finished;
// 触发事件的方法
protected void OnValueChanged(ValueEventArgs e)
{
this.ValueChanged?.Invoke(this, e);
}
public void LongTimeMethod(System.Threading.CancellationTokenSource cancellationTokenSource)
{
for (int i = 0; i < 100; i++)
{
if(cancellationTokenSource.IsCancellationRequested)
{
this.Cancelled?.Invoke(this, EventArgs.Empty);
return;
}
// 进行工作
System.Threading.Thread.Sleep(1000);
// 触发事件
ValueEventArgs e = new ValueEventArgs() { Value = i + 1 };
this.OnValueChanged(e);
}
this.Finished?.Invoke(this, EventArgs.Empty);
}
}
}
IsHandleCreated 属性告诉我们控件真的创建了吗
Control 基类 是 WinForms 中控件的基类,它定义了控件显示给用户的基础功能,需要注意的是 Control 是一个 .NET 中的类,我们创建出来的也是 .NET 对象实例。但是当控件真的需要在 Windows 上工作的时候,它必须要创建为一个实际的 GDI 控件,当它实际创建之后,可以通过 Control 的 Handle 属性提供 Windows 的窗口句柄。
new 一个 .NET 对象实例并不意味着实际的 GDI 对象被创建,例如,当执行到窗体的构造函数的时候,这时候仅仅正在创建 .NET 对象,而窗体所依赖的 GDI 对象还没有被处理,也就意味着真正的控件实际上还没有被创建出来,我们也就不能开始使用它,这就是 IsHandleCreated 属性的作用。
需要说明的是,通常我们并不需要管理底层的 GDI 处理,WinForms 已经做了良好的封装,我们需要知道的是关键的时间点。
窗体的构造函数和 Load 事件
构造函数是面向对象中的概念,执行构造函数的时候,说明正在内存中构建对象实例。而窗体的 Load 事件发生在窗体创建之后,与窗体第一次显示在 Windows 上之前的时间点上。
它们的关键区别在于窗体背后所对应的 GDI 对象创建问题。在构造函数执行的时候,背后对应的 GDI 对象还没有被创建,所以,我们并不能访问窗体以及控件。在 Load 事件执行的时候,GDI 对象已经创建,所以可以访问窗体以及控件。
在使用多线程模式开发 WinForms 窗体应用程序的时候,需要保证后台线程对窗体和控件的访问在 Load 事件之后进行。
控件访问的线程安全问题
Windows 窗体中的控件是绑定到特定线程的,不是线程安全的。 因此,在多线程情况下,如果从其他线程调用控件的方法,则必须使用控件的一个调用方法将调用封送到正确的线程。
当你在窗体的按钮上,通过双击生成一个对应的 Click 事件处理方法的时候,这个事件处理方法实际上是执行在这个特定的 UI 线程之上的。
不过 UI 线程背后的机制与 Windows 的消息循环直接相关,在 UI 线程上执行长时间的代码会导致 UI 线程的阻塞,直接表现就是界面卡顿。解决这个问题的关键是在 UI 线程之外的工作线程上执行需要花费长时间执行的任务。
这个时候,就会涉及到 UI 线程安全问题,在 工作线程上是不能直接访问 UI 线程上的控件,否则,会导致异常。
那么工作线程如何更新 UI 界面上的控件以达到更新显示的效果呢?
UI 控件提供了一个可以安全访问的属性:
- InvokeRequired
和 4 个可以跨线程安全访问的方法:
- Invoke
- BeginInvode
- EndInvoke
- GreateGraphics
不要被这些名字所迷惑,我们从线程的角度来看它们的作用。
InvokeRequired 用来检查当前的线程是否就是创建控件的线程,现在 WinForms 已经开源,你可以在 GitHub 上查看 InvokeRequired 源码,最关键的就是最后的代码行。
public bool InvokeRequired
{
get
{
using var scope = MultithreadSafeCallScope.Create();
Control control;
if (IsHandleCreated)
{
control = this;
}
else
{
Control marshalingControl = FindMarshalingControl();
if (!marshalingControl.IsHandleCreated)
{
return false;
}
control = marshalingControl;
}
return User32.GetWindowThreadProcessId(control, out _) != Kernel32.GetCurrentThreadId();
}
}
所以,我们可以通过这个 InvokeRequired 属性来检查当前的线程是否是 UI 的线程,如果是的话,才可以安全访问控件的方法。示例代码如下:
if (!this.progressBar1.InvokeRequired) {
this.progressBar1.Value = e.Value;
}
但是,如果当前线程不是 UI 线程呢?
安全访问控件的方法 Invoke
当在工作线程上需要访问控件的时候,关键点在于我们不能直接调用控件的 4 个安全方法之外的方法。这时候,必须将需要执行的操作封装为一个委托,然后,将这个委托通过 Invoke() 方法投递到 UI 线程之上,通过回调方式来实现安全访问。
这个 Invoke() 方法的定义如下:
public object Invoke (Delegate method);
public object Invoke (Delegate method, params object[] args);
这个 Delegate 实际上是所有委托的基类,我们使用 delegate 定义出来的委托都是它的派生类。这就意味所有的委托其实都是可以使用的。
不过,有两个特殊的委托被推荐使用,根据微软的文档,它们比使用其它类型的委托速度会更快。见:https://docs.microsoft.com/en-us/dotnet/api/system.windows.forms.control.invoke?view=net-5.0
- EventHandler
- MethodInvoder
当注册的委托被系统回调的时候,如果委托类型是 EventHandler,那么参数 sender 将被设置为控件本身的引用,而 e 的值是 EventArgs.Empty。
MethodInvoder 委托的定义如下,可以看到它与 Action 委托定义实际上是一样的,没有参数,返回类型为 void。
public delegate void MethodInvoker();
辅助处理线程问题的 SafeInvoke()
由于需要确保对控件的访问在 UI 线程上执行,创建辅助方法进行处理。
这里的 this 就是 Form 窗体本身。
private void SafeInvoke(System.Windows.Forms.MethodInvoker method)
{
if (this.InvokeRequired)
{
this.Invoke(method);
}
else
{
method();
}
}
这样在需要访问 UI 控件的时候,就可以通过这个 SafeInvode() 来安全操作了。
private void workder_ValueChanged(object sender, ValueEventArgs e)
{
this.SafeInvoke(
() => this.progressBar1.Value = e.Value
);
}
使用 BeginInvoke() 和 EndInvoke()
如果你查看 BeginInvoke() 的源码,可以发现它与 Invoke() 方法的代码几乎相同。
public object Invoke(Delegate method, params object[] args)
{
using var scope = MultithreadSafeCallScope.Create();
Control marshaler = FindMarshalingControl();
return marshaler.MarshaledInvoke(this, method, args, true);
}
BeginInvoke() 方法源码
public IAsyncResult BeginInvoke(Delegate method, params object[] args)
{
using var scope = MultithreadSafeCallScope.Create();
Control marshaler = FindMarshalingControl();
return (IAsyncResult)marshaler.MarshaledInvoke(this, method, args, false);
}
它们都会保证注册的委托运行在 UI 安全的线程之上,区别在于使用 BeginInvoke() 方法的场景。
如果你的委托内部使用了异步操作,并且返回一个处理异步的 IAsyncResult,那么就使用 BeginInvoke()。以后,使用 EndInvode() 来得到这个异步的返回值。
使用线程池
在 .NET 中,使用线程并不意味着一定要创建 Thread 对象实例,我们可以通过系统提供的线程池来使用线程。
线程池提供了将一个委托注册到线程池队列中的方法,该方法要求的委托类型是 WaitCallback。
public static bool QueueUserWorkItem (System.Threading.WaitCallback callBack);
public static bool QueueUserWorkItem<TState> (Action<TState> callBack, TState state, bool preferLocal);
WaitCallback 委托的定义,它接收一个参数对象,返回类型是 void。
public delegate void WaitCallback(object state);
可以将启动工作线程的方法修改为如下方式,这里使用了弃元操作,见 弃元 - C# 指南。
System.Threading.WaitCallback callback
= _ => worker.LongTimeMethod();
System.Threading.ThreadPool.QueueUserWorkItem(callback);
完整的代码:
using LongTime.Business;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace WindowsFormsApp1
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
private void Form1_Load(object sender, EventArgs e)
{
}
private void Button1_Click(object sender, EventArgs e)
{
// 禁用按钮
this.button1.Enabled = false;
// 实例化业务对象
LongTime.Business.LongTimeWork worker
= new LongTime.Business.LongTimeWork();
worker.ValueChanged
+= new LongTime.Business.ValueChangedEventHandler(workder_ValueChanged);
/*
// 创建工作线程实例
System.Threading.Thread workerThread
= new System.Threading.Thread(worker.LongTimeMethod);
// 启动线程
workerThread.Start();
*/
System.Threading.WaitCallback callback
= _ => worker.LongTimeMethod();
System.Threading.ThreadPool.QueueUserWorkItem(callback);
}
private void SafeInvoke(System.Windows.Forms.MethodInvoker method)
{
if (this.InvokeRequired)
{
this.Invoke(method);
}
else
{
method();
}
}
private void workder_ValueChanged(object sender, ValueEventArgs e)
{
this.SafeInvoke(
() => this.progressBar1.Value = e.Value
);
}
}
}
使用 BackgroundWorker
BackgroundWorker 封装了 WinForms 应用程序中,在 UI 线程之外的工作线程vs执行任务的处理。
主要特性:
- 进度
- 完成
- 支持取消
该控件实际上希望你将业务逻辑直接写在它的 DoWork 事件处理中。但是,实际开发中,我们可能更希望将业务写在单独的类中实现。
报告进度
我们直接使用 BackgroundWorker 的特性来完成。
首先,报告进度要进行两个基本设置:
- 首先需要设置支持报告进度更新
- 然后,注册任务更新的事件回调
// 设置报告进度
this.backgroundWorker1.WorkerReportsProgress = true;
// 注册进度更新的事件回调
backgroundWorker1.ProgressChanged +=
new ProgressChangedEventHandler( backgroundWorker1_ProgressChanged);
当后台任务发生更新之后,通过调用 BackgroundWorker 的 ReportProgress() 方法来报告进度,这个一个线程安全的方法。
然后,BackgroundWorker 的 ProgressChanged 事件会被触发,它会运行在 UI 线程之上,可以安全的操作控件的方法。
private void workder_ValueChanged(object sender, ValueEventArgs e)
{
// 通过 BackgroundWorker 来更新进度
this.backgroundWorker1.ReportProgress( e.Value);
}
private void backgroundWorker1_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
// BackgroundWorker 可以安全访问控件
this.progressBar1.Value = e.ProgressPercentage;
}
报告完成
由于我们并不在 DoWork 事件中实现业务,所以也不使用 BackgroundWorker 的报告完成操作。
在业务代码中,提供任务完成的事件。
this.Finished?.Invoke(this, EventArgs.Empty);
在窗体中,注册事件回调处理,由于回调处理不能保证执行在 UI 线程之上, 通过委托将待处理的 UI 操作封装为委托对象传递给 SaveInfoke() 方法。
private void worker_Finished(object sender, EventArgs e)
{
SafeInvoke(() =>
{
this.Reset();
this.resultLabel.Text = "Task Finished!";
});
}
取消任务
BackgroundWorker 的取消是建立在整个业务处理写在 DoWork 事件回调中, 我们的业务写在独立的类中。所以,我们自己完成对于取消的支持。
让我们的处理方法接收一个 对象来支持取消。每次都重新创建一个新的取消对象。
// 每次重新构建新的取消通知对象
this.cancellationTokenSource = new System.Threading.CancellationTokenSource();
worker.LongTimeMethod( this.cancellationTokenSource);
点击取消按钮的时候,发出取消信号。
private void BtnCancel_Click(object sender, EventArgs e)
{
// 发出取消信号
this.cancellationTokenSource.Cancel();
}
业务代码中会检查是否收到取消信号,收到取消信号会发出取消事件,并退出操作。
if(cancellationTokenSource.IsCancellationRequested)
{
this.Cancelled?.Invoke(this, EventArgs.Empty);
return;
}
在窗体注册的取消事件处理中,处理取消响应,还是需要注意线程安全问题
private void worker_Cancelled(object sender, EventArgs e)
{
SafeInvoke(() =>
{
this.Reset();
this.resultLabel.Text = "Task Cancelled!";
});
}
代码实现
using LongTime.Business;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace WindowsFormsApp1
{
public partial class Form1 : Form
{
private System.ComponentModel.BackgroundWorker backgroundWorker1;
private System.Threading.CancellationTokenSource cancellationTokenSource;
public Form1()
{
InitializeComponent();
// 创建后台工作者对象实例
this.backgroundWorker1
= new System.ComponentModel.BackgroundWorker();
// 设置报告进度
this.backgroundWorker1.WorkerReportsProgress = true;
// 支持取消操作
this.backgroundWorker1.WorkerSupportsCancellation = true;
// 注册开始工作的事件回调
backgroundWorker1.DoWork +=
new DoWorkEventHandler(backgroundWorker1_DoWork);
// 注册进度更新的事件回调
backgroundWorker1.ProgressChanged +=
new ProgressChangedEventHandler(backgroundWorker1_ProgressChanged);
}
private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e)
{
// 可以接收来自 RunWorkerAsync() 的参数,供实际的方法使用
object argument = e.Argument;
// 后台进程,不能访问控件
// 实例化业务对象
LongTime.Business.LongTimeWork worker
= new LongTime.Business.LongTimeWork();
worker.ValueChanged
+= new LongTime.Business.ValueChangedEventHandler(workder_ValueChanged);
worker.Finished
+= new EventHandler(worker_Finished);
worker.Cancelled
+= new EventHandler(worker_Cancelled);
// 每次重新构建新的取消通知对象
this.cancellationTokenSource = new System.Threading.CancellationTokenSource();
worker.LongTimeMethod(this.cancellationTokenSource);
}
private void worker_Cancelled(object sender, EventArgs e)
{
SafeInvoke(() =>
{
this.Reset();
this.resultLabel.Text = "Task Cancelled!";
});
}
private void worker_Finished(object sender, EventArgs e)
{
SafeInvoke(() =>
{
this.Reset();
this.resultLabel.Text = "Task Finished!";
});
}
private void SafeInvoke(System.Windows.Forms.MethodInvoker method)
{
if (this.InvokeRequired)
{
this.Invoke(method);
}
else
{
method();
}
}
private void Form1_Load(object sender, EventArgs e)
{
}
private void Button1_Click(object sender, EventArgs e)
{
// 控件操作,禁用按钮
this.button1.Enabled = false;
this.button2.Enabled = true;
// 启动后台线程工作
// 实际的工作注册在
this.backgroundWorker1.RunWorkerAsync();
}
private void workder_ValueChanged(object sender, ValueEventArgs e)
{
// 通过 BackgroundWorker 来更新进度
this.backgroundWorker1.ReportProgress(e.Value);
}
private void backgroundWorker1_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
// BackgroundWorker 可以安全访问控件
this.progressBar1.Value = e.ProgressPercentage;
}
private void BtnCancel_Click(object sender, EventArgs e)
{
// 发出取消信号
this.cancellationTokenSource.Cancel();
}
private void Reset()
{
this.resultLabel.Text = string.Empty;
// Enable the Start button.
this.button1.Enabled = true;
// Disable the Cancel button.
this.button2.Enabled = false;
this.progressBar1.Value = 0;
}
}
}
详细的示例可以参看微软 Docs 文档中的 BackgroundWorker 类。