Search code examples
c#.netasync-awaittask-parallel-librarycancellation-token

How does cancellation acknowledgment work for async continuations?


The documentation for Task.IsCanceled specifies that OperationCanceledException.CancellationToken has to match the cancellation token used to start the task in order to properly acknowledge. I'm confused about how this works within an asynchronous continuation. There appears to be some undocumented behavior that allows them to acknowledge despite not knowing their cancellation token ahead of time.

static void TestCancelSync()
{
    // Create a CTS for an already running task.
    using var cts = new CancellationTokenSource();
    cts.Cancel();

    // Attempt to acknowledge cancellation of the CT (doesn't work).
    cts.Token.ThrowIfCancellationRequested();
}

static async Task TestCancelAsync()
{
    await Task.Yield();

    // Create a CTS for an already running continuation.
    using var cts = new CancellationTokenSource();
    cts.Cancel();

    // Acknowledge cancellation of the CT (how is this possible?).
    cts.Token.ThrowIfCancellationRequested();
}

var syncCancelTask = Task.Run(TestCancelSync);
var asyncCancelTask = TestCancelAsync();

try
{
    await Task.WhenAll(syncCancelTask, asyncCancelTask);
}
catch (OperationCanceledException)
{
    // Prints "{ syncCanceled = False, asyncCanceled = True }"
    Console.WriteLine(
        new
        {
            syncCanceled = syncCancelTask.IsCanceled,
            asyncCanceled = asyncCancelTask.IsCanceled,
        });
}

I'd like to understand how this is working under the hood. When exactly is it necessary to acknowledge cancellation for a specific token? Can I trust that any tasks from an async method will have this undocumented behavior?


Solution

  • The documentation for Task.IsCanceled specifies that OperationCanceledException.CancellationToken has to match the cancellation token used to start the task in order to properly acknowledge.

    Indeed. The documentation of the Task.IsCanceled probably hasn't been updated since its introduction with the .NET Framework 4.0 (2010). It looks like it is associated with the behavior of the Task.Factory.StartNew method, which takes a (sometimes misunderstood) CancellationToken as argument, and takes into account this argument when the action delegate fails with an OperationCanceledException. Asynchronous methods that are implemented with the async keyword do not work this way. They always complete in the Canceled state whenever an OperationCanceledException is thrown, regardless of the identity of the CancellationToken that caused the cancellation. In your question you are experimenting with an async method, so you get this behavior, which deviates from the documentation of the Task.IsCanceled property.

    If for some reason you want the Task.Factory.StartNew behavior with your async methods, see this question for ideas.