閱讀導航
一、使用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; })); } }
這個代碼寫的中規中矩,沒什么特別的地方,僅僅是用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; }
如果不采用並行編程,常規實現方法:
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; })); } }
采用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; } }
這段代碼邏輯清晰、條理清楚,一看就能明白,但存在兩個問題:
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; } }
以上代碼完全實現了最初的需求,但有幾個不完美的地方:
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; } }
這段代碼看起來就像是同步代碼,其業務邏輯是如此的清晰優雅,讓人一目了然,關鍵是它還不阻塞線程,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); } }
向字典重復加入同樣的關鍵字會引發異常,所以在增加數據前我們檢查一下是否已經包含該關鍵字。以上代碼看似沒有問題,但有時還是會引發異常:“已添加了具有相同鍵的項。”原因在於我們在檢查是否包含該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); } }
你可以在新增前繼續檢查一下容器是否已經包含該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; } }
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#開發人員都不能輕視它,必須熟練掌握。 雖然在一知半解的情況下也能寫多線程程序,寫的程序也能跑,但就是那些平時一切正常偶爾抽風一下的錯誤會讓頭痛不已。只有深刻了解多線程的內部原理,並遵循結構化的設計原則才能寫出健壯、優美的代碼。