3

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?
Joey
  • 344,408
  • 85
  • 689
  • 683
  • 1
    Instead of wrapping an existing event-based API with tasks, (which your clients can do just fine), it's better to use a *task*-based API and expose events for anyone that still wants them. What does `RunFoo` do? How do you make it work *asynchronously*? How do you cancel *it*? – Panagiotis Kanavos Apr 28 '17 at 08:49
  • PS: Exceptions aren't necessary when cancelling. A method that acceps a token can check the `IsCancellationRequested` flag and cancel itself - whatever that means. Eg, you can't cancel an Http request, you can only cancel waiting for a response. – Panagiotis Kanavos Apr 28 '17 at 08:55
  • It runs an animation by registering an event handler for `CompositionTarget.OnRendering`, thus it doesn't actually need to run on the thread pool (as noted, for the purposes of this question, it's effectively just a timer). Thus it already works asynchronously, and I cancel it by deregistering the event handler for `OnRendering` (plus a bit more bookkeeping). All that has been in place before. The point here was to expose a task-based API _without_ having to rewrite all the internals if possible. Especially if things already work asynchronously just fine and just don't expose anything to await – Joey Apr 28 '17 at 08:56
  • Well, in my case the token doesn't get passed on to anything else, as nothing uses tasks internally yet. So there's no one to check `IsCancellationRequested`. But even then, as far as I understood you have two options there: One would be to just stop doing what you're doing, which results in the task running to completion, just ... short of _actual_ completion. The other is to throw the exception yourself (e.g. via the helper method on the token), which results in a canceled task ... and the TaskCanceledException. – Joey Apr 28 '17 at 09:01
  • A task abstracts a single task/job that executes asynchronously, eg the *code* that runs in response to the event. When a *single* event is fired, a *single* task gets executed. It sounds like you are trying to use it for something very different - register/unregister the event handler? How do you expect the API to look? Would clients *await* from the tasks's completion? What would that mean? – Panagiotis Kanavos Apr 28 '17 at 09:02
  • The case in question is `Animator.Animate(IAnimation)`. Which runs an animation instance asynchronously (synchronously would be rather pointless anyway). Since it's an animation, there isn't anything long-running but rather per-frame work to do, which in WPF has a dedicated event to register a handler for. From the client's POV I'd say it qualifies as a task. The old API has a callback parameter to the `Animate` method to do things after the animation finished, which in the task-based variant would be done by awaiting and then doing cleanup stuff (e.g. re-enable interaction in the UI). – Joey Apr 28 '17 at 09:08
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/142888/discussion-between-joey-and-panagiotis-kanavos). – Joey Apr 28 '17 at 09:08

3 Answers3

