1

When cancelling the following task, the task is not in state Canceled but Faulted:

    private string ReturnString()
    {
        // throw new OperationCanceledException(_cancellationToken);   // This puts task in faulted, not canceled
        Task.Delay(5000, _cancellationToken).Wait(_cancellationToken); // Simulate work (with IO-bound call)
        // throw new OperationCanceledException(_cancellationToken);   // This puts task in faulted, not canceled
        // _cancellationToken.ThrowIfCancellationRequested();          // This puts task in faulted, not canceled  
        // throw new Exception("Throwing this exception works!");      // This works as expected (faulted)
        return "Ready";
    }

    private void SetReturnValueWithTaskContinuation()
    {
        SynchronizationContext synchronizationContext = SynchronizationContext.Current;
        Task<string> task = Task.Run(() => ReturnString());
        task.ContinueWith(
        antecedent =>
        {
            if (antecedent.Status == TaskStatus.Canceled)
            {
                synchronizationContext.Post(result => _txtResultContinueWith.Text = (string)result, "Cancelled");
            }
            else if (antecedent.Status == TaskStatus.Faulted)
            {
                synchronizationContext.Post(result => _txtResultContinueWith.Text = (string)result, "Exception");
            }
            else
            {
                synchronizationContext.Post(result => _txtResultContinueWith.Text = (string)result, antecedent.Result);
            }
        });
    }

I know, that the cancellation token has to be supplied when throwing an OperationCanceled Exception. I know, there are two ways of throwing an OperationCanceled Exception where the ThrowIfCancellationRequested() is the prefered one. And I know, that the cancellation token of the continuation chain should be different than the cancellation token of the task to cancel, otherwise the continuation chain will be canceled too. For the sake of simplification, I only use one cancellation token to cancel the task itself. But, the task has state "Faulted" and not "Canceled". Is that a bug? If not, than it is a usability issue of the TPL. Can somebody help?

Erik Stroeken
  • 519
  • 4
  • 16
  • (Not directly related to your question, but note that you can use `.ContinueWith(..., TaskScheduler.FromCurrentSynchronizationContext())` instead of capturing and dispatching to it manually) – canton7 Sep 30 '19 at 12:41
  • 2
    You probably need to pass `_cancellationToken` into `Task.Run`? Currently `Task.Run` sees an Exception (in the form of the `OperationCanceledException`), and puts its `Task` into the Faulted state. If you pass `_cancellationToken` to the `Task.Run`, it'll see that the exception it caught is an `OperationCanceledException` with the same token as the one it was given, and put its `Task` into the Canceled state instead. – canton7 Sep 30 '19 at 12:44
  • I tried to pass _cancellationToken into Task.Run. Then the continuation chain is cancelled too. It requires two cancellation tokens then: one to cancel the task itself but not the continuation chain and one to cancel the continuation chain. As I stated, I left it out for the sake of simplicity. – Erik Stroeken Oct 01 '19 at 10:40
  • 1
    "*I tried to pass _cancellationToken into Task.Run. Then the continuation chain is cancelled too*" -- are you sure about that? I can't repro: https://dotnetfiddle.net/8SEYQr – canton7 Oct 01 '19 at 11:36
  • Please refer to the following article for details about using the same cancellation token for the continuation chain: https://stackoverflow.com/questions/10563214/tpl-cancellation-continuation-never-called-on-cancelled-task – Erik Stroeken Oct 01 '19 at 14:07
  • Does that apply, since you're not passing your CancellationToken to your continuation? – canton7 Oct 01 '19 at 14:08

1 Answers1

1

Task.Run does want us to provide a cancellation token for proper propagation of the cancellation status, see:

Faulted vs Canceled task status after CancellationToken.ThrowIfCancellationRequested

This is particularly important if we use the overrides of Task.Run that accept Action, or Func<T> delegate where T is anything but Task. Without a token, the returned task status will be Faulted rather than Canceled in this case.

However, if the delegate type is Func<Task> or Func<Task<T>> (e.g., an async lambda), it gets some special treatment by Task.Run. The task returned by the delegate gets unwrapped and its cancellation status is properly propagated. So, if we amend your ReturnString like below, you'll get the Canceled status as expected, and you don't have to pass a token to Task.Run:

private Task<string> ReturnString()
{
    Task.Delay(5000, _cancellationToken).Wait(_cancellationToken);
    return Task.FromResult("Ready");
}

// ...

Task<string> task = Task.Run(() => ReturnString()); // Canceled status gets propagated

If curious about why Task.Run works that way, you can dive into its implementation details.

Note though, while this behavior has been consistent from when Task.Run was introduced in .NET 4.5 through to the current version of .NET Core 3.0, it's still undocumented and implementation-specific, so we shouldn't rely upon it. For example, using Task.Factory.StartNew instead would still produce Faulted:

Task<string> task = Task.Factory.StartNew(() => ReturnString(),
     CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Current).Unwrap();

Unless your associate a cancellation token, the only time when Task.Factory.StartNew would return a Canceled task here is when ReturnString has been modified to be async. Somehow, the compiler-generated async state machine plumbing changes the behavior of the Task returned by ReturnString.

In general, it's always best to provide a cancellation token to Task.Run or Task.Factory.StartNew. In your case, if this is not possible, you may want to make ReturnString an async method:

private async Task<string> ReturnString()
{
    var task = Task.Run(() => 
    {
        Thread.Sleep(1500); // CPU-bound work
        _cancellationToken.ThrowIfCancellationRequested();
    });

    await task; // Faulted status for this task

    // but the task returned to the caller of ReturnString 
    // will be of Canceled status,
    // thanks to the compiler-generated async plumbing magic

    return "Ready";
}

Usually, doing async-over-sync is not a good idea, but where ReturnString is a private method implementing some GUI logic to offload work to a pool thread, this is might the way to go.

Now, you might only ever need to wrap this with another Task.Run if you wanted to take it off the current synchronization context (and if even you do so, the cancellation status will still be correctly propagated):

Task<string> task = Task.Run(() => ReturnString());

On a side note, a common pattern to not worry about synchronization context is to routinely use ConfigureAwait everywhere:

await Task.Run(...).ConfigureAwait(continueOnCapturedContext: false); 

But I myself have stopped using ConfigureAwait unconsciously, for the following reason:

Revisiting Task.ConfigureAwait(continueOnCapturedContext: false)

noseratio
  • 59,932
  • 34
  • 208
  • 486