前言
本文大部分內容來自於mikeperetz的Asynchronous Method Invocation及本人的一些個人體會所得,希望對你有所幫助。原英文文獻可以在codeproject中搜索到。
介紹
這篇文章將介紹異步調用的實現機制及如何調用異步方法。大多數.NET開發者在經過delegate、Thread、AsynchronousInvocation之后,通常都會對以上概念產生混淆及誤用。實際上,以上概念是.NET2.0版本中對並行編程的核心支持,基於概念上的錯誤認識有可能導致在實際的編程中,無法利用異步調用的特性優化我們的程序,例如大數據量加載引起的窗體”假死”。事實上這並不是一個困難的問題,該文將以一種逐層深入、抽絲剝繭的方式逐漸深入到異步編程的學習中。
同步與異步
大多數人並不喜歡閱讀大量的文字說明,而喜歡直接閱讀代碼,因此,我們在下文中將主要以代碼的形式闡述同步與異步的調用。
同步方法調用
假設我們有一個函數,它的功能是將當前線程掛起3秒鍾。
staticvoid 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()函數的調用何時會結束,這是下文將要解決的問 題。
● BeginInvoke可以以異步的方式完全取代Invoke,我們也不必擔心函數包含參數的情況,下文介紹傳值問題。
注意:Delegate.BeginInvoke是異步方式的。如果你要執行一項任務,但並不關心它何時完成,我們就可以使用BeginInvoke,它不會帶來調用線程的阻塞。
對於異步調用,.NET內部究竟做了什么?
一旦你使用.NET完成了一次異步調用,它都需要一個線程來處理異步工作內容(以下簡稱異步線程),異步線程不可能是當前的調用線程,因為那樣仍然會造成調用線程的阻塞,與同步無異。事實上,.NET會將所有的異步請求隊列加入線程池,以線程池內的線程處理所有的異步請求。對於線程池似乎不必了解的過於深入,但我們仍需要關注以下幾點內容:
● Sleep()的異步調用會在一個單獨的線程內執行,這個線程來自於.NET線程池。
● .NET線程池默認包含25個線程,你可以改變這個值的上限,每次異步調用都會使用其中某個線程執行,但我們並不能控制具體使用哪一個線程。
● 線程池具備最大線程數目上限,一旦所有的線程都處於忙碌狀態,那么新的異步調用將會被置於等待隊列,直到線程池產生了新的可用線程,因此對於大量異步請 求,我們有必要關注請求數量,否則可能造成性能上的影響。
簡單了解線程池
為了暴露線程池的上限,我們修改Sleep()函數,將線程掛起的時間延長至30s。在代碼的運行輸出結果中,我們需要關注以下內容:
● 線程池內的可用線程數量。
● 異步線程是否來自於線程池。
● 線程托管ID值。
上文已經提到,.NET線程池默認包含25個線程,因此我們連續調用30次異步方法,這樣可以在第25次調用后,看看線程池內部究竟發生了什么。
privatevoid 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); } privatevoid 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異步調用的關鍵內容。
class Program { static private void SleepOneSecond(int UserID) { // 當前線程掛起1秒 Thread.Sleep(1000); Console.WriteLine("你輸入的UserID為:{0}",UserID ); } public delegate void MethodInvoker(int UserID); public static void Main(string[] args) { // 創建一個指向SleepOneSecond的委托 MethodInvoker invoker =new MethodInvoker(SleepOneSecond); Console.Write("請輸入UserID"); int UserID = Convert.ToInt32(Console.ReadLine()); // 開始執行SleepOneSecond,但這次異步調用我們傳遞一些參數 // 觀察Delegate.BeginInvoke()的第二個參數 for (int i = 0; i < 3; i++) { IAsyncResult tag = invoker.BeginInvoke(UserID , null, "passing some " + i + " state"); // 應用程序在此處會造成阻塞,直到SleepOneSecond執行完成 invoker.EndInvoke(tag); // EndInvoke執行完畢,取得之前傳遞的參數內容 string strState = (string)tag.AsyncState; Console.WriteLine("EndInvoke的傳遞參數" + strState); } Console.ReadKey(); } } 請輸入UserID 1903 你輸入的UserID為:1903 EndInvoke的傳遞參數passing some 0 state 你輸入的UserID為:1903 EndInvoke的傳遞參數passing some 1 state 你輸入的UserID為:1903 EndInvoke的傳遞參數passing some 2 state
回到文章初始提到的”窗體動態更新”問題,如果你將上述代碼運行在一個WinForm程序中,會發現窗體依然陷入”假死”。對於這種情況,你可能會陷入疑惑:之前說異步函數都執行在線程池中,因此可以肯定異步函數的執行不會引起UI線程的忙碌,但為什么窗體依然陷入了”假死”?問題就在於EndInvoke。EndInvoke此時扮演的角色就是”線程鎖”,它充當了一個調用線程與異步線程之間的調度器,有時調用線程需要使用異步函數的執行結果,那么調度線程就需要在異步執行完之前一直等待,直到得到結果方可繼續運行。EndInvoke一方面負責監聽異步函數的執行狀況,一方面將調用線程掛起。
因此在Win Form環境下,UI線程的”假死”並不是因為線程忙碌造成,而是被EndInvoke”善意的”暫時封鎖,它只是為了等待異步函數的完成。
我們可以對EndInvoke總結如下:
● 在執行EndInvoke時,調用線程會進入掛起狀態,一直到異步函數執行完成。
● 使用EndInvoke可以使應用程序得知異步函數何時執行完畢。
● 如果將上述寫法稱為”異步”,你一定覺得這種”異步”徒具其名,雖然知道異步函數何時執行完畢,也得到了異步函數的傳值,但我們的調用線程仍然會等待函數執行完畢,在等待過程中線程阻塞,實際上與同步調用無異。
如何捕捉異常?
現在我們把問題稍微復雜化,考慮異步函數拋出異常的一種情形。我們需要了解在何處捕捉到異常,是BeginInvoke,還是EndInvoke?甚至是有沒有可能無法捕捉異常?答案是EndInvoke。BeginInvoke的工作只是開始線程池對於異步函數的執行工作,EndInvoke則需要處理函數執行完成的所有信息,包括其中產生的異常。
class Program { public delegate void MethodInvoker(); static private void SleepOneSecond() { // 當前線程掛起1秒 Thread.Sleep(1000); throw new Exception("Here Is An Async Function Exception"); } public static void Main(string[] args) { // 創建一個指向SleepOneSecond的委托 MethodInvoker invoker =new MethodInvoker(SleepOneSecond); // 開始執行SleepOneSecond,但這次異步調用我們傳遞一些參數 // 觀察Delegate.BeginInvoke()的第二個參數 IAsyncResult tag = invoker.BeginInvoke(null, "passing some state"); try { // 應用程序在此處會造成阻塞,直到SleepOneSecond執行完成 invoker.EndInvoke(tag); } catch (System.Exception ex) { Console.WriteLine(ex.Message); } // EndInvoke執行完畢,取得之前傳遞的參數內容 string strState = (string)tag.AsyncState; Console.WriteLine("EndInvoke的傳遞參數" + strState); Console.ReadKey(); } } Here Is An Async Function Exception EndInvoke的傳遞參數passing some state
執行以上代碼后,你將發現只有在使用EndInvoke時,才會捕捉到異常,否則異常將丟失。需要注意的是,直接在編譯器中運行程序是無法產生捕獲異常的,只有在Debug、Release環境下運行,異常才會以對話框的形式直接彈出。
向函數中傳遞參數
現在我們來改變一下異步函數,讓它接收一些參數。
class Program { public delegate string DelegateWithParameters(int param1, string param2, ArrayList param3); static private string FuncWithParameters(int param1, string param2, ArrayList param3) { // 我們在這里改變參數值 param1 = 200; param2 = "hello"; param3 = new ArrayList(); return "thank you for reading me"; } public static void Main(string[] args) { // 創建幾個參數 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); Console.WriteLine("param1: " + intValue); Console.WriteLine("param2: " + strParam); Console.WriteLine("ArrayList count: " + list.Count); Console.WriteLine("返回值: " + strResult); Console.ReadKey(); } } //param1: 100 //param2: Param1 //ArrayList count: 1 //返回值: thank you for reading me
我們的異步函數對參數的改變並沒有影響其傳出值,現在我們把ArrayList變為ref參數,看看會給EndInvoke帶來什么變化。
class Program { public delegate string DelegateWithParameters(out int param1, string param2, ref ArrayList param3); static private string FuncWithParameters(out int param1, string param2, ref ArrayList param3) { // 我們在這里改變參數值 param1 = 200; param2 = "hello"; param3 = new ArrayList(); return "thank you for reading me"; } public static void Main(string[] args) { // 創建幾個參數 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來取得返回值 string strResult = delFoo.EndInvoke(out intValue,ref list,tag); Console.WriteLine("param1: " + intValue); Console.WriteLine("param2: " + strParam); Console.WriteLine("ArrayList count: " + list.Count); Console.WriteLine("返回值: " + strResult); Console.ReadKey(); } } //param1: 200 //param2: Param1 //ArrayList count: 0 //返回值: thank you for reading me
param2沒有變化,因為它是輸入參數;param1作為輸出參數,被更新為300;ArrayList的值已被重新分配,我們可以發現它的引用被指向了一個空元素的ArrayList對象(初始引用已丟失)。通過以上實例,我們應該能理解參數是如何在BeginInvoke與EndInvoke之間傳遞的。現在我們來嘗試完成一個非阻塞模式下的異步調用,這是個重頭戲!