Understanding the order of task completion and cancellation

Viewed 47

I'm trying to untangle this async behavior. Given this code:

static async Task Main(string[] args)
{
    var cts = new CancellationTokenSource(4000);

    try
    {
        await DoOperationAsync(cts.Token);
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine
            ($"Operation canceled prematurely at {DateTime.Now}");
    }

    Console.WriteLine("Press any key...");
    Console.ReadKey(true);
}

static async Task DoOperationAsync
    (CancellationToken cancellationToken)
{
    var t1 = Method("one", 5000, cancellationToken);
    var t2 = Method("two", 5000, cancellationToken);
    var first = await Task.WhenAny(t1, t2);

    Console.WriteLine
        ($"Finished first: {(first == t2 ? "two" : "one")}, {first.Status}");

    var result = await first;
}

static async Task<string> Method
    (string name, int delayMs, 
    CancellationToken cancellationToken)
{
    Console.WriteLine($"Started method {name} at {DateTime.Now}");
    await Task.Delay(delayMs, cancellationToken);
    Console.WriteLine($"Finished method {name} at {DateTime.Now}");
    return name;
}

Why does "two" get canceled and "complete" first even though it started second, and then why does "one" never fault?

Here is the output:

Started method one at 2022-09-01 09:02:43
Started method two at 2022-09-01 09:02:43
Finished first: two, Canceled
Operation canceled prematurely at 2022-09-01 09:02:47
Press any key...
Finished method one at 2022-09-01 09:02:48
1 Answers

It's actually because of this line:

Console.ReadKey(true);

Task completion is a bit tricky because it's synchronous. This is fine as long as you don't block in asynchronous code (which the ReadKey does). I explain more fully on my blog. When I first came across this behavior, I reported it as a bug since it was so unexpected.

There are two synchronous behaviors that cause this in this example:

  1. await continues its asynchronous method synchronously (if possible).
  2. CancellationToken invokes its registered callbacks synchronously (always).

Both of those don't cause any problems unless you're doing blocking in an asynchronous method (or in a CancellationToken callback).

So, to step through what's happening in your example:

  1. Both one and two are started, and they both call Task.Delay, which registers a callback with the CancellationToken.
  2. When the code reaches the WhenAny, it returns an incomplete task, which causes Main to return an incomplete task. (See my blog for await details).
  3. Some time later, the cancellation token will fire. When this happens, it invokes all its callbacks one at a time, synchronously. "Which callback is invoked first" is an implementation detail - it may change with a future .NET version and shouldn't be relied upon. The observed behavior with today's BCL is that two fires first, so let's assume that happens.
  4. The continuation for two executes and completes its method. Note that this is run within the callback for the CancellationToken.
  5. Since two completes its method, the WhenAny also completes, and the DoOperationAsync continues executing. Note that it's still within the CancellationToken callback.
  6. DoOperationAsync observes the exception, completing the task it already returned to the Main method earlier.
  7. This causes Main's continuation to execute. Note that we're still in the first CancellationToken callback.
  8. The code in Main catches the exception, writes out the message, and then blocks waiting for user input. It is blocking from within the CancellationToken callback.
  9. Assume the user doesn't press the key within a subsecond after seeing Main's message.
  10. one now has sufficient time to complete. Its cancellation token is actually cancelled, but it hasn't been notified yet because it's second in the list of callbacks, and the callback in the first slot is blocked waiting for user input.

As a general rule, don't block in async code. If for some reason you need to, consider inserting an await Task.Yield() before the blocking code.

Side note: in Javascript, await is always asynchronous; continuations never run synchronously. Which IMO makes more sense and is less surprising, but it is also less efficient.

Related