那些年,我們一起追尋的非同步程式設計

風靈使發表於2018-06-12

術語:

APM 非同步程式設計模型,Asynchronous Programming Model

EAP 基於事件的非同步程式設計模式,Event-based Asynchronous Pattern

TAP 基於任務的非同步程式設計模式,Task-based Asynchronous Pattern

TPL 任務並行庫,Task Parallel Library

現在我給這個系列整個目錄和做個簡單介紹。

“概要 + 目錄”整理

C#語言是微軟於2000年釋出,基於.NET Framewrok框架的、物件導向的高階語言。經過近十三年的發展,經歷了5次大的升級,目前最新版本為C#5.0(對應於.NET Framework 4.5)。其中每個版本釋出都是有一個“主題”。即:C#1.0託管程式碼→C#2.0泛型→C#3.0LINQ→C#4.0動態語言→C#5.0非同步程式設計。這系列既是針對“非同步程式設計”所寫。

C#版本 .NET 版本 Visual Studio 版本 特性描述
C# 1.0 .NET 1.0/1.1 VS 2002/2003 C#的第一個正式發行版本。微軟的團隊從無到有創造了一種語言,專門為.NET程式設計提供支援
C# 2.0 .NET 2.0 VS 2005 C#語言開始支援泛型,.NET Framework 2.0新增了支援泛型的庫
C# 2.0 .NET 3.0 VS 2005 新增了一套API來支援分散式通訊(Windows Communication Foundation— WCF)、富客戶端表示(Windows Presentation Foundation)、工作流(Windows Workflow—WF)以及Web身份驗證(Cardspaces)
C# 3.0 .NET 3.5 VS 2008 新增了對LINQ的支援,對用於集合程式設計的API進行了大幅改進。.NET Framework 3.5對原有的API進行了擴充套件,從而支援了LINQ
C# 4.0 .NET 4.0 VS 2010 新增了動態型別(dynamic)的支援,引入了新的輕量級執行緒同步基元及新的非同步程式設計類庫TPL
C# 5.0 .NET 4.5 VS 2012 改進並擴充套件了.NET4.0中引入的TPL類庫,並引入async和await關鍵字輕鬆構建非同步方法。

1. 我的非同步程式設計整理

資料整理路線:執行緒—-執行緒池—-執行緒同步—-並行任務—-三種非同步程式設計模型。首先了解最基礎的執行緒(Thread類),再進一步明白執行緒管理器(ThreadPool類)。因為多個工作項之間可能出現並行執行,會造成對共享資源的訪問問題,所以引入執行緒同步基元來讓共享資源得到合理使用。最後介紹.NET4.0新引入並在.NET4.5中得到優化和擴充套件的TPL(任務並行庫),並結合C# 5.0中新引入的asyncawait關鍵字輕鬆構建非同步方法。詳細如下:

非同步程式設計:執行緒概述及使用

非同步程式設計:使用執行緒池管理執行緒

非同步程式設計:執行緒同步基元物件

非同步程式設計:輕量級執行緒同步基元物件

非同步程式設計:.NET4.5 資料並行

非同步程式設計:非同步程式設計模型 (APM)

非同步程式設計:基於事件的非同步程式設計模式(EAP)

非同步程式設計:.NET 4.5 基於任務的非同步程式設計模型(TAP)


非同步程式設計:IAsyncResult非同步程式設計模型 (APM)

大部分開發人員,在開發多執行緒應用程式時,都是使用ThreadPoolQueueUserWorkItem方法來發起一次簡單的非同步操作。然而,這個技術存在許多限制。最大的問題是沒有一個內建的機制讓你知道操作在什麼時候完成,也沒有一個機制在操作完成時獲得一個返回值。為了克服這些限制(並解決其他一些問題),Microsoft引入了三種非同步程式設計模式:

  1. .NET1.0非同步程式設計模型 (APM),基於IAsyncResult介面實現。

  2. .NET2.0基於事件的非同步程式設計模式(EMP),基於事件實現。

  3. .NET4.X基於任務的非同步程式設計模式(TPL),新型非同步程式設計模式,對於.NET4.0之後的非同步構造都推薦使用此模式

