Task.Wait 异步方法在应用程序启动期间通过,同时在 WPF 按钮处理程序中导致死锁

Task.Wait for async method passes during application startup while it causes deadlock in WPF button handler

Task.Wait() 的行为出乎意料地不同,具体取决于调用的“环境”。
在应用程序启动期间使用以下异步方法 TestAsync 调用 Task.Wait() 通过(不会导致死锁),而从 WPF 按钮处理程序中调用时相同的代码会阻塞。

重现步骤:
在 Visual Studio 中,使用向导创建一个 vanilla WPF .NET 框架应用程序(例如命名为 WpfApp)。
在应用程序文件的 App.xaml.cs 文件中粘贴下面的 Main 方法和 TestAsync 方法。
在项目属性中将 Startup object 设置为 WpfApp.App
App.xaml 的属性中,将 Build ActionApplicationDefinition 切换为 Page

    public partial class App : Application
    {
        [STAThread]
        public static int Main(string[] args)
        {
            Task<DateTime> task = App.TestAsync();
            task.Wait();

            App app = new App();
            app.InitializeComponent();
            return app.Run();
        }

        internal static async Task<DateTime> TestAsync()
        {
            DateTime completed = await Task.Run<DateTime>(() => {
                System.Threading.Thread.Sleep(3000);
                return DateTime.Now;
            });
            System.Diagnostics.Debug.WriteLine(completed);
            return completed;
        }
    }

观察应用程序是否正常启动(延迟 3 秒后)并且“已完成”DateTime 被写入调试输出。
接下来在 MainWindow.xaml 中创建一个按钮,在 MainWindow.xaml.cs

中使用 Click 处理程序 Button_Click
    public partial class MainWindow : Window
    {
        ...

        private void Button_Click(object sender, RoutedEventArgs e)
        {
            Task<DateTime> task = App.TestAsync();
            task.Wait();
        }
    }

观察点击Button后,应用程序死锁。

为什么两种情况都不能通过?
有没有一种方法可以更改调用(例如,在正确的任务中使用 ConfigureAwait 或以某种方式设置 SynchronizationContext 或其他),使其在两次调用中的行为相同,但仍同步等待完成?

更新解决方案的局限性。

像TestAsync这样的异步方法来自一个无法更改的库。
TestAsync 方法的调用代码嵌套在一个同样不能更改的调用堆栈中,callstck 之外的代码使用异步方法的返回值。
最终,解决方案代码必须通过不更改方法或调用方来将异步方法转换为 运行 同步方法。
这在 UT 代码 (NUnit) 和应用程序启动期间运行良好,但在 WPF 的处理程序中不再适用。
为什么?

有几种不同的方法可以处理这种情况,但最终在一种情况下出现死锁而不是另一种情况的原因是在 Main 方法中调用时 SynchronizationContext.Current 为空,所以没有要捕获的主 UI 上下文,所有异步回调都在线程池线程上处理。当从按钮调用时,有一个自动捕获的同步上下文,因此在这种情况下所有异步回调都在导致死锁的主 UI 线程上处理。通常,避免死锁的唯一方法是强制异步代码不捕获同步上下文,或者一直使用异步并且不从主 UI 上下文同步等待。

  1. 您可以 ConfigureAwait(false) 在您的 TestAsync 方法内部,这样它就不会捕获同步上下文并尝试在主 UI 线程上继续(这最终是造成你的死锁是因为你在阻塞 UI 线程的 UI 上调用 task.Wait(),并且你有 System.Diagnostics.Debug.WriteLine(completed); 试图被安排回 UI线程因为await自动捕获同步上下文)
DateTime completed = await Task.Run<DateTime>(() => {
                System.Threading.Thread.Sleep(3000);
                return DateTime.Now;
            }).ConfigureAwait(false);
  1. 您可以在后台线程上启动异步任务,这样就没有要捕获的同步上下文。
private void Button_Click(object sender, RoutedEventArgs e)
{
    var task = Task.Run(() => App.TestAsync());
    var dateTime = task.Result;
}
  1. 你可以在整个堆栈中使用异步
private async void Button_Click(object sender, RoutedEventArgs e)
{
    Task<DateTime> task = App.TestAsync();
    var dateTime = await task;
}
  1. 考虑到您的使用方式,如果您不必等到任务完成,您可以放手,它最终会完成,但是您失去处理任何异常的上下文
private void Button_Click(object sender, RoutedEventArgs e)
{
    //assigning to a variable indicates to the compiler that you
    //know the application will continue on without checking if
    //the task is finished. If you aren't using the variable, you
    //can use the throw away special character _
    _ = App.TestAsync();
}

这些选项没有任何特定的顺序,实际上,最佳实践可能是#3。 async void 专门用于此类您希望异步处理回调事件的情况。

据我了解,在 .NET 中,许多前端都有一个 UI 线程,因此必须一直异步编写。其他线程被保留并用于渲染等事情。

对于 WPF,这就是为什么使用 Dispatcher 以及如何排队工作项很重要,因为这是与您可以支配的一个线程进行交互的方式。更多阅读 here

放弃 .Result,因为这会阻塞,将方法重写为异步,并从 Dispatch.Invoke() 中调用它,它应该 运行 如预期的那样

Why can't it pass in both cases?

区别在于 SynchronizationContext 的存在。所有线程开始时都没有 SynchronizationContext。 UI 应用程序有一个特殊的 UI 线程,在某些时候他们需要创建一个 SynchronizationContext 并将其安装在该线程上。 何时 没有记录(或一致),但必须在 UI 主循环开始时安装它。

在这种情况下,WPF 将(最迟)在对 Application.Run 的调用中安装它。来自 UI 框架的所有用户调用(例如,事件处理程序)都在此上下文中发生。

阻塞代码与上下文死锁,因为这是 classic deadlock situation,它需要三个组件:

  1. 一次只允许一个线程的上下文。
  2. 捕获该上下文的异步方法。
  3. 该上下文中的方法也 运行 阻塞等待该异步方法。

在 WPF 代码安装上下文之前,不满足条件 (1),这就是它没有死锁的原因。

Is there a way to change invocation (e.g. using ConfigureAwait at the correct task or somehow setting SynchronizationContext or whatever) so that it behaves identical in both invocations, but still synchronously waits for completion?

We-ell...

这是对“我如何阻止异步代码”的改写,对此没有好的答案。 最好的答案是阻塞异步代码;即,使用 async all the way. Especially since this is GUI code, I'd say for the sake of UX you really want to avoid blocking. Since you're on WPF, you may find a technique like asynchronous MVVM data binding 有用。

也就是说,有一些 hacks you can use if you must. Using ConfigureAwait is one possible solution, but not one I recommend; you'd have to apply it to all awaits within the transitive closure of all methods being blocked on (Blocking Hack). Or you can shunt the work to the thread pool (Task.Run) and block on that (Thread Pool Hack). Or you can remove the SynchronizationContext - 除非被阻止的代码操纵 UI 元素或绑定数据。或者甚至还有 更多 危险的技巧,我根本不推荐(嵌套消息循环技巧)。

但即使在为黑客攻击付出了所有努力之后,您仍然会阻止 UI。正是因为不推荐,所以黑客很难。为您的用户提供 更差 的体验是一项艰巨的工作。更好的解决方案(对于您的用户 未来的代码维护者)是一直异步。