093.advanced.md 15 KB

委托事件进阶

为什么委托定义的返回值通常都为void

尽管并非必需,但是我们发现很多的委托定义返回值都为 void,为什么呢?这是因为委托变量可以供多个订阅者注册,如果定义了返回值,那么多个订阅者的方法都会向发布者返回数值,结果就是后面一个返回的方法值将前面的返回值覆盖掉了,因此,实际上只能获得最后一个方法调用的返回值。可以运行下面的代码测试一下。除此以外,发布者和订阅者是松耦合的,发布者根本不关心谁订阅了它的事件、为什么要订阅,更别说订阅者的返回值了,所以返回订阅者的方法返回值大多数情况下根本没有必要。

如何让事件只允许一个客户订阅

少数情况下,比如像上面,为了避免发生“值覆盖”的情况(更多是在异步调用方法时,后面会讨论),我们可能想限制只允许一个客户端注册。此时怎么做呢?我们可以向下面这样,将事件声明为private 的,然后提供两个方法来进行注册和取消注册:

public class Publishser
{
    private event GeneralEventHandler NumberChanged; // 声明一个私有事件
 
    // 注册事件
    public void Register(GeneralEventHandler method)
    {
        NumberChanged = method; //不能使用+=
    }
 
    // 取消注册
    public void UnRegister(GeneralEventHandler method)
    {
        NumberChanged -= method;
    }
 
    public void DoSomething()
    {
        // 做某些其余的事情
        if (NumberChanged != null)
        { 
            // 触发事件
            string rtn = NumberChanged();
            Console.WriteLine("Return: {0}", rtn); // 打印返回的字符串,输出为Subscriber3
        }
    }
}

注意上面,在UnRegister()中,没有进行任何判断就使用了NumberChanged -= method 语句。这是因为即使method 方法没有进行过注册,此行语句也不会有任何问题,不会抛出异常,仅仅是不会产生任何效果而已。

注意在Register()方法中,我们使用了赋值操作符“=”,而非“+=”,通过这种方式就避免了多个方法注册。

委托和方法的异步调用

通常情况下,如果需要异步执行一个耗时的操作,我们会新起一个线程,然后让这个线程去执行代码。但是对于每一个异步调用都通过创建线程来进行操作显然会对性能产生一定的影响,同时操作也相对繁琐一些。.NET 中可以通过委托进行方法的异步调用,就是说客户端在异步调用方法时,本身并不会因为方法的调用而中断,而是从线程池中抓取一个线程去执行该方法,自身线程(主线程)在完成抓取线程这一过程之后,继续执行下面的代码,这样就实现了代码的并行执行。使用线程池的好处就是避免了频繁进行异步调用时创建、销毁线程的开销。当我们在委托对象上调用BeginInvoke()时,便进行了一个异步的方法调用。

事件发布者和订阅者之间往往是松耦合的,发布者通常不需要获得订阅者方法执行的情况;而当使用异步调用时,更多情况下是为了提升系统的性能,而并非专用于事件的发布和订阅这一编程模型。而在这种情况下使用异步编程时,就需要进行更多的控制,比如当异步执行方法的方法结束时通知客户端、返回异步执行方法的返回值等。本节就对 BeginInvoke() 方法、EndInvoke() 方法和其相关的 IAysncResult 做一个简单的介绍。

我们先看这样一段代码,它演示了不使用异步调用的通常情况:

class Program7
{
    static void Main(string[] args)
    {
        Console.WriteLine("Client application started!\n");
        Thread.CurrentThread.Name = "Main Thread";
        Calculator cal = new Calculator();
        int result = cal.Add(2, 5);
        Console.WriteLine("Result: {0}\n", result);
 
        // 做某些其它的事情,模拟需要执行3 秒钟
        for (int i = 1; i <= 3; i++)
        {
            Thread.Sleep(TimeSpan.FromSeconds(i));
            Console.WriteLine("{0}: Client executed {1} second(s).", Thread.CurrentThread.Name, i);
        }
 
        Console.WriteLine("\nPress any key to exit...");
        Console.ReadLine();
    }
}
 
public class Calculator
{
    public int Add(int x, int y)
    {
        if (Thread.CurrentThread.IsThreadPoolThread)
        {
            Thread.CurrentThread.Name = "Pool Thread";
        }
 
        Console.WriteLine("Method invoked!");
 
        // 执行某些事情,模拟需要执行2 秒钟
        for (int i = 1; i <= 2; i++)
        {
            Thread.Sleep(TimeSpan.FromSeconds(i));
            Console.WriteLine("{0}: Add executed {1} second(s).", Thread.CurrentThread.Name, i);
        }
 
        Console.WriteLine("Method complete!");
        return x + y;
    }
}

上面代码有几个关于对于线程的操作,如果不了解可以看一下下面的说明,如果你已经了解可以直接跳过:

  1. Thread.Sleep(),它会让执行当前代码的线程暂停一段时间(如果你对线程的概念比较陌生,可以理解为使程序的执行暂停一段时间),以毫秒为单位,比如Thread.Sleep(1000),将会使线程暂停1 秒钟。在上面我使用了它的重载方法,个人觉得使用TimeSpan.FromSeconds(1),可读性更好一些。

  2. Thread.CurrentThread.Name,通过这个属性可以设置、获取执行当前代码的线程的名称,值得注意的是这个属性只可以设置一次,如果设置两次,会抛出异常。

  3. Thread.IsThreadPoolThread,可以判断执行当前代码的线程是否为线程池中的线程。

通过这几个方法和属性,有助于我们更好地调试异步调用方法。上面代码中除了加入了一些对线程的操作以外再没有什么特别之处。我们建了一个Calculator 类,它只有一个Add 方法,我们模拟了这个方法需要执行2 秒钟时间,并且每隔一秒进行一次输出。而在客户端程序中,我们使用result 变量保存了方法的返回值并进行了打印。随后,我们再次模拟了客户端程序接下来的操作需要执行2 秒钟时间。

输出结果:

Client application started!

Method invoked!
Main Thread: Add executed 1 second(s).
Main Thread: Add executed 2 second(s).
Method complete!
Result: 7

Main Thread: Client executed 1 second(s).
Main Thread: Client executed 2 second(s).
Main Thread: Client executed 3 second(s).

Press any key to exit...

如果你确实执行了这段代码,会看到这些输出并不是一瞬间输出的,而是执行了大概5 秒钟的时间,因为线程是串行执行的,所以在执行完Add()方法之后才会继续客户端剩下的代码。

接下来我们定义一个AddDelegate 委托,并使用BeginInvoke()方法来异步地调用它。在上面已经介绍过,BeginInvoke()除了最后两个参数为AsyncCallback 类型和Object 类型以外,前面的参数类型和个数与委托定义相同。另外BeginInvoke()方法返回了一个实现了IAsyncResult 接口的对象(实际上就是一个AsyncResult 类型实例,注意这里IAsyncResultAysncResult 是不同的,它们均包含在.NET Framework 中)。

AsyncResult 的用途有这么几个:传递参数,它包含了对调用了BeginInvoke()的委托的引用;它还包含了BeginInvoke()的最后一个Object 类型的参数;它可以鉴别出是哪个方法的哪一次调用,因为通过同一个委托变量可以对同一个方法调用多次。

EndInvoke()方法接受IAsyncResult 类型的对象(以及ref 和out 类型参数,这里不讨论了,对它们的处理和返回值类似),所以在调用BeginInvoke()之后,我们需要保留IAsyncResult,以便在调用EndInvoke()时进行传递。这里最重要的就是EndInvoke()方法的返回值,它就是方法的返回值。除此以外,当客户端调用EndInvoke()时,如果异步调用的方法没有执行完毕,则会中断当前线程而去等待该方法,只有当异步方法执行完毕后才会继续执行后面的代码。所以在调用完BeginInvoke()后立即执行EndInvoke()是没有任何意义的。我们通常在尽可能早的时候调用BeginInvoke(),然后在需要方法的返回值的时候再去调用EndInvoke(),或者是根据情况在晚些时候调用。说了这么多,我们现在看一下使用异步调用改写后上面的代码吧:

using System.Threading;
using System;
 
public delegate int AddDelegate(int x, int y);
class Program8
{
    static void Main(string[] args)
    {
        Console.WriteLine("Client application started!\n");
        Thread.CurrentThread.Name = "Main Thread";
        Calculator cal = new Calculator();
        AddDelegate del = new AddDelegate(cal.Add);
        IAsyncResult asyncResult = del.BeginInvoke(2, 5, null, null); // 异步调用方法
 
        // 做某些其它的事情,模拟需要执行3 秒钟
        for (int i = 1; i <= 3; i++)
        {
            Thread.Sleep(TimeSpan.FromSeconds(i));
            Console.WriteLine("{0}: Client executed {1} second(s).", Thread.CurrentThread.Name, i);
        }
        int rtn = del.EndInvoke(asyncResult);
        Console.WriteLine("Result: {0}\n", rtn);
        Console.WriteLine("\nPress any key to exit...");
        Console.ReadLine();
    }
}
 
public class Calculator
{
    public int Add(int x, int y)
    {
        if (Thread.CurrentThread.IsThreadPoolThread)
        {
            Thread.CurrentThread.Name = "Pool Thread";
        }
 
        Console.WriteLine("Method invoked!");
 
        // 执行某些事情,模拟需要执行2 秒钟
        for (int i = 1; i <= 2; i++)
        {
            Thread.Sleep(TimeSpan.FromSeconds(i));
            Console.WriteLine("{0}: Add executed {1} second(s).", Thread.CurrentThread.Name, i);
        }
 
        Console.WriteLine("Method complete!");
        return x + y;
    }
}

此时的输出结果是:

Client application started!

Method invoked!

Main Thread: Client executed 1 second(s).

Pool Thread: Add executed 1 second(s).

Main Thread: Client executed 2 second(s).

Pool Thread: Add executed 2 second(s).

Method complete!

Main Thread: Client executed 3 second(s).

Result: 7

Press any key to exit...

现在执行完这段代码只需要3 秒钟时间,两个for 循环所产生的输出交替进行,这也说明了这两段代码并行执行的情况。可以看到 Add() 方法是由线程池中的线程在执行, 因为Thread.CurrentThread.IsThreadPoolThread 返回了True,同时我们对该线程命名为了Pool Thread。另外我们可以看到通过EndInvoke()方法得到了返回值。有时候,我们可能会将获得返回值的操作放到另一段代码或者客户端去执行,而不是向上面那样直接写在BeginInvoke()的后面。比如说我们在Program 中新建一个方法GetReturn(),此时可以通过AsyncResultAsyncDelegate 获得del 委托对象,然后再在其上调用EndInvoke()方法,这也说明了AsyncResult 可以唯一的获取到与它相关的调用了的方法(或者也可以理解成委托对象)。所以上面获取返回值的代码也可以改写成这样:

private static int GetReturn(IAsyncResult asyncResult)
{
    AsyncResult result = (AsyncResult)asyncResult;
    AddDelegate del = (AddDelegate)result.AsyncDelegate;
    int rtn = del.EndInvoke(asyncResult);
    return rtn;
}

然后再将int rtn = del.EndInvoke(asyncResult);语句改为int rtn = GetReturn(asyncResult);。注意上面IAsyncResult 要转换为实际的类型AsyncResult 才能访问AsyncDelegate 属性,因为它没有包含在IAsyncResult 接口的定义中。

BeginInvoke 的另外两个参数分别是AsyncCallbackObject 类型,其中AsyncCallback 是一个委托类型,它用于方法的回调,即是说当异步方法执行完毕时自动进行调用的方法。它的定义为:

public delegate void AsyncCallback(IAsyncResult ar);

Object 类型用于传递任何你想要的数值,它可以通过IAsyncResult 的AsyncState 属性获得。下面我们将获取方法返回值、打印返回值的操作放到了OnAddComplete()回调方法中:

using System.Threading;
using System;
using System.Runtime.Remoting.Messaging;
public delegate int AddDelegate(int x, int y);
class Program9
{
    static void Main(string[] args)
    {
        Console.WriteLine("Client application started!\n");
        Thread.CurrentThread.Name = "Main Thread";
        Calculator cal = new Calculator();
        AddDelegate del = new AddDelegate(cal.Add);
        string data = "Any data you want to pass.";
 
        AsyncCallback callBack = new AsyncCallback(OnAddComplete);
        del.BeginInvoke(2, 5, callBack, data); // 异步调用方法
 
        // 做某些其它的事情,模拟需要执行3 秒钟
        for (int i = 1; i <= 3; i++)
        {
            Thread.Sleep(TimeSpan.FromSeconds(i));
            Console.WriteLine("{0}: Client executed {1} second(s).", Thread.CurrentThread.Name, i);
        }
        Console.WriteLine("\nPress any key to exit...");
        Console.ReadLine();
    }
 
    static void OnAddComplete(IAsyncResult asyncResult)
    {
        AsyncResult result = (AsyncResult)asyncResult;
        AddDelegate del = (AddDelegate)result.AsyncDelegate;
        string data = (string)asyncResult.AsyncState;
        int rtn = del.EndInvoke(asyncResult);
        Console.WriteLine("{0}: Result, {1}; Data: {2}\n", Thread.CurrentThread.Name, rtn, data);
    }
}
 
public class Calculator
{
    public int Add(int x, int y)
    {
        if (Thread.CurrentThread.IsThreadPoolThread)
        {
            Thread.CurrentThread.Name = "Pool Thread";
        }
 
        Console.WriteLine("Method invoked!");
 
        // 执行某些事情,模拟需要执行2 秒钟
        for (int i = 1; i <= 2; i++)
        {
            Thread.Sleep(TimeSpan.FromSeconds(i));
            Console.WriteLine("{0}: Add executed {1} second(s).", Thread.CurrentThread.Name, i);
        }
 
        Console.WriteLine("Method complete!");
        return x + y;
    }
}

它产生的输出为:

Client application started!

Method invoked!

Main Thread: Client executed 1 second(s).

Pool Thread: Add executed 1 second(s).

Main Thread: Client executed 2 second(s).

Pool Thread: Add executed 2 second(s).

Method complete!

Pool Thread: Result, 7; Data: Any data you want to pass.

Main Thread: Client executed 3 second(s).

Press any key to exit...

这里有几个值得注意的地方

1、我们在调用BeginInvoke()后不再需要保存IAysncResult 了,因为AysncCallback 委托将该对象定义在了回调方法的参数列表中;

2、我们在OnAddComplete()方法中获得了调用BeginInvoke()时最后一个参数传递的值,字符串“Any data you want to pass”;

3、执行回调方法的线程并非客户端线程Main Thread,而是来自线程池中的线程Pool Thread。另外如前面所说,在调用EndInvoke()时有可能会抛出异常,所以在应该将它放到try/catch 块中,这里就不再示范了。