浅谈.Net异步编程的前世今生----TPL篇
前言
我們在此前已經介紹了APM模型和EAP模型,以及它們的優缺點。在EAP模型中,可以實時得知異步操作的進度,以及支持取消操作。但是組合多個異步操作仍需大量工作,編寫大量代碼方可完成。
因此,在.Net Framework 4.0中,引入了一個新的關于異步操作的模型,叫做任務并行庫,簡稱為TPL。
第三個異步編程模型:TPL
概述
TPL,全稱為Task Parallel Library,它可以被認為是線程池之上的又一個抽象層,隱藏了部分底層細節,核心概念為任務。
一個任務代表了一個異步操作,該操作可以通過多種方式運行,可以使用或者不使用獨立線程(如Thread)運行,還可以通過多種方式和其他任務組合起來。
在本文中,我們將探究TPL的使用方式,以及如何正確處理異常,取消任務,如何使多個任務同時執行等。
創建TPL
我們首先需要創建一個控制臺程序,用來執行Task的創建和運行,并在Task內部使用委托調用一個方法,用來打印當前任務以及當前任務所在的線程信息,如圖所示:
我們分別使用了三種方式來創建任務并執行:
在第一種方式中,使用new Task類的方式,把需要執行的內容放入Action委托并傳入參數,最后使用Start方法開啟任務執行,若不調用Start方法,則不會啟動任務,切記。
在第二種方式和第三種方式中,被創建的任務會立即開始工作,所以無需顯式調用Start方法。Task.Run與Task.Factory.StartNew的區別為,前者是后者的一個快捷方式,但后者擁有附加選項,如沒有特殊需求,通常使用前者來創建任務。
相關代碼如下:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks;namespace TPLDemo {class Program{static void Main(string[] args){var t1 = new Task(() => TaskMethod("任務1"));var t2 = new Task(() => TaskMethod("任務2"));t1.Start();t2.Start();Task.Run(() => TaskMethod("任務3"));Task.Factory.StartNew(() => TaskMethod("任務4"));Task.Factory.StartNew(() => TaskMethod("任務5"), TaskCreationOptions.LongRunning);Thread.Sleep(TimeSpan.FromSeconds(1000));}/// <summary>/// 任務運行的方法/// </summary>/// <param name="name">The name.</param>static void TaskMethod(string name){Console.WriteLine($@"Task {name} 是一個正在線程id為 {Thread.CurrentThread.ManagedThreadId} 上運行的任務,是否為線程池線程:{Thread.CurrentThread.IsThreadPoolThread}");}} }接著我們來看一下運行結果,如圖所示:
可以看出任務1,2,3,4均為線程池中的線程,也印證了我們此前的概念,TPL為線程池上的一個抽象層。而任務5在實現時被我們標記為需要長時間運行的任務,因此在調度時,并未使用線程池中的線程,而是單獨開啟一個線程執行,這樣可以避免線程池中的線程被長時間占用,無法復用資源。
實現取消
在EAP模型中,我們借助BackgroundWorker組件封裝好的取消方法,可以對正在執行的線程進行取消。那么這樣的方式畢竟是有很大的局限性的,因此,在Net Framework 4.0中,微軟創建了統一的模型來協作取消涉及兩個對象的異步操作或長時間運行的同步操作,它就是CancellationTokenSource和CancellationToken。
我們需要創建CancellationTokenSource實例以傳入Task,來標識此任務包含外部取消操作,然后使用CancellationToken來傳播任務內的應取消操作的通知,如圖所示:
相關代碼如下:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks;namespace TPLDemo {class Program{static void Main(string[] args){var cts = new CancellationTokenSource();var longTask = new Task<int>(() => TaskMethod("任務1", 10, cts.Token), cts.Token);Console.WriteLine(longTask.Status);cts.Cancel();Console.WriteLine(longTask.Status);Console.WriteLine("任務1在執行前已經被取消");cts = new CancellationTokenSource();longTask = new Task<int>(() => TaskMethod("任務2", 10, cts.Token), cts.Token);longTask.Start();for (int i = 0; i < 5; i++){Thread.Sleep(TimeSpan.FromSeconds(0.5));Console.WriteLine(longTask.Status);}cts.Cancel();for (int i = 0; i < 5; i++){Thread.Sleep(TimeSpan.FromSeconds(0.5));Console.WriteLine(longTask.Status);}Console.WriteLine($"任務2執行完成,結果:{longTask.Result}");Console.Read();}/// <summary>/// 任務取消的方法/// </summary>/// <param name="name"></param>/// <param name="seconds"></param>/// <param name="token"></param>/// <returns></returns>private static int TaskMethod(string name, int seconds, CancellationToken token){Console.WriteLine($@"Task {name} 是一個正在線程id為 {Thread.CurrentThread.ManagedThreadId} 上運行的任務,是否為線程池線程:{Thread.CurrentThread.IsThreadPoolThread}");for (int i = 0; i < seconds; i++){Thread.Sleep(TimeSpan.FromSeconds(1));if (token.IsCancellationRequested){return -1;}}return 42 * seconds;}} }運行后結果如圖所示:
從代碼中,我們可以看出,我們給Task傳遞了兩次CancellationTokenSource,一次是任務內執行方法,一次是任務本身構造函數,那么為什么要這樣做呢?
因為如果我們在任務啟動之前進行取消,那么該任務所在的TPL模型,就會“接管”該取消操作,因為這些代碼根本不會繼續執行。我們查看第一個任務的狀態可以得知,它已經被取消了,如果在此時再調用Start方法,那么將會拋出一個異常。
而在第二個任務中,我們先執行任務,再做取消,那么此時我們相當于是在外部對此任務進行取消控制,而且在執行取消之后,任務2的狀態依然是RanToCompletion,而不是Canceled。因為從TPL的角度來看,該任務正常完成了它的工作,所以我們在編寫代碼時需要辨別這兩種情況,同時理解它在兩種情況下職責的不同。
處理異常
在普通情況下,我們通常使用try-catch代碼塊來處理異常,但在TPL中,最底層的異常會被封裝為一個AggregateException的通用異常,如果需要獲取真正的異常,則需要訪問InnerException屬性,相關實現如圖所示:
相關代碼如下:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks;namespace TPLDemo {class Program{static void Main(string[] args){Task<int> task;try{task = Task.Run(() => TaskMethod("任務1", 2));int result = task.Result;Console.WriteLine($"結果為:{result}");}catch (Exception ex){Console.WriteLine($"發生異常:{ex}");}Console.WriteLine("----------------------------------------------------------------------------------------");Console.Read();}static int TaskMethod(string name, int seconds){Console.WriteLine($@"Task {name} 是一個正在線程id為 {Thread.CurrentThread.ManagedThreadId} 上運行的任務,是否為線程池線程:{Thread.CurrentThread.IsThreadPoolThread}");Thread.Sleep(TimeSpan.FromSeconds(seconds));throw new Exception("異常!");}} }運行后結果如圖所示:
從代碼實現和運行結果中,我們可以看出調用Task的Result屬性,會使得當前線程等待直到該任務完成,并將異常傳播到當前線程,因此我們可以通過catch捕獲到該異常,且該異常的類型為AggregateException,同時我們打印出的結果包含底層真正異常內容。
但在TPL中,還有另外一種方式來處理異常,那就是使用Task的GetAwaiter和GetResult方法來獲取結果,相關代碼如下:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks;namespace TPLDemo {class Program{static void Main(string[] args){Task<int> task;try{task = Task.Run(() => TaskMethod("任務1", 2));int result = task.Result;Console.WriteLine($"結果為:{result}");}catch (Exception ex){Console.WriteLine($"發生異常:{ex}");}Console.WriteLine("----------------------------------------------------------------------------------------");Console.WriteLine();try{task = Task.Run(() => TaskMethod("任務2", 2));int result = task.GetAwaiter().GetResult();Console.WriteLine($"結果為:{result}");}catch (Exception ex){Console.WriteLine($"發生異常:{ex}");}Console.WriteLine("----------------------------------------------------------------------------------------");Console.Read();}static int TaskMethod(string name, int seconds){Console.WriteLine($@"Task {name} 是一個正在線程id為 {Thread.CurrentThread.ManagedThreadId} 上運行的任務,是否為線程池線程:{Thread.CurrentThread.IsThreadPoolThread}");Thread.Sleep(TimeSpan.FromSeconds(seconds));throw new Exception("異常!");}} }運行后結果如圖所示:
我們從結果中可以看出,在這種情況下,可以直接捕獲到底層異常,而無需再訪問InnerException屬性,原因是TPL模型會直接提取該異常進行處理。
由上述兩種情況我們可以得出結論:如果你需要直接獲取并處理底層異常,那么請使用GetAwaiter和GetResult方法來獲取Task的結果,反之,則可直接使用Result屬性。
任務并行
我們在之前的示例中,都是單獨創建任務并執行,每個任務的執行過程和結果都是獨立的。那么,如果我們需要多個任務并行,要怎么做呢?可以使用如下方式:
我們分別創建了三個任務,但任務之間并不再是無關聯的關系,而是使用了Task.WhenAll與ContineWith來使得它們以某種方式關聯起來。
相關代碼如下:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks;namespace TPLDemo {class Program{static void Main(string[] args){var firstTask = new Task<int>(() => TaskMethod("任務1", 3));var secondTask = new Task<int>(() => TaskMethod("任務2", 2));var whenAllTask = Task.WhenAll(firstTask, secondTask);whenAllTask.ContinueWith(x =>{Console.WriteLine($"任務1結果為:{x.Result[0]},任務2結果為:{x.Result[1]}");}, TaskContinuationOptions.OnlyOnRanToCompletion);firstTask.Start();secondTask.Start();Console.Read();}static int TaskMethod(string name, int seconds){Console.WriteLine($@"Task {name} 是一個正在線程id為 {Thread.CurrentThread.ManagedThreadId} 上運行的任務,是否為線程池線程:{Thread.CurrentThread.IsThreadPoolThread}");Thread.Sleep(TimeSpan.FromSeconds(seconds));return 42 * seconds;}} }運行后結果如圖所示:
分析代碼及運行結果,我們可以得知,在前兩個任務完成后,第三個任務才開始運行,并且該任務的結果提供了一個結果數組,第一個元素是第一個任務的結果,第二個元素是第二個任務的結果,以此類推。
在TPL中,我們也可以創建另外一系列任務,并使用Task.WhenAny的方式等待這些任務中的任何一個執行完成。當有一個任務完成時,會從列表中移除該任務并繼續等待其他任務完成,直到列表為空為止。獲取任務的完成進展情況,或在運行任務時使用超時,都可以使用Task.WhenAny方法。例如我們等待一組任務運行,并且使用其中一個任務來記錄是否超時,如果該任務先完成,那么我們只需取消其他還未完成的任務即可。
小結
我們在這一篇中,講解了TPL的發展歷程和使用方式,對比APM和EAP模型,TPL顯得比較靈活且功能強大,支持取消、異常和并行等操作。
但TPL模型仍有它的不足之處
閱讀此類程序代碼時,仍難以理解程序的實際執行順序。
處理異常時,不得不使用單獨的后續操作任務來處理在之前的異步操作中發生的錯誤,導致了代碼比較分散,增加了復雜度。
所以為了解決這些問題,微軟直接從語言層面引入了更高級別的抽象,真正簡化了異步編程,使得編寫異步程序更為容易。那么它又是什么呢?它能為我們提供多少便利性呢?預知后事如何,且聽下回分解。
您的點贊和在看是我創作的最大動力,感謝支持
公眾號:wacky的碎碎念
知乎:wacky
總結
以上是生活随笔為你收集整理的浅谈.Net异步编程的前世今生----TPL篇的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 浅谈.Net异步编程的前世今生----A
- 下一篇: 从编译器层面理解C#中的闭包的这个坑!