.Net非同步程式設計詳解入門
前言
今天週五,早上起床晚了。趕著擠公交上班。但是目前眼前有這麼幾件事情。刷牙洗臉、泡牛奶、煎蛋。在同步程式設計眼中。先刷牙洗臉,然後燒水泡牛奶。再煎蛋,最後喝牛奶吃蛋。毫無疑問,在時間緊促的當下。它完了,穩的遲到、半天工資沒了。那麼非同步程式設計眼中,或許還有一絲解救的希望。先燒水,同時刷牙洗臉。然後泡牛奶,等牛奶不那麼燙的時候煎個蛋。最後喝牛奶吃蛋。也許還能不遲到。在本篇文章中將圍繞這個事例講解非同步程式設計。
非同步程式設計不同模式
在看非同步模式之前我們先看一個同步呼叫的事例:
class Program { private const string url = "http://www.cninnovation.com/"; static void Main(string[] args) { AsyncTest(); } public static void AsyncTest() { Console.WriteLine(nameof(AsyncTest)); using (var client=new WebClient()) { string content = client.DownloadString(url); Console.WriteLine(content.Substring(0,100)); } Console.WriteLine(); } }
在這個事例中,DownloadString方法將請求的地址下載為string資源,但是在我們實際運行當中,因為DownloadString方法阻塞呼叫執行緒,直到返回結果。整個程式就一直卡在了DownloadString方法這裡。這樣的體驗是非常的不愉快的。有了問題,自然也就有了對應的解決方法,下面我們就一起來看看對應的解決方法的進步史吧。
一、非同步模式
非同步模式是處理非同步特性的第一種方式,它不僅可以使用幾個API,還可以使用基本功能(如委託型別)。不過這裡需要注意的是在使用.NET Core呼叫委託的這些方法時,會丟擲一個異常,其中包含平臺不支援的資訊。
非同步模式定義了BeginXXX方法和EndXXX方法。例如上面同步方法是DownloadString,那麼非同步就是BeginDownloadString和EndDownloadString方法。BeginXXX方法接收其同步方法的所有輸入的引數,EndXXX方法使用同步方法所有的輸出引數,並按照同步方法的返回型別來返回結果。BeginXXX定義了一個AsyncCallback引數,用於接受在非同步方法執行完成後呼叫的委託。BeginXXX方法返回IAsyncResult,用於驗證呼叫是否已經完成,並且一直等到方法執行結束。
我們看下非同步模式的事例,因為上面事例中的WebClient沒有非同步模式的實現,這裡我們使用WebRequest來代替:
class Program { private const string url = "http://www.cninnovation.com/"; static void Main(string[] args) { AsyncTest(); } public static void AsyncTest() { Console.WriteLine(nameof(AsyncTest)); WebRequest request = WebRequest.Create(url); IAsyncResult result = request.BeginGetResponse(ReadResponse, null); Console.ReadLine(); void ReadResponse(IAsyncResult ar) { using (WebResponse response = request.EndGetResponse(ar)) { Stream stream = response.GetResponseStream(); var reader = new StreamReader(stream); string content = reader.ReadToEnd(); Console.WriteLine(content.Substring(0, 100)); Console.WriteLine(); } } } }
上面事例中展現了非同步呼叫的一種方式---使用非同步模式。先使用WebRequest類的Create方法建立WebRequest,然後使用BeginGetResponse方法非同步將請求傳送到伺服器。呼叫執行緒沒有被阻塞。第一個引數上面有講,完成後回撥的委託。一旦網路請求完成,就會呼叫該方法。
在UI應用程式中使用非同步模式有一個問題:回撥的委託方法沒有在UI執行緒中允許,因此如果不切換到UI,就不能訪問UI元素的成員,而是丟擲一個異常。呼叫執行緒不能訪問這個物件,因為另一個執行緒擁有它。為了簡化這個過程在.NET Framework 2.0 中引入了基於時間的非同步模式,這樣更好的解決了此問題,下面就介紹基於事件的非同步模式。
二、基於事件的非同步模式
基於事件的非同步模式定義了一個帶有”Async”字尾的方法。下面看下如何使用這個基於事件的非同步模式,還是使用的第一個事例進行修改。
class Program { private const string url = "http://www.cninnovation.com/"; static void Main(string[] args) { AsyncTest(); } public static void AsyncTest() { Console.WriteLine(nameof(AsyncTest)); using (var client =new WebClient()) { client.DownloadStringCompleted += (sender, e) => { Console.WriteLine(e.Result.Substring(0,100)); }; client.DownloadStringAsync(new Uri(url)); Console.ReadLine(); } } }
在上述事例中,對於同步方法DownloadString,提供了一個非同步變體方法DownloadStringAsync。當請求完成時會觸發DownloadStringCompleted 事件,關於事件使用及描述前面文章已有詳細介紹了。這個事件型別一共帶有兩個引數一個是object型別,一個是DownloadStringCompletedEventArgs型別。後面個這個型別通過Result屬性返回結果字串。
這裡使用的DownloadStringCompleted 事件,事件處理成將通過儲存同步上下文的執行緒來呼叫,在應用程式中這就是UI執行緒,因此可以直接訪問UI元素。這裡就是與上面那個非同步模式相比更優之處。下面我們看看基於事件的非同步模式進一步的改進將是什麼樣的————基於任務的非同步模式。
三、基於任務的非同步模式
在.NET Framework 4.5中更新了WebClient類,也新增提供了基於任務的非同步模式,該模式也定義了一個”Async”字尾的方法,返回一個Task型別,但是由於基於事件的非同步模式已經採用了,所以更改為——DownloadStringTaskAsync。
DownloadStringTaskAsync方法宣告返回為Task<string>,但是不需要一個Task<string>型別的變數接收返回結果,只需要宣告一個string型別的變數。並且使用await關鍵字。此關鍵字會解除執行緒的阻塞,去完成其他的任務。我們看下面這個事例
class Program { private const string url = "http://www.cninnovation.com/"; static async Task Main(string[] args) { await AsyncTestTask(); } public static async Task AsyncTestTask() { Console.WriteLine("當前任務Id是:"+Thread.CurrentThread.ManagedThreadId); Console.WriteLine(nameof(AsyncTestTask)); using (var client = new WebClient()) { string content = await client.DownloadStringTaskAsync(url); Console.WriteLine("當前任務Id是:"+Thread.CurrentThread.ManagedThreadId); Console.WriteLine(content.Substring(0,100)); Console.ReadLine(); } } }
上面程式碼相對於之前的就較為簡單多了,並且也沒有阻塞,不用切換回UI執行緒。呼叫順序也和同步方法一樣。
這裡我單獨的放出了允許結果,新增了當前任務顯示,在剛進入方法時任務為1,但是執行完成DownloadStringTaskAsync方法後,任務id變成了8,上面其他的事例允許此程式碼也都是返回任務id為1,這也就是基於任務的非同步模式的不同點。
非同步程式設計的基礎
async和await關鍵字編譯器功能,編譯器會用Task類建立程式碼。如果不使用這兩個關鍵字,也是可以用c#4.0Task類的方法來實現同樣的功能,雖然會麻煩點。下面我們看下async和await這兩個關鍵字能做什麼,如何採用簡單的方式建立非同步方法,如何並行呼叫多個非同步方法等等。
這裡我們首先建立一個觀察執行緒和任務的方法,來更好的觀察理解發送的變化。
public static void SeeThreadAndTask(string info) { string taskinfo = Task.CurrentId == null ? "沒任務" : "任務id是:" + Task.CurrentId; Console.WriteLine($"{info} 線上程{Thread.CurrentThread.ManagedThreadId}和{taskinfo}中執行"); }
同時準備了一個同步方法,該方法使用Delay方法等待一段時間後返回一個字串。
static void Main(string[] args) { var name= GetString("張三"); Console.WriteLine(name); } static string GetString(string name) { SeeThreadAndTask($"執行{nameof(GetString)}"); Task.Delay(3000).Wait(); return $"你好,{name}"; }
一、建立任務
上面我們也說了不使用哪兩個關鍵字也可以使用Task類實現同樣的功能,這裡我們採用一個簡單的做大,使用Task.Run方法返回一個任務。
static void Main(string[] args) { SeeThreadAndTask($"執行{nameof(Main)}"); var name= GetStringAsync("張三"); Console.WriteLine(name.Result); Console.ReadLine(); } static Task<string> GetStringAsync(string name) => Task.Run<string>(() => { SeeThreadAndTask($"執行{nameof(GetStringAsync)}"); return GetString(name); });
二、呼叫非同步方法
我們繼續來看await和async關鍵字,使用await關鍵字呼叫返回任務的非同步方法,但是也需要使用async修飾符。
static void Main(string[] args) { SeeThreadAndTask($"執行{nameof(Main)}"); GetSelfAsync("張三"); Console.ReadLine(); } private static async void GetSelfAsync(string name) { SeeThreadAndTask($"開始執行{nameof(GetSelfAsync)}"); string result =await GetStringAsync(name); Console.WriteLine(result); SeeThreadAndTask($"結束執行{nameof(GetSelfAsync)}"); }
在非同步方法完成前,該方法內的其他程式碼不會執行。但是,啟動GetSelfAsync方法的執行緒可以被重用。該執行緒沒有被阻塞。
這裡剛開始時候中是沒有任務執行的,GetStringAsync方法開始在一個任務中執行,這裡所在的執行緒也是不同的。其中GetString和GetStringAsync方法都執行完畢,等待之後返回現在GetStringAsync開始轉變為執行緒3,同時也沒有任務。await確保任務完成後繼續執行,但是現在使用的是另一個執行緒。這一個行為在我們使用控制檯應用程式和具有同步上下文的應用程式之間是不同的。
三、使用Awaiter
可以對任何提供GetAwaiter方法並對awaiter的物件async關鍵字。其中awaiter用OnCompleted方法實現INotifyCompletion介面,完成任務時呼叫,下面事例中沒有使用await關鍵字,而是使用GetAwaiter方法,返回一個TaskAwaiter,並且使用OnCompleted方法,分配一個在任務完成時呼叫的本地函式。
static void Main(string[] args) { SeeThreadAndTask($"執行{nameof(Main)}"); GetSelfAwaiter("張三"); Console.ReadLine(); } private static void GetSelfAwaiter(string name) { SeeThreadAndTask($"執行{nameof(GetSelfAwaiter)}"); TaskAwaiter<string> awaiter = GetStringAsync(name).GetAwaiter(); awaiter.OnCompleted(OnCompletedAwauter); void OnCompletedAwauter() { Console.WriteLine(awaiter.GetResult()); SeeThreadAndTask($"執行{nameof(GetSelfAwaiter)}"); } }
我們看這個執行結果,再與上面呼叫非同步方法的執行結果進行對比,好像類似於使用await關鍵字的情形。相當於編譯器把await關鍵字後面的所有的程式碼放進OnCompleted方法的程式碼塊中完成。當然也可另外方法使用GetAwaiter方法。
static void Main(string[] args) { SeeThreadAndTask($"執行{nameof(Main)}"); GetSelfAwaiter("張三"); Console.ReadLine(); } private static void GetSelfAwaiter(string name) { SeeThreadAndTask($"執行{nameof(GetSelfAwaiter)}"); string awaiter = GetStringAsync(name).GetAwaiter().GetResult(); Console.WriteLine(awaiter); SeeThreadAndTask($"執行{nameof(GetSelfAwaiter)}"); }
四、延續任務
這裡我們介紹使用Task物件的特性來處理任務的延續。GetStringAsync方法返回一個Task<string>物件包含了任務建立的一些資訊,並一直儲存到任務完成。Task類的ContinueWith定義了完成任務之後就呼叫的程式碼。這裡指派給ContinueWith方法的委託接收將已完成的任務作為引數傳入,可以使用Result屬性訪問任務的返回結果。
static void Main(string[] args) { SeeThreadAndTask($"執行{nameof(Main)}"); GetStringContinueAsync("張三"); Console.ReadLine(); } /// <summary> /// 使用ContinueWith延續任務 /// </summary> /// <param name="name"></param> private static void GetStringContinueAsync(string name) { SeeThreadAndTask($"開始 執行{nameof(GetStringContinueAsync)}"); var result = GetStringAsync(name); result.ContinueWith(t=> { string answr = t.Result; Console.WriteLine(answr); SeeThreadAndTask($"結束 執行{nameof(GetStringContinueAsync)}"); }); }
這裡我們觀察執行結果可以發現在執行完成任務後繼續執行ContinueWith方法。其中這個方法線上程4和任務2中完成。這裡相當於又開始了一個新的任務,也就是使用ContinueWith方法對任務進行一定的延續。
五、多個非同步方法的使用
在每個非同步方法中可以呼叫一個或多個非同步方法。那麼如何進行編碼呢?這就看這些非同步方法之間是否存在相互依賴了。
正常來說按照順序呼叫:
static void Main(string[] args) { SeeThreadAndTask($"執行{nameof(Main)}"); ManyAsyncFun(); Console.ReadLine(); } private static async void ManyAsyncFun() { var result1 = await GetStringAsync("張三"); var result2 = await GetStringAsync("李四"); Console.WriteLine($"第一個人是{result1},第二個人是{result2}"); }
使用await關鍵字呼叫每個非同步方法。如果一個非同步方法依賴另一個非同步方法的話,那麼這個await關鍵字就比較有效,但是如果第二個非同步方法獨立於第一個非同步方法,這樣可以不使用await關鍵字,這樣的話整個ManyAsyncFun方法將會更快的返回結果。
還一種情況,非同步方法不依賴於其他非同步方法,而且不使用await,而是把每個非同步方法的返回結果賦值給Task比變數,這樣會執行的更快。組合器可以幫助實現這一點,一個組合器可以接受多個同一型別的引數,並返回同一型別的值。如果任務返回相同的型別,那麼該型別的陣列也可用於接收await返回的結果。當只有等待所有任務都完成時才能繼續完成其他的任務時,WhenAll方法就有實際用途,當呼叫的任務在等待完成時任何任務都能繼續完成任務的時候就可以採用WhenAny方法,它可以使用任務的結果繼續。
static void Main(string[] args) { SeeThreadAndTask($"執行{nameof(Main)}"); ManyAsyncFunWithWhenAll(); Console.ReadLine(); } private static async void ManyAsyncFunWithWhenAll() { Task<string> result1 = GetStringAsync("張三"); Task<string> result2 = GetStringAsync("李四"); await Task.WhenAll(result1, result2); Console.WriteLine($"第一個人是{result1.Result},第二個人是{result2.Result}"); }
在使用await依次呼叫兩個非同步方法時,診斷會話6.646秒,採用WhenAll時,診斷會話話費3.912秒,可以看出速度明顯提高了。
六、使用ValueTasks
C#帶有更靈活的await關鍵字:它現在可以等待任何提供GetAwaiter方法的物件。下面我們講一個可用於等待的新型別-----ValueTask,與Task相反,ValueTask是一個結構。這具有效能優勢,因ValueTask在堆上沒有物件。
static async Task Main(string[] args) { SeeThreadAndTask($"執行{nameof(Main)}"); for (int i = 0; i < 10000; i++) { string result2 = await GetStringDicAsync("張三"); } Console.WriteLine("結束"); Console.ReadLine(); } private readonly static Dictionary<string, string> names = new Dictionary<string, string>(); private static async ValueTask<string> GetStringDicAsync(string name) { if (names.TryGetValue(name,out string result)) { return result; } else { result = await GetStringAsync(name); names.Add(name,result); return result; } }
上面事例中我們使用ValueTask替代了Task,因為我們前面講,每次使用Task都會對記憶體進行分配空間,在我們反覆時會造成一定的效能上的損耗,但是使用ValueTask只會存放在Stack中,存放實際值而不是記憶地址。
七、轉換非同步模式
並非所有的.NET Framework的所有的類都引用了新的非同步方法,在使用框架中不同的類的時候會發現,還有許多類只提供了BeginXXX方法和EndXXX方法的非同步模式,沒有提供基於任務的非同步模式,但是我們可以把非同步模式更改為基於任務的非同步模式。
提供的Task.Factory.FromAsync<>泛型方法,將非同步模式轉換為基於任務的非同步模式。
static void Main(string[] args) { ConvertingAsync(); Console.ReadLine(); } private static async void ConvertingAsync() { HttpWebRequest request = WebRequest.Create("http://www.cninnovation.com/") as HttpWebRequest; using (WebResponse response = await Task.Factory.FromAsync<WebResponse>(request.BeginGetResponse(null,null),request.EndGetResponse)) { Stream stream = response.GetResponseStream(); using (var reader=new StreamReader(stream)) { string content = reader.ReadToEnd(); Console.WriteLine(content.Substring(0,100)); } } }
非同步程式設計的錯誤處理
上一節我們講了錯誤和異常處理,但是我們在使用非同步方法時,應該知道一些特殊的處理方式,我們先看一個簡單的事例
static void Main(string[] args) { Dont(); Console.WriteLine("結束"); Console.ReadLine(); } static async Task ThrowAfterAsync(int ms, string msg) { await Task.Delay(ms); throw new Exception(msg); } private static void Dont() { try { ThrowAfterAsync(200,"第一個錯誤"); } catch (Exception ex) { Console.WriteLine(ex.Message); } }
在這個事例中,呼叫了非同步方法,但是並沒有等待,try/catch就捕獲不到異常,這是因為Dont方法在丟擲異常前就執行結束了。
一、非同步方法的非同步處理
那麼非同步方法的異常怎麼處理呢,有一個較好的方法就是使用await關鍵字。將其放在try/catch中,非同步方法呼叫完後,Dont方法就會釋放執行緒,但它會在任務完成時保持任務的引用。
private static async void Dont() { try { await ThrowAfterAsync(200,"第一個錯誤"); } catch (Exception ex) { Console.WriteLine(ex.Message); } }
二、多個非同步方法的非同步處理
那麼多個非同步方法呼叫,每個都丟擲異常怎麼處理呢?我們看下面事例中
private static async void Dont() { try { await ThrowAfterAsync(200,"第一個錯誤"); await ThrowAfterAsync(100, "第二個錯誤"); } catch (Exception ex) { Console.WriteLine(ex.Message); } }
呼叫兩個非同步方法,但是都丟擲異常,因為捕獲了一個異常之後,try塊程式碼就沒有繼續呼叫第二方法,也就只丟擲了第一個異常
private static async void Dont() { try { Task t1 = ThrowAfterAsync(200, "第一個錯誤"); Task t2 = ThrowAfterAsync(100, "第二個錯誤"); await Task.WhenAll(t1,t2); } catch (Exception ex) { Console.WriteLine(ex.Message); } }
對上述事例修改,採用並行呼叫兩個方法,在2s秒後第一個丟擲異常,1s秒後第二個異常也丟擲了,使用Task.WhenAll,不管是否丟擲異常,都會等兩個任務完成。因此就算捕獲了第一個異常也會執行第二個方法。但是我們只能看見丟擲的第一個異常,沒有顯示第二個異常,但是它存在在列表中。
三、使用AggregateException
這裡為了得到所有失敗任務的異常資訊,看將Task.WhenAll返回的結果寫到一個Task變數中。這個任務會一個等到所有任務結束。
private static async void Dont() { Task taskResult = null; try { Task t1 = ThrowAfterAsync(200, "第一個錯誤"); Task t2 = ThrowAfterAsync(100, "第二個錯誤"); await (taskResult=Task.WhenAll(t1,t2)); } catch (Exception ex) { Console.WriteLine(ex.Message); foreach (var item in taskResult.Exception.InnerExceptions) { Console.WriteLine(item.Message); } } }
這裡可以訪問外部任務的Exception屬性了。Exception屬性是AggregateException型別的。這裡使用Task.Exception.InnerExceptions屬性,它包含了等待中所有的異常列表。這樣就可以輕鬆的變數所有的異常了。
總結
本篇文章介紹了三種不同的非同步模式,同時也介紹 了相關的非同步程式設計基礎。如何對應的去使用非同步方法大有學問,用的好的非同步程式設計減少效能消耗,提高執行效率。但是使用不好的非同步程式設計提高效能消耗,降低執行效率也不是不可能的。這裡也只是簡單的介紹了非同步程式設計的相關基礎知識以及錯誤處理。更深更完美的程式設計模式還得實踐中去探索。非同步程式設計使用async和await關鍵字等待這些方法。而不會阻塞執行緒。非同步程式設計的介紹到這裡就暫時結束,下一篇文章我們將詳細介紹反射、元資料和動態程式設計。
不是井裡沒有水,而是你挖的不夠深。不是成功來得慢,而是你努力的不夠多。
c#基礎知識詳解系列
歡迎大家掃描下方二維碼,和我一起學習更多的C#知識