5

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.

Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • I'll probably read in detail and think about it on Tuesday, but until then: the CleanupFoo method _does_ cancel the operation, that's why I call it in reponse to the token's request. Should have renamed it better for illustrative purposes. – Joey Apr 28 '17 at 14:32
  • Ok, read the MSDN article and the answer. I believe a significant portion here hinges on your thinking that I do not cancel the actual operation, which I do (let's name `CleanupFoo` `CancelAndCleanupFoo` instead). Also as far as I understand now, having the exception upon `await` instead of returning, e.g. a `Task` as suggested by Panagiotis seems more idiomatic and fits better into the rest of the framework, so that approach should be preferable even though it necessitates a `try`/`catch` at the user's end if they want to use cancellation? – Joey May 02 '17 at 08:18
  • Yes, throwing an exception on cancellation is more idiomatic than `Task`. However, if you use `Register`, then you must be sure to dispose it, which is awkward. I'll update my answer to illustrate. – Stephen Cleary May 02 '17 at 13:52
  • 1
    Thanks a lot. I actually went with just using the `CancellationToken` and the `AsyncCompletedEventArgs` which in the end was indeed simpler (for my current, rather trivial use case) than registering and figuring out how to dispose (I misread the example on MSDN a bit initially, but after I understood it, I've already rewritten the code anyway). Now onto the more interesting part of the API which is probably a lot harder to convert/wrap. I just don't want to release in a few weeks and then having a customer tell us that our solution is totally wrong ;-) – Joey May 02 '17 at 15:05
2

What you are doing is fine - Task represents some operation with result in future, it is not necessary related to running anything on another thread or something like that. And it's perfectly normal to use standard means of cancellation for, well, cancellation, instead of returning something like boolean value.

To answer your questions: when you do tcs.TrySetCanceled() it will move task to cancelled state (task.IsCancelled will be true) and no exceptions are thrown at this point. But when you await this task - it will notice that task is cancelled and that is the point where TaskCancelledException will be thrown. Nothing is wrapped into aggregate exception here because there is really nothing to wrap - TaskCancelledException is thrown as a part of await logic. Now if you will do something like task.Wait() instead - then it will wrap TaskCancelledException into AggregateException as you would expect.

Note that await unwraps AggregateExceptions anyway, so you might never expect await task to throw AggregateException - in case of multiple exceptions only first one will be thrown - the rest will be swallowed.

Now if you are using cancellation token with regular tasks - things are a bit different. When you do something like token.ThrowIfCancellationRequested it will actually throw OperationCancelledException (note that it's not TaskCancelledException but TaskCancelledException is subclass of OperationCancelledException anyway). Then, if CancellationToken used to throw this exception is the same as CancellationToken passed to task when it was started (like in example by your link) - task will move to the Cancelled state the same way. This is the same as tcs.TrySetCancelled in your code with the same behavior. If tokens mismatch - task will go to Faulted state instead, like the regular exception was thrown.

Evk
  • 98,527
  • 8
  • 141
  • 191
  • Okay; that helps. This was a journey of reading documentation, .NET source code (which is newer than the framework I'm using), and trial and error, so a few things have been a bit murky. – Joey Apr 28 '17 at 09:40
1

From the comments, it looks like you have an animation library that accepts an IAnimation, executes it (obviously asynchronously) and then signals back that it completed.

This isn't an actual task, in the sense that it isn't a piece of work that has to run on a thread. It's an asynchronous operation, which in .NET is exposed using a Task object.

Furthermore, you aren't actually cancelling something, you are stopping the animation. That's a perfectly normal operation so it shouldn't throw an exception. It would be better if your method returned a value that explained whether the animation completed or not, eg:

public Task<bool> Run(IAnimation animation, CancellationToken token = default(CancellationToken)) {
  var tcs = new TaskCompletionSource<bool>();

  // Regular finish handler
  EventHandler<EventArgs> callback = (sender, args) => tcs.TrySetResult(true);
  // Cancellation 
  token.Register(() => {
                         CleanupFoo(animation);
                         tcs.TrySetResult(false);
                       });
  RunFoo(animation, callback);
  return tcs.Task;
}

The call to run an animation is simple:

var myAnimation = new SomeAnimation();
var completed = await runner.Run(myAnimation,token);
if (completed)
{
}

UPDATE

This can be improved further with some C# 7 tricks.

For example, instead of using callbacks and lambdas, you can use local functions. Apart from making the code cleaner, they don't allocate a delegate each time they are called. The change doesn't require C# 7 support on the client's side:

Task<bool> Run(IAnimation animation, CancellationToken token = default(CancellationToken)) {
  var tcs = new TaskCompletionSource<bool>();

  // Regular finish handler
  void OnFinish (object sender, EventArgs args) => tcs.TrySetResult(true);
  void OnStop(){
    CleanupFoo(animation);
    tcs.TrySetResult(false);
  }

  // Null-safe cancellation 
  token.Register(OnStop);
  RunFoo(animation, OnFinish);
  return tcs.Task;
}

You can also return more complex results, eg a result type that contains a Finished/Stopped flag and the final frame if the animation was stopped. If you don't want to use meaningless fields (why specify a frame if the animation completed?), you can return a Success type or a Stopped type that implement eg IResult.

Before C# 7, you'd need to check the return type or use overloading to access different types. With pattern matching though, you can get the actual result with a switch, eg:

interface IResult{}
public class Success:IResult{}

public class Stopped { 
    public int Frame{get;}
    Stopped(int frame) { Frame=frame; }
}

....

var result=await Run(...);
switch (result)
{
    case Success _ : 
        Console.WriteLine("Finished");
        break;
    case Stopped s :
        Console.WriteLine($"Stopped at {s.Frame}");
        break;
}

Pattern matching is actually faster than type checking too. This requires that the client supports C# 7.

Panagiotis Kanavos
  • 120,703
  • 13
  • 188
  • 236
  • Huh, that's something I actually didn't even think of. I chose `Task` (without type argument), since there isn't anything useful to return anyway, but being able to distinguish cancel from normal completion sounded useful, too, but having to wrap the `await` into `try`/`catch` just for that seemd a bit ugly. In any case, there are more opportunities for `Task`s in the library, it's not just animations (there's still graph layout left to taskify), but I chose the simplest case first to test. – Joey Apr 28 '17 at 09:37
  • @Joey you'll have to remember that Task may represent a *task* that needs executing, or it may represent an async operation that is running, a kind of boilerplate indicator. C# 7 on the *library's* side can help a lot too, especially when you need to handle different shape types. In fact, shapes are the canonical example for pattern matching and deconstruction – Panagiotis Kanavos Apr 28 '17 at 10:05