Search code examples
c#taskcancellation-token

Passing cancellation token to Task.Run seems to have no effect


According to this and this, passing a cancellation token to a task constructor, or Task.Run, will cause the task to be associated with said token, causing the task to transition to Canceled instead of Faulted if a cancellation exception occurs.

I've been fiddling with these examples for a while, and I can't see any benefits other than preventing a cancelled task to start.

Changing the code on this MSDN example from

tc = Task.Run(() => DoSomeWork(i, token), token);

to

tc = Task.Run(() => DoSomeWork(i, token));

produced the exact same output:

enter image description here

This code also results in two cancelled state tasks with the same exceptions thrown:

var token = cts.Token;

var t1 = Task.Run(() =>
{
    while (true)
    {
        Thread.Sleep(1000);
        token.ThrowIfCancellationRequested();
    };
});

var t2 = Task.Run(() =>
{
    while (true)
    {
        Thread.Sleep(1000);
        token.ThrowIfCancellationRequested();
    };
}, token);

Console.ReadKey();

try
{
    cts.Cancel();
    Task.WaitAll(t1, t2);
}
catch(Exception e)
{
    if (e is AggregateException)
    {
        foreach (var ex in (e as AggregateException).InnerExceptions)
        {
            Console.WriteLine(e.Message);
        }
    }
    else
        Console.WriteLine(e.Message);
            
}

Console.WriteLine($"without token: { t1.Status }");
Console.WriteLine($"with token: { t2.Status }");
Console.WriteLine("Done.");

enter image description here

Apparently, throwing OperationCanceledException from within the task is enough to make it transition to Canceled instead of Faulted. So my question is: is there a reason for passing the token to the task other than preventing a cancelled task to run?


Solution

  • Is there a reason for passing the token to the task other than preventing a cancelled task to run?

    In this particular case, No. Past the point in time that the task has started running, the token has no effect to the outcome.

    The Task.Run method has many overloads. This case is peculiar because of the infinite while loop.

    var t1 = Task.Run(() =>
    {
        while (true)
        {
            Thread.Sleep(1000);
            token.ThrowIfCancellationRequested();
        };
    });
    

    The compiler has to choose between these two overloads:

    public static Task Run(Action action);
    
    public static Task Run(Func<Task> function);
    

    ...and for a reason explained in this question it chooses the later. Here is the implementation of this overload:

    public static Task Run(Func<Task?> function, CancellationToken cancellationToken)
    {
        if (function == null) ThrowHelper.ThrowArgumentNullException(ExceptionArgument.function);
    
        // Short-circuit if we are given a pre-canceled token
        if (cancellationToken.IsCancellationRequested)
            return Task.FromCanceled(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 important detail is the lookForOce: true. Let's look inside the UnwrapPromise class:

    // "Should we check for OperationCanceledExceptions on the outer task and interpret them
    // as proxy cancellation?"
    // Unwrap() sets this to false, Run() sets it to true.
    private readonly bool _lookForOce;
    

    ..and at another point below:

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

    So although the internally created Task<Task?> task1 ends up in a Faulted state, its unwrapped version ends up as Canceled, because the type of the exception is OperationCanceledException (abbreviated as oce in the code).

    That's a quite convoluted journey in the history of TPL, with methods introduced at different times and frameworks, in order to serve different purposes. The end result is a little bit of inconsistency, or nuanced behavior if you prefer to say it so. A relevant article that you might find interesting is this: Task.Run vs Task.Factory.StartNew by Stephen Toub.