儘管在新的設計上我們推薦都使用“.NET4.0基於任務的程式設計模式”,但我還是計劃整理出舊版的非同步程式設計模型,因為:

  1. 在一些特殊場合下我們可能覺得一種模式更適合;

  2. 可以更充分認識三種模式之間的優劣,便於選擇;

  3. 很多遺留的程式碼包含了舊的設計模式;

  4. 等等…

示例下載:非同步程式設計:IAsyncResult非同步程式設計模型.rar

IAsyncResult設計模式—-規範概述

使用IAsyncResult設計模式的非同步操作是通過名為 Begin***End*** 的兩個方法來實現的,這兩個方法分別指代開始和結束非同步操作。例如,FileStream類提供BeginReadEndRead方法來從檔案非同步讀取位元組。這兩個方法實現了 Read 方法的非同步版本。

在呼叫 Begin*** 後,應用程式可以繼續在呼叫執行緒上執行指令,同時非同步操作在另一個執行緒上執行。(如果有返回值還應呼叫 End*** 來獲取操作的結果)。

1)Begin***

a)Begin*** 方法帶有該方法的同步版本簽名中宣告的任何引數。

b)Begin*** 方法簽名中不包含任何輸出引數。方法簽名最後兩個引數的規範是:第一個引數定義一個AsyncCallback委託,此委託引用在非同步操作完成時呼叫的方法。第二個引數是一個使用者定義的物件。此物件可用來向非同步操作完成時為AsyncCallback委託方法傳遞應用程式特定的狀態資訊(eg:可通過此物件在委託中訪問End*** 方法)。另外,這兩個引數都可以傳遞null。

c)返回IAsyncResult物件。

// 表示非同步操作的狀態。
[ComVisible(true)]
public interface IAsyncResult
{
    // 獲取使用者定義的物件,它限定或包含關於非同步操作的資訊。
    object AsyncState { get; }
    // 獲取用於等待非同步操作完成的System.Threading.WaitHandle,待非同步操作完成時獲得訊號。
    WaitHandle AsyncWaitHandle { get; }
    // 獲取一個值,該值指示非同步操作是否同步完成。
    bool CompletedSynchronously { get; }
    // 獲取一個值,該值指示非同步操作是否已完成。
    bool IsCompleted { get; }
}

// 常用委託宣告(我後面示例是使用了自定義的帶ref引數的委託)
public delegate void AsyncCallback(IAsyncResult ar)

2)End***

a) End*** 方法可結束非同步操作,如果呼叫 End*** 時,IAsyncResult物件表示的非同步操作還未完成,則 End*** 將在非同步操作完成之前阻塞呼叫執行緒。

b) End*** 方法的返回值與其同步副本的返回值型別相同。End*** 方法帶有該方法同步版本的簽名中宣告的所有outref 引數以及由BeginInvoke返回的IAsyncResult,規範上 IAsyncResult 引數放最後。

i.要想獲得返回結果,必須呼叫的方法;
ii.若帶有out 和 ref 引數,實現上委託也要帶有outref引數,以便在回撥中獲得對應引用傳參值做相應邏輯;

3) 總是呼叫 End***() 方法,而且只呼叫一次

以下理由都是針對“I/O限制”的非同步操作提出。然而,對於計算限制的非同步操作,儘管都是使用者程式碼,但還是推薦遵守此規則。

I/O限制的非同步操作:比如像帶FileOptions.Asynchronous標識的FileStream,其BeginRead()方法向Windows傳送一個I/O請求包(I/O Request Packet,IRP)後方法不會阻塞執行緒而是立即返回,由WindowsIRP傳送給適當的裝置驅動程式,IRP中包含了為BeginRead()方法傳入的回撥函式,待硬體裝置處理好IRP後,會將IRP的委託排隊到CLR的執行緒池佇列中。

必須呼叫End***方法,否則會造成資源的洩露。有的開發人員寫程式碼呼叫Begin***方法非同步執行I/O限制後就不需要進行任何處理了,所以他們不關心End***方法的呼叫。但是,出於以下兩個原因,End***方法是必須呼叫的:

a) 在非同步操作時,對於I/O限制操作,CLR會分配一些內部資源,操作完成時,CLR繼續保留這些資源直至End***方法被呼叫。如果一直不呼叫End***,這些資源會直到程式終止時才會被回收。(End***方法設計中常常包含資源釋放)

b) 發起一個非同步操作時,實際上並不知道該操作最終是成功還是失敗(因為操作由硬體在執行)。要知道這一點,只能通過呼叫End***方法,檢查它的返回值或者看它是否丟擲異常。

另外,需要注意的是I/O限制的非同步操作完全不支援取消(因為操作由硬體執行),但可以設定一個標識,在完成時丟棄結果來模擬取消行為。

現在我們清楚了IAsyncResult設計模式的設計規範,接下來我們再通過IAsyncResult非同步程式設計模式的三個經典場合來加深理解。

一、基於IAsyncResult構造一個非同步API

現在來構建一個IAsyncResult的類,並且實現非同步呼叫。

// 帶ref引數的自定義委託
public delegate void RefAsyncCallback(ref string resultStr, IAsyncResult ar);

public class CalculateAsyncResult : IAsyncResult
{
    private int _calcNum1;
    private int _calcNum2;
    private RefAsyncCallback _userCallback;

    public CalculateAsyncResult(int num1, int num2, RefAsyncCallback userCallback, object asyncState)
    {
        this._calcNum1 = num1;
        this._calcNum2 = num2;
        this._userCallback = userCallback;
        this._asyncState = asyncState;
        // 非同步執行操作
        ThreadPool.QueueUserWorkItem((obj) => { AsyncCalculate(obj); }, this);
    }

    #region IAsyncResult介面
    private object _asyncState;
    public object AsyncState { get { return _asyncState; } }

    private ManualResetEvent _asyncWaitHandle;
    public WaitHandle AsyncWaitHandle
    {
        get
        {
            if (this._asyncWaitHandle == null)
            {
                ManualResetEvent event2 = new ManualResetEvent(false);
                Interlocked.CompareExchange<ManualResetEvent>(ref this._asyncWaitHandle, event2, null);
            }
            return _asyncWaitHandle;
        }
    }

    private bool _completedSynchronously;
    public bool CompletedSynchronously { get { return _completedSynchronously; } }

    private bool _isCompleted;
    public bool IsCompleted { get { return _isCompleted; } }
    #endregion

    /// <summary>
    /// 
    /// 儲存最後結果值
    /// </summary>
    public int FinnalyResult { get; set; }
    /// <summary>
    /// End方法只應呼叫一次,超過一次報錯
    /// </summary>
    public int EndCallCount = 0;
    /// <summary>
    /// ref引數
    /// </summary>
    public string ResultStr;

    /// <summary>
    /// 非同步進行耗時計算
    /// </summary>
    /// <param name="obj">CalculateAsyncResult例項本身</param>
    private static void AsyncCalculate(object obj)
    {
        CalculateAsyncResult asyncResult = obj as CalculateAsyncResult;
        Thread.SpinWait(1000);
        asyncResult.FinnalyResult = asyncResult._calcNum1 * asyncResult._calcNum2;
        asyncResult.ResultStr = asyncResult.FinnalyResult.ToString();

        // 是否同步完成
        asyncResult._completedSynchronously = false;
        asyncResult._isCompleted = true;
        ((ManualResetEvent)asyncResult.AsyncWaitHandle).Set();
        if (asyncResult._userCallback != null)
            asyncResult._userCallback(ref asyncResult.ResultStr, asyncResult);
    }
}

public class CalculateLib
{
    public IAsyncResult BeginCalculate(int num1, int num2, RefAsyncCallback userCallback, object asyncState)
    {
        CalculateAsyncResult result = new CalculateAsyncResult(num1, num2, userCallback, asyncState);
        return result;
    }

    public int EndCalculate(ref string resultStr, IAsyncResult ar)
    {
        CalculateAsyncResult result = ar as CalculateAsyncResult;
        if (Interlocked.CompareExchange(ref result.EndCallCount, 1, 0) == 1)
        {
            throw new Exception("End方法只能呼叫一次。");
        }
        result.AsyncWaitHandle.WaitOne();

        resultStr = result.ResultStr;

        return result.FinnalyResult;
    }

