4

I find myself writing code like this a lot:

try
{
    cancellationTokenSource.Cancel();
    await task.ConfigureAwait(false); // this is the task that was cancelled
}
catch(OperationCanceledException)
{
    // Cancellation expected and requested
}

Given that I requested the cancellation, it is expected and I'd really like the exception to be ignored. This seems like a common case.

Is there a more concise way to do this? Have I missed something about cancellation? It seems like there should be a task.CancellationExpected() method or something.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
Jeff Walker Code Ranger
  • 4,634
  • 1
  • 43
  • 62
  • There is a `IsCancellationRequested` property, but raising the exception is how cancellation tokens are implemented. –  Dec 20 '19 at 18:33
  • 2
    If you cancelled it, why are you trying to `await` it at all? – DavidG Dec 20 '19 at 18:34
  • 4
    requested cancellation, not canceled it. –  Dec 20 '19 at 18:34
  • 4
    @DavidG I assume to make sure the task has actually stopped doing its thing before proceeding. That's a valid requirement. – Gabriel Luci Dec 20 '19 at 18:40
  • Why doesn't your task handle cancellation cleanly? It should `return` not `throw` when cancellation happens, or it should `try { } catch OperationCancelled` internally. The caller should not need to know that the task may not handle cancellation cleanly. – Ian Mercer Dec 20 '19 at 18:44
  • 1
    I am awaiting the task because the cancellation needs to complete before continuing. I need to know that the side effects of the task are no longer ongoing. – Jeff Walker Code Ranger Dec 20 '19 at 18:45
  • @IanMercer that is not how you are supposed to handle cancellation. You need to allow the operation canceled exception to escape the task in order to correctly mark the task as canceled. Otherwise, it is marked as successful. Furthermore, if the function being canceled is also called from another function that is just passing along the cancelation token, then the exception is needed to ensure the calling function stops executing. – Jeff Walker Code Ranger Dec 20 '19 at 18:48
  • Isn't that you pass the Cancellation token somewhere (some 3rd party lib for instance) and you get that exception from the library because that is the way it is implemented to handle cancellation token? Otherwise if you don't pass the token anywhere you can control the flow on your own and don't throw anything – OlegI Dec 20 '19 at 18:48
  • Do you have control over the code of the the method that returned the task or is returned from 3rd party library ? – vasil oreshenski Dec 20 '19 at 18:57
  • @vasiloreshenski I do have control over the method that returned the task. However, I don't consider preventing that task from going into a canceled state when it is canceled a valid option. – Jeff Walker Code Ranger Dec 20 '19 at 19:02
  • @JeffWalkerCodeRanger It depends. See https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/task-cancellation which states "You can terminate the operation by using one of these options: 1) By simply returning from the delegate... 2) ..." So if it is a cooperative cancellation and it's expected (as it appears to be in your case) you can decide to return cleanly. – Ian Mercer Dec 20 '19 at 19:08
  • @JeffWalkerCodeRanger Check EDIT2 of my answer. It does the job but it over-complicates the problem i think. – vasil oreshenski Dec 20 '19 at 19:42
  • 2
    The answer to most questions of the form "I am copy-pasting this code a lot, how do I make it more concise?" is *write a method that contains the copy-pasted code and call it*. Have you rejected this solution, and if so, can you say why you rejected it? – Eric Lippert Dec 20 '19 at 20:04
  • 3
    @EricLippert I haven't rejected it. That very well may be the answer. The source of my question was that this seemed like it would be so common that there would be something in the framework for it. – Jeff Walker Code Ranger Dec 20 '19 at 20:07

6 Answers6

4

There is a built-in mechanism, the Task.WhenAny method used with a single argument, but it's not very intuitive.

Creates a task that will complete when any of the supplied tasks have completed.

await Task.WhenAny(task); // await the task ignoring exceptions
if (task.IsCanceled) return; // the task is completed at this point
var result = await task; // can throw if the task IsFaulted

