How do I cancel non-cancelable async operations?

This is a question I hear relatively frequently:

“I have an async operation that’s not cancelable.  How do I cancel it?”

The construction of the question often makes me chuckle, but I understand and appreciate what’s really being asked.  The developer typically isn’t asking how to cancel the operation itself (if they are asking that, the answer is simple: it’s not cancelable!), but rather they’re asking how to allow their program’s execution to continue upon a cancellation request even if the operation being waited on hasn’t completed yet.  That’s a different ball game.

When someone talks about canceling async operations, they typically mean one of three things:

  1. Requesting that the async operation itself cancel.  The async operation may still run for some period of time after the cancellation request comes in, either because the operation’s implementation doesn’t respect cancellation, or because the implementation isn’t very aggressive about noticing and responding to such requests, or because there’s cleanup work that needs to be done even after a cancellation request is observed, or because the operation just isn’t at a good place in its execution to be canceled.
  2. Requesting that the code waiting for the async operation to complete stop waiting.  This has nothing to do with canceling the operation itself, and in fact the operation may still execute for quite some time.  This is entirely about changing the control flow of the program such that code (which was waiting for the operation to complete before going on to do other things) stops waiting and progresses to do those other things even though the operation hasn’t completed.
  3. Both #1 and #2.  Request the async operation to cancel, but also cancel the wait on the async operation so that we may continue running sooner than the async operation might complete.

In .NET, #1 is enabled by passing a CancellationToken to the async operation in question.  For example, here I’m passing a token to a Stream operation:

FileStream fs = …;
byte [] b = …;
CancellationToken token = …;
await fs.ReadAsync(b, 0, b.Length, token);

When the token has cancellation requested, the ReadAsync operation in flight may observe that request and cancel its processing before it otherwise would have completed.  By design, the Task returned by ReadAsync won’t complete until all processing has quiesced one way or another, and the system won’t continue to execute any code after the await until it does; this is to ensure that when the await completes, you know there’s no relevant work still in flight, that you can safely manipulate any data (like the byte[] buffer) that was provided to the async operation, etc.

Ok, so what about #2… is it possible to implement #2 in .NET?  Of course… but we didn’t make it super easy.  For example, you might expect an overload of ConfigureAwait that accepts a CancellationToken, e.g.

FileStream fs = …;
byte [] b = …;
CancellationToken token = …;
await fs.ReadAsync(b, 0, b.Length).ConfigureAwait(token); // overload not in .NET 4.5

Such an overload would allow the await itself to be canceled and execution to continue after the await even if the async operation on the stream was still in flight.  But there’s the rub… this can lead to unreliability.  What if the async operation eventually completes and returns an object that should be disposed of or otherwise acted upon?  What if the async operation fails with a critical exception that gets ignored?  What if the async operation is still manipulating reference arguments provided to it? And so on.  That’s not to say a developer couldn’t cope with some such issues, e.g.

FileStream fs = …;
byte [] b = …;
CancellationToken token = …;
Task op = fs.ReadAsync(b, 0, b.Length);
try
{

    await op.ConfigureAwait(token); // overload not in .NET 4.5
}
catch(OperationCanceledException)
{
    op.ContinueWith(t => /* handle eventual completion */);
    … // whatever you want to do in the case of cancellation, but
      // be very careful if you want to use the byte[], which
      // could be modified concurrently by the async operation
      // still in flight…
}

but doing so is non-trivial.  As such, for better or worse, in .NET 4.5 such an overload of ConfigureAwait doesn’t exist, out of concern that it would become a crutch too quickly used without thinking through the ramifications, which are subtle. 

Of course, that doesn’t prevent you from implementing such functionality yourself if you believe it’s the right thing for your needs.  In fact, you can implement this #2 functionality with just a few lines of code.  Here’s one approach:

public static async Task<T> WithCancellation<T>(
    this Task<T> task, CancellationToken cancellationToken)
{
    var tcs = new TaskCompletionSource<bool>();
    using(cancellationToken.Register(
                s => ((TaskCompletionSource<bool>)s).TrySetResult(true), tcs))
       
if (task != await Task.WhenAny(task, tcs.Task))
            throw new OperationCanceledException(cancellationToken);
    return await task;
}

Here we’re using a Task.WhenAny to wait for either the task to complete or for a cancellation request to arrive (which we do by creating another task that will complete when cancellation is requested).  With that function, I now can achieve #2 as outlined previously (subject to the same caveats), e.g.

FileStream fs = …;
byte [] b = …;
CancellationToken token = …;
Task op = fs.ReadAsync(b, 0, b.Length);
try
{

    await op.WithCancellation(token);
}
catch(OperationCanceledException)
{
    op.ContinueWith(t => /* handle eventual completion */);
    … // whatever you want to do in the case of cancellation
}

Of course, once I can do #1 and #2, doing #3 is straightforward, since it’s just a combination of the other two (passing the CancellationToken to the operation in addition to passing it to a WithCancellation-like function), e.g.

FileStream fs = …;
byte [] b = …;
CancellationToken token = …;
Task op = fs.ReadAsync(b, 0, b.Length, token);
try
{

    await op.WithCancellation(token);
}
catch(OperationCanceledException)
{
    if (!op.IsCompleted)
        op.ContinueWith(t => /* handle eventual completion */);
    … // whatever you want to do in the case of cancellation
}

So, can you cancel non-cancelable operations? No.  Can you cancel waits on non-cancelable operations?  Sure… just be very careful when you do.