TPL - Task Parallel Library为我们提供了Task相关的api,供我们非常方便的编写并行代码,而不用自己操作底层的Thread类。使用Task的优势是显而易见的:

  • 提供返回值

  • 异常捕获

  • 节省Context Switch造成的开销

另一个Task带来的优势就是不再需要通过阻塞线程来等待Task结束,如果需要在Task结束时开启另一项任务,可以使用Task.ContinueWith这个方法,并传入一个指定的委托即可。而本文主要关注ContinueWith中的TaskContinuationsOptions参数中的ExecuteSynchronously这个枚举值

ExecuteSynchronously是什么

我们先来看一下官方文档对于ExecuteSynchronously给出的解释

一大长串,我们尝试解析一下这一堆话在说什么。首先,当调用者传入这个枚举值后,意味着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里的语句很简单的话,同步执行也是值得的。正如官方文档最后一句提到的:

如果是一个复杂又耗时的任务以同步方式来执行的话就有点得不偿失了。

ExecuteSynchronously在什么情况下不会同步执行

Stephen Toub提到,即使在调用ContinueWith的时候传入了TaskContinuationOptions.ExecuteSynchronously,CLR也只能尽量让continue在原Task线程上执行,但无法100%保证。

  1. 如果原Task的线程被Abort,那么与其关联的continue任务是无法在原线程上执行的。

  2. 在上一段中我们也提到了关于线程栈溢出的问题,如果TPL认为接着在该线程上运行continue任务有溢出的风险,continue任务就会转而变成异步执行。

  3. 最后一种情况就是Task Scheduler不允许同步执行Task,开发者可以自定义一个TaskScheduler,重写父类方法,决定任务的执行方式。

最后欢迎关注我的个人公众号:SoBrian,期待与大家共同交流,共同成长!

TaskContinuationsOptions.ExecuteSynchronously探秘-LMLPHP

Reference

09-11 12:00