TaskContinuationsOptions.ExecuteSynchronously探祕

蘋果沒有熟發表於2020-09-10

TPL - Task Parallel Library為我們提供了Task相關的api,供我們非常方便的編寫並行程式碼,而不用自己操作底層的Thread類。使用Task的優勢是顯而易見的:

  • 提供返回值

  • 異常捕獲

  • 節省Context Switch造成的開銷

另一個Task帶來的優勢就是不再需要通過阻塞執行緒來等待Task結束,如果需要在Task結束時開啟另一項任務,可以使用Task.ContinueWith這個方法,並傳入一個指定的委託即可。而本文主要關注ContinueWith中的TaskContinuationsOptions引數中的ExecuteSynchronously這個列舉值

ExecuteSynchronously是什麼

我們先來看一下官方文件對於ExecuteSynchronously給出的解釋

Specifies that the continuation task should be executed synchronously. With this option specified, the continuation runs on the same thread that causes the antecedent task to transition into its final state. If the antecedent is already complete when the continuation is created, the continuation will run on the thread that creates the continuation. If the antecedent's CancellationTokenSource is disposed in a finally block (Finally in Visual Basic), a continuation with this option will run in that finally block. Only very short-running continuations should be executed synchronously.

一大長串,我們嘗試解析一下這一堆話在說什麼。首先,當呼叫者傳入這個列舉值後,意味著ContinueWith中傳入的委託將會在原Task的同一執行緒上執行,但要注意的是,這裡的同一執行緒指的是:將原Task轉移到final state的執行緒。因為原Task的執行可能涉及了多個執行緒,因此這裡特意指明是final state對應的執行緒,而不是從所有涉及的執行緒中隨機挑選一個。

其次,如果呼叫ContinueWith的時候,原Task已經執行完畢,那麼continue的委託並不會在剛才提到的那個final state對應的執行緒上執行,而是由建立這個continuation的執行緒執行。

最後一點,如果原Task的CancellationTokenSource在finally塊中呼叫了Dispose方法,那麼continue的委託就會在那個finally塊中執行。(其實這一點我也沒有理解到底是什麼意思,歡迎大神拍磚)

舉個例子

 1     class Program
 2     {
 3         static void Main(string[] args)
 4         {
 5             for (int i = 0; i < 30; i++)
 6             {
 7                 Task.Run(async () =>
 8                 {
 9                     Console.WriteLine($"Running on thread {Thread.CurrentThread.ManagedThreadId}");
10                     await Task.Delay(2000);
11                 });
12             }
13             Task t = Task.Run(async () =>
14            {
15                Console.WriteLine($"=======Running on thread {Thread.CurrentThread.ManagedThreadId}");
16                await Task.Delay(2000);
17                Console.WriteLine($"=======Running on thread {Thread.CurrentThread.ManagedThreadId}");
18            });
19 
20             // Thread.Sleep(5000);
21             t.ContinueWith(_ =>
22             {
23                 Console.WriteLine($"*******Running on thread {Thread.CurrentThread.ManagedThreadId}");
24             }, TaskContinuationOptions.ExecuteSynchronously);
25 
26             Console.ReadLine();
27         }
28     }

 

這段程式碼首先建立了30個干擾Task,這樣能顯著降低即使不用ExecuteSynchronously,執行緒池也會分配原執行緒來執行Continue任務的概率。執行後發現,任務t和continue確實是在同一個執行緒上執行的。而註釋掉TaskContinuationOptions.ExecuteSynchronously後,continue就會由執行緒池重新分配執行緒。而如果取消註釋執行緒Sleep 5秒這行程式碼,即使ExecuteSynchronously,continue也會由執行緒池重新分配執行緒執行,這正如上一段文件中提到的:呼叫ContinueWith時,如果原任務已經執行完畢,那麼會由呼叫ContinueWith的執行緒執行continue任務,在這裡就會由主執行緒來執行continue任務。

ExecuteSynchronously為什麼不是預設行為

微軟工程師Stephen Toub在其一篇博文中解釋了為什麼.NET團隊沒有把ExecuteSynchronously作為預設方案。

  1. 一個Task任務有可能會多次呼叫ContinueWith方法,如果預設是在同一執行緒執行,那麼所有的continue任務都需要等待上一個continue完成後才能執行,這也就失去了並行的意義。

  2. 還有一種常見的情況就是很多個continue任務一個接一個的串在一起,如果這些continue任務都是同步順序執行的,一個任務完成了就會執行下一個任務,這將導致執行緒棧上堆積的frame越來越多,這有可能會導致執行緒棧溢位。

  3. 為了解決溢位的問題,通常的解決方式是借用一個“蹦床”,把需要完成的工作在當前執行緒棧之外儲存起來,然後利用一個更高level的frame檢索儲存的任務並執行。這樣一來,每次完成一個任務之後,並不是立即執行下一個任務,而是將其儲存至上述的frame並退出,該frame將執行下一個任務。而TPL正是利用這一方式來提升非同步的執行效率。

以上就是沒有預設同步執行任務的主要原因,雖然效能上會稍有損失,但這樣可以更好的利用並行,更安全,而這效能的損失通常來說並不是最重要的。作者最後也建議我們如果Task裡的語句很簡單的話,同步執行也是值得的。正如官方文件最後一句提到的:

Only very short-running continuations should be executed synchronously.

如果是一個複雜又耗時的任務以同步方式來執行的話就有點得不償失了。

ExecuteSynchronously在什麼情況下不會同步執行

Stephen Toub提到,即使在呼叫ContinueWith的時候傳入了TaskContinuationOptions.ExecuteSynchronously,CLR也只能儘量讓continue在原Task執行緒上執行,但無法100%保證。

  1. 如果原Task的執行緒被Abort,那麼與其關聯的continue任務是無法在原執行緒上執行的。

  2. 在上一段中我們也提到了關於執行緒棧溢位的問題,如果TPL認為接著在該執行緒上執行continue任務有溢位的風險,continue任務就會轉而變成非同步執行。

  3. 最後一種情況就是Task Scheduler不允許同步執行Task,開發者可以自定義一個TaskScheduler,重寫父類方法,決定任務的執行方式。

最後歡迎關注我的個人公眾號:SoBrian,期待與大家共同交流,共同成長!

Reference

相關文章