上篇博客【用Asp.net写自己的服务框架】 我讲述了如何实现自己的服务框架,但我想很多人应该用过WebService这类服务框架,相比起来,似乎还缺少什么东西, 是的,我也感觉到了。比如:我可以很容易地利用WebService, WCF框架编写一个服务, 在客户端也可以很容易地通过【添加服务引用】的方式来生成一个代理类,然后就可以调用服务了,非常简单, 更酷的是,IDE生成的代理类还有异步调用功能!

我一直认为,对于服务框架来说,最重要的事是将一个C#方法公开为一个服务方法,供远程客户端调用。 因此,我上篇博客中演示的服务框架显然已经可以简单地完成这个功能。 不过,目前如果要使用这个服务框架,客户端还不够方便: 总不能让使用者自己写代码发送HTTP请求吧?嗯,基于我的服务框架的一些约定,实现这个包装不是问题, 但前面提到的IDE能生成异步调用的代理类,这个功能就必须实现了,否则我认为太不完美了。

我是个追求完美的人,而异步又是一个很重要的功能,我自然不能不实现它。今天我就继续上篇博客的内容,来谈谈客户端的各种异步实现方法。
说明:异步调用服务却与服务端无关,属于客户端的事情。 此处的客户端是相对服务端来说的,它可以是任何类型的应用程序。今天的主要话题是关于客户端的异步调用。

插个问题,为什么要实现异步,异步有什么好处?
答:简单来说,对于服务程序而言,异步处理可以提高吞吐量, 对于WinForm这类桌面客户端程序而言,将耗时任务采用异步实现可以改善用户体验,而且任务可以并行执行,提高响应速度。

示例项目介绍

今天我将演示如何在客户端中,以不同的异步方式调用一个服务方法。 为了让演示更有实战性,我已准备了一个完整的示例项目。如下图:

整个示例由四个小项目构成:
1. WebSite1 是一个用于发布服务的网站(也包含一些Asp.net异步的示例)。
2. MySimpleServiceClient是一个类库项目,包含了我封装的客户端类。
3. 服务的实现放在ServiceClassLibrary项目中。
4. WindowsFormsApplication1 是调用服务的客户端,这是一个WinForm项目。
   之所以要选WinForm做为客户端演示,是因为WinForm编程模型中对操作UI方面有更多的线程要求,
   如果有调用延迟也会特别明显,因此WinForm编程模型对异步的处理更为复杂。
   为了能让演示更有意义,我宁可选择WinForm程序做为服务的客户端,而不是不负责的选择Console程序。
   事实上,演示代码也适用于其它编程模型。

服务类的代码如下:

/// <summary>
/// 要做为服务发布的服务类,其实就是一个普通的C#类,加了一些Attribute而已。
/// 所有幕后的工作,全由服务框架来实现,关于服务框架请参考我的博客:
/// 【用Asp.net写自己的服务框架】
/// http://www.cnblogs.com/fish-li/archive/2011/09/05/2168073.html
/// </summary>
[MyService]
public class DemoService
{
    [MyServiceMethod]
    public static string ExtractNumber(string str)
    {
        // 延迟3秒,模拟一个长时间的调用操作,便于客户演示异步的效果。
        System.Threading.Thread.Sleep(3000);

        if( string.IsNullOrEmpty(str) )
            return "str IsNullOrEmpty.";

        return new string((from c in str where Char.IsDigit(c) orderby c select c).ToArray());
    }

}

服务方法的功能很简单:从一个字符串中找到所有数字,然后排序输出。

客户端运行界面如下:

同步调用服务

为了更好的理解异步调用,也为了和后面的异步调用做个比较,这里先示例如何采用同步的方式调用服务。代码如下:

/// <summary>
/// 同步调用服务,此时界面应该会【卡住】。
/// </summary>
/// <param name="str"></param>
private void SyncCallService(string str)
{
    try {
        string result = HttpWebRequestHelper.SendHttpRequest(ServiceUrl, str);
        ShowResult(string.Format("{0} => {1}", str, result));
    }
    catch( Exception ex ) {
        ShowResult(string.Format("{0} => Error: {1}", str, ex.Message));
    }
}

其中,HttpWebRequestHelper.SendHttpRequest()最终调用的代码如下:

/// <summary>
/// 同步调用服务
/// </summary>
/// <param name="url"></param>
/// <param name="input"></param>
/// <returns></returns>
public static TOut SendHttpRequest(string url, TIn input)
{
    if( string.IsNullOrEmpty(url) )
        throw new ArgumentNullException("url");
    if( input == null )
        throw new ArgumentNullException("input");

    // 为了简单,这里仅使用JSON序列化方式
    JavaScriptSerializer jss = new JavaScriptSerializer();
    string jsonData = jss.Serialize(input);

    // 创建请求对象
    HttpWebRequest request = CreateHttpWebRequest(url, "json");

    // 发送请求数据
    using( BinaryWriter bw = new BinaryWriter(request.GetRequestStream()) ) {
        bw.Write(DefaultEncoding.GetBytes(jsonData));
    }

    // 获取响应对象,并读取响应内容
    using( HttpWebResponse response = (HttpWebResponse)request.GetResponse() ) {
        string responseText = ReadResponse(response);
        return jss.Deserialize<TOut>(responseText);
    }
}

以上代码,也就是我前面所说的客户端的包装工具类了。有了它,就可以很容易地调用我的服务了。
代码中的CreateHttpWebRequest()以及ReadResponse()都很简单,而且与异步一点关系也没有,就不贴出了,可以在本文结尾处下载它们。

异步接口介绍

在开始介绍各种异步实现方法之前,有必要先明说一下: 在.net中,所有异步都是基于IAsyncResult这个最基础的接口。只是不同的API在具体实现时,创建的IAsyncResult实例不同, 以及封装方式不同而已。IAsyncResult的接口定义如下:

public interface IAsyncResult
{
    // 获取用户定义的对象,它限定或包含关于异步操作的信息。
    // 通常在调用BeginXXXX方法时传入对象,供回调方法时恢复之前的状态。
    object AsyncState { get; }

    // 获取用于等待异步操作完成的 System.Threading.WaitHandle。
    // 我们可以调用它的WaitOne()方法等待调用完成。
    WaitHandle AsyncWaitHandle { get; }

    // 获取异步操作是否同步完成的指示。
    // 如果异步操作同步完成,则为 true;否则为 false。
    bool CompletedSynchronously { get; }

    // 获取异步操作是否已完成的指示。
    // 如果操作完成则为 true,否则为 false。
    bool IsCompleted { get; }
}

下面我们再来看一下各种异步方法的实现方式。

1. 委托异步调用

对于任何一个方法,.net默认是采用同步的方式去调用,即:在调用时,后面的代码一直要等待调用完成后才能继续执行。
不过,我们可以使用委托,将一个方法按异步的方式去调用。对于前面的同步调用代码,我可以使用委托来完成异步的调用:

/// <summary>
/// 委托异步调用
/// </summary>
/// <param name="str"></param>
private void CallViaDelegate(string str)
{
    Func<string, string, string> func = HttpWebRequestHelper.SendHttpRequest;

    func.BeginInvoke(ServiceUrl, str, CallViaDelegateCallback, str);
}

private void CallViaDelegateCallback(IAsyncResult ar)
{
    string str = (string)ar.AsyncState;
    Func<string, string, string> func 
                = (ar as AsyncResult).AsyncDelegate as Func<string, string, string>;
    try {
        // 如果有异常,会在这里被重新抛出。
        string result = func.EndInvoke(ar);
        ShowResult(string.Format("{0} => {1}", str, result));
    }
    catch( Exception ex ) {
        ShowResult(string.Format("{0} => Error: {1}", str, ex.Message));
    }
}

说到BeginInvoke,EndInvoke就不得不停下来看一下委托的本质。为了便于理解委托,我定义一个简单的委托:


public delegate string MyFunc(int num, DateTime dt);

我们再来看一下这个委托在编译后的程序集中是个什么样的:

委托被编译成一个新的类型,拥有BeginInvoke,EndInvoke,Invoke这三个方法。前二个方法的组合使用便可实现异步调用。第三个方法将以同步的方式调用。 其中BeginInvoke方法的最后二个参数用于回调,其它参数则与委托的包装方法的输入参数是匹配的。 EndInvoke的返回值与委托的包装方法的返回值匹配。

注意:委托的BeginInvoke方法在调用后,也会返回一个IAsyncResult对象(类型为:System.Runtime.Remoting.Messaging.AsyncResult)。在IDE窗口中,我们也可以在智能提示中看到如下提示信息:

因此,我们也可以不使用回调方法,而是直接使用它的返回值,并在一个【恰当的时候】结束异步调用(其实是以同步的方式并行执行任务)。如下代码所示:

private void CallViaDelegate_X2(string str)
{
    Func<string, string, string> func = HttpWebRequestHelper.SendHttpRequest;

    IAsyncResult ar = func.BeginInvoke(ServiceUrl, str, null, null);

    // 在此执行其它的计算操作,
    // 也可以在此再发起另一个异步调用。

    string result = func.EndInvoke(ar);
    //...处理结果
    ShowResult(string.Format("{0} => {1}", str, result));
}

小结:使用委托的异步调用方式很简单,只要用一个方法创建一个委托对象,然后调用BeginInvoke方法就可以了。 对BeginInvoke()方法的调用是以异步方式进行,但对于调用EndInvoke()方法则是以同步方式进行的(如果任务没有执行完,将会发生阻塞)。如果您想实现无阻塞的异步, 可以在调用BeginInvoke()方法时指定回调方法,那么在异步完成时,回调方法将被调用,此时对EndInvoke()的调用将不会阻塞线程。

异常的处理:在委托的异步实现中,由于BeginInvoke的调用是无阻塞的,此时方法将立即返回,而异常则是在任务执行过程中引发的, 因此,异常只能在调用EndInvoke时重新抛出,所以,也只能在调用EndInvoke时捕获异常。如果采用委托的方式异步调用某个没有返回值的方法, 那么,当你不调用EndInvoke时,你是不知道是否有异常抛出的。

注意:委托的异步调用是将任务交给线程池的工作线程来执行的。 证明这个说法很简单,可以在任务中加以如下代码,然后设置断点观察变量的取值即可:


bool isThreadPoolThread = System.Threading.Thread.CurrentThread.IsThreadPoolThread;

2. 使用IAsyncResult接口实现异步调用

