本來是打算講並行For和PLINQ的,但是我感覺前三篇我沒有講得很清晰。之前一直在看《CLR via C#》(后文簡稱CLR)的多線程部分,其中有些部分不是很明白,今天翻開《果殼中的C#》(后文簡稱果殼),看了下多線程部分,發現這本書講的內容雖然很少,但是提綱挈領,把我之前讀CLR中的知識點都串了起來。之前講關鍵字async,await時,提到了狀態機。其實,await會被編譯成awaiter.GetAwaiter()方法,以及之后的委托,果殼中有很簡單的例子來講解,讓我茅塞頓開,還有其他的部分也是這樣。因此我決定寫個總結,就是把之前我講的我認為還沒講透的地方換種方式再講一遍,目的是讓大家,也是我自己真正的明白多線程的工作原理以及如何更好的使用異步。
在總結之前,我要先介紹一下多線程的異常處理。多線程的異常處理分兩種:非池化線程(自己new出來的線程)和池化線程(調用Task)。我們先來看一個非池化的例子。
static void Main(string[] args) { try { Go(); } catch (NullReferenceException exception) { //代碼永遠執行不到這里 } } static void Go() { new Thread(() =>{ Thread.Sleep(1000); throw new NullReferenceException(); }).Start(); }
上述代碼中,程序永遠不會執行到catch里,因為當前try catch只能捕獲到主線程中的異常,無法捕獲其他線程中的異常。處理辦法是將異常處理部分放到Go()函數中。
在池化線程中,任務中拋出的異常都會被捕獲,並收集到AggregateException中,例子如下
static void Main(string[] args) { Go(); Console.ReadLine(); } static void Go() { try { Task.Run(() => throw new NullReferenceException()).Wait(); } catch (AggregateException ex){ if (ex.InnerException is NullReferenceException) Console.WriteLine("捕獲異常"); } }
若你用的是vs2013或者更低版本,當運行這段代碼時,會彈出異常提示,再次點擊運行,就能看到“捕獲異常”,vs2015和vs2017則不需要,這是因為VS將遇到的異常彈出,是為了方便調試。這個例子可以看到任務中拋出了NullReferenceException異常,並在catch塊中捕獲了該異常。可以注意到異常的類型是AggregateException,當任務中拋出了多個異常,會存放在InnerExceptions中,這個和InnerException不同,這是一個只讀集合,里面存放的是全部的異常。上述代碼中,若不顯示調用wait()方法,則不會捕獲到異常。只有等待任務,或者嘗試獲取任務的返回值時,線程池才會拋出異常列表中的第一個異常。
接下來是總結,說是總結,其實是將前面未講透的知識點仔細講解一下。
先說一下async和await關鍵字。
static void Main(string[] args){ GoAsync(); Console.WriteLine("異步運行"); Console.ReadLine(); } static async void GoAsync(){ await Task.Run(() => { //模擬其他任務 Thread.Sleep(2000); }); Console.WriteLine("任務結束"); }
可以看到程序運行了GoAsync()后,直接打印出了“異步運行”四個字,然后過了大約2秒才打印出任務結束。這表明GoAsync方法為異步方法,它不會阻塞線程,就是程序在執行到該函數后,不需要等待該方法結束,而是直接繼續執行下一行代碼。可以注意到GoAsync()方法標有async,且在Task.Run前添加了await關鍵字,這表明程序會等待Task任務,知道該任務結束后,才會繼續執行。其實,這段代碼會被編譯器翻譯成大概下面代碼的樣子(我省略了絕大部分代碼,只保留關鍵的部分,若有興趣,可以將該段代碼編譯后,調用IL反編譯器,查看編譯器真正的編譯結果)。
static void Main(string[] args){ GoAsync(); Console.WriteLine("異步運行"); Console.ReadLine(); } static void GoAsync(){ var awaiter = Task.Run(() => { //模擬其他任務 Thread.Sleep(2000); }).GetAwaiter(); awaiter.OnCompleted(() => Console.WriteLine("任務結束")); }
調用await關鍵字,相當於在此處獲取該任務的awaiter,該awaiter在任務結束后,會調用傳入到OnCompleted方法中的委托。可以看到上述的寫法沒有async和await關鍵字優美,且沒有辦法標識GoAsync()方法為異步,只能以名字區分。async關鍵字能夠很清晰的表明該方法是異步方法,且await用法簡單,只要放在想要等待的任務前面就可以了,編譯器會把await關鍵后面的部分放入到awaiter.OnCompleted()里面,等到任務結束后再開始執行。
下面來介紹下TaskCompletionSource,該類型是用來實現線程的返回值問題的。TaskCompleteSource的結構大概是這樣的:
public class TaskCompletionSource<TResult>{ public void SetResult(TResult result); public void SetException(Exception ex); public void SetCancel(); public bool TrySetException(Exception ex); ... }
每個Set方法都只能調用一次,再次調用會拋出異常,而Try方法會返回false。
下面就利用TaskCompletionSource來實現我們自己的Run()方法:
static void Main(string[] args) { GoAsync(); Console.WriteLine("異步運行"); Console.ReadLine(); } static async void GoAsync() { var t = await Run(() => { //模擬其他任務 Thread.Sleep(2000); return "任務完成"; }); Console.WriteLine(t); } //自己的Run方法 static Task<TResult> Run<TResult>(Func<TResult> func) { var tcs = new TaskCompletionSource<TResult>(); ThreadPool.QueueUserWorkItem(t => { try { tcs.SetResult(func()); } catch (Exception ex) { tcs.SetException(ex); } }, null); return tcs.Task; }
可以看到,Run()方法中,通過tcs.setResult()方法,成功的將返回值拋了出來,並返回了含有結果的Task。該段代碼和上面的調用Task.Run的GoAsync()方法一樣。也可以使用TaskCompletionSource加上定時器來實現Task.Delay()方法,而不用顯式調用線程。該方法的實現就留給讀者自行完成。
以上,本文介紹了多線程中異常的捕獲和處理,其中分為非池化線程和池化線程兩種,其中非池化的異常處理要放在待執行的方法中,而池化線程可以通過調用Result或者await來將異常統一存放到AggregateException中統一處理。然后我針對前面三篇文章中沒有講透的點重新講解了一下。包括await關鍵字的機制,編譯器是通過Awaiter.OnComplete來實現的。之后是TaskCompletionSource,該類型是用來實現線程的返回值問題的,也講解了如何實現自己的Task.Run()方法。並給讀者留了一個用TaskCompletionSource和定時器來實現Task.Delay()的練手題。
歡迎大家在我的評論區與我交流。