在 ADO.NET 2.0 以前,通过 Command 类(如 SqlCommand、OleDbCommand等)执行 SQL 命令的线程一定要停下来等待执行结果。ADO.NET 2.0 新增了异步程序访问接口(asynchronous API),让线程发出命令后可以继续执行接下去的程序代码。而在 ADO.NET 2.0 当前的版本只有 SqlClient 支持异步程序访问接口。
以往编写程序时,我们可以直接通过 .NET Framework 所提供的多线程机制,或是以 Delegate 类包装多线程的方式,在 .NET Framework 所提供的异步架构下,设计调用执行 Command 对象实例。这些方法都是让一条工作线程(Worker Thread)停止在后台中等待执行结果,一旦有结果后,工作线程再通过标准的机制告知结果。
而何谓多线程与分时多工呢?我们简单地解释一下。你可以想象自己是一个中央处理器(CPU),老板同时交付你很多的工作。在此假设有 A、B、C 三个工作,而你限制自己每小时换做一个不同的工作,所以第一个小时先做 A,时间到后换做 B,一个小时后再换做 C,如此周而复始地切换,而每一条线程就代表了使用中央处理器的单位时间与数据结构。
而你在工作时,需要从背后的公文柜中把相关的文件搬到办公桌上,时间一到就需要把正在做的数据放回到相关的公文柜中,空出桌面后再从其他的公文柜搬出下一个工作所需的数据。范例中你是 CPU,公文柜就可以类比成存储器,办公桌则是中央处理器的临时存储器。此种线程交换的过程称为 Context Switch。当然,线程的配置还有操作系统与中央处理器的用户模式和核心模式切换、优先权顺序、堆栈配置等等诸多复杂的行为,但上述简略的类比应已经让你有了利用中央处理器时间区段所完成多线程程序运行的概念。
另外,ADO.NET 2.0 的 SqlClient 利用到 Windows 2000/XP/2003 等操作系统提供的非同步 I/O 机制,也就是每个进程(Process)只需要利用一条线程来处理所有的 I/O 需求,而不是程序设计师自己创建多条工作线程分别等待不同的执行结果。如此可以避免整个应用程序耗在等待的工作线程,有效使用操作系统与中央处理器资源,进而提升整体系统的执行效率。
12.2.1 异步执行的方法
原本 ADO.NET 的 Command 对象执行 SQL 语法的方法有 ExecuteReader、ExecuteNonQuery、ExecuteXmlReader 以及 ExecuteScalar 等,搭配 .NET Framework 原来就提供的异步模型惯例,除了 ExecuteScalar 方法外,其余的方法都新增了以 Begin 和 End 关键字开始的一对方法。也就是说 ExecuteReader 方法是同步执行,若要以异步的方式执行相同的功能,则调用 BeginExecuteReader 和 EndExecuteReader 这一组方法。
在 .NET Framework 中,以 Begin 为字首的函数负责传入同名函数所需的参数,而以 End 为字首的函数用来取回执行结果,例如某个函数的定义如下:
Function MyFun(ByVal intIn As Integer) As myObj
则以异步调用的起始函数定义如下:
Function BeginMyFun(ByVal intIn As Integer, ByVal callback As System.AsyncCallback, ByVal asyncState As Object) As IAsyncResult
除了原来函数所传入的 intIn 参数放在第一个位置外,Begin~ 系列的函数会多加存放回调函数(Delegation)的指针参数,也就是上述语法中的 callback 参数。并提供语法中的 asyncState 参数,让你设置想要带到 End~ 对应函数的信息。而 Begin~ 系列函数最后返回的是代表异步执行状态的 IAsyncResult 对象实例,而不是原本同步执行函数的返回结果,你可以藉此查询异步执行的状况。
而获得执行结果的函数定义如下:
Function EndMyFun(ByVal asyncResult As System.IAsyncResult) As myObj
在调用与 Begin~ 对应的 End~ 函数时,需要带入 Begin~ 函数所返回的 IAsyncResult 对象实例。另外,你可以看到原先我们同步执行时返回的 myObj 对象,在此通过 End~ 函数返回,也就是异步执行完毕后,取回与原先同步执行函数相同的执行结果。
由于我们在执行完 Command 对象访问数据库的方法后,都会返回对象,如 ExecuteReader 取回 DataReader 实例;ExecuteNonQuery 取回受影响的记录条数;ExecuteXmlReader 取回 XmlReader 实例。因此大概都需要通过 End 系列函数来获得执行结果,否则这些结果就遗失在系统中。
若要异步执行 Command 命令,另一个必需设置的是:数据库连接字符串内要加上 async=true 属性。若连接字符串没有加上该属性,而通过 Command 对象实例调用异步执行的方法,则会产生异常(exception)。若 Command 通过连接执行时,重头到尾都是以同步的方式执行,则依照默认 async=false 的方式设置比较节省资源。若某些命令需要同步执行,另一些需要异步执行,则可以考虑使用不同的连接。
在介绍范例应用程序前,我们先稍微谈一下 .NET Framework 所提供的公共的异步运行应用程序设计模式,不只是 ADO.NET 2.0,在其他访问耗时的程序编写上,也都可以套用这个模式。
12.2.2 异步运行应用程序设计模式
.NET Framework 内置了让应用程序异步运行的功能,让你在编写应用程序时,不会因为某些耗时等待的操作让程序停止响应,操作界面停滞让用户感觉起来好像死机一样。一般会以多线程的方式处理这种需求,但若你不熟悉线程的运行,或是想利用线程池(Thread Pool)的好处,都可以在较为耗时的操作上,采用 .NET Framework 所提供的异步功能。
一般来说文件 I/O、网络访问乃至于 Web Services 访问,以及本节所讨论的 DB 访问等都较为耗时,.NET Framework 为这一类的类都提供了上述以 Begin~/End~ 开头的非同步执行方法,而这些方法皆成对出现。当然,也有可能是自己编写的函数其商业逻辑非常复杂,导致调用该函数后,需要等待一段时间来完成,这时还可以通过 .NET Framework 所提供的委托(Delegate)类来创建异步运行。
而所谓的异步运行,其工作方式如图12-2 所示:

