Reputation: 460
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.
Upvotes: 6
Views: 1348
Reputation: 456387
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:
cancellationTokenSource.Cancel()
.CancellationTokenSource
to enter the canceled state and run its callbacks.Task.Delay
. That callback is not thread-specific, so it is executed on thread 7.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)
withcancellationTokenSource.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.
Upvotes: 7