天天看點

再次探讨 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 個可以跨線程安全通路的方法:

  1. Invoke
  2. BeginInvode
  3. EndInvoke
  4. 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;
        }


    }
}