C#執行緒篇---Task(任務)和執行緒池不得不說的祕密

小y發表於2017-07-12

我們要知道的是,QueueUserWorkItem這個技術存在許多限制。其中最大的問題是沒有一個內建的機制讓你知道操作在什麼時候完成,也沒有一個機制在操作完成是獲得一個返回值,這些問題使得我們都不敢啟用這個技術。

  Microsoft為了克服這些限制(同時解決其他一些問題),引入了任務(tasks)的概念。順帶說一下我們得通過System.Threading.Tasks名稱空間來使用它們。

  現在我要說的是,用執行緒池不是呼叫ThreadPool的QueueUserWorkItem方法,而是用任務來做相同的事:

複製程式碼
 1         static void Main(string[] args)
 2         {
 3             Console.WriteLine("主執行緒啟動");
 4             //ThreadPool.QueueUserWorkItem(StartCode,5);
 5             new Task(StartCode, 5).Start();
 6             Console.WriteLine("主執行緒執行到此!");
 7             Thread.Sleep(1000);
 8         }
 9 
10         private static void StartCode(object i)
11         {
12             Console.WriteLine("開始執行子執行緒...{0}",i);
13             Thread.Sleep(1000);//模擬程式碼操作    
14         }
15     }
複製程式碼

嘿,你會發現結果是一樣的。
再來看看這個是什麼:

TaskCreationOptions這個型別是一個列舉型別,傳遞一些標誌來控制Task的執行方式。TaskCreationOptions定義如下:

慢點,註釋很詳細,看看這些有好處,TaskScheduler(任務排程器)不懂沒關係,請繼續往下看,我會介紹的,但請注意,這些標識都只是一些提議而已,在排程一個Task時,可能會、也可能不會採納這些提議,不過有一條要注意:AttachedToParent標誌,它總會得到Task採納,因為它和TaskScheduler本身無關。

  來看下這段程式碼:

複製程式碼
 1         static void Main(string[] args)
 2         {
 3             
 4             //1000000000這個數字會丟擲System.AggregateException
 5 
 6             Task<Int32> t = new Task<Int32>(n => Sum((Int32)n), 1000000000);
 7 
 8             //可以現在開始,也可以以後開始 
 9 
10             t.Start();
11 
12             //Wait顯式的等待一個執行緒完成
13 
14             t.Wait();
15            
16             Console.WriteLine("The Sum is:"+t.Result);
17         }
18 
19         private static Int32 Sum(Int32 i)
20         {
21             Int32 sum = 0;
22             for (; i > 0; i--)
23                 checked { sum += i; }
24             return sum;
25         }
26     }
複製程式碼

  這段程式碼大家應該猜得出是什麼意思吧,人人都會寫。
  但是,我的結果為什麼是t.Result而不直接是返回的Sum呢?  有沒有多此一舉的感覺?

