正確使用異步操作


本想寫一點有關LINQ to SQL異步調用的話題,但是在這之前我想還是先寫一篇文章來闡述一下使用異步操作的一些原則,避免有些朋友誤用導致程序性能反而降低。這篇文章會討論一下在.NET中有關異步操作話題,從理論出發結合實際,以澄清概念及避免誤用為目標,並且最后提出常見的異步操作場景和使用案例。這樣我們就可以知道什么時候該使用異步操作,什么時候會得不償失。

那么我們先來確認一個概念,那就是“線程”。請注意,如果沒有特殊說明,本文中出現的“線程”所指的是CLR線程池(Thread Pool)中的托管線程,它和Windows線程或纖程(fiber)並不是同一個的概念。同樣,它也不是指System.Thread類的實例。簡單地說,它是由CLR管理的工作執行單元,每當需要執行任務時,CLR就會分配一個這樣的執行單元去工作。當所有的線程池內的線程都用完之后就無法執行新的任務了,一個托管線程在任務完成之后被釋放為止。線程池本身是一個“對象池”,會在需要新對象(托管線程)時創建,而在對象不需要之后(一段特定時間之內沒有新任務需要分配托管線程)負責銷毀以釋放資源。至於線程池的線程數量,在CLR 2.0 SP1之前的版本中是CPU數 * 25,不過從CLR 2.0 SP1之后就變成了CPU數 * 250。不過不管怎么樣,線程池內的線程是有限的,我們必須合理地使用它。

以前的計算機只有一個CPU,理論上同一時刻只能執行一個任務。而如今的超線程、多核、甚至是真正的多個CPU都使計算機能夠同時運行多個任務。多線程編程的一個重要特點就是能夠充分利用CPU的運算能力,更快地完成某個任務。很明顯,如果一個非常龐大的計算任務只交由一個線程來完成,那么只能讓一個CPU參與運算。但是如果將一個大任務拆分成多個互不影響的子任務,那么就能讓多個CPU同時參與運算,所花的時間自然就少了。如果某個操作的目的是進行大量運算,或者說需要花費大量時間運算上的操作,我們將其稱作“Compute-Bound Operation”,也就是受運算能力限制的操作。

與“Compute-Bound Operation”相對的則是“IO-Bound Operation”。“IO-Bound Operation”是指那些由於受到外部條件限制,完成這樣一個任務需要在IO上花費大量時間的操作。例如讀取一個文件,或者請求網絡上的某個資源。對於這種操作,計算的線程再多,運算能力再強也無濟於事,因為任務受到的是硬盤、網絡等IO設備帶來的限制。對於IO-Bound Operation,我們能做的只有“等待”。

對於“同步操作”來說,“等待”就意味着“阻塞”,一個線程將會“無所事事”直至操作完成。這種做法在許多時候會帶來各種問題,因此就出現了“異步操作”,但是同樣是“異步操作”,不同的任務,不同的情況,它解決問題的方式和帶來的效果也是不同的。我下面就通過生活中的實例來說明這些內容:

老趙的朋友開了一家餐館,請了10個工作人員。最近那個朋友經常向老趙抱怨,說工作人員人手總是不夠,在客人比較多的時候,總是來不及招呼他們。老趙一問才得知,這家餐館的工作方式比較特別:當客人來用餐時,就會有工作人員迎上去熱情招待,當客人點好菜之后,工作人員就會去進入廚房親自下廚——沒錯,就是這樣——做完之后,工作人員會將飯菜端至客人面前,然后就去招待別的客人。因為燒菜往往需要很長時間,因此在某些時候就會發現所有的工作人員都在廚房,但是卻沒有人點菜。於是老趙給朋友出了個主意:讓幾個工作人員作為服務員,只負責招呼客人,剩下的就當廚師,一直在廚房工作。當客人點菜之后,服務員就把客人的需求告訴廚師,廚師開始工作,而服務員就可以去招呼其他客人了。朋友頓悟,問題就這樣迎刃而解了。

當然,上面故事中老趙的朋友實在太笨,現實生活中的餐館老板都不會犯這種人員調度上的低級失誤。開發一個客戶端應用程序所遇到的情況往往就和以上的情況類似。在運行程序時,UI線程(服務員)負責顯示界面(招待客人),當用戶操作應用程序(點菜)之后,UI線程可以使用同步操作進行運算(服務員親自下廚),但是如果這是個長時間的Compute-Bound Operation(燒菜是個花費人手時間較長的操作),界面就無法重繪或響應用戶請求了(無法招待客人了),這樣的應用程序用戶體驗自然不好(客人覺得服務質量低下)。但是只要UI線程使用異步操作(通知廚師),讓另一個線程(另一個工作人員)來進行運算,UI線程就可以繼續負責界面重繪或者其他用戶操作(招待其他客人)了。

在這種的情況下,異步操作並沒有提高運算能力或者節省資源(還是需要一個人員的工作),但是提供了較好的用戶體驗。不過我們這時該怎么利用異步操作呢?在實際開發中,我們可以使用委托的BeginInvoke進行異步調用。

