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

Async/Await:无需额外代码即可解除 GUI 阻塞

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (16投票s)

2015 年 9 月 12 日

CPOL

10分钟阅读

viewsIcon

24703

downloadIcon

575

在进行了简单的介绍后,文章转向了鲜为人知但有用/重要/有趣的关于使用 Async/Await 解除 GUI 阻塞的方面。

背景

首先我得说:这篇文章的标题有点耸人听闻。但实际上,我在任何地方都找不到关于 Async/Await 内核用法多么令人难以置信的简单性的指导。

目录

兑现承诺

假设正在进行一项耗时的数据加载,例如:

Private Function GetData(count As Integer) As List(Of Point)
   Dim result = New List(Of Point)
   Dim rnd = New Random(42)
   For i = 0 To count - 1
      result.Add(New Point(rnd.Next(-100, 100), rnd.Next(-100, 100)))
      System.Threading.Thread.Sleep(5)  ' blocks for 5 milliseconds
   Next
   Return result
End Function

假设有一个按钮来加载并显示它。

Private Sub btLoadData_Click(sender As Object, e As EventArgs) Handles btLoadData.Click
   DataGridView1.DataSource = GetData(999)
End Sub

当然,这会阻塞 GUI,如果你仔细看,你会发现:总共大约需要 5 秒钟。

现在,正如所说,无需额外代码即可解除阻塞。

Private Async Sub btLoadData_Click(sender As Object, e As EventArgs) Handles btLoadData.Click
   DataGridView1.DataSource = Await Task.Run(Function() GetData(999))
End Sub

信不信由你:就这样完成了 :-D

仔细看看 - 细节上有什么变化?

  • 该 Sub 被 Async 修饰符关键字修改。这是必需的,否则无法应用 Await
  • 调用 GetData(999) 被封装在一个匿名函数中。
  • 匿名函数本身被传递给 Task.Run() 方法,该方法是泛型的,并且是重载的,因此它可以接受任何类型的委托,如 Func(Of T)Action
    Func(Of T) 是一个委托,它“指向”一个没有参数但有返回值的函数——这里就是这种情况——表达式 Function() GetData(999) 没有参数,但返回 GetData() 返回的内容:一个 List(Of Point)
  • 最后但同样重要的是,调用 Task.Run() 被标记为 Await,这便是魔法所在。

请记住这个配方: 1) 将外部 Sub 标记为 Async2) 将内部工作部分封装在一个匿名函数中,3) 将该函数传递给 Await Task.Run() - 请注意:您可以直接使用返回值 - 就像在阻塞模式下一样。

在任务运行时更改 GUI

当然,上述内容在很多方面都不够。

第一个问题是——由于 GUI 现在已解除阻塞——您必须防止用户在任务仍在运行时重复单击按钮。
我喜欢这个,因为它同样简单。

Private Async Sub btLoadData_Click(sender As Object, e As EventArgs) Handles btLoadData.Click
   btLoadData.Enabled = False
   DataGridView1.DataSource = Await Task.Run(Function() GetData(999))
   btLoadData.Enabled = True
End Sub

题外话 - 尝试理解这个奇迹

如果你不加思考地看上面的代码,你可能会认为它是这样的:1) 禁用按钮,2) 执行任务,3) 重新启用按钮。

但等等——为什么它不会阻塞 GUI?如前所述——GetData(999) 需要 5 秒钟!
好吧——它确实是并行执行的,但如果是这样——为什么按钮会保持禁用状态 5 秒钟?如果 GetData() 并行运行,按钮应该立即重新启用!

秘密在于:在 Await 点,方法终止并返回到调用者。这就是 GUI 保持响应的方式。
当并行进程完成时,它会跳回方法,并在等待点继续,仿佛什么都没发生过。
编译器如何处理“Task.Continuation”、“Task.Completion”等内容仍然是一个奇迹——抱歉:我不知道细节,但对我们来说看似一致的过程:实际上是一种非常棘手的“语法糖”
,隐藏了完全不同的架构。

进度报告

但回到具体问题:接下来,用户肯定希望得到反馈,以让他觉得应用程序不是在开玩笑,而是确实在忙碌。这时,第一个 Async 助手 Progress(Of T) 类就派上用场了。

您可以实例化一个,线程可以向其传递任意的进度报告数据,然后在主线程中,它会引发一个事件,您可以在其中执行进度报告——例如增加进度条。

这需要一些改变:我希望进度条只在需要时出现,并且显示进度的先决条件是 GetData() 报告它们。

Private WithEvents _ProgressPercent As New Progress(Of Integer)

Private Sub Progress_ProgressChanged(sender As Object, e As Integer) Handles _ProgressPercent.ProgressChanged
   ProgressBar1.Value = e
End Sub

Private Async Sub btLoadData_Click(sender As Object, e As EventArgs) Handles btLoadData.Click
   btLoadData.Enabled = False
   ProgressBar1.Visible = True
   DataGridView1.DataSource = Await Task.Run(Function() GetData(999))
   ProgressBar1.Visible = False
   btLoadData.Enabled = True
End Sub

Private Function GetData(count As Integer) As List(Of Point)
   Dim result = New List(Of Point)
   Dim rnd = New Random(42)
   Dim prgs As IProgress(Of Integer) = _ProgressPercent
   For i = 0 To count - 1
      result.Add(New Point(rnd.Next(-100, 100), rnd.Next(-100, 100)))
      System.Threading.Thread.Sleep(5)
      prgs.Report(i \ 10)
   Next
   Return result
End Function

这还不是火箭科学,是吗?

但有一点

System.Threading.Thread.Sleep(5)
prgs.Report(i \ 10)

您确定要每 5 毫秒更新一次进度条吗?(我告诉您:您不想要——没有人能看得那么快!)。过于频繁的 GUI 更新是在浪费 CPU 资源——没有理由这样做。
为此,我发明了 IntervalProgress(Of T) 类,它只是忽略了发送过于频繁的报告。
它的用法与上面所示相同,但它不像那样浪费 CPU 资源。
为了简洁起见,我在这里不展示它的代码——如果您愿意,可以参考附加的源代码。

取消

接下来我们需要的是 CancelationTokenCancelationTokenSource。后者提供前者,它们共同构成了一个请求取消的机制。

Private WithEvents _ProgressPercent As New IntervalProgress(Of Integer)
Private _Cts As CancellationTokenSource

Private Function GetData(count As Integer, ct As CancellationToken) As List(Of Point)
   Dim result = New List(Of Point)
   Dim rnd = New Random(42)
   For i = 0 To count - 1
      ct.ThrowIfCancellationRequested()
      result.Add(New Point(rnd.Next(-100, 100), rnd.Next(-100, 100)))
      System.Threading.Thread.Sleep(5)
      _ProgressPercent.Report(i \ 10)
   Next
   Return result
End Function

Private Sub Any_Click(sender As Object, e As EventArgs) Handles btClear.Click, btCancel.Click, btLoadData.Click
   Select Case True
      Case sender Is btCancel : _Cts.Cancel()
      Case sender Is btClear : DataGridView1.DataSource = Nothing
      Case sender Is btLoadData : LaunchGetData()
   End Select
End Sub

Private Async Sub LaunchGetData()
   btLoadData.Enabled = False
   _Cts = New CancellationTokenSource
   Try
      DataGridView1.DataSource = Await Task.Run(Function() GetData(1010, _Cts.Token))
   Catch ex As OperationCanceledException
      Msg("cancelled")
   End Try
   _Cts.Dispose()
   btLoadData.Enabled = True
End Sub

我将获取数据移到了一个单独的 Sub 中:LaunchGetData(),因为它变得有点太复杂,无法保留在我的 Any_Click() 按钮点击处理程序中。

然后先看 GetData() ——它现在期望一个 CancellationToken,该令牌在循环中使用:ct.ThrowIfCancellationRequested() ——一个很长的词,但顾名思义:它做了它所说的事情。

LaunchGetData() 中捕获其 OperationCanceledException 可以检测进程是否因取消而完成。
一个设计有点奇怪的地方是,CancelationTokenSource 只能使用一次——没关系:微软®并非万能——不是吗?;-)

异常处理 - 惊喜!

原则上,异常处理已经通过上面的 TryCatch 覆盖,所以我只需要安装一个小小的“CausesError”机制来查看它的工作情况。

Private WithEvents _ProgressPercent As New IntervalProgress(Of Integer)
Private _CauseError As Boolean
Private _Cts As CancellationTokenSource

Private Function GetData(count As Integer, ct As CancellationToken) As List(Of Point)
   Dim result = New List(Of Point)
   Dim rnd = New Random(42)
   For i = 0 To count - 1
      ct.ThrowIfCancellationRequested()
      If _CauseError Then Throw New ArgumentException("lets be generous and make an exception")
      result.Add(New Point(rnd.Next(-100, 100), rnd.Next(-100, 100)))
      System.Threading.Thread.Sleep(5)
      _ProgressPercent.Report(i \ 10)
   Next
   Return result
End Function

Private Sub Any_Click(sender As Object, e As EventArgs) Handles btClear.Click, btCauseException.Click, btCancel.Click, btLoadData.Click
   Select Case True
      Case sender Is btCancel : _Cts.Cancel()
      Case sender Is btClear : DataGridView1.DataSource = Nothing
      Case sender Is btCauseException : _CauseError = True
      Case sender Is btLoadData : LaunchGetData()
   End Select
End Sub

Private Async Sub LaunchGetData()
   _CauseError = False
   btLoadData.Enabled = False
   _Cts = New CancellationTokenSource
   Try
      DataGridView1.DataSource = Await Task.Run(Function() GetData(1010, _Cts.Token))
   Catch ex As OperationCanceledException
      Msg("cancelled")
   Catch ex As Exception
      Msg("a real problem! - ", ex.GetType.Name, Lf, Lf, ex.Message)
   End Try
   _Cts.Dispose()
   btLoadData.Enabled = True
End Sub

如果你仔细看,你会找到按钮(第 22 行)、布尔值(第 2 行)、我故意抛出的异常(第 10 行)和捕获(第 35 行)。

LaunchGetData() 的额外 Catch 段处理其他异常,而不影响取消逻辑。
只有一点小小的遗憾:它不起作用。

什么?

是的,我也难以置信,但事实是,从辅助线程中友好抛出的异常,在主线程中没有得到处理。

真的无法相信这一点,因为在另一个项目中,我使用了 HttpClient.GetAsync() 方法,并且在那里异常处理工作得非常好,模式与这里所示的完全相同。

显然 HttpClient.GetAsync() 做的事情与 Task.Run() 不同——而且(感谢 Freddy,我的反汇编器)——我学会了实现一个真正的异步方法,而不是将其委托给 Task.Run()

Private Function GetDataAsync(count As Integer, ct As CancellationToken) As Task(Of List(Of Point))
   Dim tcs = New TaskCompletionSource(Of List(Of Point))()
   Task.Run(Sub()
               Dim result = New List(Of Point)
               Dim rnd = New Random(42)
               Try
                  For i = 0 To count - 1
                     ct.ThrowIfCancellationRequested()
                     If _CauseError Then Throw New ArgumentException("lets be generous and make an exception")
                     result.Add(New Point(rnd.Next(-100, 100), rnd.Next(-100, 100)))
                     System.Threading.Thread.Sleep(5)
                     _ProgressPercent.Report(i \ 10)
                  Next
               Catch ex As Exception
                  tcs.SetException(ex) 'pass any exception to the Completion
                  Exit Sub
               End Try
               tcs.SetResult(result) 'pass result to the Completion
            End Sub)
   Return tcs.Task