    public int Calculate(int num1, int num2, ref string resultStr)
    {
        resultStr = (num1 * num2).ToString();
        return num1 * num2;
    }
}

使用上面通過IAsyncResult設計模式實現的帶ref引用引數的非同步操作,我將展示三種阻塞式響應非同步呼叫和一種無阻塞式委託響應非同步呼叫。即:

1.執行非同步呼叫後,若我們需要控制後續執行程式碼在非同步操作執行完之後執行,可通過下面三種方式阻止其他工作:(當然我們不推薦你阻塞執行緒或輪詢浪費CPU時間)

a) IAsyncResultAsyncWaitHandle屬性,待非同步操作完成時獲得訊號。

b) 通過IAsyncResultIsCompleted屬性進行輪詢。

c) 呼叫非同步操作的 End*** 方法。

/// <summary>
/// APM 阻塞式非同步響應
/// </summary>
public class Calculate_For_Break
{
    public static void Test()
    {
        CalculateLib cal = new CalculateLib();

        // 基於IAsyncResult構造一個非同步API   (回撥引數和狀態物件都傳遞null)
        IAsyncResult calculateResult = cal.BeginCalculate(123, 456, null, null);
        // 執行非同步呼叫後,若我們需要控制後續執行程式碼在非同步操作執行完之後執行,可通過下面三種方式阻止其他工作:
        // 1、IAsyncResult 的 AsyncWaitHandle 屬性,帶非同步操作完成時獲得訊號。
        // 2、通過 IAsyncResult 的 IsCompleted 屬性進行輪詢。通過輪詢還可實現進度條功能。
        // 3、呼叫非同步操作的 `End***` 方法。
        // ***********************************************************
        // 1、calculateResult.AsyncWaitHandle.WaitOne();
        // 2、while (calculateResult.IsCompleted) { Thread.Sleep(1000); }
        // 3、
        string resultStr = string.Empty;
        int result = cal.EndCalculate(ref resultStr, calculateResult);
    }
}

2.執行非同步呼叫後,若我們不需要阻止後續程式碼的執行,那麼我們可以把非同步執行操作後的響應放到回撥中進行。(推薦使用無阻塞式回撥模式)

/// <summary>
/// APM 回撥式非同步響應
/// </summary>
public class Calculate_For_Callback
{
    public static void Test()
    {
        CalculateLib cal = new CalculateLib();

        // 基於IAsyncResult構造一個非同步API
        IAsyncResult calculateResult = cal.BeginCalculate(123, 456, AfterCallback, cal);
    }

    /// <summary>
    /// 非同步操作完成後做出響應
    /// </summary>
    private static void AfterCallback(ref string resultStr, IAsyncResult ar)
    {
        // 執行非同步呼叫後,若我們不需要阻止後續程式碼的執行,那麼我們可以把非同步執行操作後的響應放到回撥中進行。
        CalculateLib cal = ar.AsyncState as CalculateLib;
        cal.EndCalculate(ref resultStr, ar);
        // 再根據resultStr值做邏輯。
    }
}

二、使用委託進行非同步程式設計

對於委託,編譯器會為我們生成同步呼叫方法“invoke”以及非同步呼叫方法“BeginInvoke”和“EndInvoke”。對於非同步呼叫方式,公共語言執行庫 (CLR) 將對請求進行排隊並立即返回到呼叫方,由執行緒池的執行緒排程目標方法並與提交請求的原始執行緒並行執行,為BeginInvoke()方法傳入的回撥方法也將在同一個執行緒上執行。

非同步委託是快速為方法構建非同步呼叫的方式,它基於IAsyncResult設計模式實現的非同步呼叫,即,通過BeginInvoke返回IAsyncResult物件;通過EndInvoke獲取結果值。

示例:

上節的CalculateLib類中的同步方法以及所要使用到的委託如下:

// 帶ref引數的自定義委託
public delegate int AsyncInvokeDel(int num1, int num2, ref string resultStr);
public int Calculate(int num1, int num2, ref string resultStr)
{
    resultStr = (num1 * num2).ToString();
    return num1 * num2;
}

然後,通過委託進行同步或非同步呼叫:

/// <summary>
/// 使用委託進行非同步呼叫
/// </summary>
public class Calculate_For_Delegate
{
    public static void Test()
    {
        CalculateLib cal = new CalculateLib();

        // 使用委託進行同步或非同步呼叫
        AsyncInvokeDel calculateAction = cal.Calculate;
        string resultStrAction = string.Empty;
        // int result1 = calculateAction.Invoke(123, 456);
        IAsyncResult calculateResult1 = calculateAction.BeginInvoke(123, 456, ref resultStrAction, null, null);
        int result1 = calculateAction.EndInvoke(ref resultStrAction, calculateResult1);
    }
}

三、多執行緒操作控制元件

訪問 Windows 窗體控制元件本質上不是執行緒安全的。如果有兩個或多個執行緒操作某一控制元件的狀態,則可能會迫使該控制元件進入一種不一致的狀態。還可能出現其他與執行緒相關的 bug,包括爭用情況和死鎖。確保以執行緒安全方式訪問控制元件非常重要。

不過,在有些情況下,您可能需要多執行緒呼叫控制元件的方法。.NET Framework 提供了從任何執行緒操作控制元件的方式:

1.非安全方式訪問控制元件(此方式請永遠不要再使用)

多執行緒訪問視窗中的控制元件,可以在視窗的建構函式中將FormCheckForIllegalCrossThreadCalls靜態屬性設定為false

// 獲取或設定一個值,該值指示是否捕獲對錯誤執行緒的呼叫,
// 這些呼叫在除錯應用程式時訪問控制元件的System.Windows.Forms.Control.Handle屬性。
// 如果捕獲了對錯誤執行緒的呼叫,則為 true ;否則為 false 。
public static bool CheckForIllegalCrossThreadCalls { get; set; }

2.安全方式訪問控制元件

原理:從一個執行緒封送呼叫並跨執行緒邊界將其傳送到另一個執行緒,並將呼叫插入到建立控制元件執行緒的訊息佇列中,當控制元件建立執行緒處理這個訊息時,就會在自己的上下文中執行傳入的方法。(此過程只有呼叫執行緒和建立控制元件執行緒,並沒有建立新執行緒)

注意:從一個執行緒封送呼叫並跨執行緒邊界將其傳送到另一個執行緒會耗費大量的系統資源,所以應避免重複呼叫其他執行緒上的控制元件。

1)使用BackgroundWork後臺輔助執行緒控制元件方式(詳見:基於事件的非同步程式設計模式(EMP))。

2)結合TaskScheduler.FromCurrentSynchronizationContext()Task 實現。

3)捕獲執行緒上下文ExecuteContext,並呼叫ExeceteContext.Run()靜態方法在指定的執行緒上下文中執行。(詳見:執行上下文

4)使用Control類上提供的InvokeBeginInvoke方法。

5)在WPF應用程式中可以通過WPF提供的Dispatcher物件提供的Invoke方法、BeginInvoke方法來完成跨執行緒工作。

因本文主要解說IAsyncResult非同步程式設計模式,所以只詳細分析InvokeBeginInvoke跨執行緒訪問控制元件方式。

Control類實現了ISynchronizeInvoke介面,提供了InvokeBeginInvoke方法來支援其它執行緒更新GUI介面控制元件的機制。

public interface ISynchronizeInvoke
{
    // 獲取一個值,該值指示呼叫執行緒是否與控制元件的建立執行緒相同。
    bool InvokeRequired { get; }
    // 在控制元件建立的執行緒上非同步執行指定委託。
    AsyncResult BeginInvoke(Delegate method, params object[] args);
    object EndInvoke(IAsyncResult asyncResult);
    // 在控制元件建立的執行緒上同步執行指定委託。
    object Invoke(Delegate method, params object[] args);
}

1)Control類的 Invoke,BeginInvoke 內部實現如下:

a)Invoke (同步呼叫)先判斷控制元件建立執行緒與當前執行緒是否相同,相同則直接呼叫委託方法;否則使用Win32APIPostMessage 非同步執行,但是 Invoke 內部會呼叫IAsyncResult.AsyncWaitHandle等待執行完成。

