为什么要使用异步调用(Asynchronous Method Execution)
按照我们常规的思维方式,计算机应该是干完一件事,然后再干下一件。用术语来说,这种执行任务的方式叫做同步执行(Synchronous Execution)。既然这样,那么为什么要引入异步执行的概念呢?原因很简单,因为同步执行在有些情况下效果不理想,不能完成我们预期的目的。举两个简单的例子来说明一下这个问题。
a. 一个客户端程序(Client Side Program)要从后台数据库取回一个复杂的数据集合。可能这个数据库操作本身很费时,也可能是网络传输的数度比较慢,总之这个方法调用可能要花20秒时间。如果使用同步调用,那么在数据库结果返回之前,用户必须耐心等待,什么也不能做。这时候你可能会希望这个调用慢慢的在别处进行,程序马上返回好让你做其它的工作。等什么时候数据返回了,在进行其随后相应的操作。这种情形下,你就需要对数据库操作的方法进行异步调用。
b.一个网上机票查询订阅程序。当客户要查询从北京到芝加哥的所有机票的时候,这个程序可能要在后台通过Web Service对美国西北航空公司,中国国际航空公司和东方航空公司进行访问。将这些公司的机票情况汇总后一起以HTML的形式返回给用户。如果是同步调用,那么需要一个接一个进行Web Service调用。如果每个调用花费10秒钟的话,那么整个过程就要30秒钟。如果你使用异步调用,那么你可以在同几乎一时间就对三个公司发出相应的请求,10秒后当结果从三个不同的网站返回来后,你就可以汇总并返回各用户了。这样,整个过程只需要10秒左右。
看到这里你可能会说,这个问题没什么新鲜的。我在C++,Java里都可以用线程(Thread)来达到这样的效果。的确,大多数的高级语言都允许你创建新的线程来手工实现这样的调用。但是这些手工操作比较复杂,程序员需要自己控制线程的创建,销毁,协调等等许多细节工作,容易产生错误。并且在大型的服务器端的程序中,手工控制线程有时性能不够优化,不能根据当前具体服务器的处理器情况来动态的和智能的优化线程的数量。基于这个原因,.NET创建了一种相对简单的异步方法调用机制,使这一问题变得更加简单。这就是今天要谈的使用代表(Delegates)对方法进行异步调用。(本文以VB.NET来进行示范,C#的异步调用和此类似,就不再给出例程了)
实现异步调用的步骤和机理
假设有这样一个方法(Method),它接受一个班级的名称,然后查询数据库,返回这个班级所有同学的名单。
Class DemoClass public shared Function GetStudentsList(ClassName as String) as String() '查询数据库 '其它操作 End function End Class |
如果对这样一个方法进行异步调用的话,那么你首先需要定义一个有同样方法签名(Function Signature)的代表(Delegate),比如
Delegate Function GetStudentListDelegate (ClassName as String) as String() |
下一步,你需要生成一个代表实例(Instance),然后将这个代表和你的真正的方法“捆绑”起来,如
Dim delegate as GetStudentListDelegate GetStudentListDelegate = AddressOf DemoClass.GetStudentsList |
(为了简单起见,这里使用了静态方法,这其实不是必须的)
当你做到这步的时候,.NET的编译器在后台为你的代表增加了几个方法,它们是Invoke, BeginInvoke, EndInvoke.
如果你使用Invoke方法,那么其效果是同步调用,比如
delegate.Invoke("class90") |
在这种情况下,代表将输入参数"class90"传递给方法GetStudentsList,然后将这个方法的返回值返回给用户。这种使用方法是同步的,不是我们所期待的。如果要达到异步效果,我们要使用BeginInvoke和EndInvoke。
让我们先看看BeginInvoke
你的使用方法可能如下所示:
Dim ar as System. IAsyncResult ar = delegate.BeginInvoke("class90",Nothing, Nothing) |
你可能会发现,这种调用方法有些不同。首先是多出两个输入参数,其次是返回值是System. IAsyncResult。这到底是怎么一回事呢?
当你调用BeginInvoke的时候,一系列的事情在后台自动发生了。
当你用代表发出调用请求后,CLR(公共语言运行环境,Common Language Runtime)接到这个请求,并将这个请求放置到一个内部的处理队列(Queue)中去。一旦放置完成后,CLR马上就给调用者返回一个IAsyncResult的对象。这个对象很重要,我们一会儿还要解释他的具体作用。
当调用者收到返回的IAsyncResult对象后,它就可以进行下一步的工作。由于将请求放置到队列中是个非常快速的操作,所以调用者马上就可以去完成下一个动作,没有被“阻挡(Block)”。
CLR在后台维持着一个“线程池(Thread Pool)”。这些线程守候着前面提到的那个处理队列。一旦有任务被放置到队列中,一个线程就会拿到这个任务并执行它。也就是说原来要调用者线程执行的费时的操作被线程池中的一个线程代劳了。(这里你可以看出,不管是用什么样的语言,在异步调用中,一定有其它的线程出现。或者是你手工创建它(如Java),或者是系统为你创建(如.NET)。那么这个“线程池”中究竟有几个线程呢?这个问题你可以不用关心。CLR会根据程序的特点以及当前的硬件条件自行决定。比如对于运行在单处理器平台上的一般的桌面程序,这个线程池可能有几个线程;而对于一个运行在4处理器服务器上的后台应用,线程池可能会有近百个线程。这样做的好处就是降低程序员的开发难度,让.NET的CLR去解决这些和用户应用逻辑无关的问题。)
既然有线程池的线程代替完成了那个方法调用(GetStudentsList),那么我们怎么知道后台的这个调用什么时候完成呢?这个方法调用返回的值(这里是一串学生名单)我们怎么拿到呢?这里我们就要用到前面提到的那个返回的IASyncResult对象了。
这个IASyncResult对象一个“收据”似的,通过它你可以查询后台调用是否完成。如果已经完成,你可以通过它来取回你想要的结果。
Dim ar as System.IASyncResult ar = delegate.BeginInvoke("class90",Nothing, Nothing) '*** 其它一些操作 。。。 '*** 检查后台调用状态 If (ar.IsCompleted) Then '*** 取回异步调用方法的结果 End If |
如果后台调用已经结束,那么你就可以用代表的EndInvoke来得到返回值。
Dim Students as String() Students = delegate.EndInvoke(ar) |
那么,如果你没有测试后台调用是否结束而直接使用EndInvoke,那后果会怎么样呢?如果后台调用没有完成,EndInvoke调用就会被“阻挡”,直到后台调用完成后才返回。如果后台调用出现异常,那么EndInvoke还可以捕捉到这个异常
Dim Students as String() Try Students = delegate.EndInvoke(ar) Catch ex as Exception '处理这个异常 End Try |
既然EndInvoke调用就会被“阻挡”(如果后台异步调用还没有完成),那么下面这种标较复杂情况CLR是怎样处理的呢?
Dim ar1, ar2 as System.IASyncResult Dim rt1, rt2 as String() ar1 = delegate1.BeginInvoke("class90",Nothing, Nothing) ar2 = delegate2.BeginInvoke("class94",Nothing, Nothing) rt1 = delegate1.EndInvoke(ar1) rt2 = delegate2.EndInvoke(ar2) |
在这个例子中,delegate1的调用和delegate2的调用完成顺序可能会有多种情况。比如delegate2的调用后发先至,那么EndInvoke的使用顺序是不是很重要呢?事实上,你可以忽略这个问题,CLR会保证在两个异步调用都结束后,你才可以进行下面的操作。至于它是怎么实现的,你可以不去管它。
事实上,EndInvoke是非常重要的。如果你使用了BeginInvoke,那你最好使用EndInvoke。因为你如果不使用EndInvoke,那么后台调用的异常就没有机会被捕捉到。另外,使用了EndInvoke可以让CLR释放异步调用中所使用的资源,否则你的应用程序就可能出现资源泄漏(Resource Leak)。
到这里,情况已经比较清楚了。使用Delegate可以让后台线程代替当前线程去完成费时的操作,从而使当前线程不被“阻挡”,可以马上进行其它的工作。但是,如果当前线程通过EndInvoke来得到异步调用的结果,它又很可能被“阻挡”。看起来有点“拆了东墙补西墙”的样子,好像我们没有得到什么好处。打个比方来说吧,你要到复印室去复印一批材料,这个工作要费时一个多小时。同步调用就意味着你自己亲自去复印,一个多小时候再返回办公室作其它工作。异步调用意味着你可以把复印材料交到复印室,那里有专人负责复印。你放下材料后就可以回到办公室去干其它工作了。但问题是,你要不停的查看材料是否复印好了,一旦发现复印完毕后,就马上取回作相应的操作。你不停的查看(调用代表的IsComplete方法)或者是“干等”(调用代表的EndInvoke方法)实际上还是把你“捆住”了,你没有能腾出手来干其它的事。能不能我把材料放到复印室就不管了,等复印好后他们给我把材料送回来?。答案是可以的,那就是利用回调函数(Callback Function)。
还记得我们前面的那个例子吗,我们用代表调用BeginInvoke的时候,多了两个参数,其中一个就是回调函数,另外一个是执行回调函数的参数。回调函数的意思是在后台线执行完异步调用的方法后,自动去执行的函数(或方法)。在执行这个回调函数的时候,你还可以指定参数。也是就说,你让复印室的复印员完成复印后,把材料给你放回到你的办公桌上,并且每10页一摞。这个“放到办公桌上”就是回调函数,而“每10页一摞”就是回调函数执行时使用的参数。
'回调函数的参数 Dim myValue as Integer = 10 '回调函数的定义 Sub PutToDesk(Byval ar as IAsyncResult) dim x as Integer = CInt(ar.AsyncState)'拿到参数 '相应的操作 End Sub '使用回调函数的方法 Private CallBackDelegate as AsyncCallBack = AddressOf PutToDesk ... Dim ar as System.IASyncResult ar = delegate.BeginInvoke("class90",CallBackDelegate, myValue) |
在使用回调函数时要注意,你的回调函数必须和.NET系统定义的AsyncCallBack一起使用,即你的回调函数必须和AsyncCallBack具有一样的签名。也就是说它必须是子程序(Sub Procedure),必须有一个IAsyncResult类对象为输入参数。
要注意的是回调函数是由后台线程来执行的(就是我们所说的复印员)。这种执行方法在有些情况下会造成不小的问题。比如说,在Windows的桌面应用中有这样一个规则,那就是一切用户界面元素的更改(外观以及属性)必须由这些界面元素的创建线程来进行(术语上叫界面主线程,Primary UI Thread)。如果其它线程试图更新界面元素,那么将会有不可预测的后果。如果你违反了这一原则,那么你的程序在理论上讲是不安全的,即使是问题你一时还没有发现。
就上面一个例子而言,如果后台线程从数据库里拿到了学生名单,那么很可能它要执行的回调函数就是更新界面上的一个下拉式列表(Dropdown List),或是一个表格(Grid)什么的。但是这样做又违反了我们所说的界面更新的线程原则。那么我们该怎么办呢?
其实这个问题并不难解决,设计师在设计.NET的时候已经考虑到了这个问题。具体的解决办法我将在下篇文章中做出介绍。