CancellationToken.ThrowIfCancellationRequested后出现故障与已取消的任务状态

通常我不回答问题,但这次我想引起一些关注,我认为这可能是一个模糊而又常见的问题。 它是由这个问题触发的,从那时起我查看了我自己的旧代码,发现其中一些也受到了影响。

下面的代码启动并等待两个任务, task1task2 ,几乎完全相同。 task1task2不同之处在于它运行永无止境的循环。 对于执行CPU限制工作的一些现实场景,这两种情况都非常典型。

 using System; using System.Threading; using System.Threading.Tasks; namespace ConsoleApplication { public class Program { static async Task TestAsync() { var ct = new CancellationTokenSource(millisecondsDelay: 1000); var token = ct.Token; // start task1 var task1 = Task.Run(() => { for (var i = 0; ; i++) { Thread.Sleep(i); // simulate work item #i token.ThrowIfCancellationRequested(); } }); // start task2 var task2 = Task.Run(() => { for (var i = 0; i < 1000; i++) { Thread.Sleep(i); // simulate work item #i token.ThrowIfCancellationRequested(); } }); // await task1 try { await task1; } catch (Exception ex) { Console.WriteLine(new { task = "task1", ex.Message, task1.Status }); } // await task2 try { await task2; } catch (Exception ex) { Console.WriteLine(new { task = "task2", ex.Message, task2.Status }); } } public static void Main(string[] args) { TestAsync().Wait(); Console.WriteLine("Enter to exit..."); Console.ReadLine(); } } } 

小提琴在这里 。 输出:

 {task = task1,Message =操作已取消。,状态=已取消}
 {task = task2,Message =操作被取消。,Status = Faulted}

为什么task1的状态为Cancelled ,但task2的状态是Faulted 请注意,在这两种情况下,我都不会将token作为第二个参数传递给Task.Run

这里有两个问题。 首先,将CancellationToken传递给Task.Run API总是一个好主意,除了使它可用于任务的lambda。 这样做会将令牌与任务相关联,对于由token.ThrowIfCancellationRequested触发的取消的正确传播至关重要。

但是,这并不能解释为什么task1.Status == TaskStatus.Canceled的取消状态仍然正确传播( task1.Status == TaskStatus.Canceled ),而不是task2task2.Status == TaskStatus.Faulted )。

现在,这可能是一种非常罕见的情况,其中聪明的C#类型推断逻辑可以违背开发人员的意愿。 这里和这里详细讨论了它。 总而言之,对于Task.Run ,编译器推断出以下Task.Run重写:

 public static Task Run(Func function) 

而不是:

 public static Task Run(Action action) 

那是因为task1 lambda没有for循环中的自然代码路径,所以它也可能是一个Func lambda, 尽管它不是async并且它不返回任何东西 。 这是编译器比Action更有利的选项。 然后,使用Task.Run的这种重写相当于:

 var task1 = Task.Factory.StartNew(new Func(() => { for (var i = 0; ; i++) { Thread.Sleep(i); // simulate work item #i token.ThrowIfCancellationRequested(); } })).Unwrap(); 

Task.Factory.StartNew返回Task类型的嵌套任务,它通过Unwrap() 解包到TaskTask.Run 非常聪明,可以在接受Func时自动进行此类展开。 展开的promise样式任务正确地从其内部任务传播取消状态 ,由Func lambda作为OperationCanceledExceptionexception抛出。 对于task2 ,这不会发生,它接受Action lambda并且不创建任何内部任务。 取消不会传播给task2 ,因为token尚未通过Task.Runtask2相关联。

最后,这可能是task1的期望行为(当然不适用于task2 ),但我们不希望在任何一种情况下在场景后面创建嵌套任务。 此外,通过引入for循环的条件breaktask1这种行为可能很容易被破坏。

task1的正确代码应为

 var task1 = Task.Run(new Action(() => { for (var i = 0; ; i++) { Thread.Sleep(i); // simulate work item #i token.ThrowIfCancellationRequested(); } }), token);