【问题标题】:How to better handle disposed controls when using async/await使用 async/await 时如何更好地处理已释放的控件
【发布时间】:2015-09-12 18:20:46
【问题描述】:

考虑在 UI 线程上运行的这段代码:

dividends = await Database.GetDividends();
if (IsDisposed)
    return;
//Do expensive UI work here
earnings = await Database.GetEarnings();
if (IsDisposed)
    return;
//Do expensive UI work here
//etc...

请注意,每次我await 时,我也会检查IsDisposed。这是必要的,因为我说我await 长期运行Task。同时,用户在完成之前关闭表单。 Task 将完成并运行一个尝试访问已处置表单上的控件的延续。发生异常。

有没有更好的方法来处理或简化这种模式?我在 UI 代码中大量使用await,每次检查IsDisposed 都很难看,如果我忘记了也容易出错。

编辑:

有一些建议的解决方案不符合要求,因为它们改变了功能。

  • 在后台任务完成之前防止表单关闭

这会让用户感到沮丧。而且它还允许发生可能会浪费时间、损害性能并且不再相关的昂贵的 GUI 工作。在我几乎总是在做后台工作的情况下,这可能会阻止表单关闭很长时间。

  • 隐藏表单并在所有任务完成后关闭它

这有阻止表单关闭的所有问题,除非不会让用户感到沮丧。执行昂贵的 GUI 工作的延续仍然会运行。它还增加了跟踪所有任务完成时的复杂性,然后在隐藏表单时关闭表单。

  • 在表单关闭时使用CancellationTokenSource 取消所有任务

这甚至不能解决问题。事实上,我已经这样做了(也没有必要浪费后台资源)。这不是一个解决方案,因为由于隐含的竞争条件,我仍然需要检查 IsDisposed。下面的代码演示了竞态条件。

public partial class NotMainForm : Form
{
    private readonly CancellationTokenSource tokenSource = new CancellationTokenSource();

    public NotMainForm()
    {
        InitializeComponent();
        FormClosing += (sender, args) => tokenSource.Cancel();
        Load += NotMainForm_Load;
        Shown += (sender, args) => Close();
    }

    async void NotMainForm_Load(object sender, EventArgs e)
    {
        await DoStuff();
    }

    private async Task DoStuff()
    {
        try
        {
            await Task.Run(() => SimulateBackgroundWork(tokenSource.Token), tokenSource.Token);
        }
        catch (TaskCanceledException)
        {
            return;
        }
        catch (OperationCanceledException)
        {
            return;
        }
        if (IsDisposed)
            throw new InvalidOperationException();
    }

    private void SimulateBackgroundWork(CancellationToken token)
    {
        Thread.Sleep(1);
        token.ThrowIfCancellationRequested();
    }
}

竞争条件发生在任务已经完成、表单已关闭且延续仍在运行时。你会看到InvalidOperationException 偶尔被抛出。取消任务是个好习惯,当然,但这并不能减轻我检查IsDisposed 的麻烦。

澄清

原始代码示例正是我想要的功能。这只是一个丑陋的模式,做“等待后台工作然后更新 GUI”是一个很常见的用例。从技术上讲,如果表单被处理,我只是希望继续不运行。示例代码就是这样做的,但并不优雅,而且容易出错(如果我忘记在每个 await 上检查 IsDisposed,我正在引入一个错误)。理想情况下,我想编写一个可以封装这个基本设计的包装器、扩展方法等。但我想不出办法。

另外,我想我必须说明性能是一流的考虑因素。例如,抛出异常是非常昂贵的,因为我不会进入。所以我也不想在每次执行await 时尝试捕获ObjectDisposedException。即使是丑陋的代码也会损害性能。似乎只做一次IsDisposed 检查是最好的解决方案,但我希望有更好的方法。

编辑#2

关于性能 - 是的,这都是相对的。我了解绝大多数开发人员并不关心抛出异常的成本。抛出异常的真正成本是偏离主题的。在其他地方有很多关于这方面的信息。可以说它比if (IsDisposed) 支票贵了许多数量级。对我来说,不必要地抛出异常的代价是不可接受的。在这种情况下我说没必要,因为我已经有了一个不会引发异常的解决方案。同样,让延续抛出 ObjectDisposedException 不是一个可接受的解决方案,正是我试图避免的。

【问题讨论】:

  • @HansPassant 当然,我可以做到。但是我只会有一群愤怒的用户等待更新他们想要关闭的表单。此外,这个例子也大大简化了。我可以在任何给定时间运行许多捕获 UI 上下文的任务。上面的代码在功能上是我想要的,但现在我的代码库中出现了这种“如果不处理则继续”的模式。
  • @Loathing 我也想过这个问题。但它增加了跟踪正在运行的任务的复杂性。更重要的是,UI 代码最终仍会运行(包括上面的后续等待),这在许多方面都是昂贵且浪费的。检查您提到的布尔值与检查IsDisposed 相同。
  • 您似乎严重高估了在此处使用异常的性能成本。你有一个例外情况,这种行为正是例外情况的类型。在用户已经关闭应用程序后,在进程被拆除时抛出然后捕获一个异常根本不应该是性能问题。鉴于 UI 已关闭,用户甚至不会意识到清理仍在进行中,或者关心是否需要额外的毫秒才能完成。
  • @Zer0 “非常昂贵”是一个相对术语。在您的应用程序用户的人类时间尺度上,抛出和捕获一个异常的成本是微不足道的;甚至感觉不到。现在,如果您不断地每秒抛出和捕获数万次异常,那么它可能会成为一个相关的性能问题。关闭表单时执行一次或两次远非如此。即使表单关闭不是应用程序的结束,它仍然不会以任何人能够感知的方式阻碍用户体验。
  • @Zer0 哎呀,即使您提出的使用任务取消来解决此问题的解决方案正在使用异常。它在工作完成时抛出异常,只是为了稍后捕获它。它将具有解决方案的所有相同性能特征,而您出于性能原因完全忽略了这些特征。

标签: c# winforms asynchronous async-await


【解决方案1】:

我还使用IsDisposed 在这种情况下检查控件的状态。虽然它有点冗长,但它并不比处理这种情况所需要的更冗长——而且一点也不令人困惑。像 F# 这样带有 monad 的函数式语言在这里可能会有所帮助——我不是专家——但这似乎和 C# 中的一样好。

【讨论】:

  • 对象可以在调用IsDisposed 和使用对象之间进行处理。相反,正确的模式是try { ... } catch (ObjectDisposedException) { }
  • @fjch1997 控件处置不会发生在您要更新控件的主 UI 线程上吗?如果是这样,我预计当您在 UI 线程上运行任何代码时,它要么被释放,要么不被释放。
【解决方案2】:

让您的表单拥有CancellationTokenSource 应该非常简单,并在表单关闭时调用Cancel

那么你的async 方法可以观察到CancellationToken

【讨论】:

  • 这是正确的做法。此外,任何逻辑都应从表格中排除(并进行单元测试)。表单事件处理程序应该是裸露的,并且应该直接调用视图模型或服务上的异步方法,将取消令牌传递给该方法。
  • 我想过这个,但担心可能出现竞争状况。所以在FormClosing 上,我在令牌上调用Cancel。但是Task 已经完成并将其继续(await 之后的方法的其余部分)发布到捕获的SynchronizationContext。在这种情况下,它位于消息队列中并且仍将运行。我想我可以通过使消息队列严重饱和来轻松测试这一点。
  • 这里确实有一个容易重现的竞争条件。这比我的设计和防止表单关闭的建议都要糟糕,因为现在有未处理的异常。如果您愿意,我可以发布我用来测试的代码。
  • @Zer0 如果您可以更新您的问题以包含您已经考虑过此解决方案但发现它存在问题的事实,这将很有用。
  • @Zer0:是的,使用异常报告取消。所以,你抓住它。底线是,如果您有一个依赖于对象的方法,您要么必须延长该对象的生命周期,要么必须检查对象是否处于无效状态(无论是通过两行 if (IsDisposed) 还是单行token.ThrowIfCancellationRequested())。从逻辑上讲,没有办法解决这个问题。如果你想对用户隐藏它,你可以拦截关闭表单,使其隐藏但不被释放;但这不会取消实际操作。
【解决方案3】:

我曾经通过不关闭表单解决了类似的问题。相反,我一开始把它藏了起来,只有在所有未完成的工作完成后才真正关闭它。当然,我必须以Task 变量的形式跟踪这项工作。

我发现这是一个干净的解决方案,因为根本不会出现处置问题。然而,用户可以立即关闭表单。

【讨论】:

    猜你喜欢
    • 2018-09-03
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-05-04
    • 2013-08-03
    • 2021-10-30
    • 2021-12-31
    相关资源
    最近更新 更多