談談C#多執行緒開發:並行、並發與非同步編程
閱讀導航
一、使用Task
二、並行編程
三、執行緒同步
四、非同步編程模型
五、多執行緒數據安全
六、異常處理
概述
現代程式開發過程中不可避免會使用到多執行緒相關的技術,之所以要使用多執行緒,主要原因或目的大致有以下幾個:
1、 業務特性決定程式就是多任務的,比如,一邊採集數據、一邊分析數據、同時還要實時顯示數據;
2、 在執行一個較長時間的任務時,不能阻塞UI介面響應,必須通過後台執行緒處理;
3、 在執行批量計算密集型任務時,採用多執行緒技術可以提高運行效率。
傳統使用的多執行緒技術有:
- Thread & ThreadPool
- Timer
- BackgroundWorker
目前,這些技術都不再推薦使用了,目前推薦採用基於任務的非同步編程模型,包括並行編程和Task的使用。
一、使用Task:
大部分情況下,多執行緒的應用場景是在後台執行一個較長時間的任務時,不能阻塞介面響應,同時,任務還是可以取消的。
下面我們實現一個簡單的示例功能:用戶點擊Start按鈕時啟動一個任務,任務執行過程中通過進度條顯示任務進度,點擊Stop按鈕結束任務。


public partial class Form1 : Form { private volatile bool CancelWork = false; public Form1() { InitializeComponent(); } private void btnStart_Click(object sender, EventArgs e) { this.btnStart.Enabled = false; this.btnStop.Enabled = true; CancelWork = false; Task.Run(() => WorkThread()); } private void btnStop_Click(object sender, EventArgs e) { CancelWork = true; } private void WorkThread() { for (int i = 0; i < 100; i++) { this.Invoke(new Action(() => { this.progressBar.Value = i; })); Thread.Sleep(1000); if(CancelWork) { break; } } this.Invoke(new Action(() => { this.btnStart.Enabled = true; this.btnStop.Enabled = false; })); } }
View Code
這個程式碼寫的中規中矩,沒什麼特別的地方,僅僅是用Tsak取代了早期經常採用的Thread、ThreadPool等,雖然Task內部也是對ThreadPool的封裝,但仍然建議盡量採用TASK來實現多任務。
注意:雖然可以通過程式碼強行結束一個任務,但強烈建議不要這樣做,應該給它一個通知讓其自己結束。
二、並行編程:
目標:通過一個計算素數的方法,循環計算並列印出10000以內的素數。
計算一個數是否素數的方法:


private static bool IsPrimeNumber(int number) { if (number < 1) { return false; } if (number == 1 && number == 2) { return true; } for (int i = 2; i < number; i++) { if (number % i == 0) { return false; } } return true; }
View Code
如果不採用並行編程,常規實現方法:
for (int i = 1; i <= 10000; i++) { bool b = IsPrimeNumber(i); Console.WriteLine($"{i}:{b}"); }
採用並行編程方法:
Parallel.For(1, 10000, x=> { bool b = IsPrimeNumber(x); Console.WriteLine($"{i}:{b}"); });
運行程式發現時間差異並不大,主要原因是瓶頸在列印控制台上面,去掉列印程式碼,只保留計算程式碼,就可以看出性能差異。
Parallel實際是通過執行緒池進行任務的分配,執行緒池的最小執行緒數和最大執行緒數將影響到整個程式的性能,需要合理設置。(最小執行緒默認為8。)
ThreadPool.SetMinThreads(10, 10); ThreadPool.SetMaxThreads(20, 20);
按照上述設置,假設執行緒任務耗時比較長不能很快結束。在啟動前面10個執行緒時速度很快,第10~20個執行緒就比較慢一點,大約0.5秒,到達20個執行緒以後,如果前期任務沒有結束就不能繼續分配任務了。
和Task類似,Parallel類仍然是對ThreadPool的封裝,但Parallel有一個優勢,它能知道所有任務是否完成,如果採用執行緒池來實現批量任務,我們需要自己通過計數的方式確定所有子任務是否全部完成。
Parallel類還有一個ForEach方法,使用和For類似,就不重複描述了。
三、 執行緒(或任務)同步
有時我們需要通知一個任務結束,或一個任務等待某個條件進入下一個狀態,這就需要用到任務同步的技術。
一個比較簡單的方法就是定義一個變數來表示狀態。
private volatile bool CancelWork = false;
後台任務可以輪詢該變數進行判斷:
for (int i = 0; i < 100; i++) { if(CancelWork) { break; } }
這是我們常用的方法,可以稱為執行緒狀態機同步(雖然只有兩個狀態)。需要注意的是在通過輪詢去讀取狀態時,循環體內至少應該有1ms的Sleep,不然CPU會很高。
執行緒同步還有一個比較好的辦法就是採用ManualResetEvent 和AutoResetEvent :


