Asynchronous Tasks, Cancellations and Exceptions

I am currently studying how to properly expose the asynchronous parts of our library API with Task

so that they can be easier and more enjoyable to use for clients. I decided to go with a wrapping approachTaskCompletionSource

Task

around it that doesn't get the scheduled thread pool (no need for an instance here since it's basically just a timer). This works great, but canceling is a little painful now.

The example shows basic usage, registering a delegate on a token, but a little more complicated than in my case, moreover, I'm not quite sure what to do with TaskCanceledException

. The documentation talks about either returning, or switching the status of the task to RanToCompletion

, or throwing OperationCanceledException

(which results in the result of the task being Canceled

) ok. However, the examples seem to only relate to tasks, which at least are mentioned are triggered via a delegate passed to TaskFactory.StartNew

.

My code currently (roughly) looks like this:

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;
}

      

(No results and possible runtime exceptions, one reason I decided to start here rather than in more complex places in the library.)

In the current form, when I call TrySetCanceled

in TaskCompletionSource

, I always get TaskCanceledException

if I expect a returned task. My guess would be that this is normal behavior (I hope it is), and that I expect to wrap try

/ catch

around the call when I want to use cancellation.

If I don't TrySetCanceled

, then I will eventually run the finalist callback and the task looks fine. But I think if the user wants to distinguish between a job that completed normally and one that was canceled, it TaskCanceledException

is pretty much a side effect of ensuring that, right?

Another point I didn't quite understand: The documentation suggests that any exceptions, even those related to cancellation, are wrapped in AggregateException

from the TPL. However, in my tests, I always got it TaskCanceledException

directly, without any wrapper. Am I missing something here or is it just poorly documented?


TL; DR:

  • A Canceled

    corresponding exception is always required to transition a task into a state , and users need to wrap try

    / catch

    around an asynchronous call to be able to detect this, right?
  • It is expected to TaskCanceledException

    be deployed deployed and normal and I am not doing anything wrong here?
+3


source to share


3 answers


I always recommend people read Cancel in the managed thread docs. It's not entirely complete; like most MSDN docs, it tells you what you can do, not what you should be doing. But that's definitely clearer than the dotnet cancellation docs.

The example shows basic usage

Firstly, it is important to note that the cancellation in your code example overrides the problem - it does not cancel the basic operation. I highly recommend that you do not do this.

If you want to undo the operation, you will need to update RunFoo

in order to take CancellationToken

(see below how he 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 cannot cancel foo

, then there is no need to cancel API support 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 lazy wait on the task, which is a much more appropriate coding technique for this scenario (since it is the wait that is canceled, not the operation represented by the task). Doing "undo wait" can be done using my AsyncEx.Tasks library , or you can write your own equivalent extension method.

The documentation says it either just returns, has a task state switch in RanToCompletion, or throws an OperationCanceledException (which causes the task result to be canceled).

Yes, these documents are misleading. First, please don't just come back; your method will complete the task successfully - showing that the operation was successful - when the operation did not complete successfully. This might work for some code, but of course it's not a good idea.

Generally, the correct way to answer CancellationToken

is as follows:

  • Call periodically ThrowIfCancellationRequested

    . This option is better suited for processor bound code.
  • Register a cancellation callback via Register

    . This option is better for I / O related code. Please note that registration must be deleted!

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

  • In your "work with personnel" check token.IsCancellationRequested

    ; if requested, then raise the callback event with AsyncCompletedEventArgs.Cancelled

    set to true

    .


This is logically equivalent to the first correct path (by calling periodically ThrowIfCancellationRequested

), catching the exception and translating it into an event notification. Simply without exception.

I always get a TaskCanceledException if I expect a task to return. My guess would be that this is normal behavior (I hope it is) and that I expect to enable / disable the call when I want to use cancellation.

The native consumption code for a canceled task is to wrap await

in try / catch and catch OperationCanceledException

. For various reasons (many historical), some APIs will be called OperationCanceledException

and some will be called TaskCanceledException

. Since it TaskCanceledException

comes from OperationCanceledException

, the consumer code might just get a more general exception.

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

This is the accepted pattern , yes.

The documentation assumes that any exceptions, even those related to cancellation, are wrapped in AGLATEException TPL.

This is only true if your code blocks the task synchronously. Which he should really avoid doing in the first place. Therefore, the documents are certainly misleading.

However, in my tests, I always got the TaskCanceledException directly, without any wrapper.

await

avoids the wrapper AggregateException

.

Update for comments explaining CleanupFoo

is the undo method.

First, I recommend using CancellationToken

directly in your code triggered RunFoo

; this approach will almost certainly be easier.

However, if you want to use CleanupFoo

to undo, you need Register

it. You will need to remove this registration, and the easiest way to do this might 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;
}

      

Correctly coordinating and disseminating results - while preventing resource leaks - is rather inconvenient. If your code could be used CancellationToken

directly, that would be much cleaner.

+3


source


From the comments, it looks like you have an animation library that takes it in IAnimation

, executes it (asynchronously, obviously), and then reports that it's complete.

This is not a real task, in the sense that it is not part of the work that needs to be done on the thread. It is an asynchronous operation that is rendered in .NET using a Task object.

Also, you are not actually canceling anything, you are stopping the animation. This is a perfectly normal operation, so it shouldn't throw an exception. It would be better if your method would return a value explaining whether the animation is complete or not, for example:

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 start the animation is simple:

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

      

UPDATE



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

For example, instead of using callbacks and lambdas, you can use local functions. Apart from cleaning up the code, they don't allocate a delegate on every call. No client-side C # 7 support required for the change:

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, such as a result type that contains the 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 is complete?), You can return a Success or Stopped type that implements, for example, IResult.

Prior to C # 7, you would need to check the return type, or use overloading to access different types. However, using pattern matching, you can get the actual result with a switch, for example:

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. This requires the client to support C # 7.

+1


source


What you are doing is fine - the task is some kind of operation with the result in the future, there is no need to run anything on a different thread or something. And it's perfectly okay to use the standard undo facility for, well, undo, instead of returning something like a boolean value.

To answer your questions: when you do tcs.TrySetCanceled()

, it will move the task to the canceled state ( task.IsCancelled

would be true) and no exceptions will be thrown at that point. But when you await

set this task, you will notice that the task is canceled, and this will be the point at which it will be selected TaskCancelledException

. There is nothing wrapped in an aggregate exception here because there is nothing to wrap - TaskCancelledException

thrown away as part of the logic await

. Now if you do something like task.Wait()

this instead, then it will transfer TaskCancelledException

to AggregateException

as you would expect.

Note that it await

will deploy AggregateExceptions anyway, so you might never expect to await task

throw an AggregateException - in case of multiple exceptions, only the first one will be thrown - the rest will be swallowed.

Now, if you are using the undo marker with regular tasks, things are a little different. When you do something like this token.ThrowIfCancellationRequested

, it will actually throw away OperationCancelledException

(note that this is not TaskCancelledException

, but TaskCancelledException

a subclass OperationCancelledException

). Then, if the CancellationToken

one used to throw this exception matches the CancellationToken

one passed to the task when it was started (for example, in the example from your link), the task will move to the "Canceled" state in the same way. This is the same as tcs.TrySetCancelled

in your code with the same behavior. If there is a token mismatch - the task will go into the Faulted state instead, as the regular exception was thrown.

+1


source







All Articles