65.9K
CodeProject 正在变化。 阅读更多。
Home

轻松使用协作者让你的用户界面保持响应

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (12投票s)

2012年7月8日

CPOL

7分钟阅读

viewsIcon

42064

downloadIcon

1102

一种替代 .NET async/await 新关键字的方法,用于异步编程命令,使您的用户界面更具响应性。

引言

上一篇文章中,Paulo Zemek 描述了 .NET 4.5 中引入的 async/await 模型的一些限制。受此启发,我创建了自己的辅助类 Coworker,通过在算法或单个方法调用可能长时间运行时插入异步运行的代码块调用,轻松保持用户界面 (UI) 的响应性。该解决方案使得在必须与用户界面线程同步运行的代码和最好在后台异步运行的处理之间切换变得非常容易。这是 await/async 解决方案的替代方案,适用于 .NET 3.5 版本的 WinForms 和 WPF 应用程序。

背景

没有人喜欢在运行耗时操作时冻结用户界面的应用程序,但编写不冻结的应用程序仍然具有挑战性。下面是一个典型但简化的事件处理程序示例,它将使某些操作可能需要很长时间才能完成

private btnStart2_Click(object sender, RoutedEventArgs e)
{
   lboMessages.Items.Add("Operation started");

   for( i = 0; i < 10; i++) 
   {
      int result = DoLongOperation(i);

      lboMessages.Items.Add("Completed " + (i + 1) * 100 / 10 "%");

   }
}

DoLongOperation 可能代表高级数学算法,或者更常见的是 I/O 或 Web 服务调用,至少偶尔会花费大量时间执行。这种非常常见的方法的基本问题是所有工作都在用户界面线程中执行,导致整个应用程序窗口在处理过程中冻结。此外,在方法结束之前,所有进度消息都不会显示。

如何解决这个问题?.NET Framework 提供了多种选项,包括 BackgroundWorker、委托上的 BeginInvokeThreadPoolTask,但所有这些解决方案都需要添加大量代码才能使所有调用都在正确的线程上。这些额外的管道代码使得代码更难阅读和理解,并且引入缺陷的风险很高。

Microsoft 也认识到这是一个问题,因此在 .NET 4.5 和 5.0 中引入了新的 asyncawait 关键字来缓解这种情况。但是,要使用这些关键字,您必须破坏所涉及方法的接口。对于返回值也有严格的规则(只允许 voidTaskTask<T>)。在这个简化的例子中,代码将是

private async void btnStart2_Click(object sender, RoutedEventArgs e) 
{ 
   lboMessages.Items.Add("Operation started"); 

   for (int i = 0; i < 10; i++)
   { 
      int result = await DoLongOperationAsync(i); 

      lboMessages.Items.Add("Completed " + (i + 1) * 100 / 10 + "%"); 
    } 
}

这个例子简单得具有欺骗性,就像大多数已发布的 async/await 示例一样,它隐藏了原始的 DoLongOperation 已更改为 DoLongOperationAsync 的事实,而后者需要修改以创建并返回一个 Task<int>。在实际应用程序中,对 DoLongOperation 的调用可能在调用堆栈的更深层(例如,在数据访问层)。在那里引入 await 需要进行多次破坏性更改,并对调用接口施加限制。使用下面介绍的解决方案,可以在调用链中的任何位置轻松引入异步块,而无需破坏任何接口。

Using the Code

引入协作者

为了便于编程实现异步后台处理,我创建了一个辅助 Coworker 类。启用异步处理的第一步是将工作委托给 Coworker,如下所示

private void btnStart2_Click(object sender, RoutedEventArgs e) 
{ 
   Coworker.SyncBlock(() => { 

      lboMessages.Items.Add("Operation started"); 

      for (int i = 0; i < 10; i++) 
      { 
         int result = DoLongOperation(i); 

         lboMessages.Items.Add("Completed " + (i + 1) * 100 / 10 + "%"); 
       } 
   } 
}

Coworker 将“同步”运行上述所有代码,即在整个过程中仍然阻塞调用 UI 线程。然而,我们只是使代码复杂化,并没有获得任何东西。但是,现在使用“异步块”可以非常容易地指定要异步运行的部分,即不阻塞 UI 线程,如下所示

private void btnStart2_Click(object sender, RoutedEventArgs e) 
{ 
    Coworker.SyncBlock(() => { 

       lboMessages.Items.Add("Operation started"); 

       for (int i = 0; i < 10; i++) 
       { 
          using( Coworker.AsyncBlock() ) 
          { 
             int result = DoLongOperation(i); 
          } 

          lboMessages.Items.Add("Completed " + (i + 1) * 100 / 10 + "%"); 
      } 
   } 
}

调用 Coworker.AsyncBlock 将释放用户界面线程,使其服务其他请求,从而有效地使块内的调用异步。当 using 块最终结束时,用户界面线程再次被 Coworker 捕获,允许修改用户界面元素而不会出现跨线程调用问题。您可以自由选择哪些代码语句应以非阻塞模式运行,并像往常一样使用变量在阻塞和非阻塞部分之间传递信息。如果 DoLongOperation 有一个 out 参数也不会有问题。

暂时切换回同步模式

如果需要在 AsyncBlock 中执行(可能)需要修改用户界面的代码,可以使用 Coworker.SyncBlock() 暂时切换回同步模式。这可以在调用链中的任何位置执行,例如 D<code>oLongOperation 操作中的以下代码

private int DoLongOperation(int i) 
{ 
   Thread.Sleep(500); 

   if( i > 8 ) 
   { 
      using( Coworker.SyncBlock() ) 
      { 
         lboMessages.Items.Add(("Value is greater than 8!"); 

      } 

   } 
   return i; 
}

使用 SyncBlock 是方法告知“嘿,我有一些代码必须更新用户界面”的方式。

Windows Presentation Foundation ICommand 支持

以上所有示例都使用事件处理程序作为入口点。如果您在 WPF 中使用 ICommand 数据绑定,您可以将代码嵌入到 Coworker.SyncBlock 中,如附带的演示代码中的 SyncBlockCommand 所示。通过这种方式,您可以轻松地禁用异步命令,只要它正在运行。

与 .NET 5 async/await 的比较

首先,Coworker 的实现既不需要任何新的编程语言关键字,也不需要最新的 .NET Framework (5.0)。此代码使用现有的 .NET 3.5 和 4.0 框架成功运行。此外,要将同步代码转换为异步响应版本,无需更改接口:无需将返回类型更改为 Task<T>,也无需将要异步运行的代码重构为单独的方法,并按照命名约定添加 Async 后缀。您仍然可以使用带有 outref 参数的方法,而且与 await 不同,您还可以在 catchfinally 语句中使用 AsyncBlock。这意味着您可以自由地将 Coworker.AsyncBlockSyncBlock 应用到调用链中的任何位置,例如在视图、视图模型或数据访问层中,而无需更改调用接口。但是,您仍然必须考虑异步块中对相同共享资源进行并发访问的可能性,但对于 await 方法也是如此。

实现细节

这怎么可能?

Coworker 实际上在单独的线程中运行所有代码(当前从 .NET ThreadPool 获取),但在同步部分阻塞用户界面代码,使其在此期间安全地访问 UI 资源。相比之下,当使用 asyncawait 关键字时,执行在 UI 线程和后台线程之间切换。为了实现后者,编译器必须生成大量代码,以允许执行状态(包括所有局部变量)在线程之间来回传输。

为了使事情复杂化,Windows Forms 和 WPF 都执行检查以验证 UI 资源是否仅从 UI 线程访问。为了使 Coworker 工作,我们必须绕过这些检查。

在 WinForms 中,跨线程检查仅在调试模式下执行。为了避免在此模式下出现 InvalidOperationExceptions,我们必须禁用这些检查。只需在某些初始化代码中包含以下行即可完成

Control.CheckForIllegalCrossThreadCalls = false; 

在 WPF 中,事情稍微复杂一些,因为用户界面中所有继承自 DispatcherObject 的对象都绑定到创建它们的 Dispatcher 线程。为了规避用户界面对象所做的检查,Coworker 在阻塞调用期间暂时更改调用 Dispatcher 的线程绑定,使用此“hack”通过反射访问私有字段

private static readonly FieldInfo dispatcherThreadField = 
  typeof(Dispatcher).GetField("_dispatcherThread", BindingFlags.NonPublic | BindingFlags.Instance); 
private object oldDispatcherThread; 
private void SetDispatcher() 
{ 
   if (dispatcher != null) 
   { 
      oldDispatcherThread = dispatcherThreadField.GetValue(dispatcher); 
      dispatcherThreadField.SetValue(dispatcher, Thread.CurrentThread); 
   } 
}

由于这个 hack,它不能在部分信任的应用程序中使用。还存在 Microsoft 在未来版本的 .NET 中更改内部实现的风险。

结论

本文演示了一种在不使代码复杂化且不使用新的 asyncawait 关键字的情况下保持用户界面响应的方法。通过这种方法,可以在现有应用程序中提高响应性,而无需对代码进行太多更改。

免责声明

这只是一篇概念验证文章。在将其用于生产代码之前,我建议更多地考虑使用“hack”来访问隐藏的 private Dispatcher 字段以规避 WPF 线程检查以及由于代码实际上未在 UI 线程上运行而引起的其他问题。需要运行在单线程单元 (STA) 中的代码仍然必须委托给 UI 线程本身。需要进行更多测试。

© . All rights reserved.