通过 ASP.NET 异步编程实现可扩展的应用程序

http://msdn.microsoft.com/msdnmag/issues/07/03/WickedCode/Default.aspx?loc=zh#void


您想了解秘密吗?讳莫如深,不可言传的秘密?一旦揭示,将在 ASP.NET 社区引起巨大的反响,并使 Microsoft 的反对者发出“啊哈!”的惊叹,对吗?

多数使用 ASP.NET 构建的网站没有良好的可扩展性。它们受到自我强加的“玻璃天花板”的制约,这种束缚限制了它们每秒可处理的请求的数量。这些站点的扩展性一直良好,直到流量提升到这一无形限制时。然后吞吐量开始下降。很快,请求开始失败,通常返回“服务器不可用”错误。

《MSDN®杂志》曾多次就其根本原因进行过讨论。ASP.NET 使用公共语言运行库 (CLR) 线程池中的线程来处理请求。只要在线程池中存在可用线程,ASP.NET 调度传入请求就不会有任何麻烦。但是一旦线程池处于饱和状态(即所有池中的线程忙于处理请求,而没有可用的线程),则新的请求必须等待线程可用。如果这种僵局变得相当严重、队列到达容量限制,ASP.NET 将束手无策,对于新的请求只能做出“拒绝”响应。

一种解决方法是提高线程池的上限,以创建更多的线程。这是当其客户报告频繁遇到“服务器不可用”错误时,开发人员经常采取的方法。另一种经常采用的方法是放弃出现问题的硬件,向 Web 场中添加更多的服务器。但是,增加线程数或服务器数并不能从根本上解决这一问题。实际上,它仅仅暂时缓解了存在的设计问题,并非存在于 ASP.NET 中,而是实际站点实现中存在的问题。对于不能扩展的应用程序,实际的问题是线程的缺乏。不能有效使用已存在的线程是问题的所在。

真正可扩展的 ASP.NET 网站充分利用了线程池。这意味着可确保请求处理线程执行代码,而非等待 I/O 完成。如果由于所有线程都在消耗 CPU 而造成线程池饱和,除了添加服务器,您几乎无计可施。

然而,多数 Web 应用程序可以与数据库、Web 服务或其他外部实体进行通话,并通过强制线程池等待完成数据库查询、Web 服务调用和其他 I/O 操作来限制可扩展性。针对数据驱动的网页的查询可能要花费千分之几秒来执行代码,花几秒钟等待数据库查询返回。当查询未完成时,分配给请求的线程无法服务于其他的请求。这就是所谓的玻璃屋顶。如果您要构建具有高度可扩展性的网站,这种情况是您必须避免的。请记住:当涉及吞吐量时,除非处理得当,否则 I/O 会成为大问题。

当然,如果 I/O 没有破坏线程池,则算不上大问题。ASP.NET 支持三种可作为防破坏代理的异步编程模型。对于社区而言,这些模型大都未知,部分原因在于缺乏相关文档。了解如何以及何时使用这些模型对于构建先进的网站绝对至关重要。


异步页面

ASP.NET 支持的这三种异步编程模型中,首要的、通常也是最有用的是异步页面。在这三种模型中,这是唯一针对 ASP.NET 2.0 的。其他支持的模型都是针对版本 1.0 的。

在此,我不再详细介绍异步页面,因为在 2005 年 10 月期的杂志中,我曾对此进行过讨论。(msdn.microsoft.com/msdnmag/issues/05/10/WickedCode)。结论是:如果您有一些页面要执行相对较长的 I/O 操作,它们就应成为异步页面。如果某页面查询数据库,花了 5 秒钟返回(因为它既返回大量数据,又通过大量加载的连接将目标锁定到远程数据库),线程分配给该请求的 5 秒钟不可用于其他请求。如果每个请求都照此处理,应用程序将会很快陷入停顿。

图 1 显示了异步页面是如何解决这一问题的。当请求到达时,由 ASP.NET 为其分配一个线程。该请求开始在该线程中进行处理,当选择数据库时,请求将启动异步 ADO.NET 查询,并将线程返回到线程池中。当查询完成时,ADO.NET 回调到 ASP.NET,ASP.NET 从线程池中调出另一个线程,并恢复处理请求。

图 1 有效的异步页面
图 1 有效的异步页面 (单击该图像获得较大视图)