在.net framework中,许多I/O操作(文件I/O操作以及网络I/O)都提供异步版本的API,我们可以直接使用这些API来达到异步调用的目的。 在今天的示例中,发送HTTP请求的API中,就支持异步操作,我将演示使用这些异步API的操作过程。

在客户端,我将使用以下代码完成异步调用过程:

/// <summary>
/// 使用IAsyncResult接口实现异步调用
/// </summary>
/// <param name="str"></param>
private void CallViaIAsyncResult(string str)
{
    HttpWebRequestHelper.SendHttpRequestAsync(ServiceUrl, str, CallViaIAsyncResultCallback, null);
}

private void CallViaIAsyncResultCallback(string str, string result, Exception ex, object state)
{
    if( ex == null )
        ShowResult(string.Format("{0} => {1}", str, result));
    else
        ShowResult(string.Format("{0} => Error: {1}", str, ex.Message));
}

其中HttpWebRequestHelper.SendHttpRequestAsync()是个简单的包装方法,最终异步操作的实现代码如下:

/// <summary>
/// 用于所有回调状态的数据类
/// </summary>
private class MyCallbackParam
{
    public TIn InputData;
    public Action<TIn, TOut, Exception, object> Callback;
    public object State;
    public HttpWebRequest Request;
    public JavaScriptSerializer Jss;
}

/// <summary>
/// 异步调用服务
/// </summary>
/// <param name="url"></param>
/// <param name="input"></param>
/// <param name="callback">服务调用完成后的回调委托,用于处理调用结果</param>
/// <param name="state"></param>
public static void SendHttpRequestAsync(string url, TIn input, 
                Action<TIn, TOut, Exception, object> callback, object state)
{
    if( string.IsNullOrEmpty(url) )
        throw new ArgumentNullException("url");
    if( input == null )
        throw new ArgumentNullException("input");
    if( callback == null )
        throw new ArgumentNullException("callback");

    // 创建请求对象
    HttpWebRequest request = CreateHttpWebRequest(url, "json");

    // 记录必要的回调参数
    MyCallbackParam cp = new MyCallbackParam {
        Callback = callback,
        InputData = input,
        State = state,
        Request = request,
    };

    // 开始异步写入请求数据
    request.BeginGetRequestStream(AsyncWriteRequestStream, cp);

    // 虽然BeginGetRequestStream()可以返回一个IAsyncResult对象,
    // 但我却不想返回这个对象,因为整个过程需要二次异步。
}

private static void AsyncWriteRequestStream(IAsyncResult ar)
{
    // 取出回调前的状态参数
    MyCallbackParam cp = (MyCallbackParam)ar.AsyncState;

    try {
        // 为了简单,这里仅使用JSON序列化方式
        JavaScriptSerializer jss = new JavaScriptSerializer();
        string jsonData = jss.Serialize(cp.InputData);
        cp.Jss = jss;

        // 结束写入数据的操作
        using( BinaryWriter bw = new BinaryWriter(cp.Request.EndGetRequestStream(ar)) ) {
            bw.Write(DefaultEncoding.GetBytes(jsonData));
        }

        // 开始异步向服务器发起请求
        cp.Request.BeginGetResponse(GetResponseCallback, cp);
    }
    catch( Exception ex ) {
        cp.Callback(cp.InputData, default(TOut), ex, cp.State);
    }
}

private static void GetResponseCallback(IAsyncResult ar)
{
    // 取出回调前的状态参数
    MyCallbackParam cp = (MyCallbackParam)ar.AsyncState;

    try {
        // 读取服务器的响应
        using( HttpWebResponse response = (HttpWebResponse)cp.Request.EndGetResponse(ar) ) {
            string responseText = ReadResponse(response);
            TOut result = cp.Jss.Deserialize<TOut>(responseText);

            // 返回结果,通过回调用户的回调方法来完成。
            cp.Callback(cp.InputData, result, null, cp.State);
        }
    }
    catch( Exception ex ) {
        cp.Callback(cp.InputData, default(TOut), ex, cp.State);
    }
}

注意:在SendHttpRequestAsync方法的实现过程中,需要发起二次异步调用:BeginGetRequestStream, BeginGetResponse 。自然地, 也会引起二次回调,二次EndXXXXX()方法的调用。为了能在回调过程中,维持一些必要的状态参数,我定义了一个私有类型MyCallbackParam , 它包含了所有回调过程中所需要的中间状态。这里尤其要注意的是:如果某个异步操作过程需要多次异步调用,那么每个步骤都要求是异步的, 也就是要【一路异步到底】。如果中间任何一个步骤不是异步调用的,那么整个过程将不会是异步的,甚至某些API的设计者会抛出一个异常,这也是有可能的。 为了支持异步,我的包装方法也是通过回调的方式来设计的。这些都是异步设计的关键。

当某个异步操作过程需要多次调用时,该如何知道哪些步骤必须以异步形式调用呢? 比如,前面演示的发送HTTP请求的过程,我该如何知道要调用BeginGetRequestStream,BeginGetResponse这二个异步方法呢? 对于这个问题,没有一个明确的答案,因为在这方面,并没有一个规范或者约定,要根据相应组件的具体实现过程而定。 不过,通常每个支持异步组件所提供的API接口都会以BeginXxxxxx,EndXxxxxx的形式表示支持异步操作,并提供一个Xxxxxx的同步版本。 这里我可以提供一个小经验:逐个将一些关键步骤的同步调用替换成异步调用,直到实现异步过程为止。 再补充一句:对于微软提供的组件,查阅MSDN对于该方法的说明一般是可以找到线索的。