public partial class Form1 : Form { private ManualResetEvent manualResetEvent = new ManualResetEvent(false); public Form1() { InitializeComponent(); } private void btnStart_Click(object sender, EventArgs e) { this.btnStart.Enabled = false; this.btnStop.Enabled = true; manualResetEvent.Reset(); Task.Run(() => WorkThread()); } private void btnStop_Click(object sender, EventArgs e) { manualResetEvent.Set(); } private void WorkThread() { for (int i = 0; i < 100; i++) { this.Invoke(new Action(() => { this.progressBar.Value = i; })); if(manualResetEvent.WaitOne(1000)) { break; } } this.Invoke(new Action(() => { this.btnStart.Enabled = true; this.btnStop.Enabled = false; })); } }
View Code
採用WaitOne來等待比通過Sleep進行延時要更好,因為當執行manualResetEvent.WaitOne(1000)時,如果manualResetEvent沒有調用Set,該方法在等待1000ms後返回false,如果期間調用了manualResetEvent的Set方法,該方法會立即返回true,不用等待剩下的時間。
採用這種同步方式優於採用通過內部欄位變數進行同步的方式,另外盡量採用ManualResetEvent 而不是AutoResetEvent 。
四、非同步編程模型(await、async)
假設我們要實現一個簡單的功能:當點擊啟動按鈕時,運行一個任務,任務結束時要報告是否成功,如果成功就顯示綠色圖標、如果失敗就顯示紅色圖標,1秒後圖標顏色恢復為白色;任務運行期間啟動按鈕要不可用。
我寫了相關程式碼:


public partial class Form1 : Form { private void btnStart_Click(object sender, EventArgs e) { this.btnStart.Enabled = false; if(DoSomething()) { this.picShow.BackColor = Color.Green; } else { this.picShow.BackColor = Color.Red; } Thread.Sleep(1000); this.picShow.BackColor = Color.White; this.btnStart.Enabled = true; } private bool DoSomething() { Thread.Sleep(5000); return true; } }
View Code
這段程式碼邏輯清晰、條理清楚,一看就能明白,但存在兩個問題:
1、運行期間UI執行緒阻塞了,用戶介面沒有響應;
2、根本不能實現需求,點擊啟動後,程式卡死6秒種,也沒有看到顏色變化,因為UI執行緒已經阻塞,當重新獲得句柄時圖標已經是白色了。
為了實現需求,我們改用多任務來實現相關功能:


public partial class Form1 : Form { public Form1() { InitializeComponent(); } private void btnStart_Click(object sender, EventArgs e) { this.btnStart.Enabled = false; Task.Run(() => { if (DoSomething()) { this.Invoke(new Action(() => { this.picShow.BackColor = Color.Green; })); } else { this.Invoke(new Action(() => { this.picShow.BackColor = Color.Red; })); } Thread.Sleep(1000); this.Invoke(new Action(() => { this.btnStart.Enabled = true; this.picShow.BackColor = Color.White; })); }); } private bool DoSomething() { Thread.Sleep(5000); return true; } }
View Code
以上程式碼完全實現了最初的需求,但有幾個不完美的地方:
1、主執行緒的btnStart_Click方法除了啟動一個任務以外,啥事也沒幹;
2、由於非UI執行緒不能訪問UI控制項,程式碼里有很多Invoke,比較醜陋;
3、介面邏輯和業務邏輯摻和在一起,使得程式碼難以理解。
採用C#的非同步編程模型,通過使用await、async關鍵字,可以更好地實現上述需求。


public partial class Form1 : Form { public Form1() { InitializeComponent(); } private async void btnStart_ClickAsync(object sender, EventArgs e) { this.btnStart.Enabled = false; var result = await DoSomethingAsync(); if(result) { this.picShow.BackColor = Color.Green; } else { this.picShow.BackColor = Color.Red; } await Task.Delay(1000); this.picShow.BackColor = Color.White; this.btnStart.Enabled = true; } private async Task<bool> DoSomethingAsync() { await Task.Run(() => { Thread.Sleep(5000); }); return true; } }
View Code
這段程式碼看起來就像是同步程式碼,其業務邏輯是如此的清晰優雅,讓人一目了然,關鍵是它還不阻塞執行緒,UI正常響應。
可以看到,通過使用await關鍵字,我們可以專註於業務功能實現,特別是後續任務需要前序任務的返回值的情況下,可以大量減少任務之間的同步操作,程式碼的可讀性也大大增強。
五、 多執行緒環境下的數據安全
目標:我們要向一個字典加入一些數據項,為了增加效率,我們使用了多個執行緒。


private async static void Test1() { Task.Run(() => AddData()); Task.Run(() => AddData()); Task.Run(() => AddData()); Task.Run(() => AddData()); } private static void AddData() { for (int i = 0; i < 100; i++) { if(!Dic.ContainsKey(i)) { Dic.Add(i, i.ToString()); } Thread.Sleep(50); } }
View Code
向字典重複加入同樣的關鍵字會引發異常,所以在增加數據前我們檢查一下是否已經包含該關鍵字。以上程式碼看似沒有問題,但有時還是會引發異常:「已添加了具有相同鍵的項。」原因在於我們在檢查是否包含該Key時是不包含的,但在新增時其他執行緒加入了同樣的KEY,當前執行緒再增加就報錯了。
【注意:也許你多次運行上述程式都能順利執行,不報異常,但還是要清楚認識到上述程式碼是有問題的!畢竟,程式在大部分情況下都運行正常,偶爾報一次故障才是最頭疼的事情。】
上述問題傳統的解決方案就是增加鎖機制。對於核心的修改程式碼通過鎖來確保不會重入。
private object locker4Add=new object(); private static void AddData() { for (int i = 0; i < 100; i++) { lock (locker4Add) { if (!Dic.ContainsKey(i)) { Dic.Add(i, i.ToString()); } } Thread.Sleep(50); } }
以上程式碼可以解決問題,但不是最佳方案。更好的方案是使用執行緒安全的容器:ConcurrentDictionary。


