在OperationCanceledException情况下,Task.Wait的行为出乎意料

10

考虑以下代码片段:

CancellationTokenSource cts0 = new CancellationTokenSource(), cts1 = new CancellationTokenSource();
try
{
    var task = Task.Run(() => { throw new OperationCanceledException("123", cts0.Token); }, cts1.Token);
    task.Wait();
}
catch (AggregateException ae) { Console.WriteLine(ae.InnerException); }

由于MSDN中所述,如果任务的令牌与异常的令牌不匹配(并且IsCancellationRequestedfalse),则任务应处于Faulted状态:

如果令牌的IsCancellationRequested属性返回false或者异常的令牌与任务的令牌不匹配,则OperationCanceledException将被视为普通异常,导致任务过渡到Faulted状态。

当我在使用.NET 4.5.2的控制台应用程序中运行此代码时,我获得了处于Canceled状态的任务(聚合异常包含未知的TaskCanceledExeption而不是原始异常)。并且所有原始异常的信息都丢失了(消息、内部异常、自定义数据)。

我还注意到,在发生OperationCanceledException的情况下,Task.Wait的行为与await task的行为不同。

try { Task.Run(() => { throw new InvalidOperationException("123"); }).Wait(); } // 1
catch (AggregateException ae) { Console.WriteLine(ae.InnerException); }

try { await Task.Run(() => { throw new InvalidOperationException("123"); }); } // 2
catch (InvalidOperationException ex) { Console.WriteLine(ex); }

try { Task.Run(() => { throw new OperationCanceledException("123"); }).Wait(); } // 3 
catch (AggregateException ae) { Console.WriteLine(ae.InnerException); }

try { await Task.Run(() => { throw new OperationCanceledException("123"); }); } // 4
catch (OperationCanceledException ex) { Console.WriteLine(ex); }

案例 12 产生几乎相同的结果(仅在StackTrace中有所不同),但当我将异常更改为OperationCanceledException时,就会得到非常不同的结果:在第三个案例中是一个未知的TaskCanceledException,没有原始数据,而在第四个案例中则得到了所有原始数据(消息等)的预期OperationCanceledException

所以问题是:MSDN是否包含不正确的信息?还是.NET中的一个错误?或者可能只是我不理解某些东西?

3个回答

5
这是一个bug。在底层,Task.Run 调用 Task<Task>.Factory.StartNew。内部的 Task 获取了正确的 Faulted 状态,但包装的 Task 没有获取到。
你可以通过调用以下方法来解决此问题:
Task.Factory.StartNew(() => { throw new OperationCanceledException("123", cts0.Token); }, cts1.Token, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);

尽管如此,您将失去 Task.Run 的另一个特性,即取消包装。请参阅:
Task.Run vs Task.Factory.StartNew 更多细节: 以下是 Task.Run 的代码,您可以看到它创建了一个包装的 UnwrapPromise(它派生自 Task<TResult>):
public static Task Run(Func<Task> function, CancellationToken cancellationToken)
{
    // Check arguments
    if (function == null) throw new ArgumentNullException("function");
    Contract.EndContractBlock();

    cancellationToken.ThrowIfSourceDisposed();

    // Short-circuit if we are given a pre-canceled token
    if (cancellationToken.IsCancellationRequested)
        return Task.FromCancellation(cancellationToken);

    // Kick off initial Task, which will call the user-supplied function and yield a Task.
    Task<Task> task1 = Task<Task>.Factory.StartNew(function, cancellationToken, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);

    // Create a promise-style Task to be used as a proxy for the operation
    // Set lookForOce == true so that unwrap logic can be on the lookout for OCEs thrown as faults from task1, to support in-delegate cancellation.
    UnwrapPromise<VoidTaskResult> promise = new UnwrapPromise<VoidTaskResult>(task1, lookForOce: true);

    return promise;
}

它调用的Task构造函数不接受取消标记(因此它不知道内部任务的取消标记)。请注意,它创建了一个默认的CancellationToken。这是它调用的构造函数:

internal Task(object state, TaskCreationOptions creationOptions, bool promiseStyle)
{
    Contract.Assert(promiseStyle, "Promise CTOR: promiseStyle was false");

    // Check the creationOptions. We only allow the AttachedToParent option to be specified for promise tasks.
    if ((creationOptions & ~TaskCreationOptions.AttachedToParent) != 0)
    {
        throw new ArgumentOutOfRangeException("creationOptions");
    }

    // m_parent is readonly, and so must be set in the constructor.
    // Only set a parent if AttachedToParent is specified.
    if ((creationOptions & TaskCreationOptions.AttachedToParent) != 0)
        m_parent = Task.InternalCurrent;

    TaskConstructorCore(null, state, default(CancellationToken), creationOptions, InternalTaskOptions.PromiseTask, null);
}

外层任务(UnwrapPromise添加了一个后续操作)。后续操作检查内部任务。如果内部任务出现故障,它会将查找到的OperationCanceledException视为取消(无论是否匹配标记)。以下是 UnwrapPromise<TResult>.TrySetFromTask (下面也显示了调用堆栈)。请注意其处于Faulted状态:

private bool TrySetFromTask(Task task, bool lookForOce)
{
    Contract.Requires(task != null && task.IsCompleted, "TrySetFromTask: Expected task to have completed.");

    bool result = false;
    switch (task.Status)
    {
        case TaskStatus.Canceled:
            result = TrySetCanceled(task.CancellationToken, task.GetCancellationExceptionDispatchInfo());
            break;

        case TaskStatus.Faulted:
            var edis = task.GetExceptionDispatchInfos();
            ExceptionDispatchInfo oceEdi;
            OperationCanceledException oce;
            if (lookForOce && edis.Count > 0 &&
                (oceEdi = edis[0]) != null &&
                (oce = oceEdi.SourceException as OperationCanceledException) != null)
            {
                result = TrySetCanceled(oce.CancellationToken, oceEdi);
            }
            else
            {
                result = TrySetException(edis);
            }
            break;

        case TaskStatus.RanToCompletion:
            var taskTResult = task as Task<TResult>;
            result = TrySetResult(taskTResult != null ? taskTResult.Result : default(TResult));
            break;
    }
    return result;
}

调用栈:

    mscorlib.dll!System.Threading.Tasks.Task<System.Threading.Tasks.VoidTaskResult>.TrySetCanceled(System.Threading.CancellationToken tokenToRecord, object cancellationException) Line 645 C#
    mscorlib.dll!System.Threading.Tasks.UnwrapPromise<System.Threading.Tasks.VoidTaskResult>.TrySetFromTask(System.Threading.Tasks.Task task, bool lookForOce) Line 6988 + 0x9f bytes   C#
    mscorlib.dll!System.Threading.Tasks.UnwrapPromise<System.Threading.Tasks.VoidTaskResult>.ProcessCompletedOuterTask(System.Threading.Tasks.Task task) Line 6956 + 0xe bytes  C#
    mscorlib.dll!System.Threading.Tasks.UnwrapPromise<System.Threading.Tasks.VoidTaskResult>.InvokeCore(System.Threading.Tasks.Task completingTask) Line 6910 + 0x7 bytes   C#
    mscorlib.dll!System.Threading.Tasks.UnwrapPromise<System.Threading.Tasks.VoidTaskResult>.Invoke(System.Threading.Tasks.Task completingTask) Line 6891 + 0x9 bytes   C#
    mscorlib.dll!System.Threading.Tasks.Task.FinishContinuations() Line 3571    C#
    mscorlib.dll!System.Threading.Tasks.Task.FinishStageThree() Line 2323 + 0x7 bytes   C#
    mscorlib.dll!System.Threading.Tasks.Task.FinishStageTwo() Line 2294 + 0x7 bytes C#
    mscorlib.dll!System.Threading.Tasks.Task.Finish(bool bUserDelegateExecuted) Line 2233   C#
    mscorlib.dll!System.Threading.Tasks.Task.ExecuteWithThreadLocal(ref System.Threading.Tasks.Task currentTaskSlot) Line 2785 + 0xc bytes  C#
    mscorlib.dll!System.Threading.Tasks.Task.ExecuteEntry(bool bPreventDoubleExecution) Line 2728   C#
    mscorlib.dll!System.Threading.Tasks.Task.System.Threading.IThreadPoolWorkItem.ExecuteWorkItem() Line 2664 + 0x7 bytes   C#
    mscorlib.dll!System.Threading.ThreadPoolWorkQueue.Dispatch() Line 829   C#
    mscorlib.dll!System.Threading._ThreadPoolWaitCallback.PerformWaitCallback() Line 1170 + 0x5 bytes   C#

它会注意OperationCanceledException,并调用TrySetCanceled将任务置于取消状态。
另一个需要注意的事情是,当您开始使用异步方法时,没有一种真正的方法可以向异步方法注册取消令牌。因此,在异步方法中遇到的任何OperationCancelledException都被认为是取消。参见Associate a CancellationToken with an async method's Task

5

Matt Smith - 谢谢你,你的解释非常有帮助。

在阅读并测试一段时间后,我注意到原始问题并不完全正确。这不是Task.Wait的问题。我可以通过检查第一个任务的Status(它是Canceled)使用Task.ContinueWith来获得错误的行为。因此,我认为最终答案是:

如果您使用以Func<Task>Func<Task<TResult>>作为第一个参数的Task.Run重载创建任务,并且您的委托引发OperationCanceledException,如果您在返回的任务上使用Task.WaitTask.ContinueWith,则由于.NET中的错误(如Matt Smith所解释),您将失去所有数据的原始异常,并获得不正确的Canceled状态的任务,而不管是否匹配文档逻辑

所有这些条件都很重要。如果您在创建的任务上使用await,它可以正常工作。如果您使用以ActionFunc<TResult>作为第一个参数的Task.Run重载,则在所有情况下(Wait、ContinueWith、await)都可以正常工作。

我还注意到了重载方法选择逻辑的奇怪行为。当我编写时:

Task.Run(() => { throw new OperationCanceledException("123", cts0.Token); }, cts1.Token);

我希望使用没有问题的 Task.Run(Action, CancellationToken) 重载,但不知怎么回事,出现了有问题的 Task.Run(Func<Task>, CancellationToken)。所以我被迫做以下操作:

Task.Run((Action)(() => { throw new OperationCanceledException("123", cts0.Token); }), cts1.Token);

或者使用 TaskFactory.StartNew

4
这种行为非常有趣和奇怪。 正如其名称所示,AggregateException 的目的是在应用程序执行期间将多个异常/错误分组在一起。因此,在第三种情况下,您有一个作为内部异常的OperationCanceledException,并且AggregateException的堆栈跟踪应该报告关于那个异常的所有内容,包括数据(例如123),如下所示:

enter image description here

关于您的问题:
问:MSDN中是否包含不正确的信息?
答:它应该始终报告关于类、方法等行为的正确和精确信息。
问:还是.NET中的一个错误?
答:很可能是一个错误。不理解这种情况发生的原因。在这里,您会找到与此问题相关的一个问题。请向Microsoft报告此问题。

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