代码改变世界

正确使用异步操作

2008-02-24 22:03 by Jeffrey Zhao, ... 阅读, ... 评论, 收藏, 编辑

本想写一点有关LINQ to SQL异步调用的话题,但是在这之前我想还是先写一篇文章来阐述一下使用异步操作的一些原则,避免有些朋友误用导致程序性能反而降低。这篇文章会讨论一下在.NET中有关异步操作话题,从理论出发结合实际,以澄清概念及避免误用为目标,并且最后提出常见的异步操作场景和使用案例。这样我们就可以知道什么时候该使用异步操作,什么时候会得不偿失。

那么我们先来确认一个概念,那就是“线程”。请注意,如果没有特殊说明,本文中出现的“线程”所指的是CLR线程池(Thread Pool)中的托管线程,它和Windows线程或纤程(fiber)并不是同一个的概念。同样,它也不是指System.Thread类的实例。简单地说,它是由CLR管理的工作执行单元,每当需要执行任务时,CLR就会分配一个这样的执行单元去工作。当所有的线程池内的线程都用完之后就无法执行新的任务了,一个托管线程在任务完成之后被释放为止。线程池本身是一个“对象池”,会在需要新对象(托管线程)时创建,而在对象不需要之后(一段特定时间之内没有新任务需要分配托管线程)负责销毁以释放资源。至于线程池的线程数量,在CLR 2.0 SP1之前的版本中是CPU数 * 25,不过从CLR 2.0 SP1之后就变成了CPU数 * 250。不过不管怎么样,线程池内的线程是有限的,我们必须合理地使用它。

以前的计算机只有一个CPU,理论上同一时刻只能执行一个任务。而如今的超线程、多核、甚至是真正的多个CPU都使计算机能够同时运行多个任务。多线程编程的一个重要特点就是能够充分利用CPU的运算能力,更快地完成某个任务。很明显,如果一个非常庞大的计算任务只交由一个线程来完成,那么只能让一个CPU参与运算。但是如果将一个大任务拆分成多个互不影响的子任务,那么就能让多个CPU同时参与运算,所花的时间自然就少了。如果某个操作的目的是进行大量运算,或者说需要花费大量时间运算上的操作,我们将其称作“Compute-Bound Operation”,也就是受运算能力限制的操作。

与“Compute-Bound Operation”相对的则是“IO-Bound Operation”。“IO-Bound Operation”是指那些由于受到外部条件限制,完成这样一个任务需要在IO上花费大量时间的操作。例如读取一个文件,或者请求网络上的某个资源。对于这种操作,计算的线程再多,运算能力再强也无济于事,因为任务受到的是硬盘、网络等IO设备带来的限制。对于IO-Bound Operation,我们能做的只有“等待”。

对于“同步操作”来说,“等待”就意味着“阻塞”,一个线程将会“无所事事”直至操作完成。这种做法在许多时候会带来各种问题,因此就出现了“异步操作”,但是同样是“异步操作”,不同的任务,不同的情况,它解决问题的方式和带来的效果也是不同的。我下面就通过生活中的实例来说明这些内容:

老赵的朋友开了一家餐馆,请了10个工作人员。最近那个朋友经常向老赵抱怨,说工作人员人手总是不够,在客人比较多的时候,总是来不及招呼他们。老赵一问才得知,这家餐馆的工作方式比较特别:当客人来用餐时,就会有工作人员迎上去热情招待,当客人点好菜之后,工作人员就会去进入厨房亲自下厨——没错,就是这样——做完之后,工作人员会将饭菜端至客人面前,然后就去招待别的客人。因为烧菜往往需要很长时间,因此在某些时候就会发现所有的工作人员都在厨房,但是却没有人点菜。于是老赵给朋友出了个主意:让几个工作人员作为服务员,只负责招呼客人,剩下的就当厨师,一直在厨房工作。当客人点菜之后,服务员就把客人的需求告诉厨师,厨师开始工作,而服务员就可以去招呼其他客人了。朋友顿悟,问题就这样迎刃而解了。

当然,上面故事中老赵的朋友实在太笨,现实生活中的餐馆老板都不会犯这种人员调度上的低级失误。开发一个客户端应用程序所遇到的情况往往就和以上的情况类似。在运行程序时,UI线程(服务员)负责显示界面(招待客人),当用户操作应用程序(点菜)之后,UI线程可以使用同步操作进行运算(服务员亲自下厨),但是如果这是个长时间的Compute-Bound Operation(烧菜是个花费人手时间较长的操作),界面就无法重绘或响应用户请求了(无法招待客人了),这样的应用程序用户体验自然不好(客人觉得服务质量低下)。但是只要UI线程使用异步操作(通知厨师),让另一个线程(另一个工作人员)来进行运算,UI线程就可以继续负责界面重绘或者其他用户操作(招待其他客人)了。

在这种的情况下,异步操作并没有提高运算能力或者节省资源(还是需要一个人员的工作),但是提供了较好的用户体验。不过我们这时该怎么利用异步操作呢?在实际开发中,我们可以使用委托的BeginInvoke进行异步调用。

下面的例子则对应了另一种情况:

老赵的那个开餐馆的朋友在小赚一笔之后准备再开一家快餐店。快餐店和餐馆有个不同之处,那就是快餐店的食品生产了大都有机器完成。可惜在这种情况下那个朋友还是遇到了问题:机器数量绰绰有余,但是人手还是不够。原来现在的做法还是相当不科学:服务员知道客人需要的食品之后,就将原料塞入机器,并看着机器是如何将原料变为美味的。当机器的工作完成之后,服务员便将食品打包并送出,然后继续招待别的客人。老赵听后还是哭笑不得:为啥服务员不能在机器工作的时候就去招待别的客人呢?

  与这个示例对应的可以是一个ASP.NET应用程序。在ASP.NET中每个请求(客人)都会使用一个线程池内的线程(服务员)来处理(招待),处理中很可能需要访问数据库(使用机器),对于普通的做法,处理线程会等待数据库操作返回(服务员看着机器直至完成)。对于Web服务器来说,这很可能是个长时间的IO-Bound Operation,如果线程长时间被阻塞很可能就会降低Web应用程序的性能,因为线程池里的线程用完之后(服务员都去看炉子了),就无法处理新的请求了(没人招待客人了)。如果我们能够在数据库进行长时间查询操作时,让线程去处理其他的请求(招待其他客人)。这样,我们只需要在数据库操作完成之后继续处理(打包)并将数据发送给客户端(送出)即可。

这就是处理IO-Bound Operation的方式,很显然,这也是一个异步操作。当我们希望进行一个异步的IO-Bound Operation时,CLR会(通过Windows API)发出一个IRP(I/O Request Packet)。当设备准备妥当,就会找出一个它“最想处理”的IRP(例如一个读取离当前磁头最近的数据的请求)并进行处理,处理完毕后设备将会(通过Windows)交还一个表示工作完成的IRP。CLR会为每个进程创建一个IOCP(I/O Completion Port)并和Windows操作系统一起维护。IOCP中一旦被放入表示完成的IRP之后(通过内部的ThreadPool.BindHandle完成),CLR就会尽快分配一个可用的线程用于继续接下去的任务。

这种做法的需要一个重要条件,这就是发出用于请求的IRP的操作能够立即返回,并且这个IO操作不会使用任何线程。而此时,这种异步调用是真正地在节省资源,因为我们可以腾出线程用来处理其他任务了,这就是和第一种异步调用的最大区别。不过很可惜,这种做法显然需要操作系统和设备的支持,也就是只有特定的操作才能享受这些待遇。那么.NET Framework中哪些操作能从中获利呢?

  • FileStream操作:BeginRead、BeginWrite。调用BeginRead/BeginWrite时会发起一个异步操作,但是只有在创建FileStream时传入FileOptions.Asynchronous参数才能获取真正的IOCP支持,否则BeginXXX方法将会使用默认定义在Stream基类上的实现。Stream基类中BeginXXX方法会使用委托的BeginInvoke方法来发起异步调用——这会使用一个额外的线程来执行任务。虽然当前调用线程立即返回了,但是数据的读取或写入操作依旧占用着另一个线程(IOCP支持的异步操作时不需要线程的),因此并没有任何“节省”,反而还很有可能降低了应用程序的性能,因为额外的线程切换会造成性能损失。
  • DNS操作:BeginGetHostByName、BeginResolve。
  • Socket操作:BeginAccept、BeginConnect、BeginReceive等等。
  • WebRequest操作:BeginGetRequestStream、BeginGetResponse。
  • SqlCommand操作:BeginExecuteReader、BeginExecuteNonQuery等等。这可能是开发一个Web应用时最常用的异步操作了。如果需要在执行数据库操作时得到IOCP支持,那么需要在连接字符串中标记Asynchronous Processing为true(默认为false),否则在调用BeginXXX操作时就会抛出异常。
  • WebServcie调用操作:例如.NET 2.0或WCF生成的Web Service Proxy中的BeginXXX方法、WCF中ClientBase<TChannel>的InvokeAsync方法。

有一点我想再强调一下,那就是委托的BeginInvoke方法并不能获得IOCP支持,这会使用一个额外的线程来执行任务,这样不但没有节省,返而会降低性能。还有一点可能需要注意,IOCP的确可以不占用线程,但是一个真正的异步操作也不能毁在我们的代码中。例如我曾经看到过如下的代码:

SqlCommand command;

IAsyncResult ar = command.BeginExecuteNonQuery();
int result = command.EndExecuteNonQuery(ar);

虽然在调用BeginExecuteNonQuery方法之后的确获得了IOCP的支持,但是之后调用的EndExecuteNonQuery却会阻塞当前线程直至数据库操作返回——异步操作不是这样用的。至于正确的做法,网络上已经有不少文章讲述了如何在ASP.NET中正确使用异步操作,大家可以搜索相应的资料来看,我也会在以后的文章中略有提到。

关于异步操作,这次就讲到这里吧。

使用Live Messenger联系我