private static ConcurrentDictionary<int, string> Dic = new ConcurrentDictionary<int, string>(); private async static void Test1() { Task.Run(() => AddData()); Task.Run(() => AddData()); Task.Run(() => AddData()); Task.Run(() => AddData()); } private static void AddData() { for (int i = 0; i < 100; i++) { Dic.TryAdd(i, i.ToString()); Thread.Sleep(50); } }
View Code
你可以在新增前繼續檢查一下容器是否已經包含該Key,你也可以不用檢查,TryAdd方法確保不會重複添加且不會產生異常。
剛才是多個執行緒同時寫某個對象,如果就單個執行緒寫對象,其他多個執行緒僅僅是消費(訪問)對象,是否可以使用非執行緒安全的容器呢?
基本上來說多個執行緒讀取一個對象是沒有太大問題的,但還是會存在一些要注意的地方:
1、對於常用的List,在對其進行foreach時List對象不能被修改,不僅不能Remove,Add也不可以;否則會報一個異常:異常資訊:」集合已修改;可能無法執行枚舉操作。」
2、還有一個類似的問題 就是調用Dictionary的ToList方法時有時會報錯,將Dictionary 類型改成ConcurrentDictionary類型,問題依然存在,其原因是ToList會讀取字典的Count,創建相關大小的區域後執行複製,而此時字典的長度增加了。
以上只是描述了多執行緒數據訪問的兩個小例子,實際使用中相關的問題一定會遠遠不止這些,多執行緒程式的大部分異常都是因為資源競爭引起的(包括死鎖),一定要小心處理。
六、多執行緒的異常處理
(一) 異常處理的幾個基本原則
1、 基本原則:不要輕易捕獲根異常;
2、 組件或控制項拋出異常時可以根據需要自定義一些異常,不要拋出根異常,可以直接使用的常用異常有:FormatException、IndexOutOfRangException、InvalidOperationException、InvalidEnumArgumentException ;沒有合適的就自定義;
3、 用戶自定義異常從ApplicationException繼承;
4、 多執行緒的內部異常不會傳播到主執行緒,應該在內部進行處理,可以通過事件推到主執行緒來;
5、應用程式層面可以捕獲根異常,做一些記錄工作,切不可隱匿異常。
(二) 異常處理方案(基於WPF實現)
主執行緒的異常處理:
捕獲你知道的異常,並自行處理,但不要輕易捕獲根異常,下面的程式碼令人深惡痛絕:
try { DoSomething(); } catch(Exception) { //Do Nothing }
當然,如果你確定有能力捕獲根異常,並且是業務邏輯的一部分,可以捕獲根異常 :
try { DoSomething(); MessageBox.Show("OK"); } catch(Exception ex) { MessageBox.Show($"ERROR:{ex.Message}"); }
可等待非同步任務的異常處理:
可等待的任務內的異常是可以傳遞到調用者執行緒的,可以按照主執行緒異常統一處理:
try { await DoSomething(); } catch(FormatException ex) { //Do Something }
Task任務內部異常處理:
非可等待的Task任務內部異常是無法傳遞到調用者執行緒的,參考下面程式碼:
try { Task.Run(() => { string s = "aaa"; int i = int.Parse(s); }); } catch (FormatException ex) { MessageBox.Show("Error"); }
上面程式碼不會實現你期望的效果,它只會造成程式的崩潰。(有時候不會立即崩潰,後面會有解釋)
處理辦法有兩個:
1、自行處理:(1)處理可以預料的異常,(2)同時處理根異常(寫日誌等),也可以不處理根異常,後面統一處理;
2、或將異常包裝成事件推送到主執行緒,交給主執行緒處理。


public partial class FormSync : Form { private event EventHandler<UnhangdledExceptionArgs> UnhandledExceptionCatched; private void Form_Load() { UnhandledExceptionCatched += MainWindow_UnhandledExceptionCatched; } private void MainWindow_UnhandledExceptionCatched(object sender, UnhangdledExceptionArgs e) { MessageBox.Show($"Catch Exception:{e.InnerException.Message}"); } private void Thread1() { Task.Run(()=> { try { throw new ApplicationException("Thread Exception"); } catch (Exception ex) { UnhangdledExceptionArgs args = new UnhangdledExceptionArgs() { InnerException = ex }; UnhandledExceptionCatched?.Invoke(null, args); } }); } } public class UnhangdledExceptionArgs : EventArgs { public Exception InnerException { get; set; } }
View Code
Thread和ThreadPool內部異常:
雖然不推薦使用Thread,如果實在要用,其處理原則和上述普通Task任務內部異常處理方案一致。
全局未處理異常的處理:
雖然我們不推薦catch根異常,但如果一旦發生未知異常程式就崩潰,客戶恐怕難以接受吧,如果要求所有業務模組都處理根異常並進行保存日誌、彈出消息等操作又非常繁瑣,所以,處理的思路是業務模組不處理根異常,但應用程式要對未處理異常進行統一處理。
public partial class App : Application { App() { this.Startup += App_Startup; } private void App_Startup(object sender, StartupEventArgs e) { this.DispatcherUnhandledException += App_DispatcherUnhandledException; AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; } //主執行緒未處理異常 private void App_DispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e) { DoSomething(e.Exception); e.Handled = true; } //未處理執行緒異常(如果主執行緒未處理異常已經處理,該異常不會觸發) private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) { if (e.ExceptionObject is Exception ex) { DoSomething(ex); } } //未處理的Task內異常 private void TaskScheduler_UnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e) { DoSomething(e.Exception); } //保存、顯示異常資訊 private void ProcessException(Exception exception) { //保存日誌 //提醒用戶 } }
解釋一下:
1、 當主執行緒發生未處理異常時會觸發App_DispatcherUnhandledException事件,在該事件中如果設置e.Handled = true,那麼系統不會崩潰,如果沒有設置e.Handled = true,會繼續觸發CurrentDomain_UnhandledException事件(畢竟主執行緒也是執行緒),而CurrentDomain_UnhandledException事件和TaskScheduler_UnobservedTaskException事件觸發後,作業系統都會強行關閉這個應用程式。所以我們應該在App_DispatcherUnhandledException事件中設置e.Handled = true。
2、Thread執行緒異常會觸發CurrentDomain_UnhandledException事件,導致系統崩潰,所以建議盡量不要使用Thread和ThreadPool。
3、非可等待的Task內部異常會觸發TaskScheduler_UnobservedTaskException事件,導致系統崩潰,所以建議Task內部自行處理根異常或將異常封裝為事件推到主執行緒。需要額外注意一點:Task內的未處理異常不會被立即觸發事件,而是要延遲到GC執行回收的時候才觸發,這使得問題更複雜,需要小心處理。
總之
當前,非同步編程模型已經是.NET框架的基本功能了,特別是WEB開發,後台程式碼已經全面非同步化了,所以每個C#開發人員都不能輕視它,必須熟練掌握。 雖然在一知半解的情況下也能寫多執行緒程式,寫的程式也能跑,但就是那些平時一切正常偶爾抽風一下的錯誤會讓頭痛不已。只有深刻了解多執行緒的內部原理,並遵循結構化的設計原則才能寫出健壯、優美的程式碼。