ADO.NET 2.0中的异步命令执行

注:这是本人转载的文章
简介

在ADO.NET 2.0版本中,我们不仅希望使现有的方案更为简单,而且还要实现在以往不可能或者无法达到完善的新方案。

异步命令执行正是这种想法的一个优秀示例。在ADO.NET 2.0之前的版本中,执行一个命令并在继续执行之前不等待该命令完成是不可能的。增加的异步API实现了对应用程序而言非常重要的方案,即,应用程序可以继续执行而无需等待数据库完成操作。

在本文中,我将介绍异步数据库API的基础知识,以及几个使用了该API的方案。尽管我们设计API的本意是使其可以与任何数据访问提供程序协同工作,但是SqlClient(SQL Server的.NET数据访问提供程序)是唯一实际支持该API的.NET附带的四个提供程序之一。正因为如此,我将在本文后面部分的方法和类的示例及说明中使用SqlClient。请记住,第三方提供程序的编写者也可以实现该异步API,因此我们也许会看到更多的可以通过异步方式访问的数据库。只需相应地更改类名和方法名,即可将这些示例用于其他数据库。

真正的异步I/O

在.NET Framework先前的版本中,可以通过使用异步委托或ThreadPool类来模拟非阻塞执行;然而,这些解决方案只是简单地阻塞了后台中的另一个线程,这使得它离“避免线程阻塞”的目标相去甚远,正如我们将在下面的“应用程序方案”部分中看到的那样。

而且,此处最终会有一个公开“非阻塞”语句执行的早期数据库访问API:ADO。但是,ADO和ADO.NET/SqlClient实现之间存在着根本区别:ADO生成一个后台线程并阻塞该线程,而不是在数据库操作完成之前阻塞调用线程。尽管该方法适用于客户端应用程序,但是它对于中间层和服务器端方案(例如,我们将在本文后面讨论的方案)毫无用处。

ADO.NET/SqlClient异步命令执行支持基于幕后真正的异步网络I/O(或者,对于共享内存而言为非阻塞信号)。如果我们确实需要了解,或许某一天我会撰写有关该内部实现的文章。目前,我们是在执行“真正的异步”,此处没有阻塞的后台线程在等待特定的I/O操作完成,并且我们将使用Windows 2000/XP/2003操作系统的复用I/O和输入/输出完成端口功能,从而有可能使用单线程(或少数几个线程)处理给定进程的所有未完成请求。

新的API元素

在.NET Framework中的现有API之后,我们使用类似的功能对新的ADO.NET中的异步API进行了建模。。在大型框架中,最重要的事情是保持一致性。

异步方法

所有命令执行API均位于ADO.NET的Command对象中,包括ExecuteReader、ExecuteNonQuery、ExecuteXmlReader以及ExecuteScalar。我们决定将为该功能添加的API表面最小化,因此,我们只为无法从其他方法改编的方法添加了异步版本:ExecuteReader、ExecuteNonQuery和ExecuteXmlReader。ExecuteScalar只是“ExecuteReader+提取第一行/第一列+关闭读取器”的简短形式,因此,我们不包含它的异步版本。

根据.NET Framework中已经在使用的异步API模式,每个现有的同步方法现在都有一个拆分为两个方法的异步副本;即,开始工作的begin部分和完成工作的end部分。下表总结了Command对象中的新方法:

表1 ADO.NET 2.0中提供的新的异步方法

同步方法
 异步方法
 
“Begin”部分
 “End”部分
 
ExecuteNonQuery
 BeginExecuteNonQuery
 EndExecuteNonQuery
 
ExecuteReader
 BeginExecuteReader
 EndExecuteReader
 
ExecuteXmlReader
 BeginExecuteXmlReader
 EndExecuteXmlReader
 

由于采用异步模式建模方法,因此begin方法接受所有输入参数,并且end方法提供所有输出参数以及返回值。例如,以下显示了ExecuteReader的异步调用。

IAsyncResult ar = command.BeginExecuteReader();

// ...

// do other processing

// ...

SqlDataReader r = command.EndExecuteReader(ar);

// use the reader and then close the reader and the connection

