Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Task.Wait unexpected behavior in case of OperationCanceledException

Consider the following piece of code:

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

Due to MSDN task should be in Faulted state because it's token does not match exception's token (and also IsCancellationRequested is false):

If the token's IsCancellationRequested property returns false or if the exception's token does not match the Task's token, the OperationCanceledException is treated like a normal exception, causing the Task to transition to the Faulted state.

When I launch this code in console app using .NET 4.5.2 I get task in Canceled state (aggregate exception contains unknown TaskCanceledExeption, not the original). And all information of original exception is lost (message, inner exception, custom data).

I also noticed that behavior of Task.Wait differs from await task in case of 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); }

Cases 1 and 2 produce almost identical result (differ only in StackTrace), but when I change exception to OperationCanceledException, then I get very different results: an unknown TaskCanceledException in case 3 without original data, and expected OpeartionCanceledException in case 4 with all original data (message, etc.).

So the question is: Does MSDN contain incorrect information? Or is it a bug in .NET? Or maybe it's just I don't understand something?

like image 738
proman Avatar asked Aug 25 '14 14:08

proman


2 Answers

It is a bug. Task.Run under the hood calls Task<Task>.Factory.StartNew. This internal Task is getting the right Status of Faulted. The wrapping task is not.

You can work around this bug by calling

Task.Factory.StartNew(() => { throw new OperationCanceledException("123", cts0.Token); }, cts1.Token, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);

Though, you'll lose the other feature of Task.Run which is unwrapping. See:
Task.Run vs Task.Factory.StartNew

More Details:

Here's the code of Task.Run where you see that it is creating a wrapping UnwrapPromise (which derives from 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;
}

The Task constructor which it calls does not take a cancellation token (and thus it does not know about the inner Task's cancellation token). Notice it creates a default CancellationToken instead. Here's the ctor it calls:

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

The outer task (the UnwrapPromise adds a continuation). The continuation examines the inner task. In the case of the inner task being faulted, it consideres finding a a OperationCanceledException as indicating cancellation (regardless of a matching token). Below is the UnwrapPromise<TResult>.TrySetFromTask (below is also the call stack showing where it gets called). Notice the Faulted state:

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

Call stack:

    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#

It notices the OperationCanceledException and calls TrySetCanceled to put the task into the cancelled state.

An aside:

Another thing to note is that when you start using async methods, there isn't really a way to register a cancellation token with an async method. Thus, any OperationCancelledException that gets encountered in an async methods is considered a cancellation. See Associate a CancellationToken with an async method's Task

like image 71
Matt Smith Avatar answered Oct 12 '22 14:10

Matt Smith


Matt Smith - Thank you, your explanation was very helpful.

After reading it and testing for a while I noticed that original question does not fully correct. It's not a problem of Task.Wait. I can get this wrong behavior with Task.ContinueWith, checking first task's Status - it is Canceled. So I believe the final answer is:

If you create a task using Task.Run overloads that take Func<Task> or Func<Task<TResult>> as a first argument, and your delegate throws OperationCanceledException, and if you use Task.Wait or Task.ContinueWith on returned task, then you will lose original exception with all it's data because of bug in .NET (as Matt Smith explained) and get task in incorrect Canceled state instead of Faulted, regardless of matching documented logic.

All of these conditions matter. If you use await on created task - it works fine. If you use Task.Run overloads that take Action or Func<TResult> as a first argument - it works fine in all cases (Wait, ContinueWith, await).

I also noticed strange behavior of overloaded method selection logic. When I write

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

I expect to use Task.Run(Action, CancellationToken) overload, which is not broken. But somehow it appears that broken Task.Run(Func<Task>, CancellationToken) is used. So I'm forced to do something like this

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

or use TaskFactory.StartNew.

like image 37
proman Avatar answered Oct 12 '22 15:10

proman