本文大部分內容來自於mikeperetz的Asynchronous Method Invocation及本人的一些個人體會所得,希望對你有所協助。原英文文獻可以在codeproject中搜尋到。
介紹
這篇文章將介紹非同步呼叫的實現機制及如何調用非同步方法呼叫。大多數.NET開發人員在經過delegate、Thread、AsynchronousInvocation之後,通常都會對以上概念產生混淆及誤用。實際上,以上概念是.NET2.0版本中對並行編程的核心支援,基於概念上的錯誤認識有可能導致在實際的編程中,無法利用非同步呼叫的特性最佳化我們的程式,例如大資料量載入引起的表單”假死”。事實上這並不是一個困難的問題,該文將以一種逐層深入、抽絲剝繭的方式逐漸深入到非同步編程的學習中。
同步與非同步
大多數人並不喜歡閱讀大量的文字說明,而喜歡直接閱讀代碼,因此,我們在下文中將主要以代碼的形式闡述同步與非同步調用。
同步方法調用
假設我們有一個函數,它的功能是將當前線程掛起3秒鐘。
static void Sleep()
{
Thread.Sleep(3000);
}
通常,當你的程式在調用Sleep後,它將等待3秒鐘的時間,在這3秒鐘時間內,你不能做任何其他動作。3秒之後,控制權被交回給調用線程(通常也就是你的主線程,即WinForm程式的UI線程)。這種類型的調用稱為同步,本次調用順序如下:
● 調用Sleep();
● Sleep()執行中;
● Sleep()執行完畢,控制權歸還調用線程。
我們再次調用Sleep()函數,不同的是,我們要基於委託來完成這次調用。一般為了將函數綁定在委託中,我們要定義與函數傳回型別、參數值完全一致的委託,這稍有點麻煩。但.NET內部已經為我們定義好了一些委託,例如MethodInvoker,這是一種無傳回值、無參數的委託簽名,這相當於你自訂了一種委託:
public delegate void SimpleHandler();
執行以下代碼:
MethodInvoker invoker = new MethodInvoker(Sleep);
invoker.Invoke();
我們使用了委託,但依然是同步的方式。主線程仍然要等待3秒的掛起,然後得到響應。
注意:Delegate.Invoke是同步方式的。
非同步方法呼叫調用
如何在調用Sleep()方法的同時,使主線程可以不必等待Sleep()的完成,一直能夠得到相應呢?這很重要,它意味著在函數執行的同時,主線程依然是非阻塞狀態。在後台服務類型的程式中,非阻塞的狀態意味著該應用服務可以在等待一項任務的同時去接受另一項任務;在傳統的WinForm程式中,意味著主線程(即UI線程)依然可以對使用者的操作得到響應,避免了”假死”。我們繼續調用Sleep()函數,但這次要引入BeginInvoke。
MethodInvoker invoker = new MethodInvoker(Sleep);
invoker.BeginInvoke(null, null);
● 注意BeginInvoke這行代碼,它會執行委託所調用的函數體。同時,調用BeginInvoke方法的線程(以下簡稱為調用線程)會立即得到響應,而不必等待Sleep()函數 的完成。
● 以上代碼是非同步,調用線程完全可以在調用函數的同時處理其他工作,但是不足的是我們仍然不知道對於Sleep()函數的調用何時會結束,這是下文將要解決的問 題。
● eginInvoke可以以非同步方式完全取代Invoke,我們也不必擔心函數包含參數的情況,下文介紹傳值問題。
注意:Delegate.BeginInvoke是非同步方式的。如果你要執行一項任務,但並不關心它何時完成,我們就可以使用BeginInvoke,它不會帶來調用線程的阻塞。
對於非同步呼叫,.NET內部究竟做了什嗎?
一旦你使用.NET完成了一次非同步呼叫,它都需要一個線程來處理非同步工作內容(以下簡稱非同步線程),非同步線程不可能是當前的調用線程,因為那樣仍然會造成調用線程的阻塞,與同步無異。事實上,.NET會將所有的非同步請求隊列加入線程池,以線程池內的線程處理所有的非同步請求。對於線程池似乎不必瞭解的過於深入,但我們仍需要關注以下幾點內容:
● Sleep()的非同步呼叫會在一個單獨的線程內執行,這個線程來自於.NET線程池。
● .NET線程池預設包含25個線程,你可以改變這個值的上限,每次非同步呼叫都會使用其中某個線程執行,但我們並不能控制具體使用哪一個線程。
● 線程池具備最大線程數目上限,一旦所有的線程都處於忙碌狀態,那麼新的非同步呼叫將會被置於等待隊列,直到線程池產生了新的可用線程,因此對於大量非同步請 求,我們有必要關注請求數量,否則可能造成效能上的影響。
簡單瞭解線程池
為了暴露線程池的上限,我們修改Sleep()函數,將線程掛起的時間延長至30s。在代碼的運行輸出結果中,我們需要關注以下內容:
● 線程池內的可用線程數量。
● 非同步線程是否來自於線程池。
● 線程託管ID值。
上文已經提到,.NET線程池預設包含25個線程,因此我們連續調用30次非同步方法呼叫,這樣可以在第25次調用後,看看線程池內部究竟發生了什麼。
private void Sleep()
{
int intAvailableThreads, intAvailableIoAsynThreds;
// 取得線程池內的可用線程數目,我們只關心第一個參數即可
ThreadPool.GetAvailableThreads(out intAvailableThreads,
out intAvailableIoAsynThreds);
// 線程資訊
string strMessage =
String.Format("是否是線程池線程:{0},線程託管ID:{1},可用線程數:{2}",
Thread.CurrentThread.IsThreadPoolThread.ToString(),
Thread.CurrentThread.GetHashCode(),
intAvailableThreads);
Console.WriteLine(strMessage);
Thread.Sleep(30000);
}
private void CallAsyncSleep30Times()
{
// 建立包含Sleep函數的委派物件
MethodInvoker invoker = new MethodInvoker(Sleep);
for (int i = 0; i < 30; i++)
{
// 以非同步形式,調用Sleep函數30次
invoker.BeginInvoke(null, null);
}
}
輸出結果:
對於輸出結果,我們可以總結為以下內容:
● 所有的非同步線程都來自於.NET線程池。
● 每次執行一次非同步呼叫,便產生一個新的線程;同時可用線程數目減少。
● 在執行非同步呼叫25次後,線程池中不再有空閑線程。此時,應用程式會等待空閑線程的產生。
● 一旦線程池內產生了空閑線程,它會立即被分配給非同步任務等待隊列,之後線程池中仍然不具備空閑線程,應用程式主線程進入掛起狀態繼續等待空閑線程,這樣 的調用一直持續到非同步呼叫被執行完30次。
針對以上結果,我們對於非同步呼叫可以總結為以下內容:
● 每次非同步呼叫都在新的線程中執行,這個線程來自於.NET線程池。
● 線程池有自己的執行上限,如果你想要執行多次耗費時間較長的非同步呼叫,那麼線程池有可能進入一種”線程饑餓”狀態,去等待可用線程的產生。
BeginInvoke和EndInvoke
我們已經知道,如何在不阻塞調用線程的情況下執行一個非同步呼叫,但我們無法得知非同步呼叫的執行結果,及它何時執行完畢。為瞭解決以上問題,我們可以使用EndInvoke。EndInvoke在非同步方法呼叫執行完成前,都會造成線程的阻塞。因此,在調用BeginInvoke之後調用EndInvoke,效果幾乎完全等同於以阻塞模式執行你的函數(EndInvoke會使調用線程掛起,一直到非同步函數執行完畢)。但是,.NET是如何將BeginInvoke和EndInvoke進行綁定呢?答案就是IAsyncResult。每次我們使用BeginInvoke,傳回值都是IAsyncResult類型,它是.NET追蹤非同步呼叫的關索引值。每次非同步呼叫之後的結果如何?如果要瞭解具體執行結果,IAsyncResult便可視為一個標籤。通過這個標籤,你可以瞭解非同步呼叫何時執行完畢,更重要的是,它可以儲存非同步呼叫的參數傳值,解決非同步函數上下文問題。
我們現在通過幾個例子來瞭解IAsyncResult。如果之前對它瞭解不多,那麼就需要耐心的將它領悟,因為這種類型的調用是.NET非同步呼叫的關鍵內容。
private void SleepOneSecond()
{
// 當前線程掛起1秒
Thread.Sleep(1000);
}
private void UsingEndInvoke()
{
// 建立一個指向SleepOneSecond的委託
MethodInvoker invoker = new MethodInvoker(SleepOneSecond);
// 開始執行SleepOneSecond,但這次非同步呼叫我們傳遞一些參數
// 觀察Delegate.BeginInvoke()的第二個參數
IAsyncResult tag = invoker.BeginInvoke(null, "passing some state");
// 應用程式在此處會造成阻塞,直到SleepOneSecond執行完成
invoker.EndInvoke(tag);
// EndInvoke執行完畢,取得之前傳遞的參數內容
string strState = (string)tag.AsyncState;
Console.WriteLine("EndInvoke的傳遞參數" + tag.AsyncState.ToString());
}
輸出結果:
回到文章初始提到的”表單動態更新”問題,如果你將上述代碼運行在一個WinForm程式中,會發現表單依然陷入”假死”。對於這種情況,你可能會陷入疑惑:之前說非同步函數都執行線上程池中,因此可以肯定非同步函數的執行不會引起UI線程的忙碌,但為什麼表單依然陷入了”假死”?問題就在於EndInvoke。EndInvoke此時扮演的角色就是”線程鎖”,它充當了一個調用線程與非同步線程之間的調度器,有時調用線程需要使用非同步函數的執行結果,那麼調度線程就需要在非同步執行完之前一直等待,直到得到結果方可繼續運行。EndInvoke一方面負責監聽非同步函數的執行狀況,一方面將調用線程掛起。
因此在Win Form環境下,UI線程的”假死”並不是因為線程忙碌造成,而是被EndInvoke”善意的”暫時封鎖,它只是為了等待非同步函數的完成。
我們可以對EndInvoke總結如下:
● 在執行EndInvoke時,調用線程會進入掛起狀態,一直到非同步函數執行完成。
● 使用EndInvoke可以使應用程式得知非同步函數何時執行完畢。
● 如果將上述寫法稱為”非同步”,你一定覺得這種”非同步”徒具其名,雖然知道非同步函數何時執行完畢,也得到了非同步函數的傳值,但我們的調用線程仍然會等待函數執行完畢,在等待過程中線程阻塞,實際上與同步調用無異。
如何捕捉異常?
現在我們把問題稍微複雜化,考慮非同步函數拋出異常的一種情形。我們需要瞭解在何處捕捉到異常,是BeginInvoke,還是EndInvoke?甚至是有沒有可能無法捕捉異常?答案是EndInvoke。BeginInvoke的工作只是開始線程池對於非同步函數的執行工作,EndInvoke則需要處理函數執行完成的所有資訊,包括其中產生的異常。
private void SleepOneSecond()
{
Thread.Sleep(3000);
throw new Exception("Here Is An Async Function Exception");
}
private void UsingEndInvoke()
{
// 建立一個指向SleepOneSecond的委託
MethodInvoker invoker = new MethodInvoker(SleepOneSecond);
// 開始執行SleepOneSecond,但這次非同步呼叫我們傳遞一些參數
// 觀察Delegate.BeginInvoke()的第二個參數
IAsyncResult tag = invoker.BeginInvoke(null, "passing some state");
try
{
// 應用程式在此處會造成阻塞,直到SleepOneSecond執行完成
invoker.EndInvoke(tag);
}
catch (Exception ex)
{
// 此處可以捕捉異常
MessageBox.Show(ex.Message);
}
// EndInvoke執行完畢,取得之前傳遞的參數內容
string strState = (string)tag.AsyncState;
Console.WriteLine("EndInvoke的傳遞參數" + tag.AsyncState.ToString());
}
執行以上代碼後,你將發現只有在使用EndInvoke時,才會捕捉到異常,否則異常將丟失。需要注意的是,直接在編譯器中運行程式是無法產生捕獲異常的,只有在Debug、Release環境下運行,異常才會以對話方塊的形式直接彈出。
向函數中傳遞參數
現在我們來改變一下非同步函數,讓它接收一些參數。
private string FuncWithParameters(int param1, string param2, ArrayList param3)
{
// 我們在這裡改變參數值
param1 = 100;
param2 = "hello";
param3 = new ArrayList();
return "thank you for reading me";
}
下面我們使用BeginInvoke與EndInvoke來調用這個函數,首先,我們建立一個匹配該函數的委託簽名。
public delegate string DelegateWithParameters(int param1, string param2, ArrayList param3);
我們可以將BeginInvoke和EndInvoke視為將非同步函數分割為兩部分的特殊函數。BeginInvoke通過自己的兩個參數值(一個AsyncCallBack委託,一個object對象)來接收傳入參數,EndInvoke用於計算傳出參數(標記了out或者ref的參數)和函數傳回值。
現在我們回到自己的函數FuncWithParameters,param1、param2、param3是傳入值,同時,它們也作為BeginInvoke的參數來處理;函數的傳回值是string類型,它將作為EndInvoke的傳回型別。比較酷的是,編譯器可以通過委託類型,來自動為BeginInvoke和EndInvoke產生正確的參數與傳回值類型。
注意我們在非同步函數中為參數分配了新的值,這樣可以檢驗這些參數在調用非同步函數後,究竟會傳出什麼樣的值……
private void CallFuncWithParameters()
{
// 建立幾個參數
string strParam = "Param1";
int intValue = 100;
ArrayList list = new ArrayList();
list.Add("Item1");
// 建立委派物件
DelegateWithParameters delFoo =
new DelegateWithParameters(FuncWithParameters);
// 調用非同步函數
IAsyncResult tag =
delFoo.BeginInvoke(intValue, strParam, list, null, null);
// 通常調用線程會立即得到響應
// 因此你可以在這裡進行一些其他處理
// 執行EndInvoke來取得傳回值
string strResult = delFoo.EndInvoke(tag);
Trace.WriteLine("param1: " + intValue);
Trace.WriteLine("param2: " + strParam);
Trace.WriteLine("ArrayList count: " + list.Count);
}
我們的非同步函數對參數的改變並沒有影響其傳出值,現在我們把ArrayList變為ref參數,看看會給EndInvoke帶來什麼變化。
public delegate string DelegateWithParameters(out int param1, string param2, ref ArrayList param3);
private string FuncWithParameters(out int param1, string param2, ref ArrayList param3)
{
// 我們在這裡改變參數值
param1 = 300;
param2 = "hello";
param3 = new ArrayList();
return "thank you for reading me";
}
private void CallFuncWithParameters()
{
// 建立幾個參數
string strParam = "Param1";
int intValue = 100;
ArrayList list = new ArrayList();
list.Add("Item1");
// 建立委派物件
DelegateWithParameters delFoo =
new DelegateWithParameters(FuncWithParameters);
// 調用非同步函數
IAsyncResult tag =
delFoo.BeginInvoke(out intValue, strParam, ref list, null, null);
// 通常調用線程會立即得到響應
// 因此你可以在這裡進行一些其他處理
// 調用EndInvoke,發現intValue和list可以作為參數被傳出,
// 是因為他們可以被非同步函數更新
string strResult = delFoo.EndInvoke(out intValue, ref list, null);
Trace.WriteLine("param1: " + intValue);
Trace.WriteLine("param2: " + strParam);
Trace.WriteLine("ArrayList count: " + list.Count);
}
param2沒有變化,因為它是輸入參數;param1作為輸出參數,被更新為300;ArrayList的值已被重新分配,我們可以發現它的引用被指向了一個空元素的ArrayList對象(初始引用已丟失)。通過以上執行個體,我們應該能理解參數是如何在BeginInvoke與EndInvoke之間傳遞的。現在我們來嘗試完成一個非阻塞模式下的非同步呼叫,這是個重頭戲!