在以上示例中,您可以看到BeginExecuteReader没有接受任何参数(映射到没有接受任何参数的ExecuteReader重载),并且EndExecuteReader返回了一个SqlDataReader,正如ExecuteReader所做的那样。

begin方法符合基类库中的其他异步API,这些方法可返回一个IAsyncResult引用,该引用可用于跟踪操作的状态。这将在下面的“完成信号”部分中予以深入讨论。

“async”连接字符串关键字

为了在连接上使用异步命令,必须通过连接字符串中的async=true来初始化该连接。如果在具有连接的命令上调用任何异步方法,在其连接字符串中不包含async=true,则会引发异常。

请注意,如果您知道自己将只能通过同步命令使用给定的连接对象,那么最好不要在连接字符串中包含async关键字,如果要包含该关键字,请将其设置为false。在启用异步操作的连接上执行同步操作,将显著提高资源利用率。

在同时需要同步和异步API的情况下,如果可能,您应该使用不同的连接。如果没有选择的余地,那么您仍然可以在通过async=true打开的连接中使用同步方法;它们将照常运行,但是性能会稍有降低。

完成信号

异步API的基本元素之一是完成信号(Completion Signaling)机制。在同步API之中,在操作完成之前,方法调用不会返回,因此这不会成为一个问题。在异步情况中,begin调用会立即返回,因此我们需要一种方法来检测操作实际完成的时间。

在ADO.NET中(正如在其余的.NET Framework异步API中一样),有许多选择可用于检测异步命令完成执行的时间:

回调:所有的begin方法都包含将一个委托作为参数来使用的重载以及用户定义的state对象。当使用该重载时,ADO.NET将调用传入的委托,并使state对象完全可以在IAsyncResult对象(作为参数传递到该委托)中可用。请注意,该回调将在一个线程池线程中调用,该线程可能不同于初始化该操作的线程。可能会需要适当的同步,具体取决于应用程序。
同步对象:由begin方法返回的IAsyncResult对象具有包含事件对象的WaitHandle属性。事件对象可用于同步基元中,例如WaitHandle.WaitAny和WaitHandle.WaitAll。这允许调用代码等待多个挂起操作,并在一个操作完成或所有操作都完成时得到通知。此外还允许这样的方案:其中,客户端代码需要等待数据库操作和某些其他使用事件的活动,也可以等待任何其他用于同步的OS可等待句柄。
轮询:IAsyncResult对象还具有IsCompleted布尔值属性。在操作完成时,该属性将更改为true,因此它可以由需要执行某些连续活动的代码使用;该代码可以定期检查该属性,并在该属性发生改变时处理结果。
在所有三种情况下,一旦传输信号指示该操作完成,调用方就必须为初始化异步命令的begin方法调用相应的end方法。调用与begin方法匹配的end方法失败,也许会导致ADO.NET泄漏系统资源。此外,对end方法的调用将使调用方能够使用操作结果;这可以是一个SqlDataReader(对于EndExecuteReader)、受影响的记录数量(对于EndExecuteNonQuery)或一个XmlReader(对于EndExecuteXmlReader)。

请注意,在不等待操作完成的情况下调用end方法完全合法。在这种情况下,该方法将一直阻塞直到该操作完成,然后将其转换为同步操作(就功能而言,它在幕后保留了异步性)。

应用程序方案

基于功能的“酷炫因素”对其进行设计过于简单并且太过冒险,因此我们尝试通过确保具有相关的应用程序方案以支持每一个提供的功能以及新功能,从而避免此类情况。在设计异步API时,我们的脑海中已形成了几个主要方案。我还介绍了一个方案,在其中,异步命令看似是个不错的选择,但经过深思熟虑之后可能并非如此,至少它并非始终是最好的选择。

并行执行多个语句

适用于异步命令执行的一个令人感兴趣的方案是,针对相同或不同的数据库服务器来并行执行多个SQL语句。

假设您需要在应用程序中显示有关特定员工的信息,但该信息的一部分位于人力资源数据库中,而与薪资有关的信息位于会计数据库中。如果能够同时向两个数据库发送查询并让二者并行执行,而不是在启动第二个语句之前先等待第一个语句完成,那就太好了。

