Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why throwing OperationCanceledException gets me different results?

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.

like image 419
Deffiss Avatar asked Oct 18 '22 21:10

Deffiss


1 Answers

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.

like image 65
Peter Duniho Avatar answered Oct 21 '22 15:10

Peter Duniho