Search code examples
c#.nettask-parallel-librarycancellationtokensourcecancellation-token

What is the proper way to delay with cancellation support inside of a task execution delegate?


I don't see any specific mention either on MSDN or here about how to accomplish this. The use case is somewhat obscure, but still valid I suspect.

var cancel = new CancellationTokenSource();
var task = Task.Factory.StartNew(() => { Task.Delay(1000, cancel.Token).Wait(); }, cancel.Token);
cancel.CancelAfter(100);
task.Wait();

The above code will attempt to cancel a task that contains a detached child delay task after 100 milliseconds, and wait for the task to complete which will generate an AggregateException (due to the cancellation). The problem with this is that the task becomes faulted instead of cancelled. This is expected behaviour because the delay task is not attached to the parent task, even though both share the same cancellation token.

My question relates specifically to how you would go about attaching a Task.Delay to a task that is already running. Is it possible to even do this if you had access to the parent task? If it isn't possible, or not possible without access to the parent task instance what is the proper way to handle this scenario?

The best work around I could come up with was to wrap the delay task's Wait in a try/finally block, and explicitly attempt to bubble up the task cancellation.

try { Task.Delay(1000, cancel.Token).Wait(); } finally { cancel.Token.ThrowIfCancellationRequested(); }

While effective, it doesn't feel quite right, but I'm not sure if there is a better way to accomplish this. The desired outcome is that the parent task goes to Canceled instead of Faulted if cancellation occurs. So if the genesis of the cancellation occurs in a detached child task, the parent task should still transition to Canceled.

NOTE: I left out async/await here on purpose, just because it doesn't appear to change the problem or the result. If that's not the case please provide an example.


Solution

  • So, a task is considered canceled when an OperationCanceledException is thrown and uncaught inside it and its associated CancellationToken is canceled.

    In your case the exception being thrown is AggregateException that contains a TaskCanceledException (which is an OperationCanceledException) instead of the TaskCanceledException directly.

    There's a simple way to fix that. Instead of synchronously blocking with Task.Wait which wraps any exceptions in an AggregateException wrapper you can use task.GatAwaiter().GetResult(). That is what await uses in async-await. It throws the original exception and if there were multiple ones it throws the first:

    var cancel = new CancellationTokenSource();
    var task = Task.Factory.StartNew(() => { Task.Delay(1000, cancel.Token).GetAwaiter().GetResult(); }, cancel.Token);
    cancel.CancelAfter(100);
    task.Wait();
    

    If you would have used async-await you wouldn't have this issue as unlike Task.Wait it throws the TaskCanceledException itself:

    var cancel = new CancellationTokenSource();
    var task = Task.Run(() => Task.Delay(1000, cancel.Token), cancel.Token);
    cancel.CancelAfter(100);
    task.Wait();
    

    I assume this is just an example. Real production code shouldn't resembles this as you're blocking synchronously on an asynchronous operation which in turn blocks synchronously on an asynchronous operation.