查询未完成时,线程池中的任何线程均未使用,以确保所有线程均可用于传入的请求。异步处理的请求不能被快速执行。但其他请求可更快地执行,因为它们不必等待线程可用。在进入管道时,请求可引起较少的延迟,整体吞吐量会被提升。

图 2 显示了根据 SQL Server™ 数据库执行数据绑定的异步页面的代码隐藏类。Page_Load 方法调用 AddOnPreRenderCompleteAsync 以注册开始和结束处理程序。在请求生存期的末期,ASP.NET 调用 Begin 方法,该方法将启动异步 ADO.NET 查询并即刻返回,于是,分配给该请求的线程将返回线程池。当 ADO.NET 表明查询已经结束时,ASP.NET 将从线程池中检索线程(不必和以前使用的相同),并调用 End 方法。End 方法获得查询结果,请求的其余部分在执行 End 方法的线程中正常执行。

图 2 中未显示的内容是 ASPX 的 Page 指令中的 Async="true" 属性。异步页面应能够:提示 ASP.NET 在页面中实现 IHttpAsyncHandler 接口(稍后将详细介绍)。同样未在图 2 中显示的是数据库连接字符串,该字符串包含自己的 Async="true" 属性,这样,ADO.NET 就知道要执行异步查询了。

AddOnPreRenderCompleteAsync 是构建异步页面的一种方法。另一种方法是调用 RegisterAsyncTask。与 AddOnPreRenderCompleteAsync 方法相比,这种方法具有一些优势,最重要的是它简化了在一个请求中执行多个异步 I/O 操作的任务。有关于此的详细信息,请参阅 2005 年 10 月期的“超酷代码”部分。

Back to top

异步 HTTP 处理程序

ASP.NET 中的第二个异步编程模型是异步 HTTP 处理程序。HTTP 处理程序是一个作为请求终结点的对象。例如,对 ASPX 文件的请求由针对 ASPX 文件的 HTTP 处理程序处理。同样,对 ASMX 文件的请求由知道如何处理 ASMX 服务的 HTTP 处理程序处理。事实上,ASP.NET 拥有针对多种文件类型的 HTTP 处理程序。在 web.config 主文件的 <httpHandlers> 部分(在 ASP.NET 1.x中,其位于 machine.config 中),您可以看到这些文件类型和相应的 HTTP 处理程序。

通过编写自定义 HTTP 处理程序,您可以扩展 ASP.NET 以支持其他文件类型。但是,更有趣的一点是,您可以在 ASHX 文件中部署 HTTP 处理程序,并将它们用作 HTTP 请求的目标。这是构建动态生成图像或从数据库中检索图像的 Web 端点的正确方法。您只需将 <img> 标记(或 Image 控件)包含在页面中,并将其指向创建或获取图像的 ASHX。将目标锁定到带有请求的 ASHX 文件比将目标锁定到 ASPX 文件更有效,因为 ASHX 文件在处理时开销更少。

根据定义,HTTP 处理程序可实现 IHttpHandler 接口。实现该接口的处理程序不能同步进行处理。图 3 中的 ASHX 文件包含一个这类 HTTP 处理程序。在运行时,TerraServiceImageGrabber 在 Microsoft® TerraServer Web 服务之外进行多次调用以将城市和州转换为经度和纬度,检索卫星图像(如同一块块“瓷砖”),然后将图像拼接在一起形成指定位置的复合图像。

结果如图 4 所示。所显示的页面包含 Image 控件,其 ImageUrl 属性将目标锁定在图 3 中所显示的 ASHX 文件。当用户选择了城市和州并单击按钮后,HTTP 处理程序则将输入转换为卫星图像。

结果令人印象深刻。但这有一个问题。TerraServiceImageGrabber 是如何避免编写 HTTP 处理程序的完美示例。想一想。TerraServiceImageGrabber 需要几秒钟(至少)完成其所有 Web 服务调用并处理结果。大部分时间仅仅花费在等待 Web 服务调用完成上。对于 ASHX 文件的重复请求会转瞬间耗尽 ASP.NET 线程池,阻止应用程序中其他页面的使用(或者至少使它们排队等待线程可用)。您不能用这种方法构建可扩展的应用程序,除非您扩展了硬件。但是,当使用正确编写的软件通过一台服务器就能处理负载时,为什么还要将成千上万的资金耗费在 Web 场上呢?

