天天看點

[轉自JeffreyZhao]正确使用異步操作

本想寫一點有關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中正确使用異步操作,大家可以搜尋相應的資料來看,我也會在以後的文章中略有提到。

  關于異步操作,這次就講到這裡吧。