或许有些人不想定义这些回调方法,以及用于维护回调的状态类型,而选择闭包的方式。这种方法就技术的实现而言,也是可行的。 这里我就不演示了,因为我不喜欢搞大的闭包。 如果您喜欢闭包的方式,也请不要批我,每个人有每个人的喜好。

异常的处理:与委托的异步调用一样,此时也只能在调用EndXxxxxx时捕获异常。 不过,对于一个有着多个异步调用步骤的过程来说,异常的处理将要分阶段处理。

与委托异步调用的差别: 由于委托的BeginInvoke调用也能返回IAsyncResult,因此前面演示的委托的【同步并行执行】方式也可以在BeginXxxxxx/EndXxxxxx所支持的过程中使用。 但本小节所说的异步与委托的异步还是有差别,最重要的差别在于委托的异步调用阻塞发生在线程池的工作线程, 而直接使用基于IAsyncResult的异步,阻塞发生在线程池的I/O完成线程。这二种不同的线程对于不同的编程模型来说,意义是非常重大的。

小结:如果某个组件提供BeginXxxxxx/EndXxxxxx方法,通常表示可以支持异步操作,只要我们正确地调用它们就可以实现异步。

3. 基于事件的异步调用模式

前面我已演示了二种异步的使用方法,与同步调用相比,复杂性是很明显的。尤其对于WinForm这类编程模型时, 在回调时,肯定是不能操作UI界面的。因此,更是增加了使用上的难度。因此,有没有更方便地使用异步的方法? 我想这是每个开发人员想知道的。幸运的是,随着.net 2.0的发布,一种【基于事件的异步模式】的新模式出现了, 比如:IDE生成WebService的代理类就支持这种异步模式,此外,还增加了一个新的组件:BackgroundWorker, 它们完美地演示了如何方便地在各种编程模型中使用异步,并能在异步完成时,以我们熟知的事件方式处理后续操作。

比如,我可以使用以下代码就可以完成与前面一样的异步调用:

/// <summary>
/// 基于事件的异步模式
/// </summary>
/// <param name="str"></param>
private void CallViaEvent(string str)
{
    MyAysncClient<string, string> client = new MyAysncClient<string, string>(ServiceUrl);
    client.OnCallCompleted += new MyAysncClient<string, string>.CallCompletedEventHandler(client_OnCallCompleted);
    client.CallAysnc(str, str);
}

void client_OnCallCompleted(object sender, MyAysncClient<string, string>.CallCompletedEventArgs e)
{
    //bool flag = txtOutput.InvokeRequired;    // 注意:这里flag的值是false,也就是说可以直接操作UI界面
    if( e.Error == null ) 
        ShowResult(string.Format("{0} => {1}", e.UserState, e.Result));
    else
        ShowResult(string.Format("{0} => Error: {1}", e.UserState, e.Error.Message));        
}

这里尤其要说明的是,虽然还是二个方法,但有了很大的差别:第二个方法可以当我在订阅OnCallCompleted事件时, IDE可以帮我生成这个方法的空壳,我只要简单地显示结果就可以了,更为关键的是,此时的线程上下文已经和前面的异步方式不一样了, 而且调用参数也简单了。

对于组件的使用者而言,能支持这样的调用方式,的确是方便了。 为了让不同的编程模型不受线程问题困扰,以及支持事件通知,组件设计者应该提供这种接口模式。 不过,这个模式的背后实现要复杂一点,以下我将用代码来展示如何实现这种事件通知功能。 (注意代码中的注释,实现原理全在注释中)

/// <summary>
/// 我的异步调用客户端封装类
/// </summary>
/// <typeparam name="TIn"></typeparam>
/// <typeparam name="TOut"></typeparam>
public sealed class MyAysncClient<TIn, TOut>
{
    private string _url;
    private volatile bool _isBusy;
    public bool IsBusy { get { return _isBusy; } }

    public MyAysncClient(string url)
    {
        if( string.IsNullOrEmpty(url) )
            throw new ArgumentNullException("url");

        _url = url;
    }
    
    /// <summary>
    /// 调用完成后的事件参数类。它包含调用的结果,以及异常信息。
    /// </summary>
    public class CallCompletedEventArgs : AsyncCompletedEventArgs
    {
        private TOut _result;

        public CallCompletedEventArgs(TOut result, Exception e, bool canceled, object state)
            : base(e, canceled, state)
        {
            _result = result;
        }

        public TOut Result
        {
            get
            {
                base.RaiseExceptionIfNecessary();
                return _result;
            }
        }
    }

    public delegate void CallCompletedEventHandler(object sender, CallCompletedEventArgs e);
    /// <summary>
    /// 异步调用完成后的通知事件
    /// </summary>
    public event CallCompletedEventHandler OnCallCompleted;


