C#客戶端的異步操作


上篇博客【用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 。 它的操作界面如下:

至此,各種客戶端的演示應該很全面了,我也可以安心結束這篇博客了。

 

點擊此處下載示例代碼

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM