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