。NET Framework 委托:理解异步委托






4.76/5 (15投票s)
一篇帮助阐明委托及其如何异步使用的文章。
引言
委托是 .NET Framework 支持的特殊类型,代表强类型的方法签名。委托可以实例化并绑定到任何匹配方法签名的方法和实例组合。C# 允许使用 delegate
关键字创建特殊类。我们将这种类称为委托类。这些委托类的实例称为委托对象。从概念上讲,委托对象是对一个或多个方法(静态或实例)的引用。然后,我们可以使用相同的语法调用/触发委托对象来调用方法。这会导致对方法的调用。但请注意,对这些方法的调用是由调用委托对象的同一线程完成的。我们称之为同步调用。但是,在进行同步方法调用时,调用线程在调用活动期间会被阻塞。在线程被阻塞期间,它可以创建其他线程,尽管 CPU 可能处于空闲状态。因此,即使这些线程可能不消耗 CPU,它们也在浪费资源,这是不经济的。当线程进行异步方法调用时,调用会立即返回。调用线程不会被阻塞;它可以执行其他任务。 .NET 基础结构获取一个线程来调用方法,并传递调用代码传递的入参。然后,异步线程可以与调用线程并行运行该方法。如果该方法生成一些数据并返回该值,调用线程必须能够访问这些数据。 .NET 异步功能支持两种机制:调用线程可以请求结果,或者基础结构可以在结果准备好后将结果传递给调用线程。本文的目的是解释委托以及如何异步使用它们。
委托(Delegates)
在 C# 中,使用 delegate
关键字创建新的委托类型。
public delegate void MyDelegate(int x, int y);
这表示我们创建了一个名为 MyDelegate
的新 delegate
类型,它可以绑定到返回类型为 void
且接受两个 int
类型参数的方法。然后,我们的委托可以绑定到一个目标,传递,并在将来某个时候被调用。C# 中的调用看起来像一个普通的函数调用。
class Foo
{
void PrintPair(int a, int b)
{
Console.WriteLine("a = {0}", a);
Console.WriteLine("b = {0}", b);
}
void CreateAndInvoke()
{
// implied 'new MyDelegate(this.PrintPair)':
MyDelegate del = PrintPair;
del(10, 20);
}
}
还不明白?CreateAndInvoke
使用当前 this
指针作为目标,创建一个新的 MyDelegate
,并绑定到 PrintPair
方法。C# 编译器实际发出的 IL 代码显示了委托在底层类型系统中存在的一些复杂性。
struct MyDelegate : System.MulticastDelegate
{
public MyDelegate(object target, IntPtr, methodPtr);
private object target;
private IntPtr methodPtr;
public internal void Invoke(int x, int y)
public internal System.IAsyncResult BeginInvoke(int x, int y,
System.IAsyncCallback callback, object state);
public internal void EndInvoke(System.IAsyncResult result);
}
构造函数用于将委托绑定到目标对象和函数指针。Invoke
、BeginInvoke
和 EndInvoke
方法实现了委托调用例程,并被标记为 internal
(即,在 IL 中表示运行时),以指示 CLR 它提供了实现;它们的 IL 主体留空。Invoke 执行同步调用,而 BeginInvoke
和 EndInvoke
函数遵循异步编程模型模式。但请注意,首先,MyDelegate
类型违反了一个规则,即结构体不能派生自 System.ValueType
以外的其他类型。委托在通用类型系统 (CTS) 中有特殊支持,因此这是允许的。另请注意,MyDelegate
派生自 MulticastDelegate
;该类型是 C# 中所有委托的通用基类,并支持具有多个目标的委托。考虑另一个委托创建。
public delegate int StringDelegate(string str);
这可以在类中或全局范围内声明。C# 编译器将从该声明生成一个新类,并派生自 System.MulticastDelegate
。检查这个类及其基类 System.Delegate
的方法(再次)。
public sealed class StringDelegate : System.MulticastDelegate
{
public StringDelegate (object obj, int method);
public virtual int Invoke(string str);
public virtual IAsyncResult BeginInvoke(string str,
AsyncCallback asc, object stateObject);
public virtual int EndInvoke(IAsyncResult result);
}
现在,让我们看一些与我们第一个委托创建 MyDelegate
相关的代码。
using System;
public static class App {
public delegate void MyDelegate(int x, int y);
public static void PrintPair(int a, int b)
{
Console.WriteLine("a = {0}", a);
Console.WriteLine("b = {0}", b);
}
public static void Main()
{
// Implied 'new MyDelegate(this.PrintPair)':
MyDelegate d = PrintPair;
// Implied 'Invoke':
d(10, 20);
}
}
编译后,会产生此输出。
a = 10
b = 20
委托内部
假设我们(再次)定义了自己的 MyDelegate
类型,像这样在 C# 中。
delegate string MyDelegate(int x);
现在我们知道这代表了一个函数指针类型,它可以引用任何接受单个 int
参数并返回 string
的方法。而且,我们也知道在处理此委托类型的实例时,我们将声明类型为 MyDelegate
的变量。此外,我们知道在后台,编译器正在为我们生成一个新类(类型)。
private sealed class MyDelegate : MulticastDelegate
{
public extern MyDelegate(object object, IntPtr method);
public extern virtual string Invoke(int x);
public extern virtual IAsyncResult BeginInvoke(int x,
AsyncCallback callback, object object);
public extern virtual string Endinvoke((IAsyncResult result);
}
现在,假设我们有自己的自定义类型 MyType
,其中有一个名为 MyFunc
的方法,其签名与 MyDelegate
完全匹配。现在请注意,参数的名称不完全相同。这没关系,因为委托只需要找到预期的类型在正确签名中的位置。
class MyType
{
public string MyFunc(int foo)
{
return "MyFunc called with the value '" + foo + "' foo foo;
}
}
一旦我们在元数据中有了委托类型和我们想要调用的目标函数,我们必须形成一个委托实例绑定到一个目标。这使用构造函数 MyDelegate(object, IntPtr)
创建委托类型的一个新实例。代码将目标作为第一个参数,将指向函数代码的指针作为第二个参数。其语法是:
MyType mt = new MyType();
MyDelegate md = mt.MyFunc;
所以现在,我们将各部分组合在一起形成整体。
using System;
delegate string MyDelegate(int x);
class MyType
{
public string MyFunc(int foo)
{
return "MyFunc called with the value '" + foo + "' for foo";
}
}
public class Program {
public static void Main()
{
MyType mt = new MyType();
MyDelegate md = mt.MyFunc;
Console.WriteLine(md.Invoke(5));
Console.WriteLine(md(5));
}
}
代码编译并输出此结果。
MyFunc called with the value '5' for foo
MyFunc called with the value '5' for foo
那么什么是异步委托?
在使用异步委托之前,需要记住的是,所有委托类型都会自动提供两个名为 BeginInvoke
和 EndInvoke
的方法。这些方法的签名基于包含它们的委托类型的签名。例如,以下委托类型:
delegate int MyDelegate(int x, int y)
通过编译器生成暴露以下方法:
IAsyncResult BeginInvoke(int x, int y, AsyncCallback callback,
object object, IAsyncResult result);
int EndInvoke (IAsyncResult result);
这两个方法是由编译器生成的。要以异步方式调用方法,您必须首先使用具有相同签名的委托对象引用它。然后,您必须在该委托对象上调用 BeginInvoke
。正如您所见,编译器确保 BeginInvoke
方法的第一个参数是要调用的方法的参数。该方法的最后两个参数,IAsyncResult
和 object
,稍后将讨论。异步调用的返回值可以通过调用 EndInvoke
方法来恢复。编译器还确保 EndInvoke
的返回值与委托类型的返回值相同(在我们的示例中,该类型是 int
)。调用 EndInvoke
是阻塞的,这意味着调用将仅在异步执行完成后返回。以下示例说明了对 ShowSum
方法的异步调用。
using System;
using System.Threading;
public class Program {
public delegate int TheDelegate( int x, int y);
static int ShowSum( int x, int y ) {
int sum = x + y;
Console.WriteLine("Thread #{0}: ShowSum() Sum = {1}",
Thread.CurrentThread.ManagedThreadId, sum);
return sum;
}
public static void Main() {
TheDelegate d = ShowSum;
IAsyncResult ar = d.BeginInvoke(10, 10, null, null);
int sum = d.EndInvoke(ar);
Console.WriteLine("Thread #{0}: Main() Sum = {1}",
Thread.CurrentThread.ManagedThreadId, sum);
}
}
输出
Thread #3: ShowSum() Sum = 20
Thread #1: Main() Sum = 20
BeginInvoke
方法为底层委托的每个参数(类似于 Invoke
)提供一个参数,并添加两个参数:一个 IAsyncCallback
委托,在异步操作完成时会被调用;一个 object
,它作为 IAsyncResult.AsyncState
属性值传递给回调函数。该方法返回一个 IAsyncResult
,可用于监视完成、等待 WaitHandle
或完成异步调用。
public interface IAsyncResult
{
// Properties
object AsyncState { get; }
WaitHandle AsyncWaitHandle { get; }
bool CompletedSynchronously { get; }
bool IsCompleted { get; }
}
当委托执行完成后,您必须在委托上调用 EndInvoke
,并将 IAsyncResult
传递进去。这会清理 WaitHandle
(如果已分配),如果委托执行不正确则会抛出异常,并且其返回类型与底层方法的返回类型匹配。它返回委托调用返回的值。
using System;
public sealed class Program {
delegate int IntIntDelegate(int x);
private static int Square(int x) { return x * x; }
private static void AsyncDelegateCallback(IAsyncResult ar)
{
IntIntDelegate f = (IntIntDelegate)ar.AsyncState;
Console.WriteLine(f.EndInvoke(ar));
}
public static void Main()
{
IntIntDelegate f = Square;
/* Version 1: Spin wait (quick delegate method) */
IAsyncResult ar1 = f.BeginInvoke(10, null, null);
while (!ar1.IsCompleted)
// Do some expensive work while it executes.
Console.WriteLine(f.EndInvoke(ar1));
/* Version 2: WaitHandle wait (longer delegate method) */
IAsyncResult ar2 = f.BeginInvoke(20, null, null);
// Do some work.
ar2.AsyncWaitHandle.WaitOne();
Console.WriteLine(f.EndInvoke(ar2));
/* Version 3: Callback approach */
IAsyncResult ar3 = f.BeginInvoke(30, AsyncDelegateCallback, f);
// We return from the method (while the delegate executes).
}
}
输出
100
400
现在,如果方法有作为参数传递的引用类型参数,那么异步方法将能够调用这些引用类型的方法来更改其状态。您可以使用此方法从异步方法返回。下面的代码显示了一个示例。GetData
委托接受一个 System.Array
类型的对象作为 in
参数。由于它是按引用传递的,因此该方法可以更改数组中的值。但是,由于两个线程都可以访问该对象,因此您必须确保在异步方法完成之前不访问此共享对象。
using System;
class App
{
delegate void GetData(byte[] b);
static void GetBuf(byte[] b)
{
for (byte x = 0; x < b.Length; x++)
b[x] = (byte)(x*x);
}
static void Main()
{
GetData d = new GetData(App.GetBuf);
byte[] b = new byte[10];
IAsyncResult ar;
ar = d.BeginInvoke(b, null, null);
ar.AsyncWaitHandle.WaitOne();
for (int x = 0; x < b.Length; x++)
Console.Write("{0} ", b[x]);
}
}
输出
0
1
4
9
16
25
36
49
64
81
100
要使用回调方法,您需要使用类型为 System.AsyncCallback
的委托对象引用它,该对象作为 BeginInvoke
方法的倒数第二个参数传递。该方法本身必须符合委托类型,这意味着它必须具有 void
返回类型(在下面的示例中)并接受一个类型为 IAsyncResult
的单个参数。
using System;
using System.Threading;
using System.Runtime.Remoting.Messaging;
class Program {
public delegate int MyDelegate(int x, int y);
static AutoResetEvent e = new AutoResetEvent(false);
static int WriteSum( int x, int y) {
Console.WriteLine("Thread# {0}: Sum = {1}",
Thread.CurrentThread.ManagedThreadId, x + y);
return x + y;
}
static void SumDone(IAsyncResult async) {
Thread.Sleep( 1000 );
// AsyncResult of the System.Runtime.Remoting.Messaging namepsace
MyDelegate func = ((AsyncResult) async).AsyncDelegate as MyDelegate;
int sum = func.EndInvoke(async);
Console.WriteLine("Thread# {0}: Callback method sum = {1}",
Thread.CurrentThread.ManagedThreadId, sum);
e.Set();
}
static void Main() {
MyDelegate func = WriteSum;
// the C# 2.0 compiler infer a delegate object of type
// AsyncCallback to reference the SumDone() method
IAsyncResult async = func.BeginInvoke(10, 10, SumDone, null);
Console.WriteLine("Thread# {0}: BeginInvoke() called! Wait for SumDone() completion.",
Thread.CurrentThread.ManagedThreadId);
e.WaitOne();
Console.WriteLine("Thread# {0}: Bye....",
Thread.CurrentThread.ManagedThreadId);
}
}
编译此代码会产生以下结果。
Thread# 1: BeginInvoke() called! Wait for SumDone() completion.
Thread# 3: Sum = 20
Thread# 3: Callback method sum = 20
Thread# 1: Bye....
关于 control.BeginInvoke 与 delegate.BeginInvoke 的重要说明
本文使用了 C# 2.0 语法,因此其基础是编写 .NET Framework 2.0 平台上的委托代码。我之所以这样说,是因为在技术文档中,委托的主题经常会与事件耦合或有关联。事件是对象(控件)发送的消息,用于指示某个操作的发生。该操作可能是由某些用户交互引起的,例如鼠标单击,也可能是由其他程序逻辑触发的。引发事件的对象称为事件发送者。捕获事件并响应它的对象称为事件接收者。在事件通信中,事件发送者类不知道将哪个对象或方法接收(处理)它引发的事件。需要一个中介(或指针类机制)来连接源和接收者。一种称为委托的特殊类型提供了函数指针的功能。委托是一个可以持有对方法的引用的类。如前所述,委托类具有签名,并且只能持有与其签名匹配的方法的引用。在编写 Windows Forms 代码时,以下示例显示了事件委托声明。
public delegate void AlarmEventHandler( object sender, EventArgs e);
因此,虽然我们研究了如何使用委托以理解如何异步使用它们,但我们也应该澄清委托的其他用法(在本例中是 Windows Forms UI)。事件处理程序委托的标准签名定义了一个不返回任何值的方法,其第一个参数是 Object 类型,引用引发事件的实例,其第二个参数派生自 EventArgs 类型并保存事件数据。EventHandler 是一个预定义的委托,它专门代表一个不生成数据的事件的事件处理程序方法。要将事件与将要处理该事件的方法关联起来,请将委托的实例添加到事件中。除非您移除委托,否则每当事件发生时都会调用事件处理程序。话虽如此,让我们简要看一下多线程和 GUI 应用程序。
如果您曾参与过 Win32 开发,那么您会知道通常是同步访问 API;一个线程启动某个任务,然后耐心地等待任务完成。如果代码达到更高级别,它可能会创建一个工作线程来执行此同步调用,从而冻结主线程继续其工作。使用工作线程执行长时间阻塞调用对于 GUI 应用程序至关重要,因为阻塞线程会泵送消息队列,从而禁用应用程序的 UI。创建这样的工作线程的过程从来都不是直观的。简而言之,线程创建成本很高。每次需要进行阻塞调用时都创建一个新的工作线程可能会导致线程过多,从而增加资源消耗。在 .NET 中,异步执行可以是一种有价值的设计技术。例如,当您想执行一个耗时命令而不阻塞用户界面的响应能力时,您应该在 Windows® Forms 应用程序中使用异步执行。正如您将看到的,通过委托进行编程可以相对轻松地在辅助线程上异步运行命令。这意味着您可以构建一个 Windows Forms 应用程序,该应用程序可以在不冻结用户界面的情况下跨网络执行耗时调用。这里就出现了 control.BeginInvoke 与 delegate.BeginInvoke 的问题。当您调用 Control.BeginInvoke 时,Control.Invoke 调用将在一个线程池线程上进行,并且 BeginInvoke 调用会立即返回。所以,实际上,您有一个工作线程调用 BeginInvoke。然后线程池线程接管并调用 Control.Invoke,等待返回值。然后,传递给 Invoke 的委托将在 UI 线程上被调用。如果您调用 Delegate.BeginInvoke,那么您将从工作线程转到线程池线程,在那里执行委托指向的方法。如果您在该线程上访问 UI 元素,您将得到不可预测的结果。使用 Control.BeginInvoke 的目标是有一个匹配的 EndInvoke,这仅仅是为了让处理继续在调用它们的线程上进行,而不是等待 UI 更新。在检查了 delegate 的 BeginInvoke 和 Control.BeginInvoke 之间的区别后,似乎 Control 的 BeginInvoke() 确保委托在创建 Control 的上下文的线程中被调用。这意味着不涉及线程池线程。这由一位 .NET MVP 的测试应用程序进行了说明。
建议阅读
- “Professional .NET Framework 2.0”,作者 Joe Duffy。
- “The CLR via C#”,作者 Jeffrey Richter。