领先技术

再探异步 ASP.NET 页

Dino Esposito

ASP.NET 始终支持同步和异步 HTTP 处理程序。现在,ASP.NET 2.0 拥有了一些新增功能,可使开发人员更加方便快捷地创建异步页面。尤其是对于基于服务器的应用程序,异步操作是实现可伸缩性的基础。如果需要扩展现有 Web 应用程序,首先要考虑的是能向页面添加多少异步功能。

在这方面,ASP.NET 的行为与代表多个客户端执行某些后台工作的任何其他服务器应用程序十分相似。每个传入请求都分配给 ASP.NET 拥有的线程,该线程是从 ASP.NET 线程池选择的。在操作终止并为客户端生成某种响应之前,该线程将一直被阻止。线程要等待多长时间?ASP.NET 运行时环境可配置为定义一个自定义超时(默认值为 90 秒),但防止线程被阻止更为重要。

在处理可能耗时很长的操作时,超时最多只能确保该线程在给定秒数之后得到释放并返回到池中。实际上,您所需要的是防止长时间阻止该线程。理想情况下,您需要该线程开始一个请求,然后将其交给某个其他非 ASP.NET 线程。在将响应发送到客户端的操作完成时,将再次选择同一个线程或 ASP.NET 池中的另一个线程。这种模式称为异步 ASP.NET 页面。

就异步操作而言,应区分相对于用户的异步页面和相对于 ASP.NET 运行时的异步页面。对于相对于用户的异步页面,唯一的可行方法是 AJAX 操作。但是,使用 AJAX 来执行速度可能较慢的操作会降低对最终用户的影响,但不会为 ASP.NET 运行时带来任何缓解。

异步页面和 ASP.NET 运行时

线程在请求上挂起的时间越长,为了处理新的传入请求而从 ASP.NET 池减去一个线程的时间就越长。当没有可用于处理新请求的线程时,就会将这些请求排队。这可能会导致延迟和总体性能下降。

在 ASP.NET 中,HTTP 处理程序在默认情况下是同步的。必须通过应用略微不同的接口显式构建和实现异步 HTTP 处理程序。同步处理程序与异步处理程序的主要不同之处在于,异步处理程序使用下列方法(IHttpAsyncHandler 接口的组成部分),而不是使用同步 ProcessRequest 方法:

IAsyncResult BeginProcessRequest(

     HttpContext context, 

     AsyncCallback cb, 

     object extraData);


void EndProcessRequest(
     

     IAsyncResult result);

BeginProcessRequest 包含为处理请求而需要执行的操作。此代码应设计为在辅助线程上启动操作并立即返回。EndProcessRequest 包含用于完成先前已启动的请求的代码。

可以看到,一个异步 HTTP 请求分成两个部分,即“异步点”之前和之后,异步点是请求生命周期中拥有请求的线程发生更改的点。当达到异步点时,原始 ASP.NET 线程向另一个线程交出控制权。这个可能耗时较长的操作发生在 ASP.NET 请求的两个部分之间。异步请求的每个部分都独立于另一部分运行,就线程而言没有任何相关性。换句话说,不能保证用同一线程处理请求的两个部分。实际效果是,在操作过程中不会阻止任何线程。

此时,明显的问题是:哪个线程实际负责处理“耗时较长”的操作?ASP.NET 在内部使用 I/O 完成端口来跟踪请求的终止。当达到异步点时,ASP.NET 将挂起的请求绑定到一个 I/O 完成端口,并注册一个回调以在终止请求时获取通知。操作系统将使用自己的专用线程之一来监视操作的终止,从而使 ASP.NET 线程不必在完全空闲的状态下进行等待。操作终止时,操作系统将一条消息放置在完成队列中,该消息将触发 ASP.NET 回调,随后,该回调获取自己的线程之一来恢复请求。如前所述,I/O 完成端口是操作系统的一个功能。

异步页面的实际特性

在 ASP.NET 中,如果考虑改进给定页面(负责执行可能耗时较长的操作)的性能,通常就涉及异步页面。不过,还应注意几个其他问题。从用户的角度来看,同步和异步请求看起来几乎相同。比如,如果请求的操作预期需要 30 秒完成,则用户将至少等待 30 秒才能获得新的页面。无论是同步还是异步实现该页面,都会出现这种情况。此外,如果某个异步页面耗费略微长一点时间才完成一个请求,也很正常。那么,异步页面有何优点?

可伸缩性和性能并不完全一样。或者至少,可伸缩性与性能有关,不过是在不同级别上,即整个应用程序而不是单个请求。异步页面的优点是,可以显著减少 ASP.NET 池中的线程的工作量。这并不会使耗时较长的请求运行更快,但它可帮助系统按通常方式处理耗时不长的请求,也就是说,不会因运行较慢的请求而产生任何特殊延迟。

异步请求利用异步 HTTP 处理程序,这始终是 ASP.NET 平台的一个功能。但是,ASP.NET Web 窗体和 ASP.NET MVC 都提供了自己的功能,以便开发人员更方便地实现异步操作。在本文其余部分,我将讨论 ASP.NET MVC 2 中的异步操作。

异步控制器操作

在 ASP.NET MVC 1.0 中,任何控制器操作都只能以同步方式运行。不过,MVC Futures 库中添加了一个新的 AsyncController 类。在一段试验期之后,控制器的异步 API 已正式添加到 ASP.NET MVC 框架中,从 ASP.NET MVC 框架的版本 2 开始,该异步 API 已完全可用并以文档形式进行了说明。(本文讨论的是 ASP.NET MVC 2 RC 的语法和功能。)如果使用 MVC Futures 库中的 AsyncController 类,您会注意到有一些变化,API 变得更加简单清晰了。

AsyncController 的目的是确保任何公开的操作方法都以异步方式执行,而不改变 ASP.NET MVC 框架所特有的总体编程方法。图 1 中的图显示了异步操作处理之后的步骤顺序。

图 1 ASP.NET MVC 中异步操作方法的机制
图 1 ASP.NET MVC 中异步操作方法的机制

 异步点位于正在执行的事件和已执行的事件之间。当操作调用程序通知将要执行操作时,所使用的线程仍然是从 Web 服务器队列选择请求的原始 ASP.NET 线程。此时会执行该操作。最后,当操作调用程序准备好通知已执行完操作的事件时,可能另一个 ASP.NET 线程会负责处理该请求。图 2 说明了这种方案。


图 2 异步操作方法调用的线程切换

在详细讨论如何创建和调试异步方法之前,应说明异步 ASP.NET 操作的另外一个基本问题:不是所有操作都适合以异步方式执行。

异步操作的实际目标

只有受 I/O 限制的操作才适合成为异步控制器类上的异步操作方法。受 I/O 限制的操作是不依赖于本机 CPU 完成的操作。当受 I/O 限制的操作处于活动状态时,CPU 只是等待外部存储(数据库或远程服务)对数据进行处理(即下载)。受 I/O 限制的操作与受 CPU 限制的操作不同,对于后者,任务的完成取决于 CPU 的活动。

受 I/O 限制的操作的一个典型示例是调用远程服务。在这种情况下,操作方法将触发请求,然后等待下载任何响应。实际工作将通过另外一台计算机和另一个 CPU 以远程方式完成。因此,ASP.NET 线程保持为等待和空闲状态。通过异步执行操作或页面,将处于空闲状态的线程从等待状态释放出来,从而处理其他传入请求,可以使性能得到提升。

实际上,并不是所有耗时较长的操作都可通过异步操作提升性能。耗时较长的内存中计算不会显著获益于异步实现。它的运行速度甚至可能会更慢一些,因为同一个 CPU 将同时处理 ASP.NET 请求和计算。此外,您可能仍然需要使用 ASP.NET 线程来实际处理计算。异步实现受 CPU 限制的操作,几乎没有什么益处。另一方面,如果使用远程资源(甚至多个资源),异步方法即使不会提高单个请求的性能,也可大幅提高应用程序的性能。

稍后我将用示例进行说明。现在,我们重点讨论一下在 ASP.NET MVC 中定义和执行异步操作所需的语法。

认识异步路由

异步路由与同步路由有何不同?在 MVC Futures 中,您需要使用不同的方法来注册同步和异步路由。下面是注册异步路由的旧方法:

routes.MapAsyncRoute(

    "Default",

    "{controller}/{action}/{id}",

    new { controller = "Home", action = "Index", id = "" }

);

您必须使用 MapAsyncRoute 扩展方法,而不是传统同步方法所用的标准 MapRoute。不过,在 ASP.NET MVC 2 RC 中,已经消除了这种区别。现在,无论随后将如何执行操作,注册路由都采用一种方式,即使用 MapRoute 方法。

因此,以常规方式处理请求的 URL,并确定要使用的控制器类的名称。实际上,需要在派生自新 AsyncController 类的控制器类上定义异步方法,如下所示:

public class TestController : AsyncController

{

  ...

}

如果控制器类继承自 AsyncController,则用于将操作名称映射到方法的约定有些不同。AsyncController 类可以处理同步和异步请求。因此,所使用的约定可识别出方法 Run 和方法 RunAsync,如下所示:

public class TestController : AsyncController

{

  public ActionResult Run(int id) 

  {

     ...

  }

  public void RunAsync(int id) 

  {

     ...

  }

}

但是,如果执行上述代码,将引发异常(请参见图 3)。

异步操作通过名称来标识,预期的模式为 xxxAsync,其中 xxx 表示要执行的操作的默认名称。显然,如果存在另一个名为 xxx 的方法,并且未使用属性进行区分,则会引发异常,如图 3 所示。


图 3 操作名称中的不明确引用

“Async”这个词被视为后缀。用于调用 RunAsync 方法的 URL 仅包含前缀“Run”。例如,以下 URL 将调用方法 RunAsync,将值 5 作为路由参数传递:

http://myserver/demo/run/5

将此操作解析为同步操作还是异步操作,取决于 AsyncController 类中的方法。但是,xxxAsync 方法仅识别操作的触发器。请求的终结器是控制器类中的另一个方法,名为 xxxCompleted:

public ActionResult RunCompleted(DataContainer data)

{

    ...

}

请注意用于定义异步操作的这两个方法的不同签名。触发器应为 void 方法。如果将它定义为返回任何值,则返回值将被忽略。通常,xxxAsync 方法的输入参数需要进行模型绑定。终结器方法通常返回一个 ActionResult 对象,接收一个包含待处理数据的自定义对象并将数据传递给视图对象。要将由触发器计算的值与终结器声明的参数进行匹配,需要使用一个特殊协议。

AsyncController 类

AsyncController 控制器类继承自 Controller,它实现一组新的接口,如下所示:

public abstract class AsyncController : Controller, 

                IAsyncManagerContainer, 

IAsyncController, IController

异步控制器最独特的方面是具有专门的操作调用程序对象,该对象在底层使用,用于执行操作。该调用程序需要一个计数器来跟踪操作的数目,这些操作构成总体操作,并且必须在可以声明总体操作终止之前进行同步。图 4 提供了异步操作的示例实现。

图 4 简单的异步操作方法

public void RunAsync(int id) 

{

    AsyncManager.OutstandingOperations.Increment();



    var d = new DataContainer();

     ...

            

    // Do some remote work (i.e., invoking a service)

     ...



    // Terminate operations

    AsyncManager.Parameters["data"] = d;

    AsyncManager.OutstandingOperations.Decrement();

}

public ActionResult RunCompleted(DataContainer data)

{

   ...

}

AsyncManager 类上的 OutstandingOperations 成员提供一个容器,用于保持一定数目的挂起异步操作。它是 OperationCounter 帮助器类的一个实例,提供一个临时 API 进行递增和递减。Increment 方法不限于一元递增,如下所示:

AsyncManager.OutstandingOperations.Increment(2);

service1.GetData(...);

AsyncManager.OutstandingOperations.Decrement();

service2.GetData(...);

AsyncManager.OutstandingOperations.Decrement();

AsyncManager 参数字典用于对值进行分组,以将值作为参数传递给异步调用的终结器方法。在参数字典中,要传递给终结器(前面示例中的 xxxCompleted 方法)的每个参数都应有一个条目。如果字典中的条目都与参数名称不匹配,则参数采用默认值(如果是引用类型,则为 null)。除非尝试访问 null 对象,否则不会引发异常。xxxCompleted 方法接受任何受支持类型的参数,使用它们来填充 ViewData 集合或视图识别的任何强类型对象。xxxCompleted 方法负责返回 ActionResult 对象。

是否适合?

总而言之,同步请求是 ASP.NET 中的必要功能,实际上,从 ASP.NET 1.0 开始,就支持异步 HTTP 处理程序。

ASP.NET Web 窗体和 ASP.NET MVC 提供了更高级别的工具,可对异步操作进行编码,每个工具都有自己的应用程序模型。在 ASP.NET MVC 中,使用的是异步控制器;而在 Web 窗体中,需要使用异步页面。 

不过,异步操作的关键方面是确定指定的任务是否适合异步实现。只应针对受 I/O 限制的操作构建异步方法。最后,请注意,异步方法本身不会更快运行,而是使其他请求更快运行。

Dino Esposito 是 Microsoft Press 即将出版的《Programming ASP.NET MVC》的作者,也是《Microsoft .NET:Architecting Applications for the Enterprise》(Microsoft Press,2008 年)的合著者。Esposito 定居于意大利,经常在世界各地的业内活动中发表演讲。您可访问他的博客,网址为 weblogs.asp.net/despos

衷心感谢以下技术专家对本文的审阅:Stefan Schackow