My co-worker played with TPL and task cancellations. He showed me the following code:
var cancellationToken = cts.Token;
var task = Task.Run(() =>
{
while (true)
{
Thread.Sleep(300);
if (cancellationToken.IsCancellationRequested)
{
throw new OperationCanceledException();
}
}
}, cancellationToken)
.ContinueWith(t => {
Console.WriteLine(t.Status);
});
Thread.Sleep(200);
cts.Cancel();
This prints "Canceled" as expected, but if you just comment while line like this:
// ..
//while (true)
{
Thread.Sleep(300);
if (cancellationToken.IsCancellationRequested)
{
throw new OperationCanceledException();
}
}
//..
you'll get "Faulted". I am well aware about ThrowIfCancellationRequested() method and that I should pass cancellationToken in the constructor of OperationCanceledException (and this leads to "Canceled" result in both cases) but anyway I can't explain why this happens.
The behavior you're asking about would IMHO be more properly be questioned as "why does the task status transition to Canceled
when the while
loop is present?". I say this because the natural reading of the code is that it should always transition to Faulted
instead.
Normally, the way cancellation works is that you only get the Canceled
state if the OperationCanceledException
constructor was passed the same CancellationToken
instance that was passed to the Task.Run()
method. Otherwise, the task transitions to Faulted
on any exception.
That this isn't what happens when you add the while
loop is odd, to say the least. So, why does this odd thing happen?
Well, the answer is found (at least partially) in the code that the compiler generates. Here is the IL for the loop when the while
loop is present (this IL also includes a diagnostic call to Console.WriteLine()
, but is otherwise exactly the code you posted):
.method public hidebysig instance class [mscorlib]System.Threading.Tasks.Task
'<Main>b__1'() cil managed
{
// Code size 67 (0x43)
.maxstack 2
.locals init (class [mscorlib]System.Threading.Tasks.Task V_0,
bool V_1)
IL_0000: nop
IL_0001: br.s IL_003f
IL_0003: nop
IL_0004: ldstr "sleeping"
IL_0009: call void [mscorlib]System.Console::WriteLine(string)
IL_000e: nop
IL_000f: ldc.i4 0x12c
IL_0014: call void [mscorlib]System.Threading.Thread::Sleep(int32)
IL_0019: nop
IL_001a: ldarg.0
IL_001b: ldflda valuetype [mscorlib]System.Threading.CancellationToken TestSO33850046CancelVsFaulted.Program/'<>c__DisplayClass3'::cancellationToken
IL_0020: call instance bool [mscorlib]System.Threading.CancellationToken::get_IsCancellationRequested()
IL_0025: ldc.i4.0
IL_0026: ceq
IL_0028: stloc.1
IL_0029: ldloc.1
IL_002a: brtrue.s IL_003e
IL_002c: nop
IL_002d: ldstr "throwing"
IL_0032: call void [mscorlib]System.Console::WriteLine(string)
IL_0037: nop
IL_0038: newobj instance void [mscorlib]System.OperationCanceledException::.ctor()
IL_003d: throw
IL_003e: nop
IL_003f: ldc.i4.1
IL_0040: stloc.1
IL_0041: br.s IL_0003
} // end of method '<>c__DisplayClass3'::'<Main>b__1'
Note that even though the method has no return
statement, the compiler has inferred (for some reason) the method's return type as Task
instead of void
. I admit, I have no idea why this should be; the method isn't async
, never mind does it have any await
, and the lambda certainly isn't a simple expression evaluating to a Task
value. But even so, the compiler's decided to implement this method as returning Task
.
This in turn has an effect on which Task.Run()
method overload is called. Instead of calling Task.Run(Action, CancellationToken)
, it will call Task.Run(Func<Task>, CancellationToken)
. And it turns out that the implementation of each of these two methods is very different from the other. While the Action
overload simply creates a new Task
object and starts it, the Func<Task>
overload wraps the created task in an UnwrapPromise<T>
object, passing to its constructor a flag that tells it to explicitly be on the lookout for OperationCanceledException
and treat that as a Canceled
result instead of Faulted
.
If you comment out the while
, the compiler implements the anonymous method instead as having a return type of void
. Likewise if you add a (unreachable) return
statement after the while
loop. In either case, this causes the anonymous method to have return type of void
causing the Action
overload for Run()
to be called, which treats OperationCanceledException
just like any other, transitioning the task into the Faulted
state instead.
And of course, if you pass the cancellationToken
value to the OperationCanceledException
constructor, or call cancellationToken.ThrowIfCancellationRequested()
instead of explicitly checking and throwing, the exception itself will correctly indicate that it was thrown according to the CancellationToken
that was passed to the Run()
method and thus the task will transition to Canceled
as would normally be desired in this scenario.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With