Understanding Cancellation Callbacks

, , , ,

In the ideal world, all .Net asynchronous methods support cancellation tokens: When invoking a method, simply pass it a cancellation token. Then, at the appropriate time, cancel the token and the asynchronous operation terminates.

Alas! We don’t live in the ideal world. Not every method we might asynchronously invoke works with cancellation tokens. When faced with an asynchronous operation we want to be able to cancel that doesn’t support cancellation tokens, one option is to implement our own cancellation logic by registering a callback with the token. When the token is cancelled, callbacks registered with it are executed.

Introducing callback into the picture raises questions around if/when, where and how those callbacks are executed. For example:

  • Will the callback be invoked via the synchronization context that was current when it was registered?
  • If multiple callbacks are registered, are they run synchronously or in parallel?
  • If a callback raises an exception, can that exception be caught?

In the case of .Net’s Task Parallel Library and its CancellationToken and CancellationTokenSource, the answers to these questions revolve around when cancellation occurs and how it is triggered.

Cancelled Prior to Callback Registration

The simplest situation is when a cancellation callback is registered on a token that is already cancelled. In this case, the callback is synchronously executed by the method registering it (CancellationToken.Register()).

If the callback raises an exception, that exception is propagated out of Register() and can be caught just like any other exception propagated out of a method.

var source = new CancellationTokenSource();
var token = source.Token;
source.Cancel();

try
{
	using (token.Register(() => throw new Exception("help!")))
	{
		// Do something interesting here.
	}
}
catch (Exception e) when (e.Message == "help!")
{
	Console.WriteLine("caught!");
}

These behaviors holds true regardless of how the token came to be cancelled.

Cancelled After Callback Registration

The remaining scenarios all involve the token being cancelled at some point after the callback is registered.

Cancel()

When CancellationTokenSource’s Cancel() is used to signal cancellation, any callbacks associated with the token are synchronously executed in LIFO order by Cancel().

When each callback is executed, the ExecutionContext present when the callback was registered is reestablished.

If the SynchronizationContext was captured when the callback was registered, the callback is executed via that context. (By default, SynchronizationContext is not captured when cancellation callbacks are registered; however, its capture can be requested by using one of the CancellationToken.Register() overloads that accepts bool continueOnCapturedContext and setting that argument to true.)

Any exceptions raised are combined into an AggregateException which is propagated out of Cancel().

var source = new CancellationTokenSource();
var token = source.Token;

var task = Task.Run(async () =>
{
	using (token.Register(() => throw new Exception("help!")))
	{
		await Task.Delay(1000);
	}
});
await Task.Delay(20); // briefly wait to ensure that task is started

try
{
	source.Cancel();
}
catch (AggregateException e) when (e.InnerExceptions.Single().Message == "help!")
{
	Console.WriteLine("caught!");
}

Cancel(throwOnFirstException: true)

This variant of Cancel() behaves identical to the above except for how it handles exceptions: If a callback throws an exception, that exception is immediately propagated out of Cancel(true) without being wrapped in an AggregateException. Any unexecuted callbacks associated with the token are skipped.

var source = new CancellationTokenSource();
var token = source.Token;

var task1 = Task.Run(async () =>
{
	// the below callback will never be executed
	using (token.Register(() => throw new Exception("from task 1")))
	{
		await Task.Delay(1000);
	}
});
await Task.Delay(20); // briefly wait to ensure that task1 is started

var task2 = Task.Run(async () =>
{
	using (token.Register(() => throw new Exception("from task 2")))
	{
		await Task.Delay(1000);
	}
});
await Task.Delay(20); // briefly wait to ensure that task2 is started

try
{
	source.Cancel(true);
}
catch (Exception e) when (e.Message == "from task 2")
{
	Console.WriteLine("caught!");
}

CancelAfter()

CancellationTokenSource.CancelAfter() starts a timer which triggers cancellation when it reaches zero. When that occurs, any registered cancellation callbacks are executed.

In regards to callback ExecutionContext and SynchronizationContext, this method behaves the same as Cancel().

However, since CancelAfter() doesn’t execute the cancellation callbacks—rather, it simply starts the countdown timer then returns—exceptions raised by these callbacks aren’t propagated out of that method. Like Cancel(), any exceptions raised are combined into an AggregateException; however, unlike Cancel(), that exception is raised on whatever thread the timer uses to execute the cancellation process. The net effect is that the AggregateException can’t be caught using a try…catch block. However, it may be observed using an unhandled exception handler.

// outputs "help!" when the callback throws the exception
AppDomain.CurrentDomain.UnhandledException += 
	(sender, e) => { Console.WriteLine(((AggregateException)e.ExceptionObject).InnerExceptions.Single().Message);};

var source = new CancellationTokenSource();
var token = source.Token;

var task = Task.Run(async () =>
{
	using (token.Register(() => throw new Exception("help!")))
	{
		await Task.Delay(1000);
	}
});

source.CancelAfter(10);

await Task.Delay(1000);

Closing Thoughts

If a cancellation callback needs to execute on the SynchronizationContext that was current when it was registered, capturing that context must be explicitly requested at registration. In contrast, with Task, continuations are run on the SynchronizationContext captured at Task creation unless explicitly requested otherwise (i.e. by calling ConfigureAwait(continueOnCapturedContext: false)).

Handling exceptions from cancellation callbacks can be complex because of how those exceptions can be propagated from multiple places. To avoid this complexity, don’t allow these callbacks to throw exceptions.

Leave a Reply

Your email address will not be published. Required fields are marked *