Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Unexpected task cancellation behaviour

I've created a simple .NET Framework 4.7.2 WPF app with two controls - a text box and a button. Here is my code behind:

private async void StartTest_Click(object sender, RoutedEventArgs e)
{
    Output.Clear();

    var cancellationTokenSource = new CancellationTokenSource();

    // Fire and forget
    Task.Run(async () => {
        try
        {
            await Task.Delay(TimeSpan.FromMinutes(1), cancellationTokenSource.Token);
        }
        catch (OperationCanceledException)
        {
            Task.Delay(TimeSpan.FromSeconds(3)).Wait();
            Print("Task delay has been cancelled.");
        }
    });

    await Task.Delay(TimeSpan.FromSeconds(1));
    await Task.Run(() =>
    {
        Print("Before cancellation.");
        cancellationTokenSource.Cancel();
        Print("After cancellation.");
    });
}

private void Print(string message)
{
    var threadId = Thread.CurrentThread.ManagedThreadId;
    var time = DateTime.Now.ToString("HH:mm:ss.ffff");
    Dispatcher.Invoke(() =>
    {
        Output.AppendText($"{ time } [{ threadId }] { message }\n");
    });
}

After pressing StartTest button I see the following results in the Output text box:

12:05:54.1508 [7] Before cancellation.
12:05:57.2431 [7] Task delay has been cancelled.
12:05:57.2440 [7] After cancellation.

My question is why [7] Task delay has been cancelled. is executed in the same thread where token cancellation is being requested?

What I would expect to see is [7] Before cancellation. followed by [7] After cancellation. and then Task delay has been cancelled.. Or at least Task delay has been cancelled. being executed in another thread.

Note that if I execute cancellationTokenSource.Cancel() from the main thread then the output looks as expected:

12:06:59.5583 [1] Before cancellation.
12:06:59.5603 [1] After cancellation.
12:07:02.5998 [5] Task delay has been cancelled.

UPDATE

Interestingly when I replace

await Task.Delay(TimeSpan.FromMinutes(1), cancellationTokenSource.Token);

with

while (true)
{
    await Task.Delay(TimeSpan.FromMilliseconds(100));
    cancellationTokenSource.Token.ThrowIfCancellationRequested();
}

.NET keeps that background thread busy and the output is again as expected:

12:08:15.7259 [5] Before cancellation.
12:08:15.7289 [5] After cancellation.
12:08:18.8418 [7] Task delay has been cancelled..

UPDATE 2

I've updated the code example slightly in the hope to make a bit clearer.

Note that this is not purely hypothetical question but an actual problem I've spent quite some time to understand in our production code. But for the sake of brevity I've created this extremely simplified code example that illustrates the same behaviour.

like image 348
Pavels Ahmadulins Avatar asked Mar 30 '19 21:03

Pavels Ahmadulins


People also ask

How do you cancel asynchronous tasks?

A task can be cancelled at any time by invoking cancel(boolean). Invoking this method will cause subsequent calls to isCancelled() to return true.


1 Answers

My question is why [7] Task delay has been cancelled. is executed in the same thread where token cancellation is being requested?

This is because await schedules its task continuations with the ExecuteSynchronously flag. I also think this behavior is surprising, and initially reported it as a bug (closed as "by design").

More specifically, await captures a context, and if that context is compatible with the current context that is completing the task, then the async continuation executes directly on the thread that is completing that task.

To step through it:

  • Some thread pool thread "7" runs cancellationTokenSource.Cancel().
  • This causes the CancellationTokenSource to enter the canceled state and run its callbacks.
  • One of those callbacks is internal to Task.Delay. That callback is not thread-specific, so it is executed on thread 7.
  • This causes the Task returned from Task.Delay to be canceled. The await has scheduled its continuation from a thread pool thread, and thread pool threads are all considered compatible with each other, so the async continuation is executed directly on thread 7.

As a reminder, thread pool threads are only used when there is code to be run. When you send asynchronous code using await to Task.Run, it could run the first part (up to the await) on one thread and then run another part (after the await) on a different thread.

So, since thread pool threads are interchangeable, it's not "wrong" for thread 7 to continue executing the async method after the await; it's only a problem because now the code after the Cancel is blocked on that async continuation.

Note that if I execute cancellationTokenSource.Cancel() from the main thread then the output looks as expected

This is because the UI context is not considered compatible with the thread pool context. So when the Task returned from Task.Delay is canceled, the await will see that it's in a UI context and not the thread pool context, so it queues its continuation to the thread pool instead of executing it directly.

Interestingly when I replace Task.Delay(TimeSpan.FromMinutes(1), cancellationTokenSource.Token) with cancellationTokenSource.Token.ThrowIfCancellationRequested() .NET keeps that background thread busy and the output is again as expected

It's not because the thread is "busy". It's because there's no callback anymore. So the observing method is polling instead of being notified.

That code sets a timer (via Task.Delay) and then returns the thread to the thread pool. When the timer goes off, it grabs a thread from the thread pool and checks if the cancellation token source is cancelled; if not, it sets another timer and returns the thread to the thread pool again. The point of this paragraph is that Task.Run doesn't represent just "one thread"; it only has a thread while executing code (i.e., not in an await), and the thread can change after any await.


The general problem of await using ExecuteSynchronously is normally not an issue unless you're mixing blocking and asynchronous code. In that case, the best solution is to change the blocking code to asynchronous. If you can't do that, then you'll need to be careful how you continue your async methods that block after await. This is primarily a problem with TaskCompletionSource<T> and CancellationTokenSource. TaskCompletionSource<T> has a nice RunContinuationsAsynchronously option that overrides the ExecuteSynchronously flag; unfortunately, CancellationTokenSource does not; you'd have to queue your Cancel calls to the thread pool using Task.Run.

Bonus: a quiz for your teammates.

like image 167
Stephen Cleary Avatar answered Sep 20 '22 06:09

Stephen Cleary