Home > Enterprise >  How to fix the inconsistent cancellation behavior of Task.Run?
How to fix the inconsistent cancellation behavior of Task.Run?

Time:12-28

The Task.Run method has overloads that accept both sync and async delegates:

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

Unfortunately these overloads don't behave the same when the delegate throws an OperationCanceledException. The sync delegate results in a Faulted task, and the async delegate results in a Canceled task. Here is a minimal demonstration of this behavior:

CancellationToken token = new(canceled: true);

Task taskSync = Task.Run(() => token.ThrowIfCancellationRequested());
Task taskAsync = Task.Run(async () => token.ThrowIfCancellationRequested());
    
try { Task.WaitAll(taskSync, taskAsync); } catch { }
    
Console.WriteLine($"taskSync.Status: {taskSync.Status}"); // Faulted
Console.WriteLine($"taskAsync.Status: {taskAsync.Status}"); // Canceled (undesirable)

Output:

taskSync.Status: Faulted
taskAsync.Status: Canceled

Online demo.

This inconsistency has been observed also in this question:

That question asks about the "why". My question here is how to fix it. Specifically my question is: How to implement a variant of the Task.Run with async delegate, that behaves like the built-in Task.Run with sync delegate? In case of an OperationCanceledException it should complete asynchronously as Faulted, except when the supplied cancellationToken argument matches the token stored in the exception, in which case it should complete as Canceled.

public static Task TaskRun2 (Func<Task> action,
    CancellationToken cancellationToken = default);

Here is some code with the desirable behavior of the requested method:

CancellationToken token = new(canceled: true);

Task taskA = TaskRun2(async () => token.ThrowIfCancellationRequested());
Task taskB = TaskRun2(async () => token.ThrowIfCancellationRequested(), token);
    
try { Task.WaitAll(taskA, taskB); } catch { }
    
Console.WriteLine($"taskA.Status: {taskA.Status}");
Console.WriteLine($"taskB.Status: {taskB.Status}");

Desirable output:

taskA.Status: Faulted
taskB.Status: Canceled

CodePudding user response:

One way to solve this problem is to use the existing Task.Run method, and attach a ContinueWith continuation that alters its cancellation behavior. The continuation returns either the original task or a faulted task, so it returns a Task<Task>. Eventually this nested task is unwrapped to a simple Task, with the help of the Unwrap method:

/// <summary>
/// Queues the specified work to run on the thread pool, and returns a proxy
/// for the task returned by the action. The cancellation behavior is the same
/// with the Task.Run(Action, CancellationToken) method.
/// </summary>
public static Task TaskRun2 (Func<Task> action,
    CancellationToken cancellationToken = default)
{
    ArgumentNullException.ThrowIfNull(action);
    return Task.Run(action, cancellationToken).ContinueWith(t =>
    {
        if (t.IsCanceled)
        {
            CancellationToken taskToken = new TaskCanceledException(t).CancellationToken;
            if (taskToken != cancellationToken)
                return Task.FromException(new OperationCanceledException(taskToken));
        }
        return t;
    }, CancellationToken.None, TaskContinuationOptions.DenyChildAttach |
        TaskContinuationOptions.ExecuteSynchronously,
        TaskScheduler.Default).Unwrap();
}

Online demo.

The TaskCanceledException constructor is used in order to get access to the CancellationToken stored inside the canceled Task. Unfortunately the Task.CancellationToken property is not exposed (it is internal), but fortunately this workaround exists.

  • Related