It is not intuitive because the Task.WhenAny is normally used with at least two arguments. Also it is slightly inefficient because the method accepts a params Task<TResult>[] tasks argument, so on every invocation an array is allocated in the heap.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • For anyone interested, here is a related API proposal on GitHub: [Support await'ing a Task without throwing](https://github.com/dotnet/runtime/issues/22144) – Theodor Zoulias Feb 24 '22 at 21:18
  • I have posted essentially the same answer [here](https://stackoverflow.com/questions/20509158/taskcanceledexception-when-calling-task-delay-with-a-cancellationtoken-in-an-key/59300076#59300076), with some added notes about the `TaskScheduler.UnobservedTaskException` event. – Theodor Zoulias Jan 17 '23 at 20:09
2

I don't think there is anything built-in, but you could capture your logic in extension methods (one for Task, one for Task<T>):

public static async Task IgnoreWhenCancelled(this Task task)
{
    try
    {
        await task.ConfigureAwait(false);
    }
    catch (OperationCanceledException)
    {
    }
}

public static async Task<T> IgnoreWhenCancelled<T>(this Task<T> task)
{
    try
    {
        return await task.ConfigureAwait(false);
    }
    catch (OperationCanceledException)
    {
        return default;
    }
}

Then you can write your code simpler:

await task.IgnoreWhenCancelled();

or

var result = await task.IgnoreWhenCancelled();

(You might still want to add .ConfigureAwait(false) depending on your synchronization needs.)

Matt Johnson-Pint
  • 230,703
  • 74
  • 448
  • 575
  • Also note there's nothing binding this to any particular cancellation token or cancellation token source. Even when you pass a token into a method that returns a task, there's nothing that inherently binds that task to that token. So you'd still have to specify the behavior with each call. – Matt Johnson-Pint Dec 20 '19 at 18:50
1

I assume whatever task is doing uses CancellationToken.ThrowIfCancellationRequested() to check for cancellation. That throws an exception by design.

So your options are limited. If task is an operation you wrote, you could make it not use ThrowIfCancellationRequested() and instead check IsCancellationRequested and end gracefully when needed. But as you know, the task's status won't be Canceled if you do that.

If it uses code you didn't write, then you don't have a choice. You'll have to catch the exception. You can use extension methods to avoid repeating code (Matt's answer), if you want. But you'll have to catch it somewhere.

Gabriel Luci
  • 38,328
  • 4
  • 55
  • 84
1

The cancellation pattern available in C# in called cooperative cancellation.

This basically means that, in order to cancel any operation, there should be two actors which need to collaborate. One of them is the actor requesting the cancellation and the other is the actor listening to cancellation requests.

In order to implement this pattern you need an instance of CancellationTokenSource, which is an object that you can use in order to get an instance of CancellationToken. The cancellation is requested on the CancellationTokenSource instance and is propagated to the CancellationToken.

The following piece of code shows you this pattern in action and hopefully clarifies your doubt about cancellation:

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApp2
{
  public static class Program
  {
    public static async Task Main(string[] args)
    {
      using (var cts = new CancellationTokenSource())
      {
        CancellationToken token = cts.Token;

        // start the asyncronous operation
        Task<string> getMessageTask = GetSecretMessage(token);

        // request the cancellation of the operation
        cts.Cancel();


        try
        {
          string message = await getMessageTask.ConfigureAwait(false);
          Console.WriteLine($"Operation completed successfully before cancellation took effect. The message is: {message}");
        }
        catch (OperationCanceledException)
        {
          Console.WriteLine("The operation has been canceled");
        }
        catch (Exception)
        {
          Console.WriteLine("The operation completed with an error before cancellation took effect");
          throw;
        }

      }
    }

    static async Task<string> GetSecretMessage(CancellationToken cancellationToken)
    {
      // simulates asyncronous work. notice that this code is listening for cancellation
      // requests
      await Task.Delay(500, cancellationToken).ConfigureAwait(false);
      return "I'm lost in the universe";
    }
  }
}

Pay attention to the comment and notice that all the 3 outputs for the program are possible.

There is no way to predict which of them will be the actual program result. The point is that when you await for the task completion you don't know what actually is going to happen. The operation may succeeds or fails before the cancellation took effect, or maybe the cancellation request can be observed by the operation before it runs to completion or fails for an error. From the calling code point of view, all these outcomes are possible and you have no way to make a guess. You need to handle all cases.

So, basically, your code is correct and you are handling the cancellation the way you should.

This book is an excellent reference to learn these things.

Enrico Massone
  • 6,464
  • 1
  • 28
  • 56
0

My final solution was to create an extension method as suggested by Matt Johnson-Pint. However, I return a boolean indicating whether the task was canceled as shown in Vasil Oreshenski's answer.

public static async Task<bool> CompletionIsCanceledAsync(this Task task)
{
    if (task.IsCanceled) return true;
    try
    {
        await task.ConfigureAwait(false);
        return false;
    }
    catch (OperationCanceledException)
    {
        return true;
    }
}

This method has been fully unit tested. I picked the name to be similar to the WaitForCompletionStatus() method in the ParallelExtensionsExtras sample code and the IsCanceled property.

Jeff Walker Code Ranger
  • 4,634
  • 1
  • 43
  • 62
-3

If you are expecting the task to be cancelled BEFORE the await you should check the state of the cancellation token source.

if (cancellationTokenSource.IsCancellationRequested == false) 
{
    await task;
}

EDIT: As mentioned in the comments this won't do any good if the task is cancelled while awaited.


EDIT 2: This approach is overkill because it acquires additional resource - in hot path this may have performance hit. (i am using SemaphoreSlim but you can use another sync. primitive with the same success)

This is an extension method over existing task. The extension method will return new task which holds information if the original task was cancelled.

  public static async Task<bool> CancellationExpectedAsync(this Task task)
    {
        using (var ss = new SemaphoreSlim(0, 1))
        {
            var syncTask = ss.WaitAsync();
            task.ContinueWith(_ => ss.Release());
            await syncTask;

            return task.IsCanceled;
        }
    }

Here is a simple usage:

var cancelled = await originalTask.CancellationExpectedAsync();
if (cancelled) {
// do something when cancelled
}
else {
// do something with the original task if need
// you can acccess originalTask.Result if you need
}

How it works: Overall it waits for the original task to complete and returns information if was cancelled. The SemaphoraSlim is usually used to limit the access to some resource(expensive) but in this case i am using it to await until the original task has finished.

Notes: It does not returns the original task. So if you need something that has been returned from it you should inspect the original task.

vasil oreshenski
  • 2,788
  • 1
  • 14
  • 21