How do I cancel non-cancelable async operations?

How do I cancel non-cancelable async operations?

Rate This
  • Comments 27

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.

Leave a Comment
  • Please add 4 and 7 and type the answer here:
  • Post
  • Excellent post, and I'd certainly agree with the warnings.  Insofar as people use async/await to allow themselves to think/reason about their code in a somewhat linear/imperative/deterministic sense, adding in this kind of behavior will certainly end up causing subtle and hard-to-debug issues.  Calling non-async / non-cancellable database API's, for instance, and having the consumer of the code and end user expect that cancellation meant abort/rollback, but having that either not be the case ever, or not be the case consistently, will be interesting.  Admittedly just an 'extension' of the kind of behavior we already have when HTTP calls might fail/timeout and the browser/client/etc might assume things completely failed when they might not have... it'll be interesting to see how many library authors or people creating wrappers for them add tricks like this with good intentions but bad results. :)

    WRT the code snippets, in the WithCancellation extension method, it seems to be trying to call a Register overload that takes a single param of Action<object> but I don't see that available in 4.5 - am I misparsing it?

    msdn.microsoft.com/.../dd321790.aspx

    AFAICT the Register call could just use tcs (although maybe it was originally written passing tcs as a second 'state' param to avoid the capture?) with "using(cancellationToken.Register(() => tcs.TrySetResult(true)))" ?

    Thanks again for another great post (as always!), Stephen!

  • Great post as always.

    Slightly off topic, but I have wondered about the name "ConfigureAwait". It sounds *very* generic... Were there other "configuration" knobs planned that were dropped before release? Are there more knobs planned for the future?

  • bzzz is my previous comment lost?

  • James, glad you enjoyed the post, and thanks for pointing out my typo.  I've fixed it.  I was missing ", tcs" in the call to Register, as you suspected, and also as you suggested, I'm using this overload to avoid the unnecessary closure.

    Stephen, glad you enjoyed the post, as well.  There are a variety of other ways it could be configured, and so we chose this naming to leave open the possibility of additional overloads in the future (potentially accepting an enum if there are other Boolean options to be set).  Beyond cancellation (some pros and cons of which were discussed here), there are a variety of other options you could imagine, e.g. whether to force a suspension even if the task is already completed so as to ensure the remainder of the call runs on ThreadPool, whether to throw an AggregateException instead of just one exception or even not to propagate any exceptions, whether to resume on a particular context or scheduler, etc.

    Test, I don't see any other comments in the admin page, so I'm afraid if you did submit a previous comment, it's been lost.  Sorry.  Please share again if you wouldn't mind.

  • Stephen, it's looks like you should also replace TaskCompletionSource<T> with TaskCompletionSource<bool>

  • KAE, yup, you're correct... this is what I get for typing out a post quickly on the bus :)  I've fixed it. Thanks.

  • I notice you are not using the generic Result property of TCS in the WithCancellation method (I understand why). Why are you using TCS<bool> instead of TCS<object>? I guess you want to improve performance by only storing a single byte (a bool) instead of an object reference. But that forces the JIT to burn probably a kilobyte or more of specialized previously-generic x86 code into the AppDomain. Do you think it is worth it?

  • toby, it would only be extra memory pressure for specialized code if Task<bool> wasn't used anywhere else in the AppDomain, but it's very likely that it is used elsewhere, even if as an implementation detail (e.g. the cache used by async methods currently stores a Task<bool> for each of true and false).  I'm just in the habit of using TaskCompletionSource<bool> instead of TaskCompletionSource<object> in cases like this; the former will be slightly more efficient, but it's unlikely to be measurable in most cases.

  • Stephen/Test - FYI, the behavior on blogs.msdn.com is (and has been for as long as I can remember) that if the time between loading the page and submitting the comment is high enough (I'd guess 10 minutes, but never tested it), then when you submit the form (click 'Post'), you get the same page loading again without any indication that the comment wasn't submitted and was silently lost.

    It's happened to me enough in the past that I typically write the comment in OneNote or Notepad++ first and then paste it in, but this time I actually forgot to do so, and had my comment just disappear.  Since the browser did do a POST action just fine, I was able to recover it by starting Fiddler and then doing a reload and allowing it to re-POST the comment.  It still failed, of course, but then I grabbed the comment out of the Fiddler stream and then posted the comment a second time. :)  I'm very happy that at least with current Chrome on Win7 x64, I didn't have to restart the browser for Fiddler to 'take effect', so I didn't lose the comment. :)

    It'd be very nice to have this fixed, of course, but in the meantime, if you write comments on blogs.msdn.com of any length, make sure to have a copy of the text elsewhere before submitting. :)

  • page source appears to indicate the blog engine is Telligent Evolution Platform Developer Build (Build: 5.6.50428.7875) and their current release is 6.x, so the bug might already be fixed.  I don't see an interface for non-customers to open support cases, so I'll use their 'contact support' page and maybe they'll at least be able to say whether this is a known bug fixed in a later version already or not. :)

    telligent.com/.../developer6

  • James Manning, I can confirm. I always make sure to reload the page before submitting my text.

  • APM should have been the easy way to do Async programming.

    This post makes it look fragile and best and hard to get right.

    What happen if I do not wrap the configure call with try/catch? What happen if exception get thrown?

    Thank you,

    Ido

  • Ido: This whole post was about a particular mechanism (canceling a join with an async operation) that is inherently fragile, regardless of the programming model or language used to achieve it.  So I'm not quite sure what your point is; can you elaborate on your concerns and how you believe APM is less fragile in this regard?

  • you can even have a timeout using the following simple extension  method

    public static async Task<T> WithCancellation<T>(

           this Task<T> task, int timeout, CancellationToken cancellationToken)

    {

       Task t = await Task.WhenAny(task, Task.Delay(timeout, cancellationToken));

       cancellationToken.ThrowIfCancellationRequested();

       return task.Result;

    }

  • a small fix

           public static async Task<T> WithCancellation<T>(

               this Task<T> task, int timeout, CancellationToken cancellationToken)

           {

               Task t = await Task.WhenAny(task, Task.Delay(timeout, cancellationToken));

               cancellationToken.ThrowIfCancellationRequested();

               if (t != task)

                   throw new OperationCanceledException("timeout");

               return task.Result;

           }

Page 1 of 2 (27 items) 12