图12-2 非同步运行的工作模型
在以往我们都是自己创建工作线程,让耗时的工作以其他的线程来执行,主线程依然与用户交互。但 .NET Framework 通过异步工作模型,将多线程程序设计与编写的复杂度隐藏起来,只要套用这个模型,则图12-2 右边的部分 .NET Framework 会在下层删除,我们从程序代码的表面上看不出多线程的痕迹。同时,.NET Framework 在创建工作线程时,会通过线程池,所以也可以采用较有效率的多线程使用方式。
12.2.3 异步运行如何再度同步的设计模型
当主线程创建工作线程,异步地展开另外一项工作后,通常会需要参照该工作线程的执行结果。而主线程如何取得工作线程当前执行的状况,或是接收工作结果?.NET Framework 的异步运行模型提供四种让主线程和工作线程同步的方式:
回调函数(callback function):在一开始调用 Begin~ 函数时,当传入非同步执行结束后,可以调用函数所形成的委托实例。当工作线程完成所赋予的工作后,通过委托回调你所指定的回调函数。
这是最常用的方式,适合一般 Windows Form 事件驱动的工作模式,执行的步骤过程我们在之后的范例程序将会解释。
轮询(poll):通过 Begin~ 函数返回实现 IAsyncResult 接口的对象,当需要知道工作线程是否执行结束时,就检查该界面的 IsCompleted 属性,若是 true,表示工作线程执行完毕,反之,工作线程还在继续执行。
调用 End~ 函数:这会中断当前的线程,等到异步的工作做完才能继续执行。当主线程交付工作线程某项工作后,主线程继续自身的工作,但当所有可做的事情都已经做完后,必须要有工作线程的执行结果才能继续下去。可以调用 End~ 函数,一旦调用了这个函数,主线程就被迫停下来,直到异步工作完成为止。
等待同步对象收到通知:IAsyncResult 接口的 AsyncWaitHandle 属性会返回 WaitHandle 同步对象,你可以通过这个同步对象要求主线程等待通知,当工作线程执行完毕后,就会通知这个 WaitHandle 同步对象,所有等待的线程就可以继续执行下去。此种方式的效果与前一种调用 End~ 函数相似。
但如果有多个异步工作同时执行,则可以通过 System.Threading.WaitHandle.WaitAll 或 WaitAny 等方法来等待多个异步执行的 WaitHandle 对象。
若以我和你一起工作来比喻两条线程的协作,通过回调函数的方式,我把我的手机号码给你,当你做完事后打电话告诉我,而电话号码就可以比作回调函数。而轮询是你给我你的手机号码,我会经常打电话给你,问你做完没有。调用 End~ 函数则是我做我的,你做你的,当我做完后停下来一直等你做完我才能再继续做。等待同步对象收到通知与前述调用 End~ 函数的方式相似,但可以通过 WaitAll 等待多个人都完成工作,我才继续做,或者是通过 WaitAny 在多个人同时做时,只要有一个人做完,我就先接着处理该人的工作,而又有别人做完时,我再接手第二个人的工作,依此类推完成所有人的工作。
我们就上述四种取得执行结果的方式分别列举通过回调函数和等待同步对象收到通知两种机制为例,各提供一个小范例程序说明如下。首先是以回调函数来取得执行结果,其所设计的范例程序界面如图12-3所示:

图12-3 等待异步访问数据的结果时,更新用户画面的内容
在这个范例中,故意访问一个耗时的存储过程,若以同步的方式执行,一旦调用该存储过程后,整个应用程序开始等待 SQL Server 返回结果,因此画面就静止不动了。而我们采用异步的方式来调用该存储过程,由于主线程还是在负责用户界面,因此画面仍可以通过 Timer 所触发的事件来更新程序进行的状态。
实际执行异步查询的范例时,我们先在服务器创建如程序代码列表12-2的存储过程:
程序代码列表12-2 创建会停滞执行数秒钟的存储过程
USE AdventureWorks
GO
CREATE PROC spGetProduct @Delay NVARCHAR(2)
AS
DECLARE @str nvarchar(10)
SET @str='00:00:' + @Delay
--设置返回结果前要等待的秒数
WAITFOR DELAY @str
SELECT Name FROM Production.Product
在上述范例中我们通过 WAITFOR DELAY 语法让 SQL Server 端的存储过程执行停滞一段时间后才返回结果,以此模拟数据库服务器执行耗时命令的状况,前端程序若不以非同步的方式执行查询,将会让用户界面显示如死机一般的情境。程序代码范例如列表2-3:
程序代码列表12-3 通过异步方式执行查询语法
Private cmd As SqlCommand
Dim conn As SqlConnection
Delegate Sub PopulateList(ByVal dReader As SqlDataReader)
Private Sub AsyncForm_Load(ByVal sender As Object, ByVal e As System.EventArgs) _
Handles Me.Load
conn = New SqlConnection(ConfigurationSettings.ConnectionStrings("AWConnectionString").ConnectionString)
End Sub
Private Sub getDataButton_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) _
Handles getDataButton.Click
'利用 command 对象的 BeginExecuteReader 方法非同步地访问 DataReader
productListBox.Items.Clear()
cmd = New SqlCommand("spGetProduct", conn)
conn.Open()
statusLabel.Text = "Connected"
With cmd
.CommandType = CommandType.StoredProcedure
.Parameters.Add(New SqlParameter("@Delay", SqlDbType.NVarChar, 2))
.Parameters(0).Value = TextBox1.Text
.BeginExecuteReader(New AsyncCallback(AddressOf HandleAsyncCallBack),Nothing)
End With
'因为是异步,所以主线程可以继续执行以下的程序代码
ProgressBar1.Maximum = Integer.Parse(TextBox1.Text)
ProgressBar1.Value = 0
Timer1.Enabled = True
statusLabel.Text = "正在取数据"
End Sub
Public Sub HandleAsyncCallBack(ByVal result As IAsyncResult)
'异步回调函数,工作线程等到结果后,会执行这个函数
Dim dRead As SqlDataReader
Try
‘取回执行 SQL 的查询结果
dRead = cmd.EndExecuteReader(result)
'将结果交还主线程更新用户画面
Me.Invoke(New PopulateList(AddressOf ListProducts), dRead)
Catch ex As Exception
MessageBox.Show(ex.Message)
End Try
End Sub
Public Sub ListProducts(ByVal dReader As SqlDataReader)
'主线程用来更新画面的函数
Timer1.Enabled = False
ProgressBar1.Value = ProgressBar1.Maximum
While dReader.Read
productListBox.Items.Add(dReader("Name"))
End While
dReader.Close()
conn.Close()
statusLabel.Text = "断掉连线"
End Sub
Private Sub Timer1_Elapsed(ByVal sender As System.Object, _
ByVal e As System.Timers.ElapsedEventArgs) Handles Timer1.Elapsed
'通过 Timer 定时更新工作进度状态
If ProgressBar1.Value < ProgressBar1.Maximum _
Then ProgressBar1.Value += 1
End Sub Private Sub Timer1_Elapsed(ByVal sender As System.Object, _
在上述范例中我们先编写了符合 AsyncCallback Delegate 类所定的函数签名(signature)的回调函数 HandleAsyncCallBack,在主线程通过以下语法转交工作线程做数据库查询时,一并告知当查询完毕获得结果后,工作线程要执行的回调函数。
.BeginExecuteReader(New AsyncCallback(AddressOf HandleAsyncCallBack), Nothing)
当工作线程取回执行结果后,会直接执行 HandleAsyncCallBack。若如上述范例需要更新画面,这通常要主线程来完成,否则会造成多条线程抢资源,而 .NET Windows Form 和控制项为了性能并未提供多线程同步的机制,多条线程同时执行相同的程序代码,将导致变量内容中的数据结构错乱。因此在回调函数中通过以下的语法将更新画面的工作交回主线程执行:
Me.Invoke(New PopulateList(AddressOf ListProducts), dRead)
上述通过回调函数执行异步的工作是最常使用的机制。接着,我们再以等待同步对象收到通知机制编写另一个程序代码范例。程序画面如图12-4 所示:

图12-4 异步同时执行并等待三个查询结果
在这个程序范例中,我们模拟同时访问不同的数据源,因为能够简单地在同一台机器上测试,所以仅同时访问 AdventureWorks 范例数据库上的三个数据表。但在真实世界中,你可能是从不同的数据库服务器中各自取得相关的记录,而有些环境执行比较快,另一些环境较没有效率,因此数据会先后取得。这时可以 WaitHandle 类的静态方法 WaitAny 将先取得的结果先处理,后返回的结果后处理。程序代码范例如列表12-4所示:
程序代码列表12-4 以 WaitHandle 对象等待执行完毕后再更新画面
Button1.Enabled = False
Dim strDelay(2) As String
Dim cmd(2) As SqlCommand
Dim hdl(2) As System.Threading.WaitHandle
Dim dgv() As DataGridView = {DataGridView1, DataGridView2, DataGridView3}
Dim ar(2) As IAsyncResult
Dim dr(2) As SqlDataReader
Dim dt(2) As System.Data.DataTable
Dim rnd As New Random
Dim i As Integer
Label1.Text = "三个查询分别等待 "
Dim strCnn As String = _
ConfigurationSettings.ConnectionStrings("AWConnectionString").ConnectionString
Using cnn As New SqlConnection(strCnn), cnn1 As New SqlConnection(strCnn), _
cnn2 As New SqlConnection(strCnn)
For i = 0 To 2
strDelay(i) = rnd.Next(1, 5).ToString()
Label1.Text &= strDelay(i).ToString() & ","
'查询时分别让 SQL Server 等待一段随机数时间,以模拟不同的执行性能
strDelay(i) = "WAITFOR DELAY '00:00:0" & strDelay(i) & "'"
cmd(i) = New SqlCommand()
dt(i) = New System.Data.DataTable()
dgv(i).DataSource = Nothing
Next i
Label1.Text = Label1.Text.Substring(0, Label1.Text.Length - 1) & " 秒。"
Me.Refresh()
With cmd(0)
.CommandText = strDelay(0) & _
" SELECT TOP 1000 CustomerID,SalesPersonID,CustomerType FROM Sales.Customer"
.Connection = cnn
cnn.Open()
'非同步执行,并取回同步对象 WaitHandle
ar(0) = .BeginExecuteReader()
hdl(0) = ar(0).AsyncWaitHandle
End With
With cmd(1)
.CommandText = strDelay(1) & _
" SELECT TOP 1000 SalesOrderID,OrderDate,CustomerID FROM Sales.SalesOrderHeader"
.Connection = cnn1
cnn1.Open()
ar(1) = .BeginExecuteReader()
hdl(1) = ar(1).AsyncWaitHandle
End With
With cmd(2)
.CommandText = strDelay(2) & _
" SELECT TOP 1000 SalesOrderID,ProductID,LineTotal FROM Sales.SalesOrderDetail"
.Connection = cnn2
cnn2.Open()
ar(2) = .BeginExecuteReader()
hdl(2) = ar(2).AsyncWaitHandle
End With
Dim iHdl As Integer
'当三个查询中有一个完成时,就让主线程先更新该 Handle 所代表的查询
For i = 0 To 2
iHdl = System.Threading.WaitHandle.WaitAny(hdl, 5000, False)
If (System.Threading.WaitHandle.WaitTimeout = iHdl) Then
'Throw New Exception("等待超过 5 秒钟都没有任何查询返回")
MessageBox.Show("等待超过 5 秒钟都没有任何查询返回")
Exit For
End If
'取回查询结果所形成的 SqlDataReader
dr(iHdl) = cmd(iHdl).EndExecuteReader(ar(iHdl))
'直接通过 SqlDataReader 将记录装载到 DataTable
dt(iHdl).Load(dr(iHdl))
dgv(iHdl).DataSource = dt(iHdl)
Label1.Text &= iHdl.ToString() & " 返回 "
'更新画面
Me.Refresh()
Next
For i = 0 To 2
If Not dr(i) Is Nothing AndAlso Not dr(i).IsClosed Then dr(i).Close()
'hdl(i).Close()
Next
End Using
Button1.Enabled = True
在上述范例中,我们分别通过三个 SqlCommand 对象实例对 SQL Server 分别要求三段耗时的查询,并以数组存放代表异步执行状态的 WaitHandle。主线程通过 WaitHandle 类的静态方法 WaitAny等待,只要工作线程有完成任何工作,就接手该工作结果来更新画面,而 WaitAny 的调用如下:
iHdl = System.Threading.WaitHandle.WaitAny(hdl, 5000, False)
我们设置了最多等 5 秒钟(也就是程序中的 5000 毫(103)秒),因此若等待过久,会在超时后自行抛出例外。
最后要提醒一点的是错误处理,若是 ADO.NET 自身可以检测到的错误,如参数传递个数或类型不对,连接没开等等,则在执行 Begin 函数时就会直接触发例外。但若是 DB 服务器端返回的错误,则在异步执行时,ADO.NET 的底层不能任意丢出错误,因为可能并没有上层函数的调用堆栈。只有将错误记录着,若你的程序有调用 End 函数,才丢出该异常。