下面我來說說這段程式碼我想表達的意思:

  在一個執行緒呼叫Wait方法時,系統會檢查執行緒要等待的Task是否已經開始執行,如果任務正在執行,那麼這個Wait方法會使執行緒阻塞,知道Task執行結束為止。

  就說上面的程式執行,因為累加數字太大,它丟擲算術運算溢位錯誤,在一個計算限制任務丟擲一個未處理的異常時,這個異常會被“包含”不併儲存到一個集合中,而執行緒池執行緒是允許返回到執行緒池中的,在呼叫Wait方法或者Result屬性時,這個成員會丟擲一個System.AggregateException物件。

  現在你會問,為什麼要呼叫Wait或者Result?或者一直不查詢Task的Exception屬性?你的程式碼就永遠注意不到這個異常的發生,如果不能捕捉到這個異常,垃圾回收時,丟擲AggregateException,程式就會立即終止,這就是“牽一髮動全身”,莫名其妙程式就自己關掉了,誰也不知道這是什麼情況。所以,必須呼叫前面提到的某個成員,確保程式碼注意到異常,並從異常中恢復。悄悄告訴你,其實在用Result的時候,內部會呼叫Wait。

  怎麼恢復?

  為了幫助你檢測沒有注意到的異常,可以向TaskScheduler的靜態UnobservedTaskException時間等級一個回撥方法,當Task被垃圾回收時,如果出現一個沒有被注意到的異常,CLR終結器會引發這個事件。一旦引發,就會向你的時間處理器方法傳遞一個UnobservedTaskExceptionEvenArgs物件,其中包含了你沒有注意的AggregateException。然後再呼叫UnobservedTasExceptionEvenArgs的SetObserved方法來指出你的異常已經處理好了,從而阻止CLR終止程式。這是個圖省事的做法,要少做這些,寧願終止程式,也不要呆著已經損壞的狀態而繼續執行。做人也一樣,病了寧肯休息,也不要帶病堅持上班,你沒那麼偉大,公司也不需要你的這一點偉大,命是自己的。(─.─|||扯遠了。

  除了單個等待任務,Task 還提供了兩個靜態方法:WaitAny和WaitAll,他們允許執行緒等待一個Task物件陣列。

  WaitAny方法會阻塞呼叫執行緒,知道陣列中的任何一個Task物件完成,這個方法會返回一個索引值,指明完成的是哪一個Task物件。如果發生超時,方法將返回-1。它可以通過一個CancellationToken取消,會丟擲一個OperationCanceledException。

  WaitAll方法也會阻塞呼叫執行緒,知道陣列中的所有Task物件都完成,如果全部完成就返回true,如果超時就返回false。當然它也能取消,同樣會丟擲OperationCanceledException。

  說了這麼兩個取消任務的方法,現在來試試這個方法,加深下印象,修改先前例子程式碼,完整程式碼如下:

複製程式碼
 1         static void Main(string[] args)
 2         {
 3             CancellationTokenSource cts = new CancellationTokenSource();
 4             
 5             
6 7 Task<Int32> t = new Task<Int32>(() => Sum(cts.Token,10000), cts.Token); 8 9 //可以現在開始,也可以以後開始 10 11 t.Start(); 12 13 //在之後的某個時間,取消CancellationTokenSource 以取消Task 14 15 cts.Cancel();//這是個非同步請求,Task可能已經完成了。我是雙核機器,Task沒有完成過 16 17 18 //註釋這個為了測試丟擲的異常 19 //Console.WriteLine("This sum is:" + t.Result); 20 try 21 { 22 //如果任務已經取消了,Result會丟擲AggregateException 23 24 Console.WriteLine("This sum is:" + t.Result); 25 } 26 catch (AggregateException x) 27 { 28 //將任何OperationCanceledException物件都視為已處理。 29 //其他任何異常都造成丟擲一個AggregateException,其中 30 //只包含未處理的異常 31 32 x.Handle(e => e is OperationCanceledException); 33 Console.WriteLine("Sum was Canceled"); 34 } 35 36 } 37 38 private static Int32 Sum(CancellationToken ct ,Int32 i) 39 { 40 Int32 sum = 0; 41 for (; i > 0; i--) 42 { 43 //在取消標誌引用的CancellationTokenSource上如果呼叫 44 //Cancel,下面這一行就會丟擲OperationCanceledException 45 46 ct.ThrowIfCancellationRequested(); 47 48 checked { sum += i; } 49 } 50 51 return sum; 52 } 53 }
複製程式碼

  這個例子展示了一個任務在進行的時候中途取消的操作,我覺得它很有趣,你試試也會發現。
  Lamada表示式寫這個,是個亮點,得學學,將CancellationToken閉包變數“傳遞”。

  

  如果不用Lamada表示式,這問題還真不好解決:

  Task<Int32> t = new Task<Int32>(() => Sum(cts.Token,10000), cts.Token);

  Sum(cts.Token,10000) 內的Token需要和cts.Token關聯起來,你還能想出怎麼關聯起來麼?

  

  好,任務取消也講玩了,來看個更好用的技術:

  

複製程式碼
 1         static void Main(string[] args)
 2         {
 3 
 4             Task<Int32> t = new Task<Int32>(i => Sum((Int32)i),10000);
 5 
 6             //可以現在開始,也可以以後開始 
 7             
 8             t.Start();
 9 
10            Task cwt =  t.ContinueWith(task=>Console.WriteLine("The sum is:{0}",task.Result));
11            cwt.Wait();
12             
13         }
14 
15         private static Int32 Sum(Int32 i)
16         {
17             Int32 sum = 0;
18             for (; i > 0; i--)
19             {
20                 checked { sum += i; }
21             }
22             
23             return sum;
24         }
25     }
複製程式碼

ContinueWith?  啥東西~~??

  要寫可伸縮的軟體,一定不能使你的執行緒阻塞。這意味著如果呼叫Wait或者在任務未完成時查詢Result屬性,極有可能造成執行緒池建立一個新執行緒,這增大了資源的消耗,並損害了伸縮性。

  ContinueWith便是一個更好的方式,一個任務完成時它可以啟動另一個任務。上面的例子不會阻塞任何執行緒。

  當Sum的任務完成時,這個任務會啟動另一個任務以顯示結果。ContinueWith會返回對新的Task物件的一個引用,所以為了看到結果,我需要呼叫一下Wait方法,當然你也可以查詢下Result,或者繼續ContinueWith,返回的這個物件可以忽略,它僅僅是一個變數。

  還要指出的是,Task物件內部包含了ContinueWith任務的一個集合。所以,實際上可以用一個Task物件來多次呼叫ContinueWith。任務完成時,所有ContinueWith任務都會進入執行緒池佇列中,在構造ContinueWith的時候我們可以看到一個TaskContinuationOptions列舉值,不能忽視,看看它的定義:

PrefereFairness是儘量公平的意思,就是較早排程的任務可能較早的執行,先來後到,將執行緒放到全域性佇列,便可以實現這個效果。

ExecuteSynchronously指同步執行,強制兩個任務用同一個執行緒一前一後執行,然後就同步執行了。

 

看得是不是暈乎乎 ?有這麼多列舉例子,怎麼掌握啊?多看幾次,知道任務的使用情況,以後用起來得心應手~想學新技術,就要能耐住,才能基礎牢固。來看個例子,用用這些列舉。

複製程式碼
 1         static void Main(string[] args)
 2         {
 3             Task<Int32> t = new Task<Int32>(i => Sum((Int32)i),10000);
 4 
 5             t.Start();
 6 
 7             t.ContinueWith(task=>Console.WriteLine("The sum is:{0}",task.Result),
 8                 TaskContinuationOptions.OnlyOnRanToCompletion);
 9             
10             t.ContinueWith(task=>Console.WriteLine("Sum throw:"+task.Exception),
11                 TaskContinuationOptions.OnlyOnFaulted);
12            
13             t.ContinueWith(task=>Console.WriteLine("Sum was cancel:"+task.IsCanceled),
14                 TaskContinuationOptions.OnlyOnCanceled);
15             try
16             {
17                 t.Wait();  // 測試用
18             }
19             catch (AggregateException)
20             {
21                 Console.WriteLine("出錯");
22             }
23            
24             
25         }
26 
27         private static Int32 Sum(Int32 i)
28         {
29             Int32 sum = 0;
30             for (; i > 0; i--)
31             {
32                 checked { sum += i; }
33             }
34             
35             return sum;
36         }
37     }
複製程式碼

  ContinueWith講完了。可是還沒有結束哦。

  AttachedToParnt列舉型別(父任務)也不能放過!看看怎麼用,寫法有點新奇,看看:

 