b)BeginInvoke (非同步呼叫)使用Win32APIPostMessage 非同步執行,並且返回 IAsyncResult 物件。

UnsafeNativeMethods.PostMessage(new HandleRef(this, this.Handle)
                  , threadCallbackMessage, IntPtr.Zero, IntPtr.Zero);
[DllImport("user32.dll", CharSet=CharSet.Auto)]
public static extern bool PostMessage(HandleRefhwnd, intmsg, IntPtrwparam, IntPtrlparam);

PostMessagewindows api,用來把一個訊息傳送到一個視窗的訊息佇列。這個方法是非同步的,也就是該方法封送完畢後馬上返回,不會等待委託方法的執行結束,呼叫者執行緒將不會被阻塞。(對應同步方法的windows api是:SendMessage();訊息佇列裡的訊息通過呼叫GetMessagePeekMessage取得)

2)InvokeRequired

獲取一個值,該值指示呼叫執行緒是否與控制元件的建立執行緒相同。內部關鍵如下:

Int windowThreadProcessId = SafeNativeMethods.GetWindowThreadProcessId(ref2, out num);
Int currentThreadId = SafeNativeMethods.GetCurrentThreadId();
return (windowThreadProcessId != currentThreadId);

即返回“通過GetWindowThreadProcessId功能函式得到建立指定視窗執行緒的標識和建立視窗的程式的識別符號與當前執行緒Id進行比較”的結果。

3)示例(詳見示例檔案)

在使用的時候,我們使用 this.InvokeRequired 屬性來判斷是使用InvokeBeginInvoke 還是直接呼叫方法。

private void InvokeControl(object mainThreadId)
{
    if (this.InvokeRequired)
    {
        this.Invoke(new Action<String>(ChangeText), "InvokeRequired = true.改變控制元件Text值");
        //this.textBox1.Invoke(new Action<int>(InvokeCount), (int)mainThreadId);
    }
    else
    {
        ChangeText("在建立控制元件的執行緒上,改變控制元件Text值");
    }
}

private void ChangeText(String str)
{
    this.textBox1.Text += str;
}

注意,在InvokeControl方法中使用 this.Invoke(Delegate del) 和使用 this.textBox1.Invoke(Delegate del) 效果是一樣的。因為在執行InvokeBeginInvoke時,內部首先呼叫 FindMarshalingControl() 進行一個迴圈向上回溯,從當前控制元件開始回溯父控制元件,直到找到最頂級的父控制元件,用它作為封送物件。也就是說 this.textBox1.Invoke(Delegate del) 會追溯到和 this.Invoke(Delegate del) 一樣的起點。(子控制元件的建立執行緒一定是建立父控制元件的執行緒,所以這種追溯不會導致將呼叫封送到錯誤的目的執行緒)

4)異常資訊:”在建立視窗控制程式碼之前,不能在控制元件上呼叫 InvokeBeginInvoke

a) 可能是在窗體還未構造完成時,在建構函式中非同步去呼叫了InvokeBeginInvoke

b) 可能是使用輔助執行緒建立一個視窗並用Application.Run()去建立控制程式碼,在控制程式碼未建立好之前呼叫了InvokeBeginInvoke。(此時新建的視窗相當於開了另一個程式,並且為新視窗關聯的輔助執行緒開啟了訊息迴圈機制),類似下面程式碼:

new Thread((ThreadStart)delegate
    {
        WaitBeforeLogin = new Form2();
        Application.Run(WaitBeforeLogin);
    }).Start();

解決方案:在呼叫InvokeBeginInvoke之前輪詢檢查視窗的IsHandleCreated屬性。

// 獲取一個值,該值指示控制元件是否有與它關聯的控制程式碼。
public bool IsHandleCreated { get; }
while (!this.IsHandleCreated) { …… }

本節到此結束,本節主要講了非同步程式設計模式之一“非同步程式設計模型(APM)”,是基於IAsyncResult設計模式實現的非同步程式設計方式,並且構建了一個繼承自IAsyncResult介面的示例,及展示了這種模式在委託及跨執行緒訪問控制元件上的經典應用。下一節中,我將為大家介紹基於事件的程式設計模型……

相關文章