More Effective C# 38.使用 BackgroundWorker 做跨執行緒通訊 More Effective C# 38.使用 BackgroundWorker 做跨執行緒通訊(Use BackgroundWorker for Cross-Thread Communication)

這個做法介紹了 BackgroundWorker 類別可以用來跨執行緒通訊。

BackgroundWorker 最簡單的用法就是建立一個委派並把它加入到 DoWork 事件中,然後呼叫 RunWorkerAsync 方法,可以看到 RunWorkerAsync 內部是透過 Task.Factory 來建立一個新的任務並執行 WorkerThreadStart 方法,最後就是 Invoke DoWorkEventHandler 開始執行設定的任務, 成功後會再次呼叫 OnRunWorkerCompleted 執行任務成功的相關操作,流程基本上就是以事件為主。

public event DoWorkEventHandler? DoWork;
public event RunWorkerCompletedEventHandler? RunWorkerCompleted;
public event ProgressChangedEventHandler? ProgressChanged;

protected virtual void OnDoWork(DoWorkEventArgs e)
{
    DoWork?.Invoke(this, e);
}

public void RunWorkerAsync(object? argument)
{
    if (_isRunning)
    {
        throw new InvalidOperationException(SR.BackgroundWorker_WorkerAlreadyRunning);
    }

    _isRunning = true;
    _cancellationPending = false;

    _asyncOperation = AsyncOperationManager.CreateOperation(null);
    Task.Factory.StartNew(
                WorkerThreadStart,
                argument,
                CancellationToken.None,
                TaskCreationOptions.DenyChildAttach,
                TaskScheduler.Default
            );
}

private void WorkerThreadStart(object? argument)
{
    Debug.Assert(_asyncOperation != null, "_asyncOperation not initialized");

    object? workerResult = null;
    Exception? error = null;
    bool cancelled = false;

    try
    {
        DoWorkEventArgs doWorkArgs = new DoWorkEventArgs(argument);
        OnDoWork(doWorkArgs);
        if (doWorkArgs.Cancel)
        {
            cancelled = true;
        }
        else
        {
            workerResult = doWorkArgs.Result;
        }
    }
    catch (Exception exception)
    {
        error = exception;
    }

    var e = new RunWorkerCompletedEventArgs(workerResult, error, cancelled);
    _asyncOperation.PostOperationCompleted(_operationCompleted, e);
}

直接呼叫也很簡單,只需要加入委派之後呼叫 RunWorkerAsync 方法啟動即可。

void Main()
{
	Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
	var bg = new BackgroundWorker();
	bg.DoWork += ((sender, e) => {
		Console.WriteLine(Thread.CurrentThread.ManagedThreadId + " In BackgroundWorker");
	});
	
	bg.RunWorkerAsync();
}

下面這個是一般 Event 的處理流程。

public event DoWorkEventHandler? DoWork;

void Main()
{
	Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
	DoWork += ((sender, e) =>
	{
		Console.WriteLine(Thread.CurrentThread.ManagedThreadId + " In DoWorkEventHandler");
	});

	DoWork?.Invoke(this, null);
}

從這兩段程式碼可以明顯看出一般 Event 的處理流程是運行在主行緒上,但透過 BackgroundWorker 包裝過後的事件則是運行在新的執行緒上。

建立這個類別的目的就是重用背景執行的程式碼,並且方便使用者不用在寫前景執行緒與背景執行緒的溝通邏輯。

例如常用的進度回報、任務取消、異常處理等功能 BackgroundWorker 都有內建,所以我們就不用自己寫這方面的邏輯了。

下面這個例子就是啟用了回報功能與取消功能的 BackgroundWorker,關鍵是在 DoWork 委派中呼叫 ReportProgress 方法, 回報目前的進度,並且加上 RunWorkerCompleted 事件添加任務完成後的處理邏輯,也可以添加判斷並呼叫 CancelAsync,直接取消背景任務。

void Main()
{
	var worker = new BackgroundWorker()
	{
		WorkerReportsProgress = true,
		WorkerSupportsCancellation = true
	};
	
	worker.DoWork += (sender, e) => {
		for (int i = 0; i < 100; i++)
		{
			if (worker.CancellationPending)
			{
				e.Cancel = true;
				return;
			}
			
			// throw new Exception();
			
			Thread.Sleep(100);
			(sender as BackgroundWorker)?.ReportProgress(i);
		}	
	};

	// 設置 ProgressChanged 事件
	worker.ProgressChanged += (sender, e) =>
	{
		Console.WriteLine($"進度: {e.ProgressPercentage}%");
	};

	// 設置 RunWorkerCompleted 事件
	worker.RunWorkerCompleted += (sender, e) =>
	{
		if (e.Cancelled)
			Console.WriteLine("作業已取消");
		else if (e.Error != null)
			Console.WriteLine($"發生錯誤: {e.Error.Message}");
		else
			Console.WriteLine("作業完成");
	};
	
	worker.RunWorkerAsync();
	// worker.CancelAsync();
}

另外一個重要的關鍵是錯誤處理,之前有提到過沒辦法把一個錯誤從 B 執行緒拋給 A 執行緒,這個問題在 BackgroundWorker 也有處理, 可以看一下 WorkerThreadStart 的原始碼它有透過 try/catch 捕捉錯誤但它沒有直接拋出,反而是透過 RunWorkerCompletedEventArgs 提供的 Error 屬性把錯誤資訊保存在裡面,最後需要在透過 RunWorkerCompletedEventArgs 事件參數讀取錯誤資訊進行後續處理。


Summary

這個做法介紹 BackgroundWorker 讓我們可以透過這個類別方便建立背景執行的工作,並且也提供了進度回報、任務取消、異常處理等常用功能, 避免每次都要自行實做前景與背景溝通的邏輯。