【问题标题】:Utilizing Async/Await in .NET Console applications breaks when calling Application.Run() or instantiating a WinForms UserControl object在调用 Application.Run() 或实例化 WinForms UserControl 对象时,在 .NET 控制台应用程序中使用 Async/Await 会中断
【发布时间】:2019-12-26 00:33:49
【问题描述】:

背景

Async/Await 通过自动创建“状态机”来促进 .NET 中的响应式应用程序,从而使应用程序的主线程即使在执行阻塞工作时也能保持响应式。

Windows 窗体、WPF 和 ASP.NET(据我所知)都包含了 SynchronizationContext 的一种形式(尽管 ASP.NET 最近可能已将其删除;我并不肯定,因为我不使用它。)

我最近需要扩展一个 Windows 窗体应用程序以支持从命令行接受参数,并且在这样做时发现 Async/Await 停止工作。在我的应用程序执行了一些(几乎是随机的)步骤后,它会挂起或返回到不正确的点,从而有效地停止。

SynchronizationContext

经过研究,我发现在幕后,Async/Await 依赖于 SynchronizationContext 来有效地处理路由机器状态(如上所述)。不清楚的是没有 SynchronizationContext 会发生什么:Stephen Toub(在他的博客上) post here) 表明 Async/Await 将执行,但没有线程关联,并且没有 SynchronizationContext,Async/Await 最终会在随机线程上执行。

Stephen 继续解释“AsyncPump.cs”,他的类用于实现控制台应用程序的 SynchronizationContext,并在测试 AsyncPump 时,到目前为止,它是成功的。

问题

  1. Stephen 的帖子来自 2012 年;还有其他解决方案吗?也许他的 AsyncPump 类已被集成(和/或修改)到更新版本的 .NET 中?如果可用,我更愿意使用库指定的等效项,这样如果 Async/Await 的底层实现发生任何更改,它也会自动更新,就像 WindowsFormsSynchronizationContext 一样。李>
  2. 我可以安全地使用 WindowsFormsSynchronizationContext 吗?在 Program.cs 中,我正在确定是否要实例化并打开一个表单,使用 Application.Run() 来执行此操作,它会自动为我设置一个 SynchronizationContext(以及消息泵等)我尝试实例化一个 WindowsFormsSynchronizationContext 并使用 SynchronizationContext.SetSynchronizationContext() 在我的主线程上设置它,虽然它可以编译,但我遇到了与根本没有 SynchronizationContext 时相同的问题。

我正在寻找在控制台应用程序中支持 Async/Await 的最佳实践,因为(据我所知)它肯定需要 SynchronizationContext 才能正确执行。


编辑 1:添加伪代码以帮助说明场景

如果我的程序接收到多个参数,我假设它是从命令提示符调用的,并创建了一个自定义“MyCustomConsole”类,该类使用 P/Invoke 到 Win32 来调用 AttachConsole(-1)。此时,我可以从 CLI 读取/写入,因为我的程序是控制台应用程序。如果我没有收到任何额外的参数,那么我可以按预期启动 Windows 窗体 GUI ("Application.Run(new Form1());")。

问题是我最终调用的代码来执行阻塞操作(“RunBlockingOperationsAsync()”)是异步/等待保持响应,当通过 GUI 调用时(通过“Application.Run()”),工作美好的。如果我尝试在没有“Application.Run()”的情况下调用“RunBlockingOperationsAsync”,程序会在调试时死锁或跳转到意外区域,从而导致崩溃。

我尝试实现 WindowsFormsSynchronizationContext,但以同样的方式失败。但是,利用 Stephen Toub 的“AsyncPump.cs”解决方案可以解决问题(见下文。)

为此必须有一个内置的 .NET 框架,对吗?如果没有控制台应用程序的默认实现,我无法相信 Async/Await 可以如此彻底地实现。我目前的理解是,如果没有 Stephen 的“AsyncPump.cs”类(或类似类),控制台应用程序中的 Async/Await 利用率将无法正确执行;实际上,这使得在控制台应用程序中使用 Async/Await 在默认情况下无法按原样使用。

似乎控制台应用程序应该有一个等效版本的“Application.Run()”,它初始化一个适当的 SynchronizationContext(以及其他任何可能需要的东西——现在可能什么都没有。)

using System;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Threading; // <-- Note that System.Threading is required for SynchronizationContext.

namespace WindowsFormsApp1
{
    static class Program
    {
        /// <summary>
        /// The main entry point for the application—NOTE this is the default WinForms implementation for 'Program.cs'.
        /// </summary>
        [STAThread]
        static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);

            MainAsync();
        }

        private static async Task MainAsync()
        {
            // If the application has received more than one argument, assume it's been invoked from the Command Prompt.
            if (Environment.GetCommandLineArgs().Count() > 1)
            {
                using (MyCustomConsole mcc = new MyCustomConsole())
                {
                    SynchronizationContext sctx = SynchronizationContext.Current;   // <-- Initializes sctx to NULL, as at this point in the program,
                                                                                    // there is no SynchronizationContext. It is initialized when
                                                                                    // "Application.Run()" is invoked.

                    // Doesn't work (no SynchronizationContext):
                    await mcc.Run();                                    // <-- If the MyCustomConsole class is invoked without using AsyncPump.cs,
                                                                        // it has no SynchronizationContext, and without it, Async/Await operations can
                                                                        // execute on any thread from the ThreadPool, which causes deadlocks and jumping
                                                                        // (almost at random?) to unexpected parts of my program, which I can only attribute
                                                                        // to the size of the program and including numerous nested Async/Await calls, depending
                                                                        // on what the program is trying to do.

                    // Perhaps instantiate a WindowsFormsSynchronizationContext and use it?
                    SynchronizationContext.SetSynchronizationContext = new WindowsFormsSynchronizationContext();
                    await mcc.Run();                                    // <-- Also fails in the same manner as above, despite having a SynchronizationContext.
                                                                        // I don't understand why.

                    AsyncPump.Run(async () => { await mcc.Run(); });    // <-- This works. AsyncPump.cs is the custom SynchronizationContext that
                                                                        // Stephen Toub provided in his blog. It not only handles SynchronizationContext,
                                                                        // but sets itself as the SynchronizationContext for the current thread, which
                                                                        // is required for Async/Await to operate with thread affinity.
                }
            }
            else // Otherwise, display the main form and operate with a GUI.
            {
                Application.Run(new Form1());   // <-- Application.Run() instantiates a WindowsFormsSynchronizationContext,
                                                // (amongst other things, like a message pump) and this is vital to a proper
                                                // Async/Await machine state that requires thread affinity.
            }
        }
    }
}

分辨率

这个问题的根源有两个:首先,使用 Async/Await 的开发人员应该了解 Async/Await 的实现可能会因 SynchronizationContext 的不同而有所不同; Stephen Toub does an excellent job explaining here. 了解控制台应用程序在默认情况下没有特定的 SynchronizationContext,因此会将延续发布到 ThreadPool。如果你调试一个 Console 应用,你会发现监控 SynchronizationContext.Current 是 NULL。

其次,认识到(对于 Windows 窗体)Application.Run() 设置了消息泵和单线程 SynchronizationContext。在 Application.Run() 之后监视 SynchronizationContext.Current 将返回一个 WindowsFormsSynchronizationContext 对象。感谢@noseratio,我了解到实例化 Windows 窗体 UserControl 对象也会实例化并设置 SynchronizationContext.Current 以使用新的 WindowsFormsSynchronizationContext,但仅如果它为 NULL首先。

这解释了我的问题:我正在处理的应用程序是 Windows 窗体应用程序,通常启动时,Application.Run() 用于调用消息泵并设置 WindowsFormsSynchronizationContext。异步/等待完美运行。但是,在添加对 CLI 的支持时,我实例化了一个派生自 UserControl 的对象。一旦我实例化它,我以前的 NULL SynchronizationContext 现在是一个 WindowsFormsSynchronizationContext,现在 Async/Await 延续被发布到它而不是 ThreadPool - 在实例化新的 SynchronizationContext 后 ThreadPool 上的延续会发生什么,我不能说。我遇到了不稳定的程序行为,通常要么是“等待 Task.Delay()”调用无限期挂起,要么是我的应用程序(在调试器中)的控制看似随意地跳来跳去。据报道,设置 (WindowsFormsSynchronizationContext.AutoInstall = false) 应该可以防止自动将 NULL SynchronizationContext 替换为 WindowsFormsSynchronizationContext,但在我的测试中,它仍然被替换(并且 Async/Await 仍然中断。)

我没有使用 WPF 对此进行测试,但我希望 WPF 的行为类似(和/或开发人员将面临类似的问题。)

有多种解决方案:

  1. 在我看来,最好的解决方案是在 CLI 模式下执行时不实例化 Windows 窗体 UserControl(或 WPF 等效项),如果可以的话。如果可能,将工作抽象到它自己的类中,并将 UserControls(及其等价物)留给 View 抽象。这允许 Async/Await 在应用程序需要的任何同步上下文上运行:如果是 Windows 窗体,则为 WindowsFormsSynchronizationContext。如果是 WPF,则为 Dispatcher (?) SynchronizationContext。如果是控制台应用程序,它会在 ThreadPool 而不是 SynchronizationContext 上运行。

  2. 显式设置自己的 SynchronizationContext:@Stephen Toub 的 AsyncPump 类;或@Stephen Cleary 的 AsyncContext 类;或@TheodorZoulias 的任何一个解决方案都有效(在我的测试中)。可能有充分的理由在#1 上使用这些解决方案之一,例如,您可能正在处理控制台应用程序,但别无选择,只能实例化 WinForms UserControl,或者也许使用您不知道的在幕后执行此操作的库。如果遇到这种情况,我建议在应用程序的各个阶段监控 SynchronizationContext.Current。

【问题讨论】:

  • 您的意思是在不带参数调用时正确运行的同一可执行文件在带参数调用时停止正确运行?在这两种情况下你都打电话给Application.Start吗?
  • 没有办法用参数调用可执行文件会导致这种情况。你能发布你的Program.Main吗?
  • 扩展 Windows 窗体应用程序以支持从命令行接受参数 - 它仍然是同一个进程,还是需要在多个进程实例之间进行通信?
  • 我的意思是Application.Run(),而不是Application.Start()。
  • 控制台应用程序通常不需要设置 SynchronizationContext 来让 async/await 正常工作。

标签: c# .net async-await command-line-interface command-prompt


【解决方案1】:

使用 Stephen Toub 的 AsyncPump 似乎就足够了。您还可以尝试使用Application.Run()(不带表单)启动标准消息循环,并在Application.Idle 事件处理程序中运行您的代码(仅处理一次)。这样,如果出于某种原因需要,您也可以与 UI 元素进行交互(例如使用 WebBrowser 控件)。

if (Environment.GetCommandLineArgs().Count() > 1)
{
    EventHandler handler = null;
    handler = async (sender, e) =>
    {
        Application.Idle -= handler;
        using (MyCustomConsole mcc = new MyCustomConsole())
        {
            await mcc.Run();
        }
        Application.ExitThread();
    };
    Application.Idle += handler;
    Application.Run(); // Begins running a standard application message
                       // loop on the current thread, without a form.
}

更新:另一个想法是使用Dispatcher,该对象用于 WPF 应用程序中的线程同步。 Dispatcher 会自动创建一个DispatcherSynchronizationContext,因此所有缺少ConfigureAwait(false) 的等待延续都将在同一个线程中运行。需要对程序集 WindowsBase.dll 的引用。

using System.Windows.Threading;

if (Environment.GetCommandLineArgs().Count() > 1)
{
    var dispatcher = Dispatcher.CurrentDispatcher;
    var invokeTask = Task.Run(async () =>
    {
        try
        {
            await dispatcher.Invoke(async () =>
            {
                using (MyCustomConsole mcc = new MyCustomConsole())
                {
                    await mcc.Run();
                }
            });
        }
        finally
        {
            dispatcher.InvokeShutdown();
        }
    });
    Dispatcher.Run(); // blocking call
    await invokeTask; // await the task just to propagate exceptions
}

需要Task.Run,以便从线程池线程调用dispatcher.Invoke,以及调度程序的最终关闭。其他一切都发生在主线程中。

【讨论】:

  • 这是一个聪明的解决方案,在我的测试中,它似乎可以像 AsyncPump 一样正常工作。我特别喜欢这个解决方案,因为它利用了 Application.Run(),所以如果它应该在幕后更改(使用 .NET 或 .NET Core/5),用户也会自动获得这些更改的好处。
【解决方案2】:

在没有同步上下文的情况下(或使用默认的SyncrhonizationContext),await 延续通常可以同步运行,即在其先前任务已结束的同一线程上。这可能会导致难以理解的死锁,这也是在 .NET Framework 4.6 中引入 TaskContinuationOptions.RunContinuationsAsynchronously 的原因之一。有关更多详细信息和示例,请查看此博客文章:The danger of TaskCompletionSource class。

AsyncPump 阻止您的代码挂起这一事实表明您可能在mcc.Run() 的某个地方遇到类似情况。由于AsyncPump 为await 延续(尽管在同一个线程上)强加了真正的异步,它减少了死锁的机会。

也就是说,我不建议使用AsyncPump 或WindowsFormsSynchronizationContext 作为解决方法。相反,您应该尝试找出导致代码挂起的确切原因(以及挂起的位置),并在本地解决它,例如只需用Task.Run 包装有问题的电话。

我在您的代码中发现的另一个问题是您没有等待或等待MainAsync 返回的任务。因此,至少对于您的逻辑的控制台分支(尤其是不使用AsyncPump),您的程序可能会提前结束,这取决于mcc.Run() 内部发生的事情,并且您可能会忽略一些异常。

【讨论】:

  • @Justin,似乎还有另一个同步。 context 隐式安装在某处,然后Task.Delay 延续回调被发布到它。您是否在 console 代码分支的任何地方使用任何 WinFroms 或 WPF 对象?另外,你能在任何地方发现Task.Wait 或Task.Result 吗?
  • 您似乎已经诊断出问题——通过将 WinForms 类中执行的代码移植到另一个非 UserControl 派生类,我能够完美地执行所有操作,而无需安装或修改默认 SynchronizationContext .实例化 UserControl 是否会导致当前线程的 SynchronizationContext 发生某些事情?或者调用一个方法作为该 UserControl 的一部分是否会导致某些事情发生在幕后?
  • @Justin,是的。当您在没有 s 的线程上实例化 WinForms 控件时。在上下文(或使用默认值)时,WindowsFormsSynchronizationContext 的新实例会自动安装和管理。至少可以说,它的完成方式并不简单(例如,参见this)。
  • @Justin,很可能,您正在处理WindowsFormsSynchronizationContext 已经消失但仍有一些待处理的await 继续发布到它的情况。您可以使用WindowsFormsSynchronizationContext.AutoInstall 手动控制其生命周期(请参阅我在上面的 cmets 中链接的代码),但即便如此,如果不再发送 Windows 消息,这些延续将不会被执行,并且您最终可能会陷入死锁.您的线程是 STA 的事实并不能让它神奇地抽出,您确实需要停留在消息循环上,就像 Application.Run 运行的那样。
  • 感谢您的链接——看起来您在 SynchronizationContext 方面也经历了很多。你是绝对正确的:仅仅实例化一个 WinForms 控件(一个 UserControl)确实会导致当前 SynchronizationContext 被初始化(因为它以前是 NULL,因为我再次在 CLI 模式下运行。)这也解释了为什么(在存在的替代方案或已经初始化的 SynchronizationContext),一切正常。有趣的是,我尝试设置 SynchronizationContext.AutoInstall = false,包括在不同的方法中,但它没有改变任何东西。
【解决方案3】:

我正在寻找在控制台应用程序中支持 Async/Await 的最佳实践,因为(据我所知)它肯定需要 SynchronizationContext 才能正确执行。

async/await 不需要上下文。在没有上下文的情况下,它将使用线程池上下文。但是,使用 async/await 的代码当然可以对线程做出假设。在您的情况下,听起来好像您的代码期望在单线程上下文中运行。由于它是在单线程上下文 (WinForms) 中开发的,这不足为奇。

因此,async/await 在控制台应用程序中的“最佳实践”是直接运行它,无需上下文。但这在您的情况下是不可能的,因为您尝试重用的代码假定为单线程上下文。

Stephen 的帖子来自 2012 年;还有其他解决方案吗?也许他的 AsyncPump 类已被集成(和/或修改)到更新版本的 .NET 中?如果可用,我更愿意使用库指定的等效项,这样如果 Async/Await 的底层实现发生任何更改,它也会自动更新,就像 WindowsFormsSynchronizationContext 一样。

它尚未包含在 .NET 中。

有几个选项可以包含消息泵。一种是使用 Windows 窗体 UI 线程;另一个是 WPF UI 线程。我已经有一段时间没有这样做了,但是上次我检查 WPF 方法更容易运行,因为 WPF(与 WinForms 不同)旨在允许多个 UI 线程。

如果您实际上不需要带有消息泵的用户界面(即 STA)线程,您也可以使用自己的单线程上下文。我写了一个AsyncContext type (docs) 我过去曾为此使用过。与 UI 上下文不同,它不使用 Windows 消息队列。作为单线程上下文,它确实有一个队列,但它是一个委托队列。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2015-05-04
    • 1970-01-01
    • 2015-10-12
    • 2017-06-16
    • 2014-06-20
    • 1970-01-01
    • 2010-09-13
    相关资源
    最近更新 更多