Search code examples
c#task-parallel-librarytasktaskcompletionsource

Async Tasks, Cancellation, and Exceptions


I'm currently learning how to properly expose asynchronous parts of our library API with Tasks so they can be easier and nicer to use for customers. I decided to go with the TaskCompletionSource approach of wrapping a Task around it that doesn't get scheduled on the thread pool (in the instance here unneeded anyway since it's basically just a timer). This works fine, but cancellation is a bit of a headache right now.

The example shows the basic usage, registering a delegate on the token, but is a tiny bit more complicated than in my case, more to the point, I'm not really sure what to do with the TaskCanceledException. The documentation says either just returning and having the task status switch to RanToCompletion, or throwing an OperationCanceledException (which results in the task's result being Canceled) is fine. However, the examples seem to only pertain to, or at least mention, tasks that are started via a delegate passed to TaskFactory.StartNew.

My code currently is (roughly) as follows:

public Task Run(IFoo foo, CancellationToken token = default(CancellationToken)) {
  var tcs = new TaskCompletionSource<object>();

  // Regular finish handler
  EventHandler<EventArgs> callback = (sender, args) => tcs.TrySetResult(null);
  // Cancellation
  token.Register(() => {
    tcs.TrySetCanceled();
    CancelAndCleanupFoo(foo);
  });

  RunFoo(foo, callback);
  return tcs.Task;
}

(There is no result and no possible exceptions during execution; one reason why I chose to start here, and not at more complicated places in the library.)

In the current form, when I call TrySetCanceled on the TaskCompletionSource, I always get a TaskCanceledException if I await the task returned. My guess would be that this is normal behaviour (I hope it is) and that I'm expected to wrap a try/catch around the call when I want to use cancellation.

If I don't use TrySetCanceled, then I'll eventually run in the finish callback and the task looks like it finished normally. But I guess if a user wants to distinguish between a task that finished normally and one that was canceled, the TaskCanceledException is pretty much a side-effect of ensuring that, right?

Another point I didn't quite understand: Documentation suggests that any exceptions, even those that pertain to cancellation, are wrapped in an AggregateException by the TPL. However, in my tests I'd always get the TaskCanceledException directly, without any wrapper. Am I missing something here, or is that just poorly-documented?


TL;DR:

  • For a task to transition into the Canceled state, there's always a corresponding exception needed for that and users have to wrap a try/catch around the async call to be able to detect that, right?
  • The TaskCanceledException being thrown unwrapped is also expected and normal and I'm not doing anything wrong here?

Solution

  • I always recommend people read the Cancellation in Managed Threads docs. It's not quite complete; like most MSDN docs, it tells you what you can do, not what you should do. But it's definitely more clear than the dotnet docs on cancellation.

    The example shows the basic usage

    First, it's important to note that the cancellation in your example code only cancels the task - it does not cancel the underlying operation. I strongly recommend that you do not do this.

    If you want to cancel the operation, then you would need to update RunFoo to take a CancellationToken (see below for how it should use it):

    public Task Run(IFoo foo, CancellationToken token = default(CancellationToken)) {
      var tcs = new TaskCompletionSource<object>();
    
      // Regular finish handler
      EventHandler<AsyncCompletedEventArgs> callback = (sender, args) =>
      {
        if (args.Cancelled)
        {
          tcs.TrySetCanceled(token);
          CleanupFoo(foo);
        }
        else
          tcs.TrySetResult(null);
      };
    
      RunFoo(foo, token, callback);
      return tcs.Task;
    }
    

    If you can't cancel foo, then don't have your API support cancellation at all:

    public Task Run(IFoo foo) {
      var tcs = new TaskCompletionSource<object>();
    
      // Regular finish handler
      EventHandler<EventArgs> callback = (sender, args) => tcs.TrySetResult(null);
    
      RunFoo(foo, callback);
      return tcs.Task;
    }
    

    Callers can then perform a cancelable wait on the task, which is a much more appropriate code technique for this scenario (since it is the wait that is canceled, not the operation represented by the task). Performing a "cancelable wait" can be done via my AsyncEx.Tasks library, or you could write your own equivalent extension method.

    The documentation says either just returning and having the task status switch to RanToCompletion, or throwing an OperationCanceledException (which results in the task's result being Canceled) is fine.

    Yeah, those docs are misleading. First, please don't just return; your method would complete the task successfully - indicating the operation completed successfully - when in fact the operation did not complete successfully. This may work for some code, but is certainly not a good idea in general.

    Normally, the proper way to respond to a CancellationToken is to either:

    • Periodically call ThrowIfCancellationRequested. This option is better for CPU-bound code.
    • Register a cancellation callback via Register. This option is better for I/O-bound code. Note that registrations must be disposed!

    In your particular case, you have an unusual situation. In your case, I would take a third approach:

    • In your "per-frame work", check token.IsCancellationRequested; if it is requested, then raise the callback event with AsyncCompletedEventArgs.Cancelled set to true.

    This is logically equivalent to the first proper way (periodically calling ThrowIfCancellationRequested), catching the exception, and translating it into an event notification. Just without the exception.

    I always get a TaskCanceledException if I await the task returned. My guess would be that this is normal behaviour (I hope it is) and that I'm expected to wrap a try/catch around the call when I want to use cancellation.

    The proper consuming code for a task that can be canceled is to wrap the await in a try/catch and catch OperationCanceledException. For various reasons (many historical), some APIs will cause OperationCanceledException and some will cause TaskCanceledException. Since TaskCanceledException derives from OperationCanceledException, consuming code can just catch the more general exception.

    But I guess if a user wants to distinguish between a task that finished normally and one that was canceled, the [cancellation exception] is pretty much a side-effect of ensuring that, right?

    That's the accepted pattern, yes.

    Documentation suggests that any exceptions, even those that pertain to cancellation, are wrapped in an AggregateException by the TPL.

    This is only true if your code synchronously blocks on a task. Which it should really avoid doing in the first place. So the docs are definitely misleading again.

    However, in my tests I'd always get the TaskCanceledException directly, without any wrapper.

    await avoids the AggregateException wrapper.

    Update for comment explaining CleanupFoo is a cancellation method.

    I'd first recommend trying to use CancellationToken directly within the code initiated by RunFoo; that approach would almost certainly be easier.

    However, if you must use CleanupFoo for cancellation, then you'll need to Register it. You'll need to dispose that registration, and the easiest way to do this may actually be to split it into two different methods:

    private Task DoRun(IFoo foo) {
      var tcs = new TaskCompletionSource<object>();
    
      // Regular finish handler
      EventHandler<EventArgs> callback = (sender, args) => tcs.TrySetResult(null);
    
      RunFoo(foo, callback);
      return tcs.Task;
    }
    
    public async Task Run(IFoo foo, CancellationToken token = default(CancellationToken)) {
      var tcs = new TaskCompletionSource<object>();
      using (token.Register(() =>
          {
            tcs.TrySetCanceled(token);
            CleanupFoo();
          });
      {
        var task = DoRun(foo);
        try
        {
          await task;
          tcs.TrySetResult(null);
        }
        catch (Exception ex)
        {
          tcs.TrySetException(ex);
        }
      }
      await tcs.Task;
    }
    

    Properly coordinating and propagating the results - while preventing resource leaks - is quite awkward. If your code could use CancellationToken directly, it would be much cleaner.