在任务中相似的代码返回不同的状态码

4

我正在从三个不同的任务中抛出OperationCanceledException,每个任务都有轻微的差异,如下面的代码:

static async Task ThrowCancellationException()
{
    throw new OperationCanceledException();
}

static void Main(string[] args)
{
    var t1 = new Task(() => throw new OperationCanceledException());
    t1.Start();
    try { t1.Wait(); } catch { }

    Task t2 = new Task(async () => throw new OperationCanceledException());
    t2.Start();
    try { t2.Wait(); } catch { }

    Task t3 = ThrowCancellationException();

    Console.WriteLine(t1.Status); // prints Faulted
    Console.WriteLine(t2.Status); // prints RanToCompletion
    Console.WriteLine(t3.Status); // prints Canceled
}

我的问题是:

为什么每个任务的状态都不同?

我可以理解标有async的代码/lambda和没有标记async的lambda之间存在差异,但即使是运行相同代码的async lambda和async方法之间的状态也不同。


4
但是即使在运行相同代码的异步Lambda和异步方法之间,状态也是不同的”--并不正确。new Task的重载采用Action而不是Func<Task>,这意味着您的 async lambda 版本相当于传递一个 async void 方法而不是 async Task 方法。如果要传递返回 Task 的委托,请使用 Task.Run:它有一个重载,接受 Func<Task> - canton7
2个回答

6
我能理解标记为async的代码/lambda和未标记为async但运行状态不同,但即使是在运行相同代码的async lambda和async方法之间也存在差异。但这并不完全正确。
如果仔细观察new Task(async () => throw new OperationCanceledException()),你会发现它调用了重载new Task(Action action)(没有接受Func的重载)。这意味着它等同于传递一个async void方法,而不是一个async Task方法。
所以:
Task t2 = new Task(async () => throw new OperationCanceledException());
t2.Start();
try { t2.Wait(); } catch { }

这将编译成类似于以下内容:

private static async void CompilerGeneratedMethod()
{
    throw new OperationCanceledException()
}
...
Task t2 = new Task(CompilerGeneratedMethod);
t2.Start();
try { t2.Wait(); } catch { }

这会从线程池中获取一个线程,并在其上运行CompilerGeneratedMethod。当一个async void方法内部抛出异常时,在合适的位置重新引发异常(在本例中,将其重新引发到线程池上),但CompilerGeneratedMethod方法本身会立即返回。这导致Task t2立即完成,这就是为什么它的状态为RanToCompletion

那么异常发生了什么?它正在使您的应用崩溃!在您的Main末尾放置Console.ReadLine,并查看应用在您有机会按下回车之前退出。


这个:

Task t3 = ThrowCancellationException();

这段内容与普通的线程池有很大的不同。它并不尝试在ThreadPool上运行任何东西。 ThrowCancellationException是同步运行的,并且同步返回一个包含OperationCanceledExceptionTask。一个包含OperationCanceledExceptionTask会被视为Canceled


如果你想在ThreadPool上运行一个异步方法,可以使用Task.Run。它有一个重载,接受一个Func<Task>,这意味着:

Task t2 = Task.Run(async () => throw new OperationCanceledException());

会被编译成类似以下的内容:

private static async Task CompilerGeneratedMethod()
{
    throw new OperationCanceledException();
}
...
Task t2 = Task.Run(CompilerGeneratedMethod);

在这里,当在线程池中执行CompilerGeneratedMethod时,它返回一个包含OperationCanceledExceptionTask。然后,任务机制将Task t2转换为Canceled状态。


顺便提一下,如果您想在线程池上显式运行方法,请避免使用new Task,而是优先使用Task.Run。TPL中有很多方法是在async/await引入之前就引入的,与之一起使用会产生困惑。


2
好的,关于异步 lambda 和异步方法,它们的返回类型会不同。我之前不知道这一点。还有,在你的 Main 方法末尾添加一个 Console.ReadLine,看看应用程序在你按下回车之前是否退出。事实上,我之前是通过在末尾设置断点来检查的,没有注意到异常。非常好的答案! - meJustAndrew
1
@meJustAndrew 这是相关的部分,但t2t3之间还有另一个区别:t2使用new Task(...).Start(),在线程池上运行给定的方法。t3直接调用ThrowCancellationException,线程池不参与其中。 - canton7
我明白了,谢谢您以这种方式澄清,我之前对线程池的使用时机有些困惑。所以如果我调用Task的构造函数并将方法作为参数传递,然后在调用start时它会进入线程池,但是如果直接调用,则会在当前线程上运行,直到遇到await关键字才会切换线程,如果我理解得正确的话。 - meJustAndrew
@meJustAndrew 正确。将 Task.Run 替换为 new Task 会使其更清晰:它在线程池上运行给定的方法。new Task 是在 async/await 出现之前引入的,并且当时并不清楚 Task 会代表“可能尚未完成的一小部分异步工作”,而不是“在另一个线程上同步运行的代码,可能尚未完成”。如果今天引入 Task,我怀疑它是否会有一个 Start 方法。 - canton7

1
当您使用Task构造函数创建任务时,您可以提供一个可选的CancellationToken参数。只有在与此特定CancellationToken相关联的OperationCanceledException的情况下,任务才会处于Canceled状态。否则,异常将被视为故障。以下是如何将OperationCanceledExceptionCancellationToken关联的方法:
var cts = new CancellationTokenSource();
var t1 = new Task(() =>
{
    cts.Cancel();
    throw new OperationCanceledException(cts.Token);
}, cts.Token);

关于使用异步委托作为参数创建Task构造函数的正确方法,应该创建一个嵌套的Task<Task>
Task<Task> t2 = new Task<Task>(async () => throw new OperationCanceledException());
t2.Start();
try { t2.Result.Wait(); } catch { }

Console.WriteLine(t2.Result.Status); // prints Canceled

请注意,Task.Unwrap是一个可以帮助您处理Task<Task>的情况的工具(虽然这种情况很少见)。 - canton7
1
@canton7 是的,你可以在启动嵌套任务后调用 Unwrap。但是你确实需要对嵌套任务进行初始引用,以便你可以调用它的 Start 方法。如果你尝试直接启动未包装的任务,你会得到一个异常:System.InvalidOperationException: Start may not be called on a promise-style task. - Theodor Zoulias

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接