    public void CallAysnc(TIn input, object state)
    {
        if( input == null )
            throw new ArgumentNullException("input");

        if( _isBusy )
            throw new InvalidOperationException("client is busy.");

        // 准备与同步上下文有关的对象
        // 注意这个调用,这是整个事件模式的核心。
        AsyncOperation asyncOp = AsyncOperationManager.CreateOperation(state);

        //---------------------------------------------------------------------------------
        // 注意:
        //   这个客户端的封装类其实可以算是个辅助类,整个类就是辅助下面的这个调用。
        //   这个类其实只处理二个简单的功能:
        //     1. 引发异步调用完成后的事件。
        //     2. 在合适的同步上下文环境中引发完成事件。
        //   而真正发送请求的过程,在下面这个方法中实现的。

        // 开始异步调用。这个方法将完成发送请求的过程。第三个参数为回调方法。
        HttpWebRequestHelper<TIn, TOut>.SendHttpRequestAsync(_url, input, CallbackProc, asyncOp);

        _isBusy = true;
    }

    // 异步完成的回调方法
    private void CallbackProc(TIn input, TOut result, Exception exception, object state)
    {
        // 进入这个方法表示异步调用已完成。

        AsyncOperation asyncOp = (AsyncOperation)state;

        // 创建事件参数
        CallCompletedEventArgs e =
            new CallCompletedEventArgs(result, exception, false /* canceled */, asyncOp.UserSuppliedState);

        // 切换线程调用上下文。注意第一个参数为回调方法。
        asyncOp.PostOperationCompleted(CallCompleted, e);
    }

    // 用于处理完成后同步上下文切换的回调方法
    private void CallCompleted(object args)
    {
        // 运行到这里表示已经切回当初发起调用CallAysnc()时的同步上下文环境。

        CallCompletedEventArgs e = (CallCompletedEventArgs)args;

        // 引发完成事件
        CallCompletedEventHandler handler = OnCallCompleted;
        if( handler != null )
            handler(this, e);

        // 到此,异步调用以及事件的响应全部处理结束。
        _isBusy = false;
    }        
}

说明:此模式中,应使用AsyncOperationManager.CreateOperation()来创建一个异步操作对象, 异步通知事件的基类应该选择AsyncCompletedEventArgs,且对于派生类的属性仅要求实现get操作,并在返回前调用base.RaiseExceptionIfNecessary(); 切换上下文的操作可调用asyncOp.PostOperationCompleted()来实现。

异常的处理:如果在异步执行过程中,引发了异常,此模式也要求引发完成事件,并在事件中告诉调用方具体的异常对象(基类有此属性)。

小结:IAsyncResult仍然是最根本的,事件模式也是建立在它之上,只是做了点包装而已。 但使用者却能够从中受益,因此,也是值得推荐的做法。

4. 创建新线程的异步方式

对于像WinForm这样的单线程编程模型来说,还可以通过创建新线程并将任务交给新线程来执行的方式达到异步效果。 这种方法很简单,只需要在新线程中调用同步任务,并在执行完成后通知界面就可以了。 以下代码演示了这种异步方式:

/// <summary>
/// 创建新线程的异步方式
/// </summary>
/// <param name="str"></param>
private void CreateThread(string str)
{
    Thread thread = new Thread(ThreadProc);
    thread.IsBackground = true;
    thread.Start(str);
}

private void ThreadProc(object obj)
{
    string str = (string)obj;

    try {
        // 由于是在后台线程中,这里就直接调用同步方法。
        SyncCallService(str);
    }
    catch( Exception ex ) {
        ShowResult(string.Format("{0} => Error: {1}", str, ex.Message));
    }
}

小结:对于单线程编程模型的程序来说,创建新线程并调用原有的同步方法,也能实现异步调,进而提高用户体验。

5. 使用线程池的异步方式

前面我提到委托的异步调用可以实现异步效果,它其实是在使用线程沲的线程来调用原有的同步方法。 既然这样,我们也可以直接使用线程沲来达到同类效果,而且在实现方式上与前面说到的创建新线程的方法非常类似, 并且,我将继续使用上面示例中创建的ThreadProc方法。代码如下:

/// <summary>
/// 直接使用线程池的异步方式
/// </summary>
/// <param name="str"></param>
private void UseThreadPool(string str)
{
    ThreadPool.QueueUserWorkItem(ThreadProc, str);
}

小结:与创建新线程或者委托异步类似,我们也可以直接使用线程池来实现异步调用, 只需要调用ThreadPool.QueueUserWorkItem()即可。

6. 使用BackgroundWorker实现异步调用

前面我已提过BackgroundWorker这个组件,这是个没有界面元素的组件,可用于任何编程模型, 使用它可以方便地将一个耗时的任务交给线程池中的工作线程来执行,此组件提供一系列事件能让调用者方便地使用后台线程。 这个组件还可以支持进度报告,以及任务取消的功能(都需要自行实现)。 例如:取消操作只是一个通知,要求调用者在DoWork方法中自行实现, 一般是在一个循环中执行任务,每次执行循环时,先检查组件的CancellationPending属性, 如果为true,则表示已调用CancelAsync()方法请求取消任务。

下面我来简单地演示一下这个组件的使用:

