TaskContinuationsOptions.ExecuteSynchronously探秘

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

Tags: