Advanced Basics 专栏...
轻轻松松做异步
原著:Ken Getz
翻译:小刀人
源代码下载:AdvancedBasics0503.exe (152KB)
原文出处:Advanced Basics:Doing Async the Easy Way
如果你读过 Ted Pattison 在
Basic Instincts 专栏中的一系列关于多线程和异步行为的精彩文章,那么你现在应该已经成为一个处理 Windows® 应用多线程问题的专家了。最近一个朋友——显然他没有读过
Ted 的专栏——发给我一个基于 Windows 的应用程序,这个程序执行了一个的非常冗长的文件查找操作,它在执行时锁定了用户界面,虽然主活动窗口
一直在运行,但用户无法与程序交互,无法移动窗体或改变窗体大小,所以用户感觉很不爽。他想知道要如何做才能使得这个程序有响应。当然,答案就是在后台线程中执行这个操作。
我推荐了 Ted 的系列专栏文章给我的朋友,但是同时我又不能抗拒自己来解决这个问题的欲望。本月专栏的主题既是讨论这个应用程序以及在 Visual
Basic® .NET 2003 和 Visual Basic 2005 中如何用尽可能少的代码来安全地实现后台操作。
单线程冲突
如果你已下载了本专栏附带的例子,在 Visual Studio® .NET 2003 下试着运行 Async2003 例子。在范例窗体上,选择包含多个文件的驱动器,如果你喜欢的话,输入一些除 *.* 以外的参数,将“Search Subfolders”复选框选上,同时点击“Simple Search”按钮。在搜索运行时,试着
与窗体交互,比如移动窗体或改变它的大小。尝试点击“Cancel Search”按钮。我敢保证你无法做这些操作,对不对?因为窗体正在主线程中执行搜索
操作,进行搜索时用户界面在被阻塞。一旦搜索完成,在窗体中的列表框里应该包含所有找到的文件列表,如图 Figure 1 所示。
![](/images/load.gif)
Figure 1 文件搜索应用
(译注:本文所附的项目运行后的用户界面与本文附图的略有不同,但不影响使用,中间一行三个按钮位置在项目中被调整到右边一列。)
简单搜索创建了一个 FileSearch 类的实例(已包括在示例项目中),设置它的一些属性,为其 FileFound事件添加一个事件处理程序,并调用 FileSearch 对象的 Execute 方法:
Dim fs As New FileSearch(Me.cboDrives.Text, Me.txtFileSpec.Text, _
Me.chkSearchSubfolders.Checked)
AddHandler fs.FileFound, AddressOf EventHandler
fs.Execute()
FileSearch.Execute 方法建立内部参数并调用私有 FileSearch.Search 方法,该方法调用本身又递归查找符合文件定义要求的
匹配。在
Figure 2 中,Globals
类的 Globals.Cancelled 属性是一个共享的布尔值(Shared Boolean)——点击“Cancel”按钮(如果你得到一个机会的话)则
将这个值设置为 True。因为在基于 Windows 的应用程序中,窗体是单线程的,从该窗体中调用所有代码都运行在一个单线程中,并防止运行时
用户界面的交互操作。
使用第二线程
第二个按钮标签是“Incorrect Async”,其作用是通过在一个后台线程中运行 FileSearch.Execute 方法
以试图解决线程阻塞问题。代码通过首次创建一个与 FileSearch.Execute 匹配的带署名的委托类型来添加这个行为,以便他能引用 FileSearch.Execute
方法:
Private Delegate Function ExecuteDelegate() As ArrayList替代直接调用 FileSearch.Execute 方法,代码的第二个版本创建了一个 ExecuteDelegate
类型实例,它包含对 Execute 方法的引用,并调用该委托的 BeginInvoke 方法以便在第二线程上运行此代码,如下所示:
Dim ed As New ExecuteDelegate(AddressOf fs.Execute)
AddHandler fs.FileFound, AddressOf EventHandler
ed.BeginInvoke(AddressOf HandleCallback, ed) HandleCallback 过程从传递到这个过程的状态信息中获
取最初的委托实例,并调用 EndInvoke 方法:
'' 异步搜索完成之后,如果你调用 BeginInvoke,那么通常也必须调用 EndInvoke。
Dim ed As ExecuteDelegate = DirectCast(ar.AsyncState, ExecuteDelegate)
Dim al As ArrayList = ed.EndInvoke(ar)
'' 如果你愿意,可以在这里使用 ArrayList。试着再次运行这个例子,点击“Incorrect Async”按钮,在搜索运行的同时你将看到窗体的用户界面保持
着响应。点击“Cancel Search”,现在可以取消搜索操作了,并且你还可以在代码运行时移动窗体或改变窗体大小。另外,窗体包含一个Form.Closing事件的事件
处理程序,确保如果你尝试关闭窗体,代码将首先设置Globals.Cancelled标志。这样就避免了当窗体从内存中被移出后,.NET运行时试图调用窗体的 HandleCallback
过程时可能发生的不愉快。
![](/images/load.gif)
Figure 3 使用后台线程
不幸的是,尽管这个解决方案看起来已解决了这个问题,可它破坏了基于 Windows 的应用程序和多线程编程的
基本准则:“除了创建控件的线程,你将不能将控件的属性与其它线程相结合。”(Form 类是从控件类继承而来的,因此同样的问题适用于引用窗体的属性。)这个基本
指导原则的必然结果是每个过程都运行在调用它的线程中。因为 EventHandler 过程是被运行在第二个线程中的代码调用的,所以它也运行在第二个线程中。EventHandler 过程
以几种方式更新窗体,打破了非常重要的规则。你可以通过检查示例应用程序输出窗口中的内容来证实这个违规。在例子中的调试代码将当前线程的 ID 写入到输出窗口,并且你可以看
到当进行窗体写入时,活动线程与窗体线程是不同的。Figure 3 显示了示例程序运行后的输出。
使用 Invoke 以避免线程冲突
尽管上述所演示的对协议的破坏可能不会在示例程序中显示出任何征兆,但它却可能在你的多个线程争夺单个资源(比如一个控件或窗体自身的属性)时的实际应用中导致问题。Visual
Basic .NET 2003 中公认的解决方案是在事件处理程序(在例子中它总是运行在第二线程里,因为它是在第二线程中被调用的)内部使用窗体的
Invoke方法来完成返回到窗体线程的转换。Invoke 方法负责处理了这个问题。正如文档中所描述的那样,该方法“在拥有控件底层窗口句柄的线程上执行特定委托。”
你可以通过单击窗体上的“Better Async”按钮来运行第三个例子示范此行为。这个版本每发现一个文件便调用名为 EventHandlerAsync的事件处理程序。在这个处理程序中,不再直接调用 UpdateDisplay 方法,而是使用了一个在窗体类中创建的间接调用 UpdateDisplay 方法的委托类型,在该窗体的线程中:
'' 在窗体类中:
Private Delegate Sub UpdateDisplayDelegate( _
ByVal Text As String, ByVal Value As Long)
'' 在 EventHandlerAsync 过程中:
'' UpdateDisplay(e.FileFound.FullName, e.Counter)
Dim atd As New UpdateDisplayDelegate(AddressOf UpdateDisplay)
Me.Invoke(atd, New Object() {e.FileFound.FullName, e.Counter})
为了观察这个更安全的行为,再次运行示例,并观察输出窗口中的调试信息。Figure 4 显示了示例输出,
这证明了经过一定的努力,在不破坏规则的前提下,执行从后台线程中调用的代码是可能的。
![](/images/load.gif)
Figure 4 从后台线程执行代码
注意每次找到文件后要更新 UI,根据我在这里的做法,如果文件频繁被发现的话,它造成了 UI
不停地被更新。还有可能文件被发现得过快,又要不停地更新 UI,以至于无法有效地进行处理。在自己的应用程序中,你可以使用批量更新的方法来克服这个问题。
很明显,关于 Windows Forms 以及异步行为的讨论众说纷纭,要公正地评说这个复杂的主题
为时尚早。我呈现的这个 Microsoft®
NET Framework 1.x 版本下的示例只不过是为将要到来的 .NET Framework 2.0——BackgroundWorker 组件提供
背景。
BackgroundWorker 介绍
BackgroundWorker 组件是一个新的基于事件模式的实现,在 .NET Framework
2.0 的 Web 服务以及 System.Net.WebClient 中也提供这个组件。BackgroundWorker 使得在
Windows Forms
中处理异步操作成为可能,你不用提心吊胆担心有一天会发生交叉线程引发的灾难,不需要创建和调用委托。借助这个新组件提供的事件和属性模型,任何开发人员都可以轻松利用异步特性。
当然,天下没有免费的午餐——使用 BackgroundWorker 组件需要你按照组件要求的方式工作,并迫使你对应用程序进行反思,以适应其
短浅的目光。使用 BackgroundWorker 组件你将绝对不再拥有像创建和托管自己的线程那样的灵活性。另一方面,你也将摆脱复杂、困难和潜在的壶穴,并且最终的源码常常更加清晰。
转换前面的示例以便它利用新的 BackgroundWorker 组件的过程相当简单。在研究了 BackgroundWorker 组件如何运作之后,转换
过程花费了大概 10-15 分钟。它也让我裁减了大块源代码。
我以设计模式打开项目。从 Toolbox(工具箱)窗口的组件(Components)标签上拖动一个 BackgroundWorker 组件实例到窗体。对于这个示例来说,我重新命名了组件
,使得这个组件的 Name 属性为 bgw。如果你在设计视图中查看这个控件,你会在属性窗口中发现两个重要的属性:WorkerReportsProgress
和 WorkerSupportsCancellation。示例通过编程设置这两个属性。如果没有在属性窗体或代码中将这些属性设置为 True,如果用户不在属性窗口或代码中将
这些属性置为 True,那么他们将不能对后台进程进行取消操作,并且应用程序也将不能报告进程的状态。
为了让 BackgroundWorker 组件“起动”,只需调用它的 RunWorkerAsync 方法,如下所示:
Dim fs As New FileSearch(Me.cboDrives.Text, Me.txtFileSpec.Text, _
Me.chkSearchSubfolders.Checked)
'' 在单独的线程忠启动后台进程。
bgw.RunWorkerAsync(fs)RunWorkerAsync 方法通常在窗体的线程里运行,致使该组件从线程池中抓取一个后台线程,
然后触发 BackgroundWorker 的 DoWork 事件处理程序,它运行在此后台线程中。在这个过程里,你在后台线程中添加自己的异步处理代码。DoWork 事件处理程序可能包含如
Figure 5 所示的代码。(这不是在 demo 程序中实际使用的代码,因为
需要修改 Execute 方法以支持取消操作——后面将有更多关于这个问题的讨论。)
因为此过程代码运行在一个与窗体线程不同的线程中,这是强制性的,因为此代码不能与窗体的任何成员及其控件进行交互。
这个工作完成后,BackgroundWorker 引发它的 RunWorkerCompleted 事件,允许你来清场。这个过程和窗体
运行在同一线程中,因此你又可以无忧无虑地访问用户界面元素。你的事件处理程序可能看起来类似如下的代码段(注意,最终代码将被修改以支持取消操作):
Private Sub bgw_RunWorkerCompleted(ByVal sender As Object, _
ByVal e As System.ComponentModel.RunWorkerCompletedEventArgs) _
Handles bgw.RunWorkerCompleted
'' 后台进程已完成. 这个过程和宿主窗体运行在相同的线程中。
Me.lblResults.Text = "Search complete"
End Sub 当然,一旦你找到某个文件,你还是看不到实际做事情的代码。在先前的例子中,
将文件名添加到窗体列表框的代码是在 FileSearch.FileFound 事件处理程序中被调用的。BackgroundWorker 组件允许同样的行为,但是不再直接做这项
工作(如前面的“bad”例子)或创建自己的委托并调用这个窗体的 Invoke 方法来运行它,BackgroundWorker 组件以一种优雅的方式来处理这个线程
转换。BackgroundWorker 组件允许你从后台线程中调用它的 ReportProgress 方法,该方法触发其 ProgressChanged 事件
处理例程返回到窗体的线程中。
你不必使用 delegate/Invoke 方法自己处理这个线程转换,而是调用 ReportProgress,其余的事情交给组件来做。然而有一个重要的警示
:你只有将组件的 WorkerReportsProgress 属性置为 True,组件才会引发ProgressChanged 事件。否则,组件将绝不会调用你的事件处理代码,因此
必须确保设置它的值。
在你调用 ReportProgress 方法时,你提供一个在 0 到 100 之间的整数,它表示后台活动已完成的百分比。你也可能提供任何对象作为第二个参数,允许你
给事件处理程序传递状态信息。作为传递到此过程的 ProgressChangedEventArgs 参数属性,百分比和你自己的对象(如果提供的话)均要被传递到 ProgressChanged 事件
处理程序。这些属性被分别命名为 ProgressPercentage 和 UserState,并且你的事件处理程序可以以任何需要的方式使用它们。示例应用程序从它的 FileSearch.FileFound 事件
处理程序中调用 ReportProgress 方法:
Private Sub EventHandler( _
ByVal sender As Object, ByVal e As FileFoundEventArgs)
'' 某个文件被发现,报告此进程,触发 BackgroundWorker.ProgressChanged 事件:
Dim intPercent As Integer = _
CInt((e.Counter - pbStatus.Minimum) Mod pbStatus.Maximum)
bgw.ReportProgress(intPercent, e)
End Sub 每找到一个文件,示例应用程序便用 ProgressChanged 事件更新显示(请看
Figure 6)。
UpdateDisplay 过程只是获得信息并更新窗体的内容,如下所示:
Public Sub UpdateDisplay( _
ByVal Item As String, ByVal Counter As Long, ByVal Value As Integer)
'' 将该项添加到列表框,更新状态栏并在标签控件中显示找到的文件数。
lstResults.Items.Add(Item)
pbStatus.Value = Value
lblCounter.Text = String.Format("{0} file(s) found", Counter)
Me.Update()
End Sub 正如你所看到的,我们没有多花费什么功夫就获得了异步行为——不需要创建委托类型或调用委托实例。然而,仍然有一个没有解决的问题:你怎样取消搜索
操作?理论上说,这很容易。
取消搜索
只要你将 BackgroundWorker 组件的 WorkerSupportsCancellation 属性
设置为 True,你就可以象下面这样调用 BackgroundWorker 的 CancelAsync 方法来取消异步操作:
Private Sub btnCancel_Click(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles btnCancel.Click
'' 取消异步操作:
bgw.CancelAsync()
End Sub
正像 WorkerReportsProgress 方法,除非你显式地将 WorkerSupportsCancellation 属性设置为 True,
否则你将无法成功地取消这个操作。(这个版本的示例中,Form.Closing 事件处理程序也调用了 CancelAsync 方法,确保在关闭窗体时,如果
异步操作还在运行,则取消它。)
通过调用 CancelAsync 方法,你真正完成的是向组件发信号,告诉它你想要取消所有未决操作。这取决于你停止其工作的代码,它通过检查 BackgroundWorker
组件的 CancellationPending 属性来实现。一旦你发现设置了这个标志,你就可以决定是否实际挂起你的操作。你必须显式地设置传递到 DoWork
事件处理程序的 DoWorkEventArgs
对象的 Cancel 属性,以表示你想要取消操作。一般来说,你只要在 DoWork 事件处理程序里包含如下代码便可以进行取消操作:
If bgw.CancellationPending Then
e.Cancel = True
'' 在离开之前,你可以完成某些清除操作,或就在这里退出:
Exit Sub
End If 然而,在我的示例中,这个简单的解决方案不能工作。文件搜索的所有活动都发生在 FileSearch 类中,并且它是从 BackgroundWorker 的 DoWork 事件处理程序内部通过调用 fs.Execute 而触发的。使用BackgroundWorker 组件的一个缺点就是它很难从用户界面提取工作代码——就是说:为了取消这个操作,你必须
与 BackgroundWorker 组件本身(获取 CancellationPending 属性)以及传递到 DoWork 事件处理程序(设置
Cancel 属性)的 DoWorkEventArgs 对象进行交互。因为 FileSearch 类与用户界面无关,此例子中的代码需要被修正,以使得 FileSearch 类可以“看到”BackgroundWorker 组件和 DoWorkEventArgs 对象。
为了解决这个问题,我修改了 FileSearch.Execute 方法,让其参数为两个必须的对象引用。
此过程在类一级的变量中保存引用,以便在搜索操作期间使用。FileSearch 类增加了两个类一级的变量,如下所示:
'' 保持追踪 BackgroundWorker 和 DoWorkEventArgs,
'' 所以该代码可以取消并报告进程:
Private bgw As System.ComponentModel.BackgroundWorker
Private eventArgs As System.ComponentModel.DoWorkEventArgsFileSearch.Execute 方法获得这些值并将它们存入类变量,如
下所示:
Public Function Execute( _
ByVal backGroundWorker As System.ComponentModel.BackgroundWorker, _
ByVal e As System.ComponentModel.DoWorkEventArgs) As ArrayList
'' 将引用存储到 BackgroundWorker DoWorkEventArgs 对象:
bgw = backGroundWorker
eventArgs = e
'' 搜索匹配的文件.
Search(LookIn)
Return alFiles
End Function最后,FileSearch.Search 方法使用此类一级变量来确定是否应该取消搜索,并指示 BackgroundWorker 组件
,它已退出(参见
Figure 7 所示代码)。
尽管看起来使用 BackgroundWorker 组件着实费了些力气,但实际上却不然。这个特别的范例展示了这个组件的绝大多数特性,但是你没必要在每个应用程序中使用所有
这些特性。有一件事是肯定的:如果你创建的是一个需要异步行为的基于 Windows 的应用程序,并且你不需要显式地掌控后台操作,那么使用 BackgroundWorker 组件可以节省
你的开发时间并把你从创建自己的委托以及对它们的调用中解救出来。除此之外,它实在体现不出有太多的优势。
![](/images/load.gif)
Ken Getz 是 MCW Technologies 的高级顾问。他是ASP.NET Developer''s JumpStart (Addison-Wesley, 2002), Access Developer''s Handbook (Sybex, 2001),和VBA Developer''s Handbook, 2nd Edition (Sybex, 2001)等书的作者之一。 可以通过 keng@mcwtech.com 和他联系。
![](/images/load.gif)