/// <summary>
/// 使用BackgroundWorker实现异步调用
/// </summary>
/// <param name="str"></param>
private void UseBackgroundWorker(string str)
{
    BackgroundWorker worker = new BackgroundWorker();
    worker.DoWork += new DoWorkEventHandler(worker_DoWork);
    worker.RunWorkerCompleted += new RunWorkerCompletedEventHandler(worker_RunWorkerCompleted);
    worker.RunWorkerAsync(str);
}
void worker_DoWork(object sender, DoWorkEventArgs e)
{
    //bool isThreadPoolThread = System.Threading.Thread.CurrentThread.IsThreadPoolThread;
    string str = (string)e.Argument;
    string result = HttpWebRequestHelper.SendHttpRequest(ServiceUrl, str);

    // 这个结果将在RunWorkerCompleted事件中使用
    e.Result = string.Format("{0} => {1}", str, result);
}
void worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
    //bool isThreadPoolThread = System.Threading.Thread.CurrentThread.IsThreadPoolThread;
    // 此时可以直接使用UI控件。
    if( e.Error != null )
        txtOutput.Text += "\r\n" + string.Format("Error: {0}", e.Error.Message);
    else 
        txtOutput.Text += "\r\n" + (string)e.Result;
}

请注意那些被我注释的代码,您可以取消注释并在调试状态下观察这些变量的值,以加深对这个组件使用线程的理解。

小结:BackgroundWorker可以非常方便地让我们使用后台线程,尤其适合WinForm这样的编程模型, 它解决了线程之间的沟通的复杂性。这里我将引用MSDN对于这个组件的描述:

BackgroundWorker 类允许您在单独的专用线程上运行操作。耗时的操作(如下载和数据库事务)在长时间运行时可能会导致用户界面 (UI) 似乎处于停止响应状态。如果您需要能进行响应的用户界面,而且面临与这类操作相关的长时间延迟,则可以使用 BackgroundWorker 类方便地解决问题。

客户端的其它代码

前面贴出了这个WinForm客户端的部分实现调用服务的代码。为了方便大家直接阅读,我将贴出另一些与这些调用有关的代码。

由于WinForm的特殊性:控件只能由UI线程操作,因此处理显示结果也需要特殊的处理:

/// <summary>
/// 显示结果
/// </summary>
/// <param name="line"></param>
private void ShowResult(string line)
{
    // 可以在这个方法中设置断点观察这些变量的状态(在使用前取消注释)。
    // 注意要对比各种调用方式的差别。
    //bool isBackground = System.Threading.Thread.CurrentThread.IsBackground;
    //bool isThreadPoolThread = System.Threading.Thread.CurrentThread.IsThreadPoolThread;

    if( txtOutput.InvokeRequired )
        // 采用同步上下文的方式切换线程调用。
        _syncContext.Post(x => txtOutput.Text += "\r\n" + line, null /*直接使用闭包参数*/);
    else
        txtOutput.Text += "\r\n" + line;
}

如果txtOutput.InvokeRequired为true,表示当前线程不是UI线程,此时不能直接修改控件内容。 此时,我为了简单,采用SynchronizationContext的方式来处理。相关的变量定义如下:

private SynchronizationContext _syncContext;

public Form1()
{
    InitializeComponent();
    _syncContext = SynchronizationContext.Current;
}

前面列出了每个调用服务的事件处理方法,这些方法是在这里被统一调用的:

private void btnCall_Click(object sender, EventArgs e)
{
    string str = txtInput.Text.Trim();
    if( str.Length == 0 ) {
        MessageBox.Show("没有要处理的字符串。",
                            this.Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
        txtInput.Focus();
        return;
    }

    string method = (
            from c in this.groupBox1.Controls.OfType<RadioButton>()
            where c.Checked
            select c.Tag.ToString()
        ).First();
    
    this.GetType().InvokeMember(method,
        BindingFlags.InvokeMethod | BindingFlags.Instance | BindingFlags.NonPublic,
        null, this, new object[] { str });
}

说明:为了简单,避免一堆机械式的判断,我为每个RadioButton设置了Tag属性,指向要调用的方法名称。

各种异步方式的优缺点

前面我已介绍了6种不同的异步实现方法,但这些方法并不适合所有的编程模型。 因为异步只是将原来需要等待的时机转移到了其它线程, 不同的编程模型在线程的使用上又存在差别,因此,在具体的编程模型中,选择合适的异步方法也很重要。 本小节将来讨论这个话题,并对不同的异步方法做个简单的优缺点分析。

1. 委托异步调用:由于它的实现是将原本需要阻塞的操作交给线程池的工作线程来处理了, 此时线程池的工作线程被阻塞了。因此,此方法对于依赖【线程池的工作线程】来处理任务的编程模型来说是没有意义的。 比如:Asp.net, Windows Services这类服务类的编程模型。但对于WinForm这样的单线程编程模型来说, 是比较方便的,尤其是还可以实现并行执行一些任务。

2. 使用IAsyncResult接口实现异步调用:它的实现是将原本需要阻塞的操作交给线程池的I/O完成线程来处理了, 而在.net中,还没有任何编程模型使用此类线程来执行处理任务,因此,适合于任何编程模型。 但并不是所有的API都支持此类接口,因此适用面有限,且使用较为复杂,尤其是某个过程需要多次异步调用时。 一般说来,许多I/O操作(文件I/O操作以及网络I/O)是支持此类API接口的。

3. 基于事件的异步调用模式:这种方式可以认为是一种封装模式,主要是为了简化线程模型以及简化调用方式,增强了API的易用性。 如果此模式用于对IAsyncResult接口(并非委托异步)实现包装,那么它具有第2种方法的所有优点。

4. 创建新线程的异步方式:这种方式有点特殊,主要和什么样的编程模型以及创建了多少线程有关。 对于服务类的编程模型来说,如果每次的请求处理都采用这种方式,显然会创建大量线程,反而损害性能。 反之,在其它情况下也是可以考虑的。

5. 使用线程池的异步方式:基本上与第1种相似,不适合一些服务类的编程模型,仅仅适用于与用户交互的桌面程序。

6. 使用BackgroundWorker的方式:其实也是在使用线程池的工作线程,因此最适用的领域与1,5相似,只是它在使用上更方便而已。

一般说来,在.net中,标准的异步模式都是使用IAsyncResult接口, 因此后三种方法并不算是真正的异步,但它们却实可以在某些场合下实现异步效果。
我并没有找到一个关于异步的明确定义,因此希望这句话不会误导大家。

异步文件I/O操作

前面说到,在微软的实现中,一些常见的I/O操作API都支持返回IAsyncResult接口,它们是效率最好的异步方式。 下面我将继续演示文件I/O操作以及网络I/O(远程调用)这二类异步操作。

.net中支持文件异步操作的功能由FileStream类来实现的,FileStream类中与异步有关的成员定义如下:

//  公开以文件为主的 System.IO.Stream,既支持同步读写操作,也支持异步读写操作。
public class FileStream : Stream
{
    // 使用指定的路径、创建模式、读/写和共享权限、
    // 缓冲区大小和同步或异步状态初始化 System.IO.FileStream 类的新实例。
    public FileStream(string path, FileMode mode, FileAccess access, FileShare share, 
                        int bufferSize, bool useAsync);

    // 获取一个值,该值指示 FileStream 是异步还是同步打开的。
    // 如果 FileStream 是异步打开的,则为 true,否则为 false。
    public virtual bool IsAsync { get; }

    // 开始异步读。
    public override IAsyncResult BeginRead(byte[] array, int offset, int numBytes, 
                        AsyncCallback userCallback, object stateObject);

    // 开始异步写。
    public override IAsyncResult BeginWrite(byte[] array, int offset, int numBytes, 
                        AsyncCallback userCallback, object stateObject);

    // 等待挂起的异步读取完成。
    public override int EndRead(IAsyncResult asyncResult);

    // 结束异步写入,在 I/O 操作完成之前一直阻止。
    public override void EndWrite(IAsyncResult asyncResult);
}

如果您需要使用文件的异步读写操作,请注意要使用上面列出的构造方法,并将最后一个参数设为true 。 关于这个参数,MSDN给出了详细的解释:

useAsync
类型:System.Boolean
指定使用异步 I/O 还是同步 I/O。但是,请注意,基础操作系统可能不支持异步 I/O,因此在指定 true 后,根据所用平台,句柄可能同步打开。当异步打开时,BeginRead 和 BeginWrite 方法在执行大量读或写时效果更好,但对于少量的读/写,这些方法速度可能要慢得多。如果应用程序打算利用异步 I/O,将 useAsync 参数设置为 true。正确使用异步 I/O,可以使应用程序的速度加快 10 倍,但是如果在没有为异步 I/O 重新设计应用程序的情况下使用异步 I/O,则可能使性能降低 10 倍。

还有一点要特别提醒:

在 Windows 上,所有小于 64 KB 的 I/O 操作都将同步完成,以获得更高的性能。当缓冲区大小小于 64 KB 时,异步 I/O 可能会妨碍性能。

由于异步文件操作的使用并不常见,我也实在找不出一个有意见的场景演示这些操作,因此就不给出示例了。
MSDN中有一段这方面的示例代码:http://msdn.microsoft.com/zh-cn/library/7db28s3c(VS.90).aspx

数据库的异步操作

【网络I/O】其实是一个含糊的说法,它包含所有与网络调用有关的操作, 如:网络调用(WebService, Remoting, WCF),或者用底层的方式发送HTTP, TCP请求(WebRequest, FTP, Socket)。 对于数据库的操作,由于也需要经过网络调用,因此,访问数据库的API也可以支持异步操作(需要各自实现)。 在.net中,由于微软仅对SQL SERVER的访问实现了异步操作,因此,我也只能演示对SQL SERVER的异步调用。 以下代码演示了在WinForm中采用异步的方式获取数据库中由用户创建的数据库名称列表。

/// <summary>
/// 获取数据库中所有由用户创建的数据库的查询语句。注意我特意延迟了3秒。
/// </summary>
private static readonly string s_QueryDatabaseListScript =
    @"  WAITFOR DELAY '00:00:03';
        SELECT dtb.name AS [Database_Name] FROM master.sys.databases AS dtb 
        WHERE (CAST(case when dtb.name in ('master','model','msdb','tempdb') then 1 else dtb.is_distributor end AS bit)=0 
        and CAST(isnull(dtb.source_database_id, 0) AS bit)=0) 
        ORDER BY [Database_Name] ASC";

private void linkLabel3_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e)
{
    Action action = BeginExecuteReader;
    // 采用委托的异步调用防止在打开连接时界面停止响应。
    // 这里并不需要回调。相当于 OneWay 的操作。
    action.BeginInvoke(null, null);
}

private void BeginExecuteReader()
{
    string connectionString = @"server=localhost\sqlexpress;Integrated Security=SSPI;Asynchronous Processing=true";
    SqlConnection connection = new SqlConnection(connectionString);
    try {
        // 注意:这里是同步调用,第一次连接或者连接字符串无效时会让界面停止响应。
        connection.Open();
    }
    catch( Exception ex ) {
        ShowResult(ex.Message + "\r\n当前连接字符串:" + connectionString);
        return;
    }

    SqlCommand command = new SqlCommand(s_QueryDatabaseListScript, connection);
    command.BeginExecuteReader(EndExecuteReader, command);
}

private void EndExecuteReader(IAsyncResult ar)
{
    SqlCommand command = (SqlCommand)ar.AsyncState;
    StringBuilder sb = new StringBuilder();

    try {
        // 如果SQL语句有错误,会在这里抛出。
        using( SqlDataReader reader = command.EndExecuteReader(ar) ) {
            while( reader.Read() ) {
                sb.Append(reader.GetString(0)).Append("; ");
            }
        }
    }
    catch( Exception ex ) {
        ShowResult(ex.Message);
    }
    finally {
        command.Connection.Close();
    }

    if( sb.Length > 0 )
        ShowResult("可用数据库列表:" + sb.ToString(0, sb.Length - 2));
}

代码很简单,我相信您能看懂,该讲的异步细节前面已说了,因此这里就不多说了,只有一点需要注意的是: 在连接字符串中必须要加入 Asynchronous Processing=true 。 当然了,您要是忘记了也没有关系,会有一个异常提示您的:

恰到好处的异常真是给力!

异步设计的使用总结

前面谈了许多关于异步实现的方法,涉及到一些.net中的API,也演示了一些我提供的示例代码。 下面再来总结一下在异步设计时需要遵循的一些惯用法。 由于目前的.net版本(4.0以内),语言本身、编译器、框架都没有很好的办法简化异步的实现或者规范设计要求, 因此,遵循一些惯用法将会使代码更容易让别人理解与使用,以可以在无形中避开一些怪异的错误。

以下是我总结的关于异步设计的一些惯用法

1. 基础的异步操作通常会提供BeginXXXXX/EndXXXXX的一对方法用于完成某个异步操作, 这些方法通常会使用一个类型为IAsyncResult的对象。
通常,所有规范的异步API方法中,BeginXXXXX的最后二个参数应该是固定的: 倒数第二个参数是回调方法,最后一个参数则为回调方法所需要的必要状态数据。 如果状态数据要包含多个信息时,可以采用定义一个额外的类型来解决,这也是异步API方法的最后一个参数的类型是object的原因。 注意:对于BeginXXXXX方法的最后二个参数,都是允许为null的。
而EndXXXXX的方法的签名几乎是类似的:只有一个IAsyncResult类型的传入参数,返回值也是整个异步操作的结果, 如果在异步操作过程中发生异常时,也是在这个方法中重新抛出的。
因此,如果您要提供类似BeginXXXXX/EndXXXXX这种API,也请遵循这个惯用法。

2. 如果要采用异步事件模式包装您的API,请注意事件对象的基类应该选择AsyncCompletedEventArgs,并且应该将结果设计成只读属性, 并在返回前调用base.RaiseExceptionIfNecessary();以防止在调用失败时用户得到一个无效的结果。 开始异步调用的方法名也应该采用如下方式:MethodNameAsync 表示将启动一个异步过程。

3. 如果您提供了一个MethodNameAsync的异步方法,请考虑在方法的传入参数后面加一个【object state】的参数, 以便于向回调或者事件通知时,传入所需的状态数据。

说完了异步的惯用法,再来说说异步使用的注意事项

1. 要实现无阻塞的异步调用过程,那么就要保证整个调用过程中,所有的操作都是异步的,也说是前面所说的【一路异步到底】。 通常我们可以采用传入回调函数的方式来实现无阻塞的调用过程。

2. 如果要实现自己的异步包装,请注意:需要在异步执行过程中捕获任何异常,并在执行完成后,用户试图获取结果时重新抛出。

3. 对于服务类的编程模型来说,异步仅能提高并发的访问量,如果此时服务器的压力已经足够大,那么使用异步是没有任何意义的。

4. 为了能维护一些异步回调时所需的必要数据,我不建议在客户端的调用类中,采用Field,Property的方式定义数据成员。 因为这样做的维护成本很高,尤其是在多次异步时,事实上,规范的异步方法的最后一个参数就是用于解决这个问题的!

在Asp.net中使用异步

由于Asp.net程序也可以调用我的服务框架,因此,这类程序相对于我的服务框架而言,也是客户端。 不过,Asp.net对于异步的实现方式有着特殊的要求,如果细说下去,将会造成这篇博客特别长,因此,我计划以后再谈这方面的话题。 为了不吊大家的胃口,我已准备好了一些关于Asp.net异步的示例代码,您要是有兴趣,可以先自行阅读, 以后有时间,我们再来聊这块内容。还有一点要提示:Asp.net的异步也是基于在前面所讲述的异步内容!

一些关于Asp.net的示例代码可以参考以下文件(红色方框内):

其中有个文件名是【JsCall.aspx】的页面,它则演示如何在浏览器中使用JavaScript调用我的服务框架或者调用ashx 。 它的操作界面如下:

至此,各种客户端的演示应该很全面了,我也可以安心结束这篇博客了。

点击此处下载示例代码

posted on 2011-10-23 20:37  Fish Li  阅读(51737)  评论(122编辑  收藏  举报