7

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.

Deffiss
  • 1,136
  • 7
  • 12
  • You haven't cancelled out the operation block { just the while, so that is why there is a fault. – tonypartridge Nov 21 '15 at 23:55
  • @XWS: there is a fault in the entire operation either way. Without a try/catch, the exception escapes the entire anonymous method regardless. The question is, why does the fault lead to the task having the `Canceled` state with the `while` and `Faulted` without it. – Peter Duniho Nov 22 '15 at 01:50

1 Answers1

5

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.

Peter Duniho
  • 68,759
  • 7
  • 102
  • 136
  • Thank you for the great research. Although we still have a question "Why the behavior of the compiler so inconsistent?" I am not sure that I'll receive deeper explanations than yours. I'll mark the answer as a right during the day if no other appears. – Deffiss Nov 22 '15 at 10:11
  • 1
    Just found quite good explanation on almost the same question here: http://stackoverflow.com/questions/24359761/faulted-vs-canceled-task-status-after-cancellationtoken-throwifcancellationreque – Deffiss Nov 22 '15 at 13:31
  • 1
    @Deffiss: yup, that's almost the same question. Only difference is that they aren't passing the token to `Run()` (and so don't expect state `Canceled` even using `ThrowIfCancellationRequested()`). As useful is the link to [the answer to the question of why the lambda return type is inferred as it is](http://stackoverflow.com/a/24316474/3538012). Short version: the compiler chooses `Func` over `Action` because it can. – Peter Duniho Nov 22 '15 at 17:41
  • *They aren't passing the token to `Run`*, AFAIR - the state will still be `Canceled`. Passing it to `Run` is only important when the token is already canceled, so the task lambda doesn't even run. FYI @Deffiss, too. – noseratio Nov 23 '15 at 22:38
  • @Noseratio: _"Passing it to Run is only important when the token is already canceled"_ -- nope. try it. If you don't pass the token, and you do use the `Run(Action)` overload, then even if you use `ThrowIfCancellationRequested()`, the task completes as `Faulted` instead of `Canceled`. – Peter Duniho Nov 23 '15 at 22:42
  • @PeterDuniho, you're right about the `Task.Run(Action)` override, the token has to be associated with task for proper cancellation propagation. However, it doesn't for the unwrapping `Task.Run(Func)` override, where the `Func` doesn't even have to be `async`. – noseratio Nov 24 '15 at 11:57
  • @Noseratio: _"it doesn't for the unwrapping `Task.Run(Func)`"_ -- well, sure. That is in fact the entire point of this and the other question. Not sure what clarification you're trying to make here, if any. The difference in the call is related to what the behavior _expected_ by the questioner is, not what the behavior in the case of the `Run(Func)` overload actually turns out to be. – Peter Duniho Nov 24 '15 at 16:22
  • You're right, I stand corrected about the `Action`/`Func` + `token` behavior. I still wanted to point out how `Func` behavior is different (caused by `Task.Unwrap`). – noseratio Nov 24 '15 at 21:32
  • @Noseratio: _"I still wanted to point out how Func behavior is different"_ -- I'm curious: why is that important to point out? Don't the answer above and the one to the very similar [question](http://stackoverflow.com/questions/24359761/faulted-vs-canceled-task-status-after-cancellationtoken-throwifcancellationreque) already adequately point that out? – Peter Duniho Nov 24 '15 at 21:39
  • Peter, "why is that important to point out?" As a reminder to to myself and others how may bother to read this thread, perhaps. I was the asker/answerer of that very similar [question](http://stackoverflow.com/questions/24359761/faulted-vs-canceled-task-status-after-cancellationtoken-throwifcancellationreque) and now - 1 year later - I forgot that `token` has to be associated with the `Action`-style lambda, as you see. – noseratio Nov 24 '15 at 21:58