再次探討 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;
        }


    }
}

詳細的示例可以參看微軟 Docs 文檔中的 BackgroundWorker 類


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM