【问题标题】:.NET Async in shutdown methods?.NET 异步关闭方法?
【发布时间】:2019-10-16 05:36:32
【问题描述】:

我有一个使用异步方法连接到 REST API 的应用程序。我几乎在所有连接到 API 的地方都使用 async/await 进行了设置,但是我有一个问题和一些我不完全理解的奇怪行为。我想做的只是在程序关闭时在某些情况下返回许可证。这是由窗口关闭事件启动的;事件处理程序如下:

async void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
        {
            ...other synchronous code...

            //Check for floating licensing
            if (KMApplication.License != null && KMApplication.License.Scope != Enums.LicenseScope.Standalone)
            {
                for (int i = 0; i < 3; i++)
                {
                    try
                    {

                        await KMApplication.License.ShutDown(KMApplication.Settings == null
                                                                 ? Enums.LicenseReturnModes.PromptOnShutdown
                                                                 : KMApplication.Settings.LicenseReturnMode)
                                           .ConfigureAwait(false);
                        break;
                    }
                    catch (Exception ex)
                    {
                        _logger.Warn("Exception in license release, attempt " + i, ex);
                    }
                }
            }

            await KMApplication.ApiService.Disconnect().ConfigureAwait(false);

            _logger.Info("Shutdown Complete");

            Application.Current?.Shutdown();
        }

当它运行时,我可以在调试器中单步执行,它会进入第一个许可证关闭调用,这是第一个等待异步调用的调用。然后,当我按 F10 进入下一行代码时,它就会关闭并消失。我验证了应该在该行中发生的许可证发布正在发生,因此它似乎运行到该行的完成,但随后关闭或崩溃或其他东西。我还查看了日志,它永远不会到达Shutdown Complete 行,我也不相信它会到达ApiService.Disconnect

我还尝试使用Task.Run(() =&gt; ...the method...).GetAwaiter().GetResult() 将其作为同步方法运行,但这只会在第一次调用时出现死锁。

我该如何处理并让它运行异步发布,等待它完成,然后关闭?

【问题讨论】:

  • async void 不会等待方法完成,因为它是 void 而不是 Task。当然,对于 WinForms 中的事件,不能使用async Task。可能的情况:该方法被触发,然后您的应用程序关闭(因为它不等待)。完成后您可能需要延迟关机。
  • 是的,因为它是事件处理程序,我无法做到async Task,因为它与我正在努力解决的签名不匹配。我真正的困惑是它确实关闭了,但关闭是在 log 方法之后调用的,它永远不会被调用......
  • 关闭时的异步流听起来很奇怪。当第一个异步启动时,UI 线程返回到消息泵处理并有效地关闭窗口。
  • 您是否尝试过取消活动(通过CancelEventArgs)?无论如何,您的处理程序都会明确关闭应用程序。您可能需要设置一个标志以确保您不会陷入无限循环。
  • 我没有尝试取消活动,但我只是这样做了,没有任何改变。我同意关闭时的异步听起来有点奇怪,但我不知道该怎么做。我希望程序在用户关闭时返回许可证,这是一个异步方法,因为它是一个 http 调用来执行它...

标签: c# .net wpf asynchronous


【解决方案1】:

您尝试做的基本问题是 async/await 假定主应用程序线程继续运行。这个假设与关闭操作直接冲突,关闭操作的任务是终止所有正在运行的任务。

如果您查看Window_Closing 上的文档,它会说明以下内容(并且仅说明以下内容):

Close()被调用后直接发生,可以处理取消关闭窗口。

这很重要。唯一应该做的就是允许您以编程方式取消窗口关闭,从而提示一些额外的用户操作。

由于 async/await 的工作方式,您的期望被弄糊涂了。 Async/await 似乎以线性方式运行;然而,实际发生的是控制权在第一个await 处被传回给调用者。框架此时假定您不希望取消表单关闭,并允许程序终止并执行所有其他操作。

从根本上说,所有 C 风格的程序都有一个主入口点,它运行一个循环。从 C 的早期开始就是这样,并且在 WPF 中继续这样。然而,在 WPF 中,微软有点聪明,决定对程序员隐藏这一点。有几个选项可以处理在主窗口关闭后需要发生的事情:

  1. 从您的程序中重新劫持主循环,并将代码放在那里。有关如何执行此操作的详细信息,请参阅here

  2. 设置explicit shutdown mode,然后启动任务以启动它。调用Application.Shutdown() 作为您需要执行的最后一行代码。

【讨论】:

  • 谢谢,但#2本质上是我在Theodor的帮助下在我的回答中已经想出的......不过,赞成详细的解释,谢谢。
  • 您的方法有利有弊。优点:通过在调用异步任务之前关闭窗口,您可以少担心一件事。就像计时器在隐藏窗口中启动并更改内部状态一样。缺点:没有退路。如果异步任务失败,则无法取消关闭窗口,因为它已经关闭。
  • @TheodorZoulias - 你似乎不明白框架是如何设计的,尤其是这个事件处理程序是如何设计的。事件处理程序应该是同步的。要么允许关闭窗体,要么不允许关闭窗体,这应该立即确定。通过事件处理程序保持窗体打开是不正确的。如果希望向用户显示状态,那么您 (1) 取消表单关闭,(2) 启动后台任务,该任务 (3) 显示某种类型的通知并 (4) 关闭表单完成。
  • 我比较的是 FormClosing 事件的异步版本,您在表单关闭后触发异步任务的方法。我了解您不能使用内置同步 FormClosing 并期望它能够正常工作。这实际上是 OP 的原始问题,促使他发布这个问题。我在my answer 中为Windows 窗体提供了FormClosingAsync 的实现。
  • 和你争论是没用的。您编造了一个没有异步操作的异步操作。那是行不通的。我不知道为什么这很难得到,但这就是我们所处的位置。
【解决方案2】:

这是FormClosing 事件的异步版本。它会延迟表单的关闭,直到提供的Task 完成。用户无法在任务完成之前关闭表单。

OnFormClosingAsync 事件将FormClosingEventArgs 类的增强版本传递给处理代码,并带有两个附加属性:bool HideFormint Timeout。这些属性是读/写的,很像现有的Cancel 属性。将HideForm 设置为true 具有在异步操作正在进行时隐藏表单的效果,以避免让用户感到沮丧。将Timeout 设置为 > 0 的效果是在指定的持续时间(以毫秒为单位)后放弃异步操作,并关闭表单。否则,应用程序可能会在隐藏 UI 的情况下无限期地运行,这肯定是个问题。 Cancel 属性仍然可用,并且可以由事件处理程序设置为true,以防止表单关闭。

static class WindowsFormsAsyncExtensions
{
    public static IDisposable OnFormClosingAsync(this Form form,
        Func<object, FormClosingAsyncEventArgs, Task> handler)
    {
        Task compositeTask = null;
        form.FormClosing += OnFormClosing; // Subscribe to the event
        return new Disposer(() => form.FormClosing -= OnFormClosing);

        async void OnFormClosing(object sender, FormClosingEventArgs e)
        {
            if (compositeTask != null)
            {
                // Prevent the form from closing before the task is completed
                if (!compositeTask.IsCompleted) { e.Cancel = true; return; }
                // In case of success allow the form to close
                if (compositeTask.Status == TaskStatus.RanToCompletion) return;
                // Otherwise retry calling the handler
            }
            e.Cancel = true; // Cancel the normal closing of the form
            var asyncArgs = new FormClosingAsyncEventArgs(e.CloseReason);
            var handlerTask = await Task.Factory.StartNew(
                () => handler(sender, asyncArgs),
                CancellationToken.None, TaskCreationOptions.DenyChildAttach,
                TaskScheduler.Default); // Start in a thread-pool thread
            var hideForm = asyncArgs.HideForm;
            var timeout = asyncArgs.Timeout;
            if (hideForm) form.Visible = false;
            compositeTask = Task.WhenAny(handlerTask, Task.Delay(timeout)).Unwrap();
            try
            {
                await compositeTask; // Await and then continue in the UI thread
            }
            catch (OperationCanceledException) // Treat this as Cancel = true
            {
                if (hideForm) form.Visible = true;
                return;
            }
            catch // On error don't leave the form hidden
            {
                if (hideForm) form.Visible = true;
                throw;
            }
            if (asyncArgs.Cancel) // The caller requested to cancel the form close
            {
                compositeTask = null; // Forget the completed task
                if (hideForm) form.Visible = true;
                return;
            }
            await Task.Yield(); // Ensure that form.Close will run asynchronously
            form.Close(); // Finally close the form
        }
    }

    private struct Disposer : IDisposable
    {
        private readonly Action _action;
        public Disposer(Action disposeAction) => _action = disposeAction;
        void IDisposable.Dispose() => _action?.Invoke();
    }
}

public class FormClosingAsyncEventArgs : EventArgs
{
    public CloseReason CloseReason { get; }
    private volatile bool _cancel;
    public bool Cancel { get => _cancel; set => _cancel = value; }
    private volatile bool _hideForm;
    public bool HideForm { get => _hideForm; set => _hideForm = value; }
    private volatile int _timeout;
    public int Timeout { get => _timeout; set => _timeout = value; }

    public FormClosingAsyncEventArgs(CloseReason closeReason) : base()
    {
        this.CloseReason = closeReason;
        this.Timeout = System.Threading.Timeout.Infinite;
    }
}

由于OnFormClosingAsync 是一个扩展方法而不是一个真正的事件,它只能有一个处理程序。

使用示例:

public Form1()
{
    InitializeComponent();
    this.OnFormClosingAsync(Window_FormClosingAsync);
}

async Task Window_FormClosingAsync(object sender, FormClosingAsyncEventArgs e)
{
    e.HideForm = true; // Optional
    e.Timeout = 5000; // Optional
    await KMApplication.License.ShutDown();
    //e.Cancel = true; // Optional
}

Window_FormClosingAsync 处理程序将在线程池线程中运行,因此它不应包含任何 UI 操作代码。

可以取消订阅事件,方法是保留IDisposable 返回值的引用并处理它。


更新:阅读this answer后,我意识到可以在表单中添加真实事件FormClosingAsync,而无需创建从表单继承的类。这可以通过添加事件来实现,然后运行一个将事件挂钩到本机FormClosing 事件的初始化方法。像这样的:

public event Func<object, FormClosingAsyncEventArgs, Task> FormClosingAsync;

public Form1()
{
    InitializeComponent();
    this.InitFormClosingAsync(() => FormClosingAsync);

    this.FormClosingAsync += Window_FormClosingAsync_A;
    this.FormClosingAsync += Window_FormClosingAsync_B;
}

在初始化器内部,在原生FormClosing事件的内部处理程序中,可以检索到该事件的所有订阅者 使用GetInvocationList 方法:

var eventDelegate = handlerGetter();
if (eventDelegate == null) return;
var invocationList = eventDelegate.GetInvocationList()
    .Cast<Func<object, FormClosingAsyncEventArgs, Task>>().ToArray();

...然后适当地调用。所有这些都增加了复杂性,同时允许多个处理程序的有用性存在争议。所以我可能会坚持原来的单处理器设计。


更新: 使用原始方法OnFormClosingAsync 仍然可以有多个处理程序。实际上这很容易。 Func&lt;T&gt; 类继承自Delegate,因此它具有像真实事件一样的调用列表:

Func<object, FormClosingAsyncEventArgs, Task> aggregator = null;
aggregator += Window_FormClosingAsync_A;
aggregator += Window_FormClosingAsync_B;
this.OnFormClosingAsync(aggregator);

OnFormClosingAsync 方法无需修改。

【讨论】:

  • 太棒了,谢谢。我会看看我是否可以将这个概念适应 WPF,但我认为这应该不会那么难。接受这是一个比我更彻底的答案。
  • @sfaust 我的荣幸!我希望那里没有错误,因为涵盖了很多场景,而且我不是单元测试的忠实拥护者。 :-)
  • 我删除了对.Result 的调用,这可能会导致死锁。
  • 我为活动选择的名称 (FormClosingAsync) 可能不太合适。 Async 后缀指定异步成员,即它们返回 Task 或其他可等待类型。此事件不涵盖该标准。也许“AsyncAware”会更准确,因为它接受返回任务的处理程序,但这对于后缀来说太长了。
【解决方案3】:

好的,这就是我最终要做的。基本上,窗口关闭会启动一个任务,该任务将等待释放发生,然后调用关闭。这是我之前尝试做的,但它似乎不适用于 async void 方法,但似乎是这样做的。这是新的处理程序:

void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
    {
        ...other sync code...

        Task.Run(async () =>
        {
            await InvokeKmShutdown();
            (Dispatcher ?? Dispatcher.CurrentDispatcher).InvokeShutdown();
        });
    }

而关机方法是这样的:

async Task InvokeKmShutdown()
    {
        ...other sync code...

        await KMApplication.ApiService.Disconnect();

        //Check for floating licensing
        if (KMApplication.License != null && KMApplication.License.Scope != License.Core.Enums.LicenseScope.Standalone)
        {
            for (int i = 0; i < 3; i++)
            {
                try
                {

                    await KMApplication.License.ShutDown(KMApplication.Settings == null
                                                             ? Enums.LicenseReturnModes.PromptOnShutdown
                                                             : KMApplication.Settings.LicenseReturnMode);
                    break;
                }
                catch (Exception ex)
                {
                    _logger.Warn("Exception in license release, attempt " + i, ex);
                }
            }
        }
    }

希望它对某人有所帮助。

编辑

请注意,这是在 App.xaml 中设置为 ShutdownMode="OnExplicitShutdown" 的 WPF 应用程序,因此在我调用关闭之前它不会关闭实际的应用程序。如果您使用 WinForms 或 WPF 设置为在最后一个窗口或主窗口关闭时关闭(我相信主窗口关闭是默认设置),您最终会遇到下面 cmets 中描述的竞争条件,并且可能会在之前关闭线程事情要完成了。

【讨论】:

  • 现在你有一个竞争条件。当应用程序退出时,将中止所有仍在运行的后台线程,并且线程池线程是后台线程。通过调用Task.Run(async...),您将完成的工作卸载到线程池线程。你没有Wait/await 任务,所以如果你的终结器在应用程序调用Abort 到运行终结器的线程之前完成,那是个运气问题。
  • 明白,但我不这么认为...这是主窗口关闭处理程序,因为它没有被取消,所以窗口将消失,这将阻止用户在执行任务时做任何其他事情在跑。本质上,这个关闭任务应该是最后运行的事情,并且由于该方法在任务内部等待,它会在实际调用应用程序关闭之前等待该方法完成。到那时,不应该担心任何后台线程我不认为......如果我不在某个地方,请告诉我。
  • 我假设您使用Application.Run(new Form1()) 打开表单。以下是documentation 对此的说明: 此方法将事件处理程序添加到Closed 事件的mainForm 参数。事件处理程序调用ExitThread 来清理应用程序。
  • 这是一个很好的观点。我刚刚编辑了我的答案,以澄清 WPF 需要设置为显式关闭,否则您确实会遇到这种竞争条件。
  • 您可能应该编辑您的问题以包含wpf 标记...
猜你喜欢
  • 1970-01-01
  • 2012-09-09
  • 2014-07-09
  • 1970-01-01
  • 1970-01-01
  • 2020-12-26
  • 2019-12-26
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多