End Function

主要的事情是 TaskCompletionSource,并且它不抛出异常或返回结果,而是必须将异常或结果设置为它。
如下调用 GetDataAsync()
DataGridView1.DataSource = Await GetDataAsync(1010, _Cts.Token)

是的——运行正常!

嗯——说真的——这就是如今令人惊叹的 Async 模式提供的:“简单的多线程”?... 避免说脏话……让我温和地表达一下:“它并没有我们预期的那么简单——是吗?”

当然,我也尝试摆脱它——如果你仔细看上面的代码,所有的类型操作都涉及 List(Of Point)——也许有一种方法可以使其泛化,并将其封装在一个我们再也不必看到它的地方?
是的——来了。

Public Class AsyncHelper

   Public Shared Function Run(Of T)(func As Func(Of T)) As Task(Of T)
      Dim tcs = New TaskCompletionSource(Of T)()
      Task.Run(Sub()
                  Try ' long lasting, in parallel...
                     tcs.SetResult(func()) 'pass the result to the Completion, or...
                  Catch ex As Exception
                     tcs.SetException(ex) '...on arbitrary exception pass it to the Completion
                  End Try
               End Sub)
      Return tcs.Task ' but return the Completion immediately
   End Function

End Class

现在将 Task.Run() 替换为 AsyncHelper.Run()

Private Async Sub LaunchGetData()
   _CauseError = False
   btLoadData.Enabled = False
   _Cts = New CancellationTokenSource
   Try
      DataGridView1.DataSource = Await AsyncHelper.Run(Function() GetData(1010, _Cts.Token))
   Catch ex As OperationCanceledException
      Msg("cancelled")
   Catch ex As Exception
      Msg("a real problem! - ", ex.GetType.Name, Lf, Lf, ex.Message)
   End Try
   _Cts.Dispose()
   btLoadData.Enabled = True
End Sub

它按预期工作,而 Task.Run() 的那个则不起作用。

这是一个 Bug - 不是一个特性!

与此同时,我得到了一个关于 Task.Run() 表现出如此令人难以置信的意外行为的原因:这是 Task 类或 VisualStudio 2013 的一个 bug。
因为当编译后的 Exe 在 VisualStudio 外部启动时,Task.Run() 按预期工作。
您可以使用附加的源代码轻松地检查这一点。

现在我有一个请求:也许您可以将此 bug 报告给微软?或者——如果 bug 报告已存在——稍微给它点个赞?
当我尝试自己这样做时,我得到有意义的回复:

引用

您无权为此连接提交反馈。

……第一部分结束……

(现在是完全不同的内容……)

使用 Async/Await 可视化算法

Async/Await 为可视化算法开辟了一条新途径。

在 Async/Await 之前,人们要么使用有问题的 Application.DoEvents() 来防止 GUI 完全阻塞,要么不得不将算法转化为一种由计时器驱动的“状态机”,该状态机在每次滴答时向前推进。
这些状态机仍然是最经济的资源利用方式,但转换后的算法——即使是最简单的 Foreach 循环——看起来也完全不同,并且不再能被识别为原始算法。

但现在我们可以保留算法不变,只插入一行:Await Task.Delay(100) ——我们的算法将在此期间暂停,而 GUI 保持完全响应,我们可以看到可视化效果。

例如,我创建了一个 floodfill 算法,有两种变体,一种使用堆栈,另一种使用队列——看一些代码。

Private Async Sub FloodFillQueue(grid As DataGridView, start As DataGridViewCell)
   Dim uBound = New Point(grid.ColumnCount - 1, grid.RowCount - 1)
   Dim validColor = CInt(start.Value)
   Dim newColor = If(validColor = _Colors(0), _Colors(1), _Colors(0))
   Dim queue = New Queue(Of Point)
   queue.Enqueue(New Point(start.ColumnIndex, start.RowIndex))
   While queue.Count > 0
      If _IsStopped Then Return
      Dim pos = queue.Dequeue
      If Not (pos.X.IsBetween(0, uBound.X) AndAlso pos.Y.IsBetween(0, uBound.Y)) Then Continue While
      If CInt(grid(pos.X, pos.Y).Value) <> validColor Then Continue While
      Await Wait()
      If grid.IsDisposed Then Return
      grid(pos.X, pos.Y).Value = newColor
      For Each offset In _NeighborOffsets
         queue.Enqueue(pos + offset)
      Next
   End While
End Sub

我认为这个算法并不那么复杂:在 While 循环中,每次从队列中取出一个点作为当前位置。然后检查它是否有效,如果有效,则设置新颜色并入队其所有邻居。
位置可能无效,这取决于它的 X/Y 值,或者(颜色)值的数据网格在该位置——有效性是开始时起始单元格的颜色。
对我们来说最重要的是调用:Await Wait(),因为我创建了一个 Task.Delay() 的替代方案——看看。

Private _Blocker As New AutoResetEvent(False)

Private Function Wait() As Task
   If ckAutoRun.Checked Then Return Task.Delay(50)
   Return task.Run(Sub() _Blocker.WaitOne())
End Function

Private Sub btStep_Click(sender As Object, e As EventArgs) Handles btStep.Click
   _Blocker.Set()
End Sub

您可以看到:有两种“等待模式”,第一种——“AutoRun”——是按预期实现的,通过 Task.Delay(50)

第二种模式——称之为“Stepwise”——是通过启动一个 Task 来完成的,该 Task 所做的就是被 AutoResetEvent 阻塞,直到 btStep 信号解除阻塞——然后立即运行。等待这个“愚蠢”的 Task 也会导致 GUI 解锁延迟,但现在是用户定义的持续时间,而不是预定义的延迟时间。

关于 FloodFill 的题外话

偶然间我发现了一个引人入胜的行为:由于 Async/Await 保持了我的 GUI 响应,我可以启动多个 FloodFill 执行!
而且,我有两种不同的算法,有不同的偏好,哪个单元格将是下一个进入的。
所以,当我启动一个 FloodFill,将绿色单元格变为红色,然后同时启动另一个,将红色变为绿色,它们会“互相吞噬”,结果是一种混乱的动画,类似于生命游戏等等。

我强烈建议您尝试一下 :-)

从理性角度看,堆栈 floodfill 和队列 floodfill 的比较非常清楚地表明:当使用队列时,会发生真正的洪水(如流体——朝各个方向)。堆栈 floodfill 更像一个路径查找器或一个试图朝特定方向逃脱的“乌龟进程”。
此外,堆栈版本占用的内存要多得多,因为在其“生命中期”它压入的项目比弹出的要多。另一方面,队列 floodfill 创建了一个已访问位置的封闭区域,内部区域由已出队的 D位置组成。
(希望您明白我的意思——实际上,这对主主题来说并不那么重要 ;-) )

总结

本文涵盖了(不,不是“涵盖”——更好地说,是“触及”)多线程的一个用途:即在长时间运行的操作进行时保持 GUI 响应。我们看到了几个需求立即出现,因此可以将“响应式 GUI”视为五个挑战的集合:1) 响应性,2) 限制 GUI 并事后释放它,3) 进度报告,4) 取消,5) 异常处理。
而取消和异常处理是Task.Run() Bug 的一个令人不快的意外,需要 AsyncHelper.Run() 才能解决,直到微软修复 Bug。
Async/Await 对我来说仍然有点神奇,而且我对此不太确定。例如,在循环中使用 Await——每次都会创建一个和销毁一个 Task 吗?还是会占用和释放?它的性能如何,资源效率如何?

最后但并非最不重要的一点:也许您会想参考 Paolo Zemek 的文章“Async/Await Could Be Better”——他的文章比我的更深入地探讨了性能和资源问题。

© . All rights reserved.