例如:

// obtain connection strings from configuration files or

// similar facility

// NOTE: these connection strings have to include "async=true", for

// example: 

// "server=myserver;database=mydb;integrated security=true;async=true"

string connstrAccouting = GetConnString("accounting");

string connstrHR = GetConnString("humanresources");

 

// define two connection objects, one for each database

using(SqlConnection connAcc = new SqlConnection(connstrAccounting))

using(SqlConnection connHumanRes  = new SqlConnection(connstrHR)) {

 

  // open the first connection

  connAcc.Open();

 

  // start the execution of the first query contained in the

  // "employee_info" stored-procedure

  SqlCommand cmdAcc = new SqlCommand("employee_info", connAcc);

  cmdAcc.CommandType = CommandType.StoredProcedure;

  cmdAcc.Parameters.AddWithValue("@empl_id", employee_id);

  IAsyncResult arAcc = cmdAcc.BeginExecuteReader();

 

  // at this point, the "employee_info" stored-proc is executing on

  // the server, and this thread is running at the same time


  // now open the second connection

  connHumanRes.Open();

 

  // start the execution of the second stored-proc against

  // the human-resources server

  SqlCommand cmdHumanRes = new SqlCommand("employee_hrinfo", 

                                          connHumanRes);

  cmdHumanRes.Parameters.AddWithValue("@empl_id", employee_id);

  IAsyncResult arHumanRes = cmdHumanRes.BeginExecuteReader();

 

  // now both queries are running at the same time

  // at this point; more work can be done from this thread, or we

  // can simply wait until both commands finish – in our case we'll

  // wait

  SqlDataReader drAcc = cmdAcc.EndExecuteReader(arAcc);

  SqlDataReader drHumanRes = cmdHumanRes.EndExecuteReader(arHumanRes);

 

  // now we can render the results, for example, bind the readers to an ASP.NET

  // web control, or scan the reader and draw the information in a 

  // WebForms form.

}

请注意,此处我们只是简单地调用了一次EndExecuteReader;在数据库操作结束之前,我们没有进行任何其它操作。EndExecuteReader将一直阻塞,直到操作完成,然后返回一个SqlDataReader对象。

对于更为复杂的情况,即,您需要等待多个活动且它们并非全都是异步ADO.NET操作,您可以使用WaitHandle.WaitAll或WaitHandle.WaitAny;IAsyncResult.WaitHandle包含一个可用于同步的事件对象。

这种较复杂方法的一个特定应用是无序呈现。假设您具有一个包含多个数据源的ASP.NET页。您可以执行多个命令,然后在这些命令结束后,呈现该页面的相应部分,同时其他的数据库仍然在处理其他操作。这样,只要有数据可用,我们就可以推进进度,而无需考虑哪一个操作先完成。

