浅谈.Net异步编程的前世今生----异步函数篇(完结)
前言
上一篇我們著重講解了TPL任務并行庫,可以看出TPL已經很符合現代API的特性:簡潔易用。但它的不足之處在于,使用者難以理解程序的實際執行順序。
為了解決這些問題,在C# 5.0中,引入了新的語言特性,被稱為異步函數(asynchronous function)。對應的.Net版本為.Net Framework 4.5。
最后一個異步編程模型:異步函數
概述
由于異步函數為語言特性的實現,因此它的本質依然屬于TPL模型,但提供了更高級別的抽象,真正簡化了異步編程。抽象可以隱藏主要的實現細節,使得開發人員無需考慮許多重要的事情,從而達到簡化的效果。
在本文中,我們主要會講解異步函數的聲明和使用方式,以及在多種場景下使用異步函數,處理異常等。
聲明異步函數
聲明異步函數的方法很簡單,只需使用async關鍵字標注任意一個方法即可。需要注意的是,如果只使用了async標注方法,而方法內部未使用await,會導致編譯警告,如圖所示:
另一個重要的事實是,異步函數必須返回Task或Task<T>類型。也可使用async void,但不推薦,若使用async void方式, 異常處理及跟蹤將不由TPL模型處理,而是會直接在SynchronizationContext上引發,這樣會引起整個進程的崩潰。因此通常會在UI層處理事件時,才會使用async void方式。
改寫后相關代碼示例如下:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks;namespace asyncDemo {public class Utils{public async Task<string> GetStringAsync(){await Task.Delay(TimeSpan.FromSeconds(2));return "Hello World!";}} }這里我們執行完await調用的代碼行后,會立即返回,而不是阻塞兩秒,如果是同步執行則結果相反。當執行完await操作后,TPL會立即將工作線程放回線程池,我們的程序會進行異步等待。直到2秒后,我們又一次從線程池中得到工作線程,并繼續運行其中剩余的異步方法。這樣就允許我們在等待2秒時,可以重用工作線程來做其他事,提升了應用程序的可伸縮性。
事實上,異步函數在編譯器后臺會被編譯成復雜的程序結構,一般稱之為迭代器。迭代器的內部是一種狀態機,由于狀態機的概念理解較為復雜,因此這里不再贅述。所以我們在日常編寫代碼時,并不需要將每一個方法都標記為async,尤其是并不需要使用異步的方法。通過上述概念可知,濫用async會導致編譯器編譯時生成大量的迭代器,會有顯著的性能損失。
獲取異步任務結果
既然我們已經了解了async-await本質上依然為TPL模型,那么在使用TPL和await操作符獲取異步結果中有什么不同呢?此處我們可以通過實驗來探究。
如圖所示,我們分別使用Task和await執行:
二者都調用了同一個異步函數打印當前線程的Id和狀態。
在第一個中啟動了一個任務,運行2秒后返回關于工作線程的信息。我們還定義了一個后續操作,用于在異步操作完成后,打印出操作結果;另一個后續操作用于有錯誤發生時,打印異常信息。最終返回一個代表其中一個后續操作任務的任務,并在Main中等待其執行完成。
而在第二個中,我們直接使用await對任務進行操作,獲取異步執行的結果,同時使用try-catch代碼塊來捕獲可能發生的異常,這和我們編寫同步方法的代碼風格是一致的,簡化了程序編寫的復雜度。實際上在await之后編譯器創建了一個任務及后續操作,并處理了可能發生的異常信息。
相關代碼如下:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks;namespace asyncDemo {class Program{static void Main(string[] args){Task t = AsyncTPL();t.Wait();t = AsyncAwait();t.Wait();Console.Read();}static Task AsyncTPL(){Task<string> t = GetInfoAsync("任務1");Task t2 = t.ContinueWith(x => Console.WriteLine(t.Result), TaskContinuationOptions.NotOnFaulted);Task t3 = t.ContinueWith(x => Console.WriteLine(t.Exception.InnerException), TaskContinuationOptions.OnlyOnFaulted);return Task.WhenAny(t2, t3);}async static Task AsyncAwait(){try{string result = await GetInfoAsync("任務2");Console.WriteLine(result);}catch (Exception ex){Console.WriteLine(ex);}}async static Task<string> GetInfoAsync(string name){await Task.Delay(TimeSpan.FromSeconds(2));return $"{name}的線程Id為:{Thread.CurrentThread.ManagedThreadId},是否為線程池線程:" +$"{Thread.CurrentThread.IsThreadPoolThread}";}} }運行后,如圖所示:
從結果中我們可以看出,兩種操作的方式在概念上是等同的,但是第二種方式中編譯器隱式處理了異步相關的代碼,背后的邏輯更為復雜,我們在后續小節中會借助示例再詳細說明這些內容。
多個連續的await
我們已經得知了使用await的代碼行將會異步執行,那么如果我們在同一個async方法中使用多個連續的await,它們會并行異步執行嗎?我們不妨一試。
如圖所示,我們依然定義TPL和Async函數進行對比:
我們在定義AsyncAwait方法時,依然使用同步代碼的方式進行書寫,唯一的不同之處是連續使用了兩個await聲明。
而在TPL方法中,則使用了一個容器任務,來處理所有相互依賴的任務。然后啟動主任務,并為其添加一系列的后續操作。當該任務完成時,會打印出其結果,然后再啟動第二個任務,并拋出一個異常,打印出異常信息。
相關代碼如下:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks;namespace asyncDemo {class Program{static void Main(string[] args){Task t = AsyncTPL();t.Wait();t = AsyncAwait();t.Wait();Console.Read();}static Task AsyncTPL(){var continueTask = new Task(() =>{Task<string> t = GetInfoAsync("TPL1");t.ContinueWith(task =>{Console.WriteLine(t.Result);Task<string> t2 = GetInfoAsync("TPL2");t2.ContinueWith(innerTask =>Console.WriteLine(innerTask.Result),TaskContinuationOptions.NotOnFaulted | TaskContinuationOptions.AttachedToParent);t2.ContinueWith(innerTask =>Console.WriteLine(innerTask.Exception.InnerException),TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.AttachedToParent);},TaskContinuationOptions.NotOnFaulted | TaskContinuationOptions.AttachedToParent);t.ContinueWith(task =>Console.WriteLine(t.Exception.InnerException),TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.AttachedToParent);});continueTask.Start();return continueTask;}async static Task AsyncAwait(){try{string result = await GetInfoAsync("Async1");Console.WriteLine(result);result = await GetInfoAsync("Async2");Console.WriteLine(result);}catch (Exception ex){Console.WriteLine(ex);}}async static Task<string> GetInfoAsync(string name){Console.WriteLine($"{name} 開始執行!");await Task.Delay(TimeSpan.FromSeconds(2));if (name == "TPL2"){throw new Exception("發生異常!");}return $"{name}的線程Id為:{Thread.CurrentThread.ManagedThreadId},是否為線程池線程:" +$"{Thread.CurrentThread.IsThreadPoolThread}";}} }運行后,執行結果如圖所示:
我們從結果中可以看出,TPL的后續依賴任務會按照我們的書寫順序依次執行,讓人訝異的是await,它并沒有并行執行,而也是順序執行的。Async2任務只有等Async1任務完成后才會開始執行,但它為什么是異步程序呢?
事實上,它并不總是異步的,當使用await時,如果一個任務已經完成,我們會異步地得到相應的任務結果。否則,在看到await聲明時,通常的行為是方法執行到await代碼行應立即返回,且剩下的代碼會在一個后續操作任務中執行。因此等待操作結果時,并沒有阻塞程序執行,這是一個異步調用。當AsyncAwait方法中的代碼在執行時,除了可以在Main中執行t.Wait外,我們可以執行其他任何任務。但主線程必須等待直到所有異步操作完成,否則主線程完成后會停止所有異步操作的后臺線程。
這兩段代碼中,如果要比較TPL和await,那么則是TPL方法的書寫更容易閱讀和理解,調用層次更為清晰,請記住一點,異步并不總是意味著并行執行。
并行執行的await
現在我們已經得知了,異步并不總是并行的,那么它能不能通過某種手段或方式進行并行操作呢?答案是可以的,我們一起看一下如何實現:
這里我們定義了2個不同的Task分別運行3秒和5秒,然后使用Task.WhenAll來創建另一個任務,該任務只有在所有底層任務完成后才會執行,之后我們等待所有任務的結果。
相關實現代碼如下:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks;namespace asyncDemo {class Program{static void Main(string[] args){Task t = AsyncProcessing();t.Wait();Console.Read();}async static Task AsyncProcessing(){Task<string> t1 = GetInfoAsync("任務1", 3);Task<string> t2 = GetInfoAsync("任務2", 5);string[] results = await Task.WhenAll(t1, t2);foreach (string result in results){Console.WriteLine(result);}}async static Task<string> GetInfoAsync(string name, int seconds){await Task.Delay(TimeSpan.FromSeconds(seconds));//await Task.Run(() => Thread.Sleep(TimeSpan.FromSeconds(seconds)));return $"{name}的線程Id為:{Thread.CurrentThread.ManagedThreadId},是否為線程池線程:" +$"{Thread.CurrentThread.IsThreadPoolThread}";}} }運行后,結果如圖所示:
根據程序運行的結果我們可以看到,5秒之后,我們獲取到了所有的結果,說明這些任務是同時運行的。這里還有一個有趣的現象是,兩個任務是被同一個線程池中的工作線程執行的,為什么會這樣呢?這時候我們可以注釋掉Task.Delay這行代碼,并取消對Task.Run的注釋,再次運行后,結果如圖所示:
此時我們會發現,兩個任務會被不同的工作線程執行。
造成這種情況的原因是Task.Delay在幕后使用了一個計時器,它的執行過程如下:
1、從線程池中獲取工作線程,它將等待Task.Delay返回結果;
2、Task.Delay方法啟動計時器,并指定一塊代碼,該代碼會在計時器到了Task.Delay中指定的時間后進行調用,之后立即將工作線程返回線程池中;
3、當計時器事件運行時(類似于Timer類),我們會再次從線程池中獲取一個可用的工作線程并運行計時器給它的代碼(可能會是我們之前使用過的工作線程)。
而Task.Run方法則不同,它的執行過程如下:
1、從線程池中獲取工作線程,并將其阻塞幾秒鐘;
2、獲取第二個工作線程,也將其阻塞幾秒鐘。
在此過程中,兩個工作線程并無法做其他事,只能進行等待操作,因此在某種程度上,這兩個工作線程是被浪費掉了。
所以我們在實際使用時,盡量使用Task.Delay的方式進行并行操作,而不是使用Task.Run。
處理異常
在異步函數中,處理異常可以像同步代碼那樣使用try-catch去處理,但是在不同的場景下,也有不同的使用方式,下面我們一起來看看有哪些常見的使用場景,如圖所示:
我們分別定義了三種場景:單個異常、多個異常及多個異常的異常集合。相關實現代碼如下:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks;namespace asyncDemo {class Program{static void Main(string[] args){Task t = AsyncProcessing();t.Wait();Console.Read();}async static Task AsyncProcessing(){Console.WriteLine("1、單個異常");try{string result = await GetInfoAsync("任務1", 2);Console.WriteLine(result);}catch (Exception ex){Console.WriteLine($"異常內容:{ex}");}Console.WriteLine("-----------------------------------------------------");Console.WriteLine("2、多個異常");Task<string> t1 = GetInfoAsync("任務1", 3);Task<string> t2 = GetInfoAsync("任務2", 2);try{string[] results = await Task.WhenAll(t1, t2);Console.WriteLine(results.Length);}catch (Exception ex){Console.WriteLine($"異常內容:{ex}");}Console.WriteLine("-----------------------------------------------------");Console.WriteLine("3、多個異常的異常集合");t1 = GetInfoAsync("任務1", 3);t2 = GetInfoAsync("任務2", 2);Task<string[]> t3 = Task.WhenAll(t1, t2);try{string[] results = await t3;Console.WriteLine(results.Length);}catch{var ae = t3.Exception.Flatten();var exceptions = ae.InnerExceptions;Console.WriteLine($"異常發生數量:{exceptions.Count}");}}async static Task<string> GetInfoAsync(string name, int seconds){await Task.Delay(TimeSpan.FromSeconds(seconds));throw new Exception($"異常來自于:{name}");}} }執行后的結果如圖所示:
從執行結果我們可以看出,如果在可能發生多個異常的場景下,仍直接使用try-catch的方式處理異常,那么只能從底層的AggregateException中獲取到第一個異常。
為了得到所有的異常信息,我們需要使用await任務的Exception屬性。在第三種場景中,我們使用了AggregateException的Flatten方法,將層級異常放入一個列表,從而達到獲取所有異常的效果,在實際使用時應多加注意。
小結
至此為止,關于異步函數的特性及使用方式就已經介紹完畢。通過異步模型的發展歷程我們可以看出,為了應對不同時期的需求,異步模型也經歷了由復雜到簡單的過程。最終我們使用的異步函數模式,可以使得程序在編寫代碼時,能用編寫同步代碼的方式來實現異步,大大降低了復雜度,也提升了代碼可讀性。由于該思想和語法相當簡潔,在其他語言中也借鑒了類似的語法,如JavaScript在ES6標準中也引入了async-await的寫法來實現異步,避免了多個回調嵌套的尷尬方式。
但關于async-await本身,C#編譯器在背后通過及其復雜的原理為我們屏蔽了底層的細節,包括為何不能使用async void等等,這些原理還是建議大家有時間的話進行一些挖掘和探究,學習背后的設計思想,會對我們的程序設計思維大有裨益。
.Net異步編程系列的文章,到此也暫時告一段落了。我個人在后面的日子中也會將主要精力投入到架構設計和微服務等前沿技術中,同時會總結一些個人的心得與體會形成其他系列的分享,請大家拭目以待。也感謝所有閱讀此系列文章的讀者,感謝大家的反饋,陪伴我度過一段難忘的時光,我們下一期再會!
參考
1.避免 Async Void?https://docs.microsoft.com/zh-cn/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming
總結
以上是生活随笔為你收集整理的浅谈.Net异步编程的前世今生----异步函数篇(完结)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Envoy实现.NET架构的网关(四)集
- 下一篇: 字节前端终于开源!吹爆!