1、簡介
為什么MS要推出Task,而不推Thread和ThreadPool,以下是我的見解:
(1)、Thread的Api並不靠譜,甚至MS自己都不推薦,原因,它將整個Thread類都不開放給Windows Sotre程序,且它的Api過於強大,如果在程序中過度使用,維護的成本太高,想想代碼中充斥着掛起線程,阻塞線程、后期的應用程序很難維護.
(2)、ThreadPool最大的問題是,所有的輔助線程都是異步的,沒有向Thread的Join方法那樣去等待一個線程執行完,然后執行回調函數的機制,也就是你無法判斷線程什么時候執行完,也沒有機制獲得線程的返回值,所有MS推出了Task來解決Thread和ThreadPool的問題
當然最主要的是,Thread和Thread好用.因為Task是它們的升級版,升級版當然比較好.
2、Task的缺點
雖然Task以其強大的Api,以及封裝,讓我們在CLR環境下,能完成高效率的編程,但是它並不是沒有缺點的,高效率的背后,肯定帶來的性能的損失,這一點很多類似的框架都能說明,比如EF,強大的背后,大量的使用了反射等操作,所以雖然開發效率提升了,但是性能卻下降了,這里不想說太多,所以簡單的api可能不會產生過多的性能損耗,所以這也是為什么大型互聯網項目,更願意使用原生Ado或者Dapper去做.所以這些在我們的實際開發中,這些都需要我們去權衡.有得必有失.下面來簡單的說下Task具體在哪里會產生性能損失:
很直觀,直接分析ThreadPool類和Task類的構造:
ThreadPool類
很簡潔,沒有任何的字段和屬性!
Task類,1700行代碼,里面有大量的字段和屬性,大致如下:
還包括對父任務的引用、任務調度器(TaskScheduler)的引用、對回調方法的引用、對執行上下文(ExecutionContext)的引用、對ManualResetEventSlim信號量的引用、還有CancellationToken取消信號量(我把它理解為信號量)的引用、一個ContinueWithTask的任務集合的引用、還有未拋出異常的Task對象集合的引用等等,這些后面的文章都會介紹.
所以,不分析具體的性能損耗點,但是單單兩個類的構造,你就能清楚使用那個類創建線程所產生的性能消耗大.
3、實戰
(1)、不帶返回值,實現和ThreadPool線程池線程一樣的效果
static void Main(string[] args) { var result=Task.Run(() => Calculate("這個參數很六啊")); Console.WriteLine("主線程有沒有在繼續執行,look look"); Console.ReadKey(); } static void Calculate(string param) { Console.WriteLine("子線程開始執行,帶着主線程給它傳遞的參數呢!參數是:{0}",param); Thread.Sleep(2000); Console.WriteLine("子線程執行完了"); }
根據輸出,發現主線程並沒有等帶子線程執行完畢,通過開啟一個新線程之后,立刻返回去執行它自己的任務.
(2)、帶返回值
static void Main(string[] args) { var result=Task.Run(() => Calculate(1)); Console.WriteLine(result.Result); Console.WriteLine("主線程有沒有在繼續執行,look look"); Console.ReadKey(); } /// <summary> /// 簡單遞歸計算n+(n-1)+.....+1 /// </summary> /// <param name="param"></param> /// <returns></returns> static int Calculate(int param) { if (param == 1) return 1; return param + Calculate(param - 1); }
無論給Calculate方法傳遞的參數多小,主線程都等待子線程返回結果后,在繼續執行它的任務.所以可以得出結論.調用子線程返回值的Result屬性
相等於調用了Wait方法,當然Task確實提供了這個實例方法,但是使用Result屬性一樣有這個效果.主線程會等待子線程執行完畢在執行它的任務.
(3)、關於Task的小要點
當主線程通過Task開啟了一個子線程之后,返回做自己的事情,當它執行到Wait方法,這個時候主線程會阻塞,CPU的執行速度很快,所以它會去判斷子線程有沒有開始執行,如果沒有執行,那么它會自己去做子線程的任務,而不是開啟一個新的線程去做.這樣就節約了系統資源.這樣就不會存在線程阻塞的情況.所有事情都由主線程干完.
(4)、關於簡單的死鎖問題
一般死鎖的產生,都是多線程爭用相同的資源導致的.下面就來重現一下.
private static object lockObj = new object(); static void Main(string[] args) { var result=Task.Run(() => Calculate(100)); lock (lockObj) { Console.WriteLine("主線程這個時候爭用了lockObj鎖,並執行子線程"); Console.WriteLine(result.Result); } Console.WriteLine("主線程有沒有在繼續執行,look look"); Console.ReadKey(); } /// <summary> /// 簡單遞歸計算n+(n-1)+.....+1 /// </summary> /// <param name="param"></param> /// <returns></returns> static int Calculate(int param) { lock (lockObj) { Console.WriteLine("子線程這個時候也去爭用lockObj鎖,發現主線程已經爭用了這個鎖,那么它等待主線程釋放這個鎖,但是主線程正等待它執行完!"); Console.WriteLine("好了,這個時候就發生了死鎖現象.主線程等子線程執行完,子線程等主線程釋放lockObj鎖,兩個線程在相互等待,死鎖了"); } if (param == 1) return 1; return param + Calculate(param - 1); }
光標一直在那閃啊閃,好吧,那就都等着吧.誰都執行不下去了.
解決辦法很簡單.在創建一個新的鎖,這個就不代碼演示了.
(5)、取消Task創建的子線程
取消Task創建的線程和取消ThreadPool創建的子線程一樣,通過CancellationTokenSource類實現,代碼如下:
var cancellationSource = new CancellationTokenSource(); cancellationSource.Cancel(); try { Task.Run(() => ChildThread(cancellationSource.Token)); } catch(AggregateException ex) { //處理子線程拋出的異常 ex.Handle((x) => x is OperationCanceledException); } Console.WriteLine("主線程繼續做它的事情"); Console.ReadKey(); } /// <summary> /// 子線程 /// </summary> static void ChildThread(CancellationToken token) { token.ThrowIfCancellationRequested(); Console.WriteLine("子線程做完了它的事情"); }
(6)、任務完成時啟動新的任務 ContinueWith
當使用Task進行多線程任務開發時,不建議使用Wait方法或者Result屬性,去阻塞主線程,原因如下:
i、會卡界面
ii、伸縮性好的軟件,不會這么做,除非迫不得已
iii、很有可能創建新的線程,浪費資源(如果主線程執行的足夠快,它可能自己去完成子線程的任務,而不是創建新的線程)
代碼如下:
static void Main(string[] args) { //開啟一個子線程進行計算操作 var watch = Stopwatch.StartNew(); Task<int> task=Task.Run(() => ChildThreadOne()); //當子線程一計算完畢之后,開啟一個新的線程去執行輸出子線程一的結果,這里新的線程不會阻塞 //只有當子線程完成計算輸出后,它才會開啟,並輸出子線程的值 //所以該程序並不會發生線程阻塞的情況 task.ContinueWith(x => { watch.Stop(); Console.WriteLine("輸出子線程一的返回值:{0},耗時:{1}", task.Result, watch.ElapsedMilliseconds / 1000); }); Console.WriteLine("主線程繼續做它的事情"); Console.ReadKey(); } /// <summary> /// 子線程一 /// </summary> /// <returns></returns> static int ChildThreadOne() { Thread.Sleep(2000);//模擬長時間運算 return 666; }
這里注意兩點:
(1)、這里ContinueWith會檢測到子線程完成之后,立即啟動一個新的線程去顯示結果.不會存在子線程還沒有完成計算的情況下,輸出一個空值,或者發生異常,這一點,CLR能保證.
(2)、這里ContinueWith會返回一個Task對象示例,所以可以調用Wait方法,或者Result屬性,單一般不建議這么做,還是那句話會阻塞線程.一般都忽略這個Task實例,所以需要謹慎使用.
static void Main(string[] args) { Task<int> task = Task.Run(() => ChildThreadOne()); var t1=task.ContinueWith((x) => ChildOneContinueOne(task.Result)); var t2=task.ContinueWith((x) => ChildOneContinueTwo(task.Result)); t1.ContinueWith(x => { Console.WriteLine("輸出子線程一的計算結果加10后的結果值:{0}", t1.Result); }); t2.ContinueWith(x => { Console.WriteLine("輸出子線程一的計算結果乘10后的結果值:{0}", t2.Result); }); Console.WriteLine("主線程繼續做它的事情"); Console.ReadKey(); } /// <summary> /// 子線程一 /// </summary> /// <returns></returns> static int ChildThreadOne() { Thread.Sleep(2000);//模擬長時間運算 return 10; } /// <summary> /// 在子線程一完成計算后,開啟一個新的線程對子線程一的結果進行+66操作 /// </summary> /// <param name="childOneResult"></param> /// <returns></returns> static int ChildOneContinueOne(int childOneResult) { Console.WriteLine("ChildOneContinueOne線程拿到的子線程一的結果值為{0}", childOneResult); Thread.Sleep(2000);//模擬長時間計算任務 return 10 + childOneResult; } /// <summary> /// 在子線程一完成計算后,開啟一個新的線程對子線程一的結果進行乘66操作 /// </summary> /// <param name="childOneResult"></param> /// <returns></returns> static int ChildOneContinueTwo(int childOneResult) { Console.WriteLine("ChildOneContinueTwo線程拿到的子線程一的結果值為{0}", childOneResult); Thread.Sleep(2000);//模擬長時間計算任務 return 10 * childOneResult; }
用ContinueWith做了一件有趣的事情,大致思路是我們在開發過程中會遇到,到我們拿到一個線程的返回值后,立即開啟兩個新的線程去做兩個方向的任務,如下圖:
這在開發中經常使用,整個過程沒有任務阻塞線程.暫時沒有發現多線程爭用問題.
原理淺析:
Task對象實例包含一個ContinueWith任務的一個集合,所以可以使用Task對象多次調用ContinueWith方法(就像上面的代碼一樣),所有的線程都會進入線程池的隊列中,當Task任務執行完畢,線程池回依次調用它們.
(2)、使用ContinueWith中產生的特殊情況
當子線程發生異常、取消、或者超時時,這個時候就要告訴線程池如何處理喚起線程,而不是無視,子線程的異常,所以MS給ContinueWith提供了一個TaskContinuationOptions枚舉,來處理這個問題.下面介紹幾個常用的.
TaskContinuationOptions.OnlyOnRanToCompletion 主要當前面的任務,完美的完成任務,才能執行延續任務.
class Program { static void Main(string[] args) { Task<int> task = Task.Run(() => ChildThreadOne()); task.ContinueWith(t => Console.WriteLine("子線程一的延續任務,只有在子線程一完美的完成的任務的情況下,才會執行"), TaskContinuationOptions.OnlyOnRanToCompletion); Console.WriteLine("主線程繼續執行它的操作"); Console.ReadKey();//必須加這行代碼,因為Task時線程池線程,屬於后台線程 } /// <summary> /// 子線程一 /// </summary> static int ChildThreadOne() { Thread.Sleep(2000);//模擬執行長時間計算任務 Console.WriteLine("子線程一完成了計算任務,返回值6"); return 6; } }
這里,看着,讓子線程一拋出異常,看看延續任務會不會繼續執行.
static void Main(string[] args) { Task<int> task = Task.Run(() => ChildThreadOne()); task.ContinueWith(t => Console.WriteLine("子線程一的延續任務,只有在子線程一完美的完成的任務的情況下,才會執行"), TaskContinuationOptions.OnlyOnRanToCompletion); Console.WriteLine("主線程繼續執行它的操作"); Console.ReadKey();//必須加這行代碼,因為Task時線程池線程,屬於后台線程 } /// <summary> /// 子線程一 /// </summary> static int ChildThreadOne() { Thread.Sleep(2000);//模擬執行長時間計算任務 Console.WriteLine("子線程一完成了計算任務,返回值6"); throw new Exception("模擬拋出異常"); }
因為子線程一拋出了異常,所以延續任務沒有執行.這里取消線程,也不會執行延續任務,因為MS為了區分Task的任務完成和任務取消,選擇讓取消的任務拋出OperationCanceledException異常,所以和拋出簡單一樣,延續任務並不會執行.超時同理.
TaskContinuationOptions.OnlyOnFaulted 當前面的任務拋出未處理的異常是,執行延續任務.
static void Main(string[] args) { CancellationTokenSource source = new CancellationTokenSource(); Task<int> task = Task.Run(() => ChildThreadOne(source.Token)); task.ContinueWith(t => { Console.WriteLine("子線程一的延續任務,只有在子線程一拋出了未處理的異常,才會執行,這里嘗試處理拋出的異常"); //一般記日志,Logger.Error(""); task.Exception.Handle(x => { Console.WriteLine("最好在這里就處理掉異常,以免讓外部try canth捕獲到,並處理產生的性能損失"); if (x is OperationCanceledException) { Console.WriteLine("子線程一拋出了取消異常,異常信息為{0}", x.Message); } else { Console.WriteLine("子線程一拋出了一般異常,異常信息為{0}", x.Message); } return true;//返回true,告訴CLR異常已被處理,這樣外部try catch就捕獲不到了. }); }, TaskContinuationOptions.OnlyOnFaulted); Console.WriteLine("主線程繼續執行它的操作"); source.Cancel(); Console.ReadKey();//必須加這行代碼,因為Task時線程池線程,屬於后台線程 } /// <summary> /// 子線程一 /// </summary> static int ChildThreadOne(CancellationToken cancellation) { Thread.Sleep(2000);//模擬執行長時間計算任務 Console.WriteLine("子線程一完成了計算任務,返回值6"); cancellation.ThrowIfCancellationRequested();//拋出取消異常 return 6; }
這里建議對TaskContinueWith做一個封裝,讓它能處理不同的異常,並且這樣異常,能在內部就被全部處理掉,而不需要在外部進行try catch處理,並且有一個友好的異常記錄和輸出.這里我就不做了,有需要的可以聯系我.
ok,這里就介紹TaskContinuationOptions常用的兩個值,其余的用法都差不多,可以看Ms的提供的備注,或者參看MSDN,這里就不全介紹了.