图 4 有效的 TerraServiceImageGrabber
图 4 有效的 TerraServiceImageGrabber (单击该图像获得较大视图)

HTTP 处理程序不必是同步的。通过实现 IHttpAsyncHandler 接口,该接口本身可从 IHttpHandler 派生出来,HTTP 处理程序可以是异步的。如果正确使用,异步处理程序可更有效地利用 ASP.NET 线程。这可采用与异步页面相同的方式来完成。事实上,异步页面可利用在 ASP.NET 中将异步页面日期提前的异步处理程序支持。

图 5 包含图 3 所示的处理程序的异步版本。Async-TerraServiceImageGrabber 稍微有点复杂,但具有更高的可扩展性。

当 ASP.NET 调用处理程序的 BeginProcessRequest 方法时,开始异步处理。通过 TerraService 代理的 BeginConvertPlaceToLonLatPt 方法,BeginProcessRequest 可对 TerraService 进行异步调用。然后,分配给该请求的线程返回线程池中。异步调用完成时,另一个线程被从线程池中调出以执行 ConvertPlaceToLonLatCompleted 方法。该线程会检索上次调用的结果,进行自己的异步调用,然后返回线程池。这种模式不断重复直至所有异步调用完成,此时,调用处理程序的 EndProcessRequest 方法,产生的位图被返回给请求者。

要阻止 EndProcessRequest 直至最后的 Web 服务调用完成,AsyncTerraServiceImageGrabber 返回来自 BeginProcessRequest 的 IAsyncResult 的自我实现。如果它要返回由 BeginConvertPlaceToLonLatPt 返回的 IAsyncResult,则在第一个 Web 服务调用完成时,需调用 EndProcessRequest(并终止请求)。

实现 IAsyncResult 和 TerraServiceAsyncResult 的类具有可随时调用以完成请求的公共 CompleteCall 方法。通常,只有在最后的 Web 服务调用完成后,AsyncTerraServiceImageGrabber 才调用 CompleteCall。不过,如果在 BeginProcessRequest 和 EndProcessRequest 之间执行的某一方法抛出异常,处理程序将异常缓存在私有字段 (_ex) 中,调用 CompleteCall 以终止请求,然后从 EndProcessReques 中重新抛出异常。否则,异常将丢失,请求将无法完成。

由于 AsyncTerraServiceImageGrabber 使用 ASP.NET 线程的时间只是处理请求所需的总时间的一小部分,因此,AsyncTerraServiceImageGrabber 比其同步版的同类方法具有更高的可扩展性。大部分时间里,它只是等待异步 Web 服务调用完成。

理论上,AsyncTerraServiceImageGrabber 还胜过 TerraServiceImageGrabber,因为它不是顺序地重复调用 TerraService's GetTile 方法,而是并行调用。不过,实际上,每次只有两个针对给定 IP 地址的出站调用可以被挂起,除非您提高了运行库的默认 maxconnection 设置:

<system.net>
<connectionManagement>
<add address="*" maxconnection="20" />
</connectionManagement>
</system.net>

 

其他配置设置也可影响并发。有关详细信息,请参考知识库文章“从 ASP.NET 应用程序进行 Web 服务请求时出现的争用、性能不佳和死锁等问题”(support.microsoft.com/kb/821268)。

即使每次只执行一个调用,但 AsyncTerraServiceImageGrabber 并不比 TerraServiceImageGrabber 差。它的设计非常出色,因为它尽可能有效地使用了 ASP.NET 线程。

Back to top

异步 HTTP 模块

您在 ASP.NET 中可能利用的第三个异步编程模型是异步 HTTP 模块。HTTP 模块是位于 ASP.NET 管道中的对象,在管道中,它可以查看甚至修改传入请求和传出响应。ASP.NET 中的许多主要服务都是以 HTTP 模块的形式实现的,包括身份验证、授权和输出缓存。通过编写自定义 HTTP 模块并将它们插入管道,您可以扩展 ASP.NET。当您这样做的时候,一定要认真考虑这些 HTTP 模块是否应当是异步的。

