Usually I don't post a question with the answer, but this time I'd like to attract some attention to what I think might be an obscure yet common issue. It was triggered by this question, since then I reviewed my own old code and found some of it was affected by this, too.
The code below starts and awaits two tasks, task1
and task2
, which are almost identical. task1
is only different from task2
in that it runs a never-ending loop. IMO, both cases are quite typical for some real-life scenarios performing CPU-bound work.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApplication
{
public class Program
{
static async Task TestAsync()
{
var ct = new CancellationTokenSource(millisecondsDelay: 1000);
var token = ct.Token;
// start task1
var task1 = Task.Run(() =>
{
for (var i = 0; ; i++)
{
Thread.Sleep(i); // simulate work item #i
token.ThrowIfCancellationRequested();
}
});
// start task2
var task2 = Task.Run(() =>
{
for (var i = 0; i < 1000; i++)
{
Thread.Sleep(i); // simulate work item #i
token.ThrowIfCancellationRequested();
}
});
// await task1
try
{
await task1;
}
catch (Exception ex)
{
Console.WriteLine(new { task = "task1", ex.Message, task1.Status });
}
// await task2
try
{
await task2;
}
catch (Exception ex)
{
Console.WriteLine(new { task = "task2", ex.Message, task2.Status });
}
}
public static void Main(string[] args)
{
TestAsync().Wait();
Console.WriteLine("Enter to exit...");
Console.ReadLine();
}
}
}
The fiddle is here. The output:
{ task = task1, Message = The operation was canceled., Status = Canceled } { task = task2, Message = The operation was canceled., Status = Faulted }
Why the status of task1
is Cancelled
, but the status of task2
is Faulted
? Note, in both cases I do not pass token
as the 2nd parameter to Task.Run
.
There are two problems here. First, it's always a good idea to pass
CancellationToken
to theTask.Run
API, besides making it available to the task's lambda. Doing so associates the token with the task and is vital for the correct propagation of the cancellation triggered bytoken.ThrowIfCancellationRequested
.This however doesn't explain why the cancellation status for
task1
still gets propagated correctly (task1.Status == TaskStatus.Canceled
), while it doesn't fortask2
(task2.Status == TaskStatus.Faulted
).Now, this might be one of those very rare cases where the clever C# type inference logic can play against the developer's will. It's discussed in great details here and here. To sum up, in case with
task1
, the following override ofTask.Run
is inferred by compiler:rather than:
That's because the
task1
lambda has no natural code path out of thefor
loop, so it may as well be aFunc<Task>
lambda, despite it is notasync
and it doesn't return anything. This is the option that compiler favors more thanAction
. Then, the use of such override ofTask.Run
is equivalent to this:A nested task of type
Task<Task>
is returned byTask.Factory.StartNew
, which gets unwrapped toTask
byUnwrap()
.Task.Run
is smart enough to do such unwrapping automatically for when it acceptsFunc<Task>
. The unwrapped promise-style task correctly propagates the cancellation status from its inner task, thrown as anOperationCanceledException
exception by theFunc<Task>
lambda. This doesn't happen fortask2
, which accepts anAction
lambda and doesn't create any inner tasks. The cancellation doesn't get propagated fortask2
, becausetoken
has not been associated withtask2
viaTask.Run
.In the end, this may be a desired behavior for
task1
(certainly not fortask2
), but we don't want to create nested tasks behind the scene in either case. Moreover, this behavior fortask1
may easily get broken by introducing a conditionalbreak
out of thefor
loop.The correct code for
task1
should be this: