【问题标题】:Better solution for sync/async problem in desktop app?桌面应用程序中同步/异步问题的更好解决方案?
【发布时间】:2022-01-28 08:10:57
【问题描述】:

我有 WinForms 应用程序,其中按钮单击调用外部库的一些异步方法。

private async void button1_Click(object sender, EventArgs e)
{
    await CallLibraryAsync();
}

private static async Task CallLibraryAsync()
{
    var library = new Library();
    await library.DoSomethingAsync();
}

库如下所示:

public class Library
{
    public async Task DoSomethingAsync()
    {
        Thread.Sleep(2000);
        await Task.Delay(1000).ConfigureAwait(false);

        // some other code
    }
}

在任何异步代码之前,都有一些由Thread.Sleep 调用模拟的计算。在这种情况下,此调用将阻塞 UI 线程 2 秒。 我无法更改DoSomethingAsync 中的代码

如果我想解决阻塞问题,我可以像这样调用Task.Run 中的库:

private static async Task CallLibraryAsync()
{
    var library = new Library();

    // added Task.Run
    await Task.Run(() => library.DoSomethingAsync());
}

它解决了这个问题,UI 不再阻塞,但我从 ThreadPool 中消耗了一个线程。这不是很好的解决方案。

如果我想在没有其他线程的情况下解决这个问题,我可以这样做:

private static async Task CallLibraryAsync()
{
    var library = new Library();

    // added
    await YieldOnlyAsync().ConfigureAwait(false);

    await library.DoSomethingAsync();
}

// added
private static async Task YieldOnlyAsync()
{
    await Task.Yield();
}

此解决方案有效。 Task.Yield() 导致该方法 YieldOnlyAsync() 始终异步运行,ConfigureAwait(false) 导致下一个代码 (await library.DoSomethingAsync();) 在某个 ThreadPool 线程上运行,而不是 UI 线程。

但这是一个相当复杂的解决方案。有没有更简单的?

编辑:
如果库方法看起来像这样

public class Library
{
    public async Task DoSomethingAsync()
    {
        await Task.Delay(1000).ConfigureAwait(false);
        Thread.Sleep(2000);
        await Task.Delay(1000);

        // some other code
    }
}

UI 线程不会被阻塞,我不需要做任何事情。但这就是我没有直接看到的一些实现细节的问题,因为这可能在一些 nuget 包中。当我看到 UI 在某些情况下冻结时,我可能会在一些调查后发现这个问题(意味着在异步方法中的任何等待之前的 CPU 绑定计算)。没有Wait()Result,那会很容易找到,这个比较麻烦。

如果可能,我希望以更简单的方式为这种情况做好准备。这就是为什么我不想在调用某个第三方库时使用Task.Run

【问题讨论】:

  • 还有更简单的吗? - 如果你无法更改库,请将DoSomethingAsync 的代码复制到你自己的代码库中并注释掉 Sleep?或者如何编辑编译的库以删除调用?
  • 不要担心使用线程池中的线程,特别是桌面应用程序,因为您的情况就是这样。他们在那里的唯一目的是被使用。他们将在工作完成后立即退出。
  • 所以库执行了一个很长的 CPU 绑定计算,在它等待之前。我有点不明白为什么不愿意 Task.Run 它;它必须在某个地方运行,那么你会在哪里运行呢?
  • 我认为您是说您不一定知道某些外部库在做什么以及它是否是 CPU 密集型操作。但是你总是会知道它在做什么,否则你为什么要使用它呢?而且,你会在测试中发现:如果它锁定了 UI,那么使用Task.Run
  • @JeremyLakeman,不,ConfigureAwait()Task 类的方法。 YieldAwaitable(由Task.Yield()返回)没有那个方法。

标签: c# winforms async-await threadpool


【解决方案1】:

如果我想解决阻塞问题,我可以像这样在 Task.Run 中调用库:

它解决了这个问题,UI 不再阻塞,但我从 ThreadPool 中消耗了一个线程。这不是很好的解决方案。

这正是您想要在 WinForms 应用程序中执行的操作。 CPU 密集型代码应移至单独的线程以释放 UI 线程。在 WinForms 中使用新线程没有任何缺点。

使用Task.Run 将其移动到不同的线程,并与 UI 线程异步等待它完成。

引用 Microsoft 的 Asynchronous programming 文章:

如果您的工作是CPU 密集型,并且您关心响应能力,请使用asyncawait,但使用Task.Run 在另一个线程上生成工作。

【讨论】:

  • Task.Run 不会必然创建新线程。
  • @LeonardoHerrera 是也不是。根据the documentation,它将“将指定的工作排入队列以在线程池上运行”。如果碰巧有另一个可用的 ThreadPool 线程,它可能会使用它,在这种情况下,不,它不会创建一个新线程。但如果不是,那么它创建一个新线程。它是否创建一个新线程并不真正相关。关键是它不在 UI 线程上运行。
  • @LeonardoHerrera 你说得对,这就是为什么我写了 我已经从 ThreadPool 中消耗了一个线程,而不是“创建” :) 如果没有可用的线程,那么只有创建新的(如果可能的话当然基于 ThreadPool 配置)。
  • @GabrielLuci 你说得对,在桌面应用程序中从 ThreadPool 中消耗一个(或两个、三个)线程是没有问题的。这将是网络应用程序中的问题,但没有 UI 线程;)我已经编辑了我的原始问题(在最后添加了更多解释)试图澄清更多我的担忧。请检查一下。
【解决方案2】:

我无法更改代码

人们会这么说,但实际上你可能不会因此而受挫......

这是一个与您遇到相同问题的简单应用:

肯定很困:

让我们在加载了 Reflexil 插件的情况下将其打入 ILSpy:

我们或许可以稍微缩短超时时间。右键单击,编辑。

让它1ms,右键单击程序集并另存为..

这有点快!

玩一玩,不玩等等。

【讨论】:

  • Thread.Sleep 并不是真正的问题,它只是一个模拟消耗cpu 任务的例子:'Thread.Sleep 模拟了一些计算'。虽然我认为应该尽可能避免这种解决方案,但它可能会在非常罕见的情况下给某人一个想法。
【解决方案3】:

你写道:

如果我想解决阻塞问题,我可以像这样调用Task.Run 中的库:

private static async Task CallLibraryAsync()
{
    var library = new Library();

    // added Task.Run
    await Task.Run(() => library.DoSomethingAsync());
}

它解决了这个问题,UI 不再被阻塞,但我已经消耗了来自ThreadPool 的一个线程。 这不是很好的解决方案

(强调)

...然后您继续发明一个复杂的 hack 来做同样的事情:将DoSomethingAsync 方法的调用卸载到ThreadPool。所以你要么想要:

  1. 在不使用任何线程的情况下调用DoSomethingAsync 方法,或者
  2. 在非ThreadPool 线程上调用DoSomethingAsync 方法。

第一个是不可能的。不使用线程就不能调用方法。代码在 CPU 上运行,而不是凭空运行。第二个可以通过多种方式完成,最简单的方法是使用Task.Factory.StartNew 方法,结合LongRunning 标志:

await Task.Factory.StartNew(() => library.DoSomethingAsync(), default,
    TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap();

这样您将在新创建的线程上调用DoSomethingAsync,该线程将在方法调用完成后立即销毁。需要明确的是,线程将在调用完成时被销毁,而不是在异步操作完成时被销毁。基于您在问题中包含的DoSomethingAsync 实现(第一个),调用将在创建Task.Delay(1000) 任务并启动此任务的await 后立即完成。此后线程将无事可做,因此将被回收。

旁注:

  1. CallLibraryAsync 方法违反了not exposing asynchronous wrappers for synchronous methods 的准则。由于DoSomethingAsync 方法被实现为部分同步和部分异步,该指南仍然适用于恕我直言。
  2. 如果您喜欢命令式控制当前上下文的想法,而不是像 Task.Run 方法那样使用包装器来控制它,您可以查看这个问题:Why was SwitchTo removed from Async CTP / Release? 喜欢它的人(不是很多)好吧,有一些可用的库使之成为可能 (SwitchTo - Microsoft.VisualStudio.Threading)。

【讨论】:

  • 我同意,这两种解决方案(Task.Run 与使用YieldOnlyAsync() 的hack)都使用线程池中的线程(因此没有区别),这不是一个好的论点。 Task.Run 经常被错误地用作异步包装器而不是同步方法,我担心当开发人员看到这个解决方案时,他们会在其他代码中错误地使用它。在这里,我将使用Task.Run 运行看起来很奇怪的 async 方法。但确实在这种特殊情况下它是更好的解决方案。我不同意您写的其他一些注释,我将在单独的 cmets 中作出反应:)
  • 为同步方法公开异步包装器是不同的。当我有某个方法Calculate() 时,我会创建CalculateAsync() { await Task.Run(Calculate); }。那将违反该准则。在我的示例中,DoSomethingAsync() 是某个第三方库中的方法。
  • @Lubos 使用带有异步委托的Task.Run 第一次看到它确实有点奇怪,因为它的用途并不明显。它完全符合您的问题中说明的目的:处理具有异步签名但部分或完全同步实现的方法。这种方法理想情况下不应该存在,但实际上它们确实存在,Task.Runspecifically designed 来处理它们。
  • @Lubos 至于 “不为同步方法公开异步包装器” 准则,这是一个准则,而不是规则。如果您认为由于情况需要而违反它更好,并且您有充分的论据来证明违反的合理性,请随意这样做。 Stephen Toub 不会用电锯和大锤来追你。除非他碰巧成为你 API 的用户,在这种情况下你可以预料他会变得有点不高兴。
  • 同意第一个,第二个(和 Stephen Toub 一起)让我笑了:)
【解决方案4】:

当您将 async/await 用于 I/O 或 CPU 密集型操作时,您的 UI 线程不会阻塞。在您的示例中,您使用 Thread.Sleep(2000);command 来模拟您的 CPU 绑定操作,但这会阻止您的线程池线程而不是 UI 线程。您可以使用Task.Delay(2000); 来模拟您的 I/O 操作,而不会阻塞线程池线程。

【讨论】:

  • 不完全是。当你调用 async 方法时,方法内部的代码会同步到第一个 await。这意味着它在调用该方法的同一线程上运行。在我的示例中,它是 UI 线程。您可以尝试运行此代码,您会看到 UI 冻结了 2 秒。
  • 你可以使用Task.Delay(2000);用于在不阻塞线程池线程的情况下模拟 I/O 操作。 你是对的,这就是我在示例中使用的。 Thread.Sleep 模拟 CPU 密集型操作,Task.Delay 模拟 IO 密集型操作。问题在于受 CPU 限制的操作,因为在示例中它会阻塞 UI。
猜你喜欢
  • 2022-10-14
  • 1970-01-01
  • 2018-04-08
  • 2022-11-04
  • 2010-12-31
  • 1970-01-01
  • 2014-11-21
  • 2011-12-11
  • 1970-01-01
相关资源
最近更新 更多