通常我不会发布带有答案的问题,但这次我想引起一些注意,我认为这可能是一个晦涩但常见的问题。它是由这个问题触发的,从那时起我回顾了自己的旧代码,发现其中一些也受此影响。
下面的代码启动并等待两个任务,
task1
和task2
,它们几乎相同。 task1
与 task2
的唯一不同之处在于它运行永无止境的循环。 IMO,这两种情况对于执行 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();
}
}
}
小提琴在这里。输出:
{ 任务 = 任务1, 消息 = 操作已取消。, 状态 = 已取消 } { 任务 = 任务2,消息 = 操作已取消。,状态 = 故障 }
为什么
task1
的状态是Cancelled
,而task2
的状态是Faulted
?注意,在这两种情况下,我都不将token
作为第二个参数传递给Task.Run
。
这里有两个问题。首先,除了让任务的 lambda 可用之外,将
CancellationToken
传递给 Task.Run
API 始终是一个好主意。这样做会将令牌与任务相关联,对于正确传播由 token.ThrowIfCancellationRequested
触发的取消至关重要。
但这并不能解释为什么
task1
的取消状态仍能正确传播 (task1.Status == TaskStatus.Canceled
),而 task2
(task2.Status == TaskStatus.Faulted
) 则不然。
现在,这可能是非常罕见的情况之一,其中巧妙的 C# 类型推断逻辑可能会违背开发人员的意愿。 此处和此处对此进行了详细讨论。总而言之,在使用
task1
的情况下,编译器会推断出以下对 Task.Run
的覆盖:
public static Task Run(Func<Task> function)
而不是:
public static Task Run(Action action)
这是因为
task1
lambda 没有脱离 for
循环的自然代码路径,因此它也可能是 Func<Task>
lambda,尽管它不是 async
并且不返回任何内容。这是编译器比Action
更喜欢的选项。那么,使用这样的覆盖 Task.Run
等价于:
var task1 = Task.Factory.StartNew(new Func<Task>(() =>
{
for (var i = 0; ; i++)
{
Thread.Sleep(i); // simulate work item #i
token.ThrowIfCancellationRequested();
}
})).Unwrap();
类型为
Task<Task>
的嵌套任务由 Task.Factory.StartNew
返回,它通过 Task
将 unwrapped变为
Unwrap()
。 Task.Run
足够聪明,可以在接受 Func<Task>
时自动进行此类展开。 未包装的 Promise 样式任务正确地从其内部任务传播取消状态,由 OperationCanceledException
lambda 作为 Func<Task>
异常抛出。对于 task2
来说,这种情况不会发生,它接受 Action
lambda 并且不会创建任何内部任务。取消不会传播到 task2
,因为 token
尚未通过 task2
与 Task.Run
关联。
最后,这可能是
task1
所期望的行为(当然不是 task2
),但在任何情况下我们都不希望在场景后面创建嵌套任务。此外,通过在 task1
循环之外引入条件 break
,可以很容易地破坏 for
的这种行为。
task1
的正确代码应该是这样:
var task1 = Task.Run(new Action(() =>
{
for (var i = 0; ; i++)
{
Thread.Sleep(i); // simulate work item #i
token.ThrowIfCancellationRequested();
}
}), token);
该示例也可以在没有取消标记的情况下工作,只需抛出一个OperationCanceledException。结果是相同的 - 任务 1 被取消,而任务 2 出现故障。 使用 async/await 时,任务在抛出 OperationCanceledException 时始终处于取消状态。 对我来说,这不是一贯的行为,因为你不能依赖国家。 只要使用 async/await 模式就没有问题(取消状态和错误状态都会抛出可以捕获的异常)。