複製程式碼
 1         static void Main(string[] args)
 2         {
 3             Task<Int32[]> parent = new Task<Int32[]>(() => {
 4                 var results = new Int32[3];
 5                 //
 6                 new Task(() => results[0] = Sum(10000), TaskCreationOptions.AttachedToParent).Start();
 7                 new Task(() => results[1] = Sum(20000), TaskCreationOptions.AttachedToParent).Start();
 8                 new Task(() => results[2] = Sum(30000), TaskCreationOptions.AttachedToParent).Start();
 9                 return results;
10             });
11 
12             var cwt = parent.ContinueWith( parentTask=>Array.ForEach(parentTask.Result,Console.WriteLine));
13                    
14 
15             parent.Start();
16             cwt.Wait();
17         }
18 
19         private static Int32 Sum(Int32 i)
20         {
21             Int32 sum = 0;
22             for (; i > 0; i--)
23             {
24                 checked { sum += i; }
25             }
26             return sum;
27         }
28     }
複製程式碼

Oh,我都寫暈了。。。(+﹏+)~
例子中,父任務建立兵啟動3個Task物件。預設情況下,一個任務建立的Task物件是頂級任務,這些任務跟建立它們的那個任務沒有關係。

TaskCreationOptions.AttachedToParent標誌將一個Task和建立它的那個Task關聯起來,除非所有子任務(子任務的子任務)結束執行,否則建立任務(父任務)不會認為已經結束。呼叫ContinueWith方法建立一個Task時,可以指定TaskContinuationOptions.AttachedToParent標誌將延續任務置頂為一個子任務。

  看了這麼多工的方法操作示例了,現在來挖挖任務內部構造

  每個Task物件都有一組構成任務狀態的欄位。

  •   一個Int32 ID(只讀屬性)
  • 代表Task執行狀態的一個Int32
  • 對父任務的一個引用
  • 對Task建立時置頂TaskSchedule的一個引用
  • 對回撥方法的一個引用
  • 對要傳給回撥方法的物件的一個引用(通過Task只讀AsyncState屬性查詢)
  • 對一個ExceptionContext的引用
  • 對一個ManualResetEventSlim物件的引用