下面是一个使用Northwind数据库的有趣的部分示例(完整的示例包含在本文附带的Zip文件中)。请注意,该示例通常对于进入不同数据库的操作更为有用,或者,如果您的数据库服务器的功能足够强大,可以同时处理所有的查询。

 // NOTE: connection strings denoted by "connstring" have to include 

 // "async=true", for example: 

 // "server=myserver;database=mydb;integrated security=true;async=true"

 

 // we'll use three connections for this

 using(SqlConnection c1 = new SqlConnection(connstring))

 using(SqlConnection c2 = new SqlConnection(connstring))

 using(SqlConnection c3 = new SqlConnection(connstring))

 {

  // get customer info

  c1.Open();

  SqlCommand cmd1 = new SqlCommand(

    "SELECT CustomerID, CompanyName, ContactName FROM Customers " +

    "WHERE CustomerID=@id", c1);

  cmd1.Parameters.Add("@id", SqlDbType.Char, 5).Value = custid;

  IAsyncResult arCustomer = cmd1.BeginExecuteReader();

 

  // get orders

  c2.Open();

  SqlCommand cmd2 = new SqlCommand(

    "SELECT * FROM Orders WHERE CustomerID=@id", c2);

  cmd2.Parameters.Add("@id", SqlDbType.Char, 5).Value = custid;

  IAsyncResult arOrders = cmd2.BeginExecuteReader();

 

  // get order detail if user picked an order

  IAsyncResult arDetails = null;

  SqlCommand cmd3 = null;

  if(null != orderid) {

   c3.Open();

   cmd3 = new SqlCommand(

      "SELECT * FROM [Order Details] WHERE OrderID=@id", c3);

   cmd3.Parameters.Add("@id", SqlDbType.Int).Value =         

                                                    int.P***(orderid);

   arDetails = cmd3.BeginExecuteReader();

  }

 

  // build the wait handle array for WaitForMultipleObjects

  WaitHandle[] handles = new WaitHandle[null == arDetails ? 2 : 3];

  handles[0] = arCustomer.AsyncWaitHandle;

  handles[1] = arOrders.AsyncWaitHandle;

  if(null != arDetails)

   handles[2] = arDetails.AsyncWaitHandle;

 

  // wait for commands to complete and render page controls as we 

  // get data back

  SqlDataReader r;

  for(int results = (null==arDetails) ? 1 : 0; results < 3;results++) {

 

   // wait for any handle, then process results as they come

   int index = WaitHandle.WaitAny(handles, 5000, false); // 5 secs

 

   if(WaitHandle.WaitTimeout == index)

    throw new Exception("Timeout");

 

   switch(index) {

    case 0: // customer query is ready

     r = cmd1.EndExecuteReader(arCustomer);

     if (!r.Read())

      continue;

     lblCustomerID.Text = r.GetString(0);

     lblCompanyName.Text = r.GetString(1);

     lblContact.Text = r.GetString(2);

     r.Close();

     break;

 

    case 1: // orders query is ready

     r = cmd2.EndExecuteReader(arOrders);

     dgOrders.DataSource = r; // data-bind to the orders grid

     dgOrders.DataBind();

     r.Close();

     break;

 

    case 2: // details query is ready

     r = cmd3.EndExecuteReader(arDetails);

     dgDetails.DataSource = r; // data-bind to the details grid

     dgDetails.DataBind();

     r.Close();

     break;

    }

   }

  }

 }

}

请注意,在以上两个示例中,通过使用传统的同步ADO.NET和异步委托、诸如QueueUserWorkItem之类的线程池API或用户创建的线程,可以达到类似的效果。但是,在所有情况下,每个命令都将阻塞一个线程。在某些情况下,阻塞线程是件好事,例如客户端应用程序,它也许会危及到中间层和服务器端应用程序之间的可伸缩性。我将在下一部分中就此进行详细讨论。

非阻塞ASP.NET处理程序和页面

Web服务器通常使用线程池的变量来管理用于处理Web页请求(或类似的任何其他类型的请求,例如Web服务或HttpHandler调用)的线程。

在数据库驱动的高负载网站上,使用同步数据库API可能会导致大部分的线程池线程在等待该数据库服务器返回结果时受阻。在这种情况下,Web服务器几乎无法使用CPU和网络,它也不能接受新的请求或将只有极少数的线程可用于请求。

ASP.NET具有一个称为“异步HTTP处理程序”的构造,该构造是实现IHttpAsyncHandler并与扩展名为“ashx”的ASP.NET文件相关联的类。这些类可以处理请求,并以异步方式生成响应。该功能与异步ADO.NET命令很好地集成在一起。

有关IHttpAsyncHandler的详细信息,请参阅MSDN上的IHttpAsyncHandler Interface主题。此外,这里还有一篇有关如何使用IHttpAsyncHandler的好文章,即MSDN Magazine中的Use Threads and Build Asynchronous Handlers in Your Server-Side Web Code(参见http://msdn.microsoft.com/msdnmag/issues/03/06/Threading/。这篇文章提供了非常酷的图片,说明了整个事物是如何在幕后进行运作的;我真希望自己能做出这些图片……)。

包含在本文随附示例中的文件asyncorders.cs和asyncorders.ashx是关于该技术的一个简单而有效的示例。以下是我们感兴趣的部分。

public class AsyncOrders : IHttpAsyncHandler

{

 protected SqlCommand _cmd;

 protected HttpContext _context;

 

 // asynchronous execution support is split between 

 // BeginProcessRequest and EndProcessRequest

 

 public IAsyncResult BeginProcessRequest(HttpContext context,                  

                                         AsyncCallback cb, 

                                         object extraData) {

  // get the ID of the customers we need to list the orders for 

  // (it's in the query string)

  string customerId = context.Request["customerId"];

  if(null == customerId)

   throw new Exception("No customer ID specified");

 

  // obtain the connection string from the configuration file

  string connstring = 

                ConfigurationSettings.AppSettings["ConnectionString"];

 

  // connect to the database and kick-off the query

  SqlConnection conn = new SqlConnection(connstring);

  try {

   conn.Open();

 

   // we use an stored-procedure here, but this could be any statement

   _cmd = new SqlCommand("get_orders", conn);

   _cmd.CommandType = CommandType.StoredProcedure;

   _cmd.Parameters.AddWithValue("@ID", customerId);

   // begin execution of the command. This method will return post 

   // the query

   // to the database and return without waiting for the results

   

   // NOTE: we are passing to BeginExecuteReader the callback 

   // that ASP.NET passed to us; so ADO.NET will call cb directly 

   // once the first database results are ready. You can also use 

   // your own callback and invoke the ASP.NET one as appropiate

   IAsyncResult ar = _cmd.BeginExecuteReader(cb, extraData);

 

   // save the HttpContext to use it in EndProcessRequest

   _context = context;

 

   // we're returning ADO.NET's IAsyncResult directly. a more 

   // sophisticated application might need its own IAsyncResult 

   // implementation

   return ar;

  }

  catch {

   // only close the connection if we find a problem; otherwise, we'll

   // close it once we're done with the async handler

   conn.Close();

   throw;

  }

 }

 

 // ASP.NET will invoke this method when it detects that the async 

 // operation finished

 public void EndProcessRequest(IAsyncResult result) {

  try {

   // obtain the results from the database

   SqlDataReader reader = _cmd.EndExecuteReader(result);

 

   // render the page

   RenderResultsTable(_context, "Orders (async mode)", reader);

  }

  finally {

   // make sure we close the connection before returning from 

   // this method

   _cmd.Connection.Close();

   _cmd = null;

  }

 }

 

 // rest of AsyncOrders members

 // ...

}

在上述示例中,我们可以看到HTTP处理程序是如何处理输入参数、开始数据库查询,然后从BeginProcessRequest方法返回的。通过从该方法返回,我们又将控制权交回给ASP.NET,这样,就可以随时重用该线程来处理另一个请求,而同时数据库服务器处理我们的查询。一旦我们这样做了,信号传输机制将导致EndProcessRequest被调用,我们将完成页面的呈现。请注意,EndProcessRequest可能会在相同或不同的线程上调用。

在需要多个查询以呈现某个页面时,您可以将这些查询作为单个批处理一起发送(如果它们均针对相同的数据库),或者可以使用多个连接并开始多个异步命令执行。在后一种情况中,需要附加的代码来协调这些命令的完成,并在所有命令都结束后通知ASP.NET。

保持WinForms应用程序的响应

如果运行耗时较长的、从WinForms应用程序执行的数据库操作,那么您可能已经注意到了,该应用程序在执行那些操作时会冻结。这是因为该事件处理程序受阻于对数据库的调用,它无法同时处理其他Windows消息。

这可能会使您想到用异步命令执行来解决这个问题。毕竟,这恰好就是我们所需的:一种执行命令并立即从事件处理程序返回的方法。但是在这种情况下,WinForms应用程序技高一筹:您无法从创建WinForms控件的线程以外的线程来接触这些控件;这意味着,您无法启动ADO.NET异步操作,并且在回调中,您也无法用新数据刷新控件或执行与数据绑定的操作。您必须将数据封送回UI线程并在那里进行更新。要使事情更复杂,在许多情况下,您需要许多查询来填充UI,其中一部分可能取决于前一个查询的结果。这意味着,您必须协调多个异步查询,然后将所有结果封送至UI线程,然后再刷新UI控件。

一种更为简单的方法是使用新的BackgroundWorker类(.NET 2.0将提供)。使用该类,您可以执行传统的同步数据库操作,而不会阻塞UI线程。有关BackgroundWorker类的详细信息,请在.NET Framework 2.0的文档可用后,查阅该文档。

如果您在分析了这些选择后,认为它仍然是可以采用的方法,并了解了所有必需的注意事项,那么您仍然可以使用异步命令。我们将这个功能设计为更加适用于那些及其关注避免阻塞线程的方案;一般而言,这对于客户端应用程序并非十分重要。因此,简单的BackgroundWorker值得您一试。

需要牢记的一些细节

这里有几个注意事项,您需要在使用异步命令执行时牢记于心。

错误处理

在命令执行期间,任何时候都可能出现错误。当ADO.NET在启动实际的数据库操作之前检测出错误时,它会从begin方法中抛出异常;这非常类似于直接从对ExecuteReader或类似方法的调用中获得异常的同步情况。其中包括无效参数、相关对象的不良状态(例如,没有为命令设置连接)或某些连接性问题(例如,服务器或网络断开)。

现在,一旦我们将操作发送至服务器并将其返回给用户,那么,如果出现问题,我们将无法及时通知您故障出在何处。我们不能只是引发异常;当我们进行中间处理时,在堆栈中,我们(的代码)之上没有用户代码,因此,如果引发异常,您也不能捕获到该异常。我们在此处所做的就是保留错误信息,并在操作完成时发出信号。之后,当您的代码调用end方法时,我们会检测到处理期间存在错误,并引发异常。

最终结果是,您需要准备处理begin和end方法中的错误。

阻塞操作

即使是在使用异步执行时,仍然有许多情况需要阻塞I/O。下面是一个可能阻塞调用的粗略列表,这些调用均特定于SqlClient:

Begin方法:如果我们必须发送大量的参数或非常长的SQL语句,我们可能会阻塞网络写入。
SqlDataReader.Read:如果应用程序读取数据的速度比服务器产生数据的速度要快,并且网络可以将数据传输至客户端,则Read()可能会阻塞网络读取。
SqlDataReader.Get*:DataReader在其CLR和SQL类型系统(例如,分别是GetString和GetSqlString)中,对于每个数据类型都具有Get方法;如果在begin调用上使用CommandBehavior.SequentialAccess,则这些Get方法可能会阻塞网络读取。
SqlDataReader.Close()和SqlDataReader.Dispose():如果存在尚未使用的挂起行,或者,如果尚未从网络中获取输出参数,那么这些方法可能会阻塞。
取消挂起命令

命令对象具有一个可用于取消执行命令的Cancel()方法。至少对于ADO.NET 2.0 Beta 1而言,在异步命令上不支持该方法。遗憾的是,是否在.NET Framework 2.0的最终版本中支持该功能,我也不能确定。

版本

该功能真正讨人喜欢的事情之一是,它可以在受SqlClient支持的所有SQL Server版本中使用;其中包括SQL Server 7.0、SQL Server 2000和新的SQL Server 2005。

可是,还有一点需要说明:在安装了SQL Server 2005之前版本的服务器上,我们在共享内存上不支持异步执行。因此,如果您在同一台计算机上具有服务器和客户端并希望使用异步命令,请将localhost用作服务器名,或将tcp:添加到服务器名以强迫提供程序使用TCP/IP而不是共享内存。

此外,该功能主要取决于只出现在基于Windows NT的操作系统中的特定功能,这些操作系统包括Windows 2000、Windows XP、Windows 2003 Server以及将来的Windows版本。异步执行在Windows 9x和Windows Me中不受支持。

注 为了试运这些示例,您需要.NET Framework 2.0。您可以使用预发布版本,例如May CTP或Beta1版。

小结

异步命令执行是对ADO.NET的强大扩展。它支持高伸缩性,同时其复杂性会有所增加。

和其他复杂的功能一样,我建议仅在真正需要时使用异步命令执行,因为它会向应用程序添加额外的代码,而将来的所有者必须理解并维护该应用程序。

你可能感兴趣的:(.net)