缓存(异步页面)
理解了 ASP.NET 缓存的基本原理后,现在可以深入研究一下另一种提升性能的技术:异步 Web 页面。这项特别的技术可以大大提高网站的可扩展性。它尤其适合用于那些包含大量比较耗时的查询数据库代码的页面。
要理解这一技术带来的潜在利益,需要对 ASP.NET 如何处理请求有较深的理解。从本质上来说,.NET 维持一个能够处理页面请求的线程池。获得一个新的请求时,ASP.NET 从中取出一个可用的线程并用它处理整个页面。这个线程实例化页面,运行事件处理代码,返回呈现的 HTML。如果 ASP.NET 获得请求的频率很高(比它处理这些请求还要快),那么未处理的请求将被保存在队列里。如果队列也满了,ASP.NET 将不得不产生一个 503 错误“服务器不可用”来拒绝额外的请求。
注解:
池中的线程数以及请求队列的大小受多个因素影响,包括 IIS 的版本和 CPU 的数量。最好让 ASP.NET 处理这些细节,它通常能够平衡使用的请求。
如果页面涉及到频繁的等待,可用通过异步页面特性释放 ASP.NET 请求线程。这样,请求被迁移到其他线程池(从技术上讲,使用 I/O 端口特性,它被内建到 Windows 操作系统里)。异步完成时,ASP.NET 被通知,ASP.NET 线程池里的下一个可用线程结束工作,呈现最终的 HTML。
注解:
不要混淆异步 Web 页面和异步客户端技术(如 Ajax):
- 异步Web页面处理的潜在优势在于它允许你更高效的处理耗时的请求,因此在请求繁忙的时候其他用户不需要等待。
- 客户端异步技术的潜在优势在于页面对最终端用户的体验性和响应性。
创建异步页面
创建异步页面的第一步是把 Page 指令里的 Async 特性设置为 true,这一步告诉 ASP.NET 它要生成的页面类应该实现 IHttpAsyncHandler 而不是 IHttpHandler,IHttpAsyncHandler 提供了异步的支持:
<%@ Page Async="true" %>
下一步是调用页面的 AddOnPreRenderCompleteAsync()方法,这通常在页面第一次加载时调用。这个方法接收两个委托,分别指向两个单独的方法。第一个方法启动异步任务,第二个方法处理异步任务结束时的回调:
AddOnPreRenderCompleteAsync(new BeginEventHandler(BeginTask), new EndEventHandler(EndTask));
// 还可以使用压缩的语法直接提供2个方法名
AddOnPreRenderCompleteAsync(BeginTask, EndTask);
ASP.NET 碰到这样的语句时,它记下委托并继续执行正常的页面处理生命周期,直到 PreRender 事件发生,然后 ASP.NET 调用通过注册过的开始方法(BeginTask),如果编码正确,开始方法会启动异步任务并立刻返回,允许 ASP.NET 线程在异步任务继续执行另一个线程的同时处理其他请求。任务完成时,ASP.NET 从线程池获得一个线程来运行结束方法并呈现页面。
遗憾的是,这种方式有一个明显的缺点:要利用这一设计,必须往这一结构里插入一个异步方法。也就是说,需要一个在独立的线程上启动自身并返回一个 AsyncResult 对象的任务,其中,IAsyncResult 对象让 ASP.NET 确定任务何时完成。
初看起来,似乎有好几项技术可以利用,不过他们中大多数在 ASP.NET 中不可用:
- 使用委托方法 BeginInvoke()或 ThreadPool.QueueUserWorkItem()。然而,这两个方法是从 ASP.NET使用的同一个线程池获取线程,这使得它们效率不高。它们会释放掉原始的页面线程,但又从同一个池中获取另一个线程。
- 通过 Thread 类显式创建自己的线程。这种方式非常危险,假设一个自定义线程的页面被快速连续的请求100次,Web 服务器要管理100个线程,即使它们什么也不做,也会损耗性能。
- 编写一个自定义的线程池。也就是说,要用低级的 Thread 类并要限制将要创建的线程的个数。这个技术超出了这里讨论的范围。
那么,还有什么好的选择呢?推荐方法是使用 .NET 类库内建的支持。
例如,.NET 类库包含了各种类,它们为网络上下载内容提供适当的异步支持,从文件中读取数据,了解 Web 服务,通过 DataReader 查询数据。一般而言,这样的支持通过叫做 BeginXXX()和 EndXXX()的配对方法提供。例如,System.IO.FileStream 类提供 BeginRead()和 EndRead()方法以便异步读取文件中的数据。这些方法使用 Windows I/O 完成端口,所有不必从 ASP.NET 的共享线程池中获取线程。
下面几节将会介绍一个使用异步支持的相似的示例,该异步支持内建在 DataReader 中。
在异步页面中查询数据
数据源控件没有提供任何异步支持。不过,ADO.NET 的很多基类(SqlCommand、SqlDataReader等)都提供了异步支持。但必须在连接字符串中显式起用异步支持才允许一步查询。如下,在 web.config 文件中的代码片段:
<connectionStrings>
<add name="NorthwindAsync" connectionString="Data Source=localhost;
Initial Catalog=Northwind;Integrated Security=SSPI;Asynchronous Processing=true"
providerName="System.Data.SqlClient"/>
</connectionStrings>
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Data.SqlClient;
using System.Web.Configuration;
public partial class Chapter11_AsyncTest : System.Web.UI.Page
{
// 首先要注册执行异步任务的方法,这一步对于所有 Web 页面都相同
protected void Page_Load(object sender, EventArgs e)
{
// 注册异步方法为以后使用
// 这个方法会立即返回
Page.AddOnPreRenderCompleteAsync(BeginTask, EndTask);
}
// ADO.NET 对象需要若干个不同的参数,所以它们必需被声明为成员变量
private SqlConnection conn;
private SqlCommand cmd;
private SqlDataReader reader;
// 当调用 BeginTask() 时,就可以启动异步操作了
private IAsyncResult BeginTask(object sender, EventArgs e, AsyncCallback cb, object state)
{
string connectionString =
WebConfigurationManager.ConnectionStrings["NorthwindAsync"].ConnectionString;
conn = new SqlConnection(connectionString);
cmd = new SqlCommand("SELECT * FROM Employees", conn);
conn.Open();
return cmd.BeginExecuteReader(cb, state);
}
// 当 IAsyncResult 对象提示 BeginExecuteReader() 方法已经执行完毕
// 并已获得所有数据时,EndTask() 方法就会自动触发
private void EndTask(IAsyncResult ar)
{
// 现在可以取回数据对象 DataReader
reader = cmd.EndExecuteReader(ar);
}
// 如果需要执行更多的页面处理,可以处理 Page.PreRenderComplete 事件
// 在这个示例中,将获取的数据填充到网格中
protected void Page_PreRenderComplete(object sender, EventArgs e)
{
grid.DataSource = reader;
grid.DataBind();
conn.Close(); // DataReader 对象必须绑定后才能断开连接
}
// 最后,需要重写页面的 Dispose() 方法保证发生错误时连接被关闭
public override void Dispose()
{
if (conn != null && conn.State == System.Data.ConnectionState.Open)
{
conn.Close();
}
base.Dispose();
}
}
总结:
异步获取数据使得这个页面更加复杂。实际的绑定工作需要手工执行且跨越多个方法。不过,如果查询任务的执行需要大量的时间,将会产生一个更具扩展性的 Web 应用程序。
异步页面并不比普通页面快。实际上,切换到新线程再切换回来的额外负载可能使它更慢一些。好处在于其他请求(不涉及到长时间操作的那些请求)可以更快的被处理,这提升了网站的整体的可扩展性。
错误处理
当前,这个示例的异步 DataReader 页面没有错误处理代码,这使得它不适用于真实世界的应用程序。实现错误处理并不困难,不过由于异步页面天生的多阶段性,可能需要在多个地方进行错误处理。
错误处理中,最简单的部分是处理异步操作中发生的异常。按照约定,这些异常在调用 EndXXX()方法时抛出。这意味着所有查询问题会在调用 EndExecuteReader()时抛出异常,可以这样捕获它:
private void EndTask(IAsyncResult ar)
{
try
{
reader = cmd.EndExecuteReader(ar);
}
catch (SqlException err)
{
lblInfo.Text = "The query faild.";
}
}
故意把查询代码改为查询一个不存在的表,可以测试这个异常。
虽然捕获最终的异常很容易,但要很好的处理它就不太容易了。因为这一错误发生在开始方法中,而一旦到达了开始方法,就没有办法回头。即你已经开始了一个异步操作,并且 ASP.NET 期望你返回一个 IAsyncResult 对象。如果返回一个空引用,页面处理将被 InvalidOperationException 中断。
解决的办法是创建一个自定义的 IAsyncResult 类表示操作已经完成。IAsyncResult 类还可以追踪异常的细节,这样你可以在结束方法中读取它们并报告错误。下面这个基于 IAsyncResult 的类包含了这些细节:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
public class CompleteAsyncResult : IAsyncResult
{
// 实现 IAsyncResult 接口同时增加了一个自定义异常
private Exception operationException;
public Exception OperationException
{
get { return operationException; }
set { operationException = value; }
}
public CompleteAsyncResult(Exception operationException,
AsyncCallback asyncCallback, object asyncState)
{
state = asyncState;
OperationException = operationException;
// 如果异步操作中使用了回调方法,就触发回调方法
// 例如触发 EndTask(IAsyncResult ar)
if (asyncCallback != null)
{
asyncCallback(this);
}
}
// 实现接口
// 使用硬编码的值
private object state;
object IAsyncResult.AsyncState
{
get { return state; }
}
public System.Threading.WaitHandle AsyncWaitHandle
{
get { return null; }
}
public bool CompletedSynchronously
{
get { return true; }
}
public bool IsCompleted
{
get { return true; }
}
}
现在如果发生连接错误的话,你不必再依赖 BeginExecuteReader()方法就可以返回这个连接对象的实例:
private IAsyncResult BeginTask(object sender, EventArgs e, AsyncCallback cb, object state)
{
......
try
{
conn.Open();
}
catch (Exception err)
{
return new CompleteAsyncResult(err, cb, state);
}
return cmd.BeginExecuteReader(cb, state);
}
这个方法的唯一问题是你需要在结束方法里显式的检查 IAsyncResult 对象的类型,这样就可以知道错误的条件了:
private void EndTask(IAsyncResult ar)
{
if (ar is CompleteAsyncResult)
{
lblInfo.Text = "A connection error occurred.<br />";
lblInfo.Text += ((CompleteAsyncResult)ar).OperationException.Message;
return;
}
try
{
reader = cmd.EndExecuteReader(ar);
}
catch (SqlException err)
{
lblInfo.Text = "The query faild.";
}
}
现在可将连接字符串指向一个无效的服务器或数据库,运行页面测试。
在异步任务中使用缓存
在前面的示例中,你已经看到了如何使用自定义的实现 IAsyncResult 的类在发生错误时跳过异步处理阶段。不过,你可能还会因为其他原因而希望在执行前就停止异步操作的请求。比如,你发现缓存中就有需要的数据,这时就不必浪费时间去访问数据库。
可以采用多种方式处理这一情形:
- 页面第一次创建时首先检查缓存,并且仅在找不到需要的数据时才注册异步任务。
- 有时候可能希望在稍后的开始方法已经被调用的情况下跳过异步处理阶段。
- 还有一种情况,你可能希望即使没有执行异步操作,ASP.NET 也会运行结束方法的代码。
遇到这些情形时,你需要立刻取消异步任务并返回必需的数据。解决办法同样是使用自定义的 IAsyncResult 对象。实际上,只需一点点修改,就可以直接使用前面开发的 CompleteAsyncResult 类。
首先,需要保存希望返回的数据:
private DataTable result;
public DataTable Result
{
get
{
if (OperationException != null)
{
throw OperationException;
}
return result;
}
set { result = value; }
}
注意,这个属性和第一个版本的 CompleteAsyncResult 使用不同的错误处理方法。现在,当你试图读取 Result 属性时,CompleteAsyncResult 检查是否有异常信息。如果有异常发生,它不会返回数据,此时,正是重新抛出异常通知调用者的最佳时机。
你需要注意的第二个细节是构造函数,构造函数应该接收结果对象,但不需要任何异常信息:
public CompleteAsyncResult(DataTable result, AsyncCallback asyncCallback, object asyncState)
{
state = asyncState;
Result = result;
if (asyncCallback != null)
{
asyncCallback(this);
}
}
现在可以修改开始方法来实现缓存了。在这个示例中,数据保存在 DataTable 对象中(DataReader 不能被有效的缓存,因为它只能被使用一次,并且它保持着一个打开的连接。)。
这段代码检查 DataTable 的缓存,如果存在,就用 CompleteAsyncResult 返回该缓存而不执行任何异步处理:
private IAsyncResult BeginTask(object sender, EventArgs e, AsyncCallback cb, object state)
{
// 检查缓存中是否存在需要的数据
if (Cache["Employees"]!=null)
{
return new CompleteAsyncResult((DataTable)Cache["Employees"], cb, state);
}
string connectionString =
WebConfigurationManager.ConnectionStrings["NorthwindAsync"].ConnectionString;
conn = new SqlConnection(connectionString);
cmd = new SqlCommand("SELECT * FROM Employees", conn);
try
{
conn.Open();
}
catch (Exception err)
{
return new CompleteAsyncResult(err, cb, state);
}
return cmd.BeginExecuteReader(cb, state);
}
EndTask()也需要做一些修改。首先,它检查接收到的 IAsyncResult 对象是否是 CompleteAsyncResult 的实例。如果是,那么尝试读取 CompleteAsyncResult.Result 属性。此时,如果有必要,会抛出错误。如果 IAsyncResult 不是 CompleteAsyncResult 的实例,则调用 EndExecuteReader()获取 DataReader,然后用现成的 DataTable.Load()方法把 DataReader 填充到 DataTable 中,最后缓存之。
下面是结束方法的完整代码:
private DataTable table;
private void EndTask(IAsyncResult ar)
{
CompleteAsyncResult completedSync = ar as CompleteAsyncResult;
if (completedSync != null)
{
try
{
table = completedSync.Result;
lblInfo.Text = "Completed with data from the cache.";
}
catch (Exception err)
{
lblInfo.Text = "A connection error occurred.";
}
}
else
{
try
{
reader = cmd.EndExecuteReader(ar);
table = new DataTable("Employees");
table.Load(reader);
Cache.Insert("Employees", table, null, DateTime.Now.AddSeconds(15), TimeSpan.Zero);
lblInfo.Text = "Cache inserted.";
}
catch (SqlException err)
{
lblInfo.Text = "The query failed.";
}
}
}
最后,Page_PreRenderComplete 事件触发时,DataTable 被绑定到网格:
protected void Page_PreRenderComplete(object sender, EventArgs e)
{
grid.DataSource = table;
grid.DataBind();
}
多异步任务和超时
有时,可能会有多个异步任务同时结束。例如,假设你需要同时调用多个 Web 服务且它们都需要等待较长的时间。通过同步执行这些调用,可以大大缩短要等待的时间(换句话说,,可以同时等待 3 个 Web 服务的响应)。
提示:
任务涉及多种资源时,执行并发异步操作时一项很好的技术。如果你的任务需要竞争同一个资源,这项技术就不行了(例如,同时要执行3个数据库查询的页面就不适合并发执行,这会给整个站点的扩展性带来负面影响)。
如果通过 Page.AddOnPreRenderCompleteAsync()方法注册多个任务,这些任务将顺序执行。如果希望同时执行多个任务,则需要用 Page.RegisterAsyncTask(),该方法接收一个封装了所有请求细节的 PageAsyncTask 对象。
下面这个示例和前面示例中的 AddOnPreRenderCompleteAsync()语句得到相同的结果:
PageAsyncTask task = new PageAsyncTask(BeginTask,EndTask,null,null);
Page.RegisterAsyncTask(task);
要同时执行多个任务时:
PageAsyncTask taskA = new PageAsyncTask(BeginTaskA,EndTaskA,null,null);
Page.RegisterAsyncTask(taskA);
PageAsyncTask taskB = new PageAsyncTask(BeginTaskB, EndTaskB, null, null);
Page.RegisterAsyncTask(taskB);
......
此时,最终的页面呈现阶段将被延迟,直到所有的异步操作全部完成。
和 AddOnPreRenderCompleteAsync()相比,RegisterAsyncTask()还有其他一些区别。你可能注意到了,它还接收两个额外的参数。
- 参数一:允许你提供一个指向超时方法的委托。异步请求超时时,这个方法会被触发。默认情况下,45秒后超时。不过可以在 Page 指令中将 AsyncTimeout 重新设置。超时会影响所有任务,不能为不同的异步任务设置不同的超时。
<%@ Page AsyncTimeout="60" Async="true" %>
- 参数二:这是个可选的状态对象。可以利用它来传递开始方法所需要的信息。
RegisterAsyncTask()另一个区别是当前的 HttpContext 会传给结束方法和超时方法。也就是说,可以使用 Page.Request 之类的属性获得当前请求的信息。对于通过 AddOnPreRenderCompleteAsync()注册的异步任务,这些信息是不可获得的。
总结:
- 缓存是 ASP.NET 最重要的特性之一。
- 一名专业的 ASP.NET 开发人员,应该在一开始就记住使用缓存策略的设计
- 缓存在使用数据源控件时尤为重要。
(这些数据源控件看似简单,实际上并不简单。它们可以轻易构建一个对一个请求执行多次数据库查询的页面)