還有沒個Task物件都有對根據需要建立的一些補充狀態的一個引用,補充狀態包含這些:

  • 一個CancellationToken
  • 一個ContinueWithTask物件集合
  • 為丟擲未處理異常的子任務,所準備的一個Task物件集合

說了這麼多,只想要大家知道:

  雖然任務提供了大量功能,但並不是沒有代價的。因為必須為所有的這些狀態分配記憶體。

如果不需要任務提供的附加功能,使用ThreadPool.QueueUserWorkItem,資源的使用效率會更高一些。

Task類還實現了IDispose介面,允許你在用完Task物件後呼叫Dispose,不過大多數不管,讓垃圾回收器回收就好。

建立一個Task物件時,代表Task唯一的一個Int32欄位初始化為零,TaskID從1開始,每分配一個ID都遞增1。順帶說一下,在你除錯中檢視一個Task物件的時候,會造成偵錯程式顯示Task的ID,從而造成為Task分配一個ID。

  這個ID的意義在於,每個Task都可以用一個唯一的值來標識。Visual Studio會在它的“並行任務”和並行堆疊“視窗中顯示這些任務ID。要知道的是,這是Visual Studio自己分配的ID,不是在自己程式碼中分配的ID,幾乎不可能將Visual Studio分配的ID和程式碼正在做的事情聯絡起來。要檢視自己正在執行的任務,可以在除錯的時候檢視Task的靜態CurrentId屬性,如果沒有任務在執行,CurrentId返回null。

  再看看TaskStatus的值,這個可以查詢Task物件的生存期:

這些在任務執行的時候都是可以一一查到的,還有~判斷要像這樣:

1 if(task.Status==TaskStatus.RantoCompletion)...

為了簡化編碼,Task只提供幾個只讀Boolean屬性:IsCanceled,IsFaulted,IsCompleted,它們能返回最終狀態true/false。
如果Task是通過呼叫某個函式來建立的,這個Task物件就會出於WaitingForActivation狀態,它會自動執行。

最後我們要來了解一下TaskFactory(任務工廠):

  1.需要建立一組Task物件來共享相同的狀態

  2.為了避免機械的將相同的引數傳給每一個Task的構造器。

滿足這些條件就可以建立一個任務工廠來封裝通用的狀態。TaskFactory型別和TaskFactory<TResult>型別,它們都派生System.Object。

你會學到不一樣的編碼方式:

複製程式碼
 1         static void Main(string[] args)
 2         {
 3             Task parent = new Task(() =>
 4             {
 5                 var cts = new CancellationTokenSource();
 6                 var tf = new TaskFactory<Int32>(cts.Token, TaskCreationOptions.AttachedToParent, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
 7 
 8                 //建立並啟動3個子任務
 9                 var childTasks = new[] {
10             tf.StartNew(() => Sum(cts.Token, 10000)),
11             tf.StartNew(() => Sum(cts.Token, 20000)),
12             tf.StartNew(() => Sum(cts.Token, Int32.MaxValue))  // 這個會拋異常
13          };
14 
15                 // 任何子任務丟擲異常就取消其餘子任務
16                 for (Int32 task = 0; task < childTasks.Length; task++)
17                     childTasks[task].ContinueWith(t => cts.Cancel(), TaskContinuationOptions.OnlyOnFaulted);
18 
19                 // 所有子任務完成後,從未出錯/未取消的任務獲取返回的最大值
20                 // 然後將最大值傳給另一個任務來顯示最大結果
21                 tf.ContinueWhenAll(childTasks,
22                    completedTasks => completedTasks.Where(t => !t.IsFaulted && !t.IsCanceled).Max(t => t.Result),
23                    CancellationToken.None)
24                    .ContinueWith(t => Console.WriteLine("The maxinum is: " + t.Result),
25                       TaskContinuationOptions.ExecuteSynchronously).Wait(); // Wait用於測試
26             });
27 
28             // 子任務完成後,也顯示任何未處理的異常
29             parent.ContinueWith(p =>
30             {
31                 // 用StringBuilder輸出所有
32 
33                 StringBuilder sb = new StringBuilder("The following exception(s) occurred:" + Environment.NewLine);
34                 foreach (var e in p.Exception.Flatten().InnerExceptions)
35                     sb.AppendLine("   " + e.GetType().ToString());
36                 Console.WriteLine(sb.ToString());
37             }, TaskContinuationOptions.OnlyOnFaulted);
38 
39             // 啟動父任務
40             parent.Start();
41 
42             try
43             {
44                 parent.Wait(); //顯示結果
45             }
46             catch (AggregateException)
47             {
48             }
49         }
50 
51         private static Int32 Sum(CancellationToken ct, Int32 n)
52         {
53             Int32 sum = 0;
54             for (; n > 0; n--)
55             {
56                 ct.ThrowIfCancellationRequested();
57                 checked { sum += n; }
58             }
59             return sum;
60         }
61     }
複製程式碼

任務工廠就這麼用,就是一個任務的集合。

現在看看TaskScheduler(任務排程) 

  任務基礎結構是很靈活的,TaskScheduler物件功不可沒。

  TaskScheduler物件負責執行排程的任務,同時向Visual Studio偵錯程式公開任務資訊,就像一座橋樑,讓我們能夠掌控自己的任務執行緒。

  TaskScheduler有兩個派生類:thread pool task scheduler(執行緒池任務排程),和synchronization context task scheduler(同步上下文任務排程器)。預設情況下,所以應用程式使用的都是執行緒池任務排程器,這個任務排程器將任務排程給執行緒池的工作者執行緒。可以查詢TaskScheduler的靜態Default屬性來獲得對預設任務排程器的一個引用。

  同步上下文任務排程器通常用於桌面應用程式,Winfrom,WPF及Silverlight。這個任務排程器將多有任務都排程給應用程式的GUI執行緒,使所有任務程式碼都能成功更新UI組建,比如按鈕、選單項等。同步上下文任務排程器根本不使用執行緒池。同樣,可以查詢TaskScheduler的靜態FromCurrentSynchronizationContext方法來獲得對一個同步上下文任務排程器的引用。

就像這樣建立型別:

  

1 //同步上下文任務排程
2 TaskScheduler m_syncContextTaskScheduler =
3            TaskScheduler.FromCurrentSynchronizationContext();


任務排程有很多的,下面列舉一部分,供參考,更多的請參看http://code.msdn.microsoft.com/ParExtSamples  它包括了大量的示例程式碼。

相關文章