我创建了一个简单的.NET Framework 4.7.2 WPF应用程序,其中包含两个控件 - 一个文本框和一个按钮。这是我背后的代码:
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");
});
}
按下StartTest
按钮后,我在Output
文本框中看到以下结果:
12:05:54.1508 [7] Before cancellation.
12:05:57.2431 [7] Task delay has been cancelled.
12:05:57.2440 [7] After cancellation.
我的问题是为什么[7] Task delay has been cancelled.
在请求令牌取消的同一线程中执行?
我希望看到的是[7] Before cancellation.
,然后是[7] After cancellation.
,然后是Task delay has been cancelled.
。或者至少Task delay has been cancelled.
在另一个线程中执行。
请注意,如果我从主线程执行cancellationTokenSource.Cancel()
,则输出看起来符合预期:
12:06:59.5583 [1] Before cancellation.
12:06:59.5603 [1] After cancellation.
12:07:02.5998 [5] Task delay has been cancelled.
更新
有趣的是,当我更换时
await Task.Delay(TimeSpan.FromMinutes(1), cancellationTokenSource.Token);
跟
while (true)
{
await Task.Delay(TimeSpan.FromMilliseconds(100));
cancellationTokenSource.Token.ThrowIfCancellationRequested();
}
.NET 使后台线程保持繁忙状态,输出再次按预期进行:
12:08:15.7259 [5] Before cancellation.
12:08:15.7289 [5] After cancellation.
12:08:18.8418 [7] Task delay has been cancelled..
更新 2
我稍微更新了代码示例,希望更清楚一点。
请注意,这不是纯粹的假设问题,而是我在生产代码中花费了相当多时间来理解的实际问题。但为了简洁起见,我创建了这个极其简化的代码示例,用于说明相同的行为。
我的问题是为什么
[7] Task delay has been cancelled.
在请求令牌取消的同一线程中执行?
这是因为await
使用ExecuteSynchronously
标志计划其任务延续。我也认为这种行为令人惊讶,最初将其报告为错误(作为"设计"关闭)。
更具体地说,await
捕获上下文,如果该上下文与正在完成任务的当前上下文兼容,则async
继续直接在完成该任务的线程上执行。
要逐步完成它,请执行以下操作:
- 某些线程池线程"7"运行
cancellationTokenSource.Cancel()
。 - 这会导致
CancellationTokenSource
进入已取消状态并运行其回调。 - 其中一个回调是
Task.Delay
的内部回调。该回调不是特定于线程的,因此它在线程 7 上执行。 - 这会导致从
Task.Delay
返回的Task
被取消。await
已从线程池线程调度其延续,并且线程池线程都被视为彼此兼容,因此async
延续直接在线程 7 上执行。
提醒一下,线程池线程仅在有代码要运行时使用。当你使用await
向Task.Run
发送异步代码时,它可以在一个线程上运行第一部分(直到await
),然后在另一个线程上运行另一部分(在await
之后)。
因此,由于线程池线程是可互换的,线程 7 在await
后继续执行async
方法并不是"错误的";这只是一个问题,因为现在Cancel
后的代码在该async
延续中被阻止。
请注意,如果我从主线程执行 cancelTokenSource.Cancel(),则输出看起来符合预期
这是因为 UI 上下文被视为与线程池上下文不兼容。因此,当从Task.Delay
返回的Task
被取消时,await
将看到它位于 UI 上下文中,而不是线程池上下文中,因此它会将其延续排队到线程池,而不是直接执行它。
有趣的是,当我用 .NET 替换
Task.Delay(TimeSpan.FromMinutes(1), cancellationTokenSource.Token)
时cancellationTokenSource.Token.ThrowIfCancellationRequested()
后台线程保持繁忙并且输出再次按预期
这不是因为线程"忙"。这是因为不再有回调了。因此,观察方法是轮询而不是通知。
该代码设置一个计时器(通过Task.Delay
),然后将线程返回到线程池。当计时器关闭时,它会从线程池中获取一个线程并检查取消令牌源是否已取消;如果没有,它将设置另一个计时器,并再次将线程返回到线程池。这一段的要点是,Task.Run
不仅仅代表"一条线";它在执行代码时只有一个线程(即不在await
中),并且线程可以在任何await
后更改。
使用ExecuteSynchronously
await
的一般问题通常不是问题,除非您混合使用阻塞和异步代码。在这种情况下,最好的解决方案是将阻止代码更改为异步。如果您不能做到这一点,那么您需要小心如何继续async
方法,这些方法在await
后阻止。这主要是TaskCompletionSource<T>
和CancellationTokenSource
的问题。TaskCompletionSource<T>
有一个很好的RunContinuationsAsynchronously
选项,可以覆盖ExecuteSynchronously
标志;不幸的是,CancellationTokenSource
没有;您必须使用Task.Run
将Cancel
调用排队到线程池。
奖励:为您的队友提供测验。