Search code examples
c#tasktask-parallel-library

Why does direct throwing OperationCanceledException cancels the task


I read the documentation says to set status to Canceled requires three conditions:

  • OperationCanceledException(or its derived exception type such as TaskCanceledException) is thrown
  • token.IsCancellationRequested is true
  • token in delegate passed to OperationCanceledException is identical to token passed as parameter on task creation

However the following example throws directly without any token but succeed the cancellation. Is it because the token is a struct so the default value always satisfies the last two conditions? I am not confident about this thought, hopefully to get some explaination if I were wrong

var task = Task.Run(() =>
{
    throw new OperationCanceledException();
});

try
{
    task.Wait();
}
catch (AggregateException)
{
    Console.WriteLine(task.Status); // Cancelled
}

Solution

  • It's because the compiler has chosen the overload of Task.Run that is meant for async lambdas such as

    Task.Run(async() => await Task.Delay(100));
    

    This has surprising behavior in your case however, because you are supposed to test vanilla Task behavior and not that of async methods.

    // Expected behavior (note that we use action to choose the overload)
    var task = Task.Run(action:() => {
        throw new OperationCanceledException();
    });
    
    try {
        await task;
    } catch (OperationCanceledException ex) {
        Console.WriteLine(task.Status); // Faulted
    }
    
    // Unexpected behavior for Task, but expected for async methods 
    task = Task.Run(function:() => {
        throw new OperationCanceledException();
    });
    
    try {
        await task;
    } catch (OperationCanceledException ex) {
        Console.WriteLine(task.Status); // Cancelled
    }
    

    Тhe function overload is wrapping your original task and has logic such as this to process it and give you a "processed task":

    case TaskStatus.Faulted:
    {
        List<ExceptionDispatchInfo> exceptionDispatchInfos = task.GetExceptionDispatchInfos();
        ExceptionDispatchInfo exceptionDispatchInfo;
        if (lookForOce && exceptionDispatchInfos.Count > 0 && (exceptionDispatchInfo = exceptionDispatchInfos[0]) != null)
        {
            OperationCanceledException ex = exceptionDispatchInfo.SourceException as OperationCanceledException;
            if (ex != null)
            {
                result = TrySetCanceled(ex.CancellationToken, exceptionDispatchInfo);
                break;
            }
        }
        result = TrySetException(exceptionDispatchInfos);
        break;
    }
    

    Basically it converts a Faulted inner task to a Cancelled outer task if the exception was OperationCanceledException.

    If you want to experiment and test things, make sure to choose the first overload with action - where such magic is not happening:

    var cts = new CancellationTokenSource();
    var cts2 = new CancellationTokenSource();
    
    task = Task.Run(action:() => {
        throw new OperationCanceledException(); // Faulted - no match your case
        cts.Cancel();
        cts2.Cancel();
        //cts2.Token.ThrowIfCancellationRequested(); // Faulted - mismatch of tokens
        //cts.Token.ThrowIfCancellationRequested(); // Cancelled - match tokens
        //throw new OperationCanceledException(cts.Token); // Cancelled Manual throw - matching
    }, cts.Token);
    
    try {
        await task;
    } catch (OperationCanceledException ex) {
        Console.WriteLine(task.Status);
    }