Задача. Подождите, когда произойдет непредвиденное поведение в случае OperationCanceledException

Рассмотрим следующий фрагмент кода:

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 задача должна быть в состоянии Faulted, потому что токен не совпадает с токеном исключения ( а также IsCancellationRequested false):

Если свойство token IsCancellationRequested возвращает false или если токен исключения не соответствует токену задачи, исключение OperationCanceledException рассматривается как обычное исключение, в результате чего задача переходит к состоянию Faulted.

Когда я запускаю этот код в консольном приложении с использованием .NET 4.5.2, я получаю задачу в состоянии Canceled (суммарное исключение содержит неизвестный TaskCanceledExeption, а не оригинал). И вся информация об исходном исключении теряется (сообщение, внутреннее исключение, пользовательские данные).

Я также заметил, что поведение Task.Wait отличается от await task в случае OperationCanceledException.

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); }

Случаи 1 и 2 производят почти одинаковый результат (отличаются только StackTrace), но когда я изменяю исключение на OperationCanceledException, то получаю очень разные результаты: неизвестный TaskCanceledException в случае 3 > без исходных данных и ожидаемых OpeartionCanceledException в случае 4 со всеми исходными данными (сообщение и т.д.).

Итак, вопрос в том, содержит ли MSDN неправильную информацию? Или это ошибка в .NET? Или, может быть, я просто ничего не понимаю?

Ответ 1

Это ошибка. Task.Run под капотом называет Task<Task>.Factory.StartNew. Эта внутренняя задача получает правильный статус неисправности. Задача об упаковке не является.

Вы можете обойти эту ошибку, позвонив

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;
}

Конструктор задачи, который он вызывает, не принимает маркер отмены (и, следовательно, он не знает о внутреннем марке отмены задачи). Обратите внимание, что вместо этого создается стандартная 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 для перевода задачи в отмененное состояние.

В стороне:

Еще одно замечание: при запуске методов async на самом деле нет способа зарегистрировать токен отмены с помощью метода async. Таким образом, любое событие OperationCancelledException, которое встречается в асинхронных методах, считается отменой. Видеть Связать аннулирование с помощью задачи асинхронного метода

Ответ 2

Мэтт Смит - Спасибо, ваше объяснение было очень полезно.

После прочтения и тестирования некоторое время я заметил, что исходный вопрос не совсем корректен. Это не проблема Task.Wait. Я могу получить это неправильное поведение с помощью Task.ContinueWith, проверяя первую задачу Status - это Canceled. Поэтому я считаю, что окончательный ответ:

Если вы создаете задачу с использованием перегрузок Task.Run, которые принимают Func<Task> или Func<Task<TResult>> в качестве первого аргумента, а ваш делегат выдает OperationCanceledException, и если вы используете Task.Wait или Task.ContinueWith в возвращенной задаче, то вы потеряете исходное исключение со всеми его данными из-за ошибки в .NET(как объясняется Matt Smith) и получите задачу в неправильном состоянии Canceled вместо Faulted, независимо от соответствия документальная логика.

Все эти условия имеют значение. Если вы используете await для созданной задачи - она ​​отлично работает. Если вы используете перегрузки Task.Run, которые принимают Action или Func<TResult> в качестве первого аргумента - он отлично работает во всех случаях (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.

Ответ 3

Такое поведение очень интересно и странно, в то же время. Цель AggregateException, как следует из названия, состоит в объединении нескольких исключений/ошибок, которые происходят во время выполнения приложения. Итак, в вашем третьем случае у вас есть OperationCanceledException как внутреннее исключение, и трассировка стека AggregateException должна сообщать обо всем этом, включая данные (например, 123), как показано ниже в 4-м случае

enter image description here

О ваших вопросах:

В MSDN содержится некорректная информация?

Он должен всегда сообщать правильную и точную информацию о поведении классов, методов и т.д.

Или это ошибка в .NET?

Скорее всего, да, это ошибка. Непонятно, почему это происходит. Здесь вы найдете связанный с этим вопрос. сообщить этой проблеме Microsoft.