下面的例子則對應了另一種情況:

老趙的那個開餐館的朋友在小賺一筆之后准備再開一家快餐店。快餐店和餐館有個不同之處,那就是快餐店的食品生產了大都有機器完成。可惜在這種情況下那個朋友還是遇到了問題:機器數量綽綽有余,但是人手還是不夠。原來現在的做法還是相當不科學:服務員知道客人需要的食品之后,就將原料塞入機器,並看着機器是如何將原料變為美味的。當機器的工作完成之后,服務員便將食品打包並送出,然后繼續招待別的客人。老趙聽后還是哭笑不得:為啥服務員不能在機器工作的時候就去招待別的客人呢?

  與這個示例對應的可以是一個ASP.NET應用程序。在ASP.NET中每個請求(客人)都會使用一個線程池內的線程(服務員)來處理(招待),處理中很可能需要訪問數據庫(使用機器),對於普通的做法,處理線程會等待數據庫操作返回(服務員看着機器直至完成)。對於Web服務器來說,這很可能是個長時間的IO-Bound Operation,如果線程長時間被阻塞很可能就會降低Web應用程序的性能,因為線程池里的線程用完之后(服務員都去看爐子了),就無法處理新的請求了(沒人招待客人了)。如果我們能夠在數據庫進行長時間查詢操作時,讓線程去處理其他的請求(招待其他客人)。這樣,我們只需要在數據庫操作完成之后繼續處理(打包)並將數據發送給客戶端(送出)即可。

這就是處理IO-Bound Operation的方式,很顯然,這也是一個異步操作。當我們希望進行一個異步的IO-Bound Operation時,CLR會(通過Windows API)發出一個IRP(I/O Request Packet)。當設備准備妥當,就會找出一個它“最想處理”的IRP(例如一個讀取離當前磁頭最近的數據的請求)並進行處理,處理完畢后設備將會(通過Windows)交還一個表示工作完成的IRP。CLR會為每個進程創建一個IOCP(I/O Completion Port)並和Windows操作系統一起維護。IOCP中一旦被放入表示完成的IRP之后(通過內部的ThreadPool.BindHandle完成),CLR就會盡快分配一個可用的線程用於繼續接下去的任務。

這種做法的需要一個重要條件,這就是發出用於請求的IRP的操作能夠立即返回,並且這個IO操作不會使用任何線程。而此時,這種異步調用是真正地在節省資源,因為我們可以騰出線程用來處理其他任務了,這就是和第一種異步調用的最大區別。不過很可惜,這種做法顯然需要操作系統和設備的支持,也就是只有特定的操作才能享受這些待遇。那么.NET Framework中哪些操作能從中獲利呢?

  • FileStream操作:BeginRead、BeginWrite。調用BeginRead/BeginWrite時會發起一個異步操作,但是只有在創建FileStream時傳入FileOptions.Asynchronous參數才能獲取真正的IOCP支持,否則BeginXXX方法將會使用默認定義在Stream基類上的實現。Stream基類中BeginXXX方法會使用委托的BeginInvoke方法來發起異步調用——這會使用一個額外的線程來執行任務。雖然當前調用線程立即返回了,但是數據的讀取或寫入操作依舊占用着另一個線程(IOCP支持的異步操作時不需要線程的),因此並沒有任何“節省”,反而還很有可能降低了應用程序的性能,因為額外的線程切換會造成性能損失。
  • DNS操作:BeginGetHostByName、BeginResolve。
  • Socket操作:BeginAccept、BeginConnect、BeginReceive等等。
  • WebRequest操作:BeginGetRequestStream、BeginGetResponse。
  • SqlCommand操作:BeginExecuteReader、BeginExecuteNonQuery等等。這可能是開發一個Web應用時最常用的異步操作了。如果需要在執行數據庫操作時得到IOCP支持,那么需要在連接字符串中標記Asynchronous Processing為true(默認為false),否則在調用BeginXXX操作時就會拋出異常。
  • WebServcie調用操作:例如.NET 2.0或WCF生成的Web Service Proxy中的BeginXXX方法、WCF中ClientBase<TChannel>的InvokeAsync方法。

有一點我想再強調一下,那就是委托的BeginInvoke方法並不能獲得IOCP支持,這會使用一個額外的線程來執行任務,這樣不但沒有節省,返而會降低性能。還有一點可能需要注意,IOCP的確可以不占用線程,但是一個真正的異步操作也不能毀在我們的代碼中。例如我曾經看到過如下的代碼:

SqlCommand command;

IAsyncResult ar = command.BeginExecuteNonQuery();
int result = command.EndExecuteNonQuery(ar);

雖然在調用BeginExecuteNonQuery方法之后的確獲得了IOCP的支持,但是之后調用的EndExecuteNonQuery卻會阻塞當前線程直至數據庫操作返回——異步操作不是這樣用的。至於正確的做法,網絡上已經有不少文章講述了如何在ASP.NET中正確使用異步操作,大家可以搜索相應的資料來看,我也會在以后的文章中略有提到。

關於異步操作,這次就講到這里吧。


免責聲明!

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



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