图 6 包括称为 RequestLogModule 的简单、同步 HTTP 模块的源代码,它在名为 RequestLog.txt 的文本文件中记录了传入请求。在站点的 App_Data 目录下创建该文件,这样用户就无法浏览它。(要注意 ASP.NET 作为安全主体的运行(例如,ASPNET 或网络服务)必须写入对 App_Data 的使用权限。)该模块实现 IHttpModule 接口,这是 HTTP 模块的唯一要求。加载该模块时,其 Init 方法会为 HttpApplication.PreRequestHandlerExecute 事件注册一个处理程序,该程序从每个请求的管道中被触发。事件处理程序打开 RequestLog.txt(或在该文件不存在的情况下创建一个),然后将一行包含关于当前请求的有针对性的信息写入其中,包括请求到达的时间和日期、请求者的用户名(如果请求是要进行身份验证的,或者如果身份验证关闭,则要包含请求者的 IP 地址),以及请求的 URL。该模块在 web.config 的 <httpModules> 部分进行注册,以便在每次应用程序启动时,提示 ASP.NET 加载该文件。

RequestLogModule 存在两方面的问题。首先,每次请求时均要执行 I/O 文件。其次,它使用请求处理线程来执行 I/O,否则,线程可能被用于为其他传入请求服务。由于简单,该模块会导致吞吐量损失。通过批处理 I/O 文件操作,您可能会缓解延迟,更好的方法是使模块异步(或者最好批处理 I/O 文件并使模块异步)。

图 7 显示了异步版本的 RequestLogModule。调用 AsyncRequestLogModule 后,它将执行完全相同的工作,并将分配给请求的线程返回线程池,然后写入文件。当写入完成时,从线程池中调出新的线程,用于完成请求。

如何使 AsyncRequestLogModule 异步?其 Init 方法调用 HttpApplication.AddOnPreRequestHandlerExecuteAsync 以便为 PreRequestHandlerExecute 事件注册 Begin 和 End 方法。HttpApplication 类包含针对其他 per-request 事件的其他 AddOn 方法。例如,HTTP 模块可以调用 AddOnBeginRequestAsync 以便为 BeginRequest 事件注册异步处理程序。AsyncRequestLogModule 的 BeginPreRequestHandlerExecute 方法使用 Framework 的 FileStream.BeginWrite 方法来开始异步写入。BeginPreRequestHandlerExecute 返回时,线程返回线程池。

AsyncRequestLogModule 包含一些值得特别一提的线程同步逻辑。运行在多个线程中的多个请求可能要同时写入日志文件。为了确保并发写入不会相互覆盖,AsyncRequestLogModule 在由所有模块实例共享的私有字段中保存了下一个写入在文件中的位置 (_position)。每次调用 BeginWrite 之前,模块从字段中读取该位置并更新字段以指向要写入该文件的内容的第一个字节。读取并更新 _position 的逻辑包含在 lock 语句中,这样每次就有不止一个线程可执行它。这防止了在一个线程有机会更新位置之前,另一个线程读取该位置。

现在,谈谈不足之处。对于未使用线程池中另一个线程的 BeginWrite,FileStream 构建函数的 isAsync 参数必须设置为“true”,正如我在示例中所做的那样。不过,使用 FileStream.BeginWrite 启动异步写入的一个已知结果是无法保证写入实际上是异步的,即使您已经请求异步操作。如果确信同步 I/O 更快,Windows® 保留同步执行异步 I/O 文件的权利。有关详细信息,请参阅 support.microsoft.com/kb/156932 上的知识库文章。好的方面是如 Windows 同步写入请求日志,理论上讲,写入可以更快执行,这样它们对主机应用程序可扩展性造成的影响最小。

Back to top

总结

异步编程是尽可能高效地使用 ASP.NET 线程池来构建扩展性更强的应用程序的一种很好的方法。以往,我很少看到 ASP.NET 开发人员使用异步编程模型,部分原因在于他们并不知道存在这些模型。不要让稀疏文档成为您的“拦路虎”,从现在起就开始异步思考,今后您将会构建出更好的应用程序。

请注意,本文提供了 C# 和 Visual Basic® 版本的可下载示例代码。我常常收到要求提供 Visual Basic 版示例的电子邮件。这一次,您不必再问了,我已经提供了该版本的示例!

posted @ 2007-05-12 11:22  永不言败  阅读(739)  评论(0编辑  收藏  举报