Jeffrey Richter
Get the sample code for this article.
<script type="text/javascript"> at_attach("dl_parent", "dl_child", "hover", "y", "pointer"); </script>
通常 I/O 操作的特点是速度慢、不可预见。当应用程序执行同步 I/O 操作时,基本上会放弃对正在完成实际工作的设备的控制。例如,如果应用程序调用 StreamRead 方法从 FileStream 或 NetworkStream 读取某些字节,我们无法预计该方法需要多长时间才能返回。如果正在被读取的文件位于本地硬盘上,那么 Read 操作可能会立即返回。如果存储文件的远程服务器脱机,则 Read 方法可能会等待几分钟,然后超时并引发异常。在此期间,发出同步请求的线程会被占用。如果该线程是 UI 线程,则应用程序将被冻结并停止响应用户的输入。
正在等待同步 I/O 完成的线程受阻,意味着该线程虽然空闲,但无法执行有用工作。为提高可伸缩性,许多应用程序开发人员会创建更多线程。但遗憾的是,每个线程都会带来相当大的管理开销,如其内核对象、用户模式和内核模式堆栈、增加的环境切换、调用带有线程附加/分离通知的 DllMain 方法等。最终的结果是可伸缩性被降低了。
如果应用程序希望保持对用户的响应能力、提高可伸缩性和吞吐量并提高可靠性,则不应同步执行 I/O 操作。该应用程序应使用公共语言运行库 (CLR) 异步编程模型 (APM) 来执行异步 I/O 操作。关于如何使用 CLR APM 有许多书面资料,其中包括我的著作《CLR via C#》第二版 (Microsoft Press®, 2006) 的第 23 章。但我没有注意到有哪个资料解释了如何定义一个类,提供用于实施 APM 的方法。因此我决定在此专栏中着重关注这一主题。
开发人员希望实施 APM 基本上有四个原因。第一,您可能要构建一个类,用于直接与硬件(如硬盘上的文件系统、网络、串行端口或并行端口)进行通信。正如上面提到的,设备 I/O 是不可预见的,因此应用程序在与硬件通信时应该执行异步 I/O 操作,以使应用程序保持响应能力、伸缩性和可靠性。
幸运的是,Microsoft® .NET Framework 已经包含了与许多硬件设备通信的类。因此,除非您要定义一个用于与 Framework 类库 (FCL) 不支持的硬件设备(如并行端口)进行通信的类,否则不需要亲自实施 APM。可是尽管 FCL 支持某些设备,但不支持其中的某些特定子功能。在这种情况下,如果您希望执行 I/O 操作,则可能需要实施 APM。例如,FCL 虽然提供了允许应用程序与磁盘进行通信的 FileStream 类,但 FileStream 不允许您访问伺机锁定 (microsoft.com/msj/0100/win32/win320100.aspx)、稀疏文件流 (microsoft.com/msj/1198/ntfs/ntfs.aspx) 或 NTFS 文件系统提供的其他新奇的功能。如果要编写 P/Invoke 包装来调用提供这些功能的 Win32® API,那么您可能会希望包装支持 APM,从而可以异步执行操作。
第二,您可能要在一个已经定义的与硬件直接通信的类上构建一个抽象层。.NET Framework 中已经提供了几个这样的示例。例如,假设您要将数据发送给一个 Web 服务方法。在 Web 服务客户端代理类上,有一个方法可接受您的参数,这些参数可能是一个复杂的数据结构集。在内部,该方法将这些复杂的数据结构序列化为一个字节数组。然后使用 NetworkStream 类(该类已经具备了使用异步 I/O 与硬件通信的能力)通过网络发送该字节数组。另一个示例出现在访问数据库时。ADO.NET SqlCommand 类型提供了 BeginExecuteNonQuery、BeginExecuteReader 和其他 BeginXxx 方法,这些方法可以分析参数,以便将数据通过网络发送到数据库。在 I/O 完成时,会调用相应的 EndExecuteNonQuery、EndExecuteReader 和其他 EndXxx 方法。在内部,这些 EndXxx 方法分析得到的数据并将富数据对象返回给调用方。
第三,您的类可能会提供一种方法,这种方法执行起来可能需要很长时间。在这种情况下,您可能希望提供 BeginXxx 和 EndXxx 方法,为调用方提供方便的 APM。前面的示例最终是 I/O 密集型操作,与之不同,这次您的方法是执行计算密集型操作。由于是计算密集型操作,因此必须使用一个线程来执行该工作;定义 BeginXxx 和 EndXxx 方法只是为了方便您的类用户。
最后,您的类可能包含执行同步 I/O 的 Win32 方法。遗憾的是,有大量执行 I/O 的 Win32 方法,但对于这些方法 Win32 无法异步执行这些 I/O 操作。例如,Win32 注册表和事件日志函数可能会与本地或远程注册表/事件日志通信。Microsoft 可能会创建这些 Win32 函数的异步版本,使线程在这些版本上不受阻。但到目前为止,这些 Win32 函数的异步版本还不存在。当我用托管代码来包装这类方法时,我始终提供 BeginXxx 和 EndXxx 方法,因此即使在我的应用程序不够高效时也能用托管代码完成操作,因为在 Windows® 执行同步 I/O 操作时我的方法肯定存在线程受阻。但是,如果 Microsoft 将这些方法的异步版本添加到 Win32,我可能会更改我的包装,以便利用新方法提高效率,而根本不必更改我的客户端代码。
APM 的核心:IAsyncResult
CLR APM 的核心是 IAsyncResult 接口,定义如图 1 所示。
当调用任何 BeginXxx 方法时,该方法必须在内部构建一个对象,其类型用于实施 IAsyncResult 及其四个只读属性。该对象可识别刚刚启动的异步操作的状态。在 BeginXxx 方法返回给应用程序代码后,应用程序可以查询这些属性,以确定操作是否已完成。此对象还包含已完成操作的状态:如果操作成功完成则是结果值,如果操作没有成功完成则是异常。应用程序将 IAsyncResult 对象传递给 EndXxx 方法,该方法等待操作完成(假定其尚未完成)。EndXxx 方法会返回结果,或引发异常,使调用方获知操作的结果或操作错误。
图 2 定义了一个 AsyncResultNoResult 类,用于实施 IAsyncResult 接口。这个简单的类可以用于没有返回值的异步操作 - 具体来说就是,操作成功或者失败。Stream 的 BeginWrite 和 EndWrite 方法就属于这种情况。当您启动对流的异步写操作时,结果是成功或失败 - EndWrite 方法还原为返回 void。
正如您所见,AsyncResultNoResult 类有一个构造函数,可接受 AsyncCallback 和 Object 参数,这些参数用于启动所有异步操作。该构造函数仅将这些参数保存在私有字段中。IAsyncResult 的 AsyncState 属性将 Object 字段返回给调用方。该类定义了用于实施 IAsyncResult 的 IsCompleted 和 CompletedSynchronously 属性的 m_CompletedState 字段。它还定义了用于实施 IAsyncResult 的 AsyncWaitHandle 属性的 m_AsyncWaitHandle 字段。最后,该类定义了 m_exception 字段。当操作完成时设置此字段。如果操作成功完成,则该字段被设置为 null(与其初始值相同);如果操作失败,则该字段被设置为异常派生的对象,表明失败的原因。
如果对 AsyncResultNoResult 类进行分析,您会发现整个代码非常直观 - 处理 m_AsyncWaitHandle 字段的部分出外。此字段作为对 ManualResetEvent 对象的引用,只有在启动异步操作的代码查询 AsyncWaitHandle 属性或代码在操作实际完成执行之前调用 EndInvoke 方法时才需要该字段。使用 APM 的最常见(也是推荐的)方法就是指定一种 AsyncCallback 方法,当操作完成时应自动调用该方法。对于这种最常见的使用方法,根本不需要 ManualResetEvent 对象。因此,我能够在很大程度上避免创建和使用此对象,除非使用 AsyncResultNoResult 对象的代码确实需要它。
我要极力避免这一点的原因在于,创建和使用内核对象(如 ManualResetEvent)相对来说成本高昂。有关使用内核对象带来的性能损失的详细信息,请参阅我在 2005 年 10 月撰写的“并发事件”专栏 (msdn.microsoft.com/msdnmag/issues/05/10/ConcurrentAffairs)。
当异步操作完成时,一些代码必须调用 AsyncResultNoResult 的 SetAsCompleted 方法,如果操作成功完成则传入空值,如果操作失败则传入对异常派生的对象的引用。该代码还会表明操作是同步完成(几乎从不)还是异步完成(几乎总是)。IAsyncResult 的 CompletedSynchronously 属性会返回这一信息,但应用程序很少会真正关心这一点。
在内部,SetAsCompleted 会将异常保存在 m_exception 字段中,并更改 m_completedSynchronously 字段的状态。然后,如果创建了手动重置事件对象,则会对其进行设置。最后,如果在构建 AsyncResultNoResult 对象时指定了 AsyncCallback 方法,则回调该方法,使应用程序代码获知异步操作是否已完成,从而使其可以处理结果(或失败)。
为获得操作的结果,应用程序代码将调用某种 EndXxx 方法,而该方法将调用 AsyncResultNoResult 的 EndInvoke 方法,以确定操作是否已成功。如果在操作完成前调用了 EndInvoke,则 EndInvoke 会使用手动重置事件挂起调用线程,直到操作完成。如果操作完成,EndInvoke 会返回或引发先前在调用 SetAsCompleted 时保存的异常。
由于许多异步操作都有返回值,我也定义了一个类以支持这种情况:AsyncResult<TResult>(参见图 3)。此泛型类由 AsyncResultNoResult 派生而来,实际上只是增加了对 TResult 类型的返回值的支持。此支持采用私有字段的形式来存放结果 (m_result)、接受 TResult 值的 SetAsCompleted 方法的重载以及等待操作完成的新的 EndInvoke 方法,然后如果操作成功完成则返回结果,如果操作失败则引发异常。
许多 BeginXxx 方法也接受除 AsyncCallback 和 Object 之外的参数。例如,Socket 类具有一种带有 IPAddress(地址)和 Int32(端口)参数的 BeginAccept 方法。如果您希望使用具有 BeginXxx 方法(带有附加的参数)的 AsyncResultNoResult 或 AsyncResult<TResult> 类,您会希望定义由这两个基类中的一个派生出的特有类型(取决于您的 EndXxx 方法是否返回 void)。在您的类中,为每个参数定义一个附加的字段,并在您的类的构造函数中对它们进行设置。然后完成实际工作的方法就可以在适当的时候从您的类的字段提取这些参数值。
实施 APM
现在您了解了如何定义一个类型用以实施 IAsyncResult 接口,接下来我将介绍如何使用我的 AsyncResult<TResult> 和 AsyncResultNoResult 类。我定义了一个 LongTask 类(参见图 4),其中提供了一种同步 DoTask 方法,该方法执行时间较长,会返回一个 DateTime 实例,表明操作是何时完成的。
为方便起见,我还提供了遵循 CLR APM 的 BeginDoTask 和 EndDoTask 方法,使用户可以异步执行 DoTask 方法。当用户调用我的 BeginDoTask 方法时,我会构造一个 AsyncResult<DateTime> 对象。然后我用一个线程池线程调用一个小的帮助器方法 DoTaskHelper,该方法包含对该同步 DoTask 方法的调用。
DoTaskHelper 方法只是通过一个 try 块调用同步版本的方法。如果 DoTask 方法从开始运行到完成的过程中没有出现故障(引发异常),那么我将调用 SetAsCompleted 来设置操作的返回值。如果 DoTask 方法引发异常,则 DoTaskHelper 的 catch 块将捕获异常,并通过调用 SetAsCompleted 表明该操作已完成,传入对异常派生的对象的引用。
应用程序代码调用 LongTask 的 EndDoTask 方法,以获得操作结果。将 IAsyncResult 传递给所有 EndXxx 方法。在内部,EndDoTask 方法获知传递给它的 IAsyncResult 对象是真正的 AsyncResult<DateTime> 对象,对其进行转换并利用它调用 EndInvoke。正如上面讨论的,AsyncResult<TResult> 的 EndInvoke 方法等待操作完成(如果必要),然后返回结果或引发异常,表明已将异步操作的结果返回给调用方。
测试和性能
FunctionalTest 方法(参见图 5)显示了使用我的 APM 实施方法的部分代码。它对 APM 提供的三种集合方法进行了测试:等待直到完成、轮询和回调方法。如果检查该代码,您会发现它与您所见过的其他 APM 的用法完全相同。当然,这是整个练习的关键所在。
PerformanceTest 方法(参见图 6)将我的 IAsyncResult 实施方法与当使用委托的 BeginInvoke 和 EndInvoke 方法时 CLR 提供的实施方法进行了比较。我的实施执行情况似乎比 FCL 的当前实施情况要好,显然是由于后者无论何时创建其 IAsyncResult 对象时始终都要构造 ManualResetEvent,无论应用程序是否需要该事件。
总结
我认为在我们使用 APM 这样的机制时了解 CLR 内部发生的情况非常有趣。在考察了我在本文中介绍的实施方法后,您会对 IAsyncResult 对象的大小、其状态以及它们如何管理其状态有大致的了解。了解这些内容可以帮助您改善构建应用程序的方法,并获得更好的性能。
在本专栏中,我使用我的 IAsyncResult 实施方法来执行采用线程池线程的计算密集型任务。在今后的专栏中,我将介绍如何使用我的 IAsyncResult 实施方法处理 I/O 密集型操作。
将您想向 Jeffrey 询问的问题和提出的意见发送至:mmsync@microsoft.com mmsync@microsoft.com.
Jeffrey Richter是 Wintellect (www.Wintellect.com) 公司的创始人之一,该公司是一家体系结构评估、咨询和培训公司。Jeffrey Richter 是多本图书的作者,其中包括《CLR via C#》(Microsoft Press,2006)。Jeffrey 也是《MSDN 杂志》的一名特约编辑,并且自 1990 年以来一直担任 Microsoft 的顾问。
摘自 March 2007 期刊 MSDN Magazine.
public interface IAsyncResult {
WaitHandle AsyncWaitHandle { get; } // For Wait-Until-Done technique
Boolean IsCompleted { get; } // For Polling technique
Object AsyncState { get; } // For Callback technique
Boolean CompletedSynchronously { get; } // Almost never used
}
internal class AsyncResultNoResult : IAsyncResult
{
// Fields set at construction which never change while
// operation is pending
private readonly AsyncCallback m_AsyncCallback;
private readonly Object m_AsyncState;
// Fields set at construction which do change after
// operation completes
private const Int32 c_StatePending = 0;
private const Int32 c_StateCompletedSynchronously = 1;
private const Int32 c_StateCompletedAsynchronously = 2;
private Int32 m_CompletedState = c_StatePending;
// Field that may or may not get set depending on usage
private ManualResetEvent m_AsyncWaitHandle;
// Fields set when operation completes
private Exception m_exception;
public AsyncResultNoResult(AsyncCallback asyncCallback, Object state)
{
m_AsyncCallback = asyncCallback;
m_AsyncState = state;
}
public void SetAsCompleted(
Exception exception, Boolean completedSynchronously)
{
// Passing null for exception means no error occurred.
// This is the common case
m_exception = exception;
// The m_CompletedState field MUST be set prior calling the callback
Int32 prevState = Interlocked.Exchange(ref m_CompletedState,
completedSynchronously ? c_StateCompletedSynchronously :
c_StateCompletedAsynchronously);
if (prevState != c_StatePending)
throw new InvalidOperationException(
"You can set a result only once");
// If the event exists, set it
if (m_AsyncWaitHandle != null) m_AsyncWaitHandle.Set();
// If a callback method was set, call it
if (m_AsyncCallback != null) m_AsyncCallback(this);
}
public void EndInvoke()
{
// This method assumes that only 1 thread calls EndInvoke
// for this object
if (!IsCompleted)
{
// If the operation isn't done, wait for it
AsyncWaitHandle.WaitOne();
AsyncWaitHandle.Close();
m_AsyncWaitHandle = null; // Allow early GC
}
// Operation is done: if an exception occured, throw it
if (m_exception != null) throw m_exception;
}
#region Implementation of IAsyncResult
public Object AsyncState { get { return m_AsyncState; } }
public Boolean CompletedSynchronously {
get { return Thread.VolatileRead(ref m_CompletedState) ==
c_StateCompletedSynchronously; }
}
public WaitHandle AsyncWaitHandle
{
get
{
if (m_AsyncWaitHandle == null)
{
Boolean done = IsCompleted;
ManualResetEvent mre = new ManualResetEvent(done);
if (Interlocked.CompareExchange(ref m_AsyncWaitHandle,
mre, null) != null)
{
// Another thread created this object's event; dispose
// the event we just created
mre.Close();
}
else
{
if (!done && IsCompleted)
{
// If the operation wasn't done when we created
// the event but now it is done, set the event
m_AsyncWaitHandle.Set();
}
}
}
return m_AsyncWaitHandle;
}
}
public Boolean IsCompleted {
get { return Thread.VolatileRead(ref m_CompletedState) !=
c_StatePending; }
}
#endregion
}
internal class AsyncResult<TResult> : AsyncResultNoResult
{
// Field set when operation completes
private TResult m_result = default(TResult);
public AsyncResult(AsyncCallback asyncCallback, Object state) :
base(asyncCallback, state) { }
public void SetAsCompleted(TResult result,
Boolean completedSynchronously)
{
// Save the asynchronous operation's result
m_result = result;
// Tell the base class that the operation completed
// sucessfully (no exception)
base.SetAsCompleted(null, completedSynchronously);
}
new public TResult EndInvoke()
{
base.EndInvoke(); // Wait until operation has completed
return m_result; // Return the result (if above didn't throw)
}
}
internal sealed class LongTask
{
private Int32 m_ms; // Milliseconds;
public LongTask(Int32 seconds)
{
m_ms = seconds * 1000;
}
// Synchronous version of time-consuming method
public DateTime DoTask()
{
Thread.Sleep(m_ms); // Simulate time-consuming task
return DateTime.Now; // Indicate when task completed
}
// Asynchronous version of time-consuming method (Begin part)
public IAsyncResult BeginDoTask(AsyncCallback callback, Object state)
{
// Create IAsyncResult object identifying the
// asynchronous operation
AsyncResult<DateTime> ar = new AsyncResult<DateTime>(
callback, state);
// Use a thread pool thread to perform the operation
ThreadPool.QueueUserWorkItem(DoTaskHelper, ar);
return ar; // Return the IAsyncResult to the caller
}
// Asynchronous version of time-consuming method (End part)
public DateTime EndDoTask(IAsyncResult asyncResult)
{
// We know that the IAsyncResult is really an
// AsyncResult<DateTime> object
AsyncResult<DateTime> ar = (AsyncResult<DateTime>)asyncResult;
// Wait for operation to complete, then return result or
// throw exception
return ar.EndInvoke();
}
// Asynchronous version of time-consuming method (private part
// to set completion result/exception)
private void DoTaskHelper(Object asyncResult)
{
// We know that it's really an AsyncResult<DateTime> object
AsyncResult<DateTime> ar = (AsyncResult<DateTime>)asyncResult;
try
{
// Perform the operation; if sucessful set the result
DateTime dt = DoTask();
ar.SetAsCompleted(dt, false);
}
catch (Exception e)
{
// If operation fails, set the exception
ar.SetAsCompleted(e, false);
}
}
}
private static void FunctionalTest()
{
IAsyncResult ar;
LongTask lt = new LongTask(5);
// Prove that the Wait-until-done technique works
ar = lt.BeginDoTask(null, null);
Console.WriteLine("Task completed at: {0}", lt.EndDoTask(ar));
// Prove that the Polling technique works
ar = lt.BeginDoTask(null, null);
while (!ar.IsCompleted)
{
Console.WriteLine("Not completed yet.");
Thread.Sleep(1000);
}
Console.WriteLine("Task completed at: {0}", lt.EndDoTask(ar));
// Prove that the Callback technique works
lt.BeginDoTask(TaskCompleted, lt);
Console.ReadLine();
}
private static void TaskCompleted(IAsyncResult ar)
{
LongTask lt = (LongTask)ar.AsyncState;
Console.WriteLine("Task completed at: {0}", lt.EndDoTask(ar));
Console.WriteLine("All done, hit Enter to exit app.");
}
private const Int32 c_iterations = 100 * 1000; // 100 thousand
private static Int32 s_numDone;
private delegate DateTime DoTaskDelegate();
private static void PerformanceTest()
{
AutoResetEvent are = new AutoResetEvent(false);
LongTask lt = new LongTask(0);
Stopwatch sw;
s_numDone = 0;
sw = Stopwatch.StartNew();
for (Int32 n = 0; n < c_iterations; n++)
{
lt.BeginDoTask(delegate(IAsyncResult ar)
{
if (Interlocked.Increment(ref s_numDone) == c_iterations)
are.Set();
}, null);
}
are.WaitOne();
Console.WriteLine("AsyncResult Time: {0}", sw.Elapsed);
s_numDone = 0;
DoTaskDelegate doTaskDelegate = lt.DoTask;
sw = Stopwatch.StartNew();
for (Int32 n = 0; n < c_iterations; n++)
{
doTaskDelegate.BeginInvoke(delegate(IAsyncResult ar)
{
if (Interlocked.Increment(ref s_numDone) == c_iterations)
are.Set();
}, null);
}
are.WaitOne();
Console.WriteLine("Delegate Time: {0}", sw.Elapsed);
}