上一章講到了用線程池,任務,並行類的函數,PLINQ等各種方式進行基於線程池的計算限制異步操作。
而本章講的是如何異步執行I/O限制操作,允許將任務交給硬件設備來處理,期間完全不占用線程和CPU資源。
然而線程池仍然扮演著重要的角色,因為各種I/O操作的結果還是要由線程池線程來處理。
Windows如何執行同步I/O操作
既然說道異步I/O操作,那麼首先可以先看看同步操作是如何執行。
就比如操作硬盤上的一個文件,通過構造一個FileStream對象打開磁盤文件,然後調用Read方法從文件讀取數據。
調用Read方法時,線程從托管代碼轉變為本機/用戶模式代碼,Read內部調用Win32的ReadFile函數。
ReadFile分配一個小的數據結構,稱為I/O請求包(I/O Request Packet,IRP)。
IRP結構初始化後包含的內容有:文件句柄,文件中的偏移量(從這個位置開始讀取字節),一個Byte[]數組的地址,要傳輸的字節數以及其它常規性內容。
然後ReadFile函數將線程從本機/用戶模式代碼轉變為本機/內核模式代碼,像內核傳遞IRP,從而調用Windows內核。根據IRP中的設備句柄,Windows內核知道I/O操作要傳送給哪個硬件設備。
因此,Windows將IRP傳送給恰當的設備驅動程序的IRP隊列。每個設備驅動程序都維護自己的IRP隊列,其中包含了機器上運行的所有進程發出的I/O請求。
IRP數據包到達時,設備驅動程序將IRP信息傳遞給物理硬件設備上安裝的電路板。現在,硬件設備將執行請求的I/O操作。
在硬件執行I/O操作期間,發出了I/O請求的線程將無事可做,所以Windows將線程變成睡眠狀態,防止它仍然浪費CPU時間。(然而仍然浪費內存,因為它的用戶模式棧,內核模式棧,線程環境塊和其它數據結構依然在內存中,而且沒有東西訪問這些內存)。
最終硬件設備會完成I/O操作,然後Windows喚醒線程,將其調度給一個CPU,使它從內核模式返回用戶模式,再返回至托管代碼。FileStream的Read方法返回一個Int32,指明從文件中讀取的字節數,使我們知道在傳給Read的Byte[]中,實際能檢索到多少字節。
對於Web服務器而言,這麼做的話就坑爹了。可以想象,如果有很多用戶請求服務器,獲取某文件或數據庫的信息,在獲取時線程阻塞,等待返回,那麼就會創建很多線程,如果用戶量足夠大,服務器根本就不夠用。
而當獲取到了信息,大量線程被喚醒,那麼此時就存在大量的線程,而CPU內核一般不會很多,所以就會頻繁切換上下文,這進一步損害了性能。
Windows如何執行異步I/O操作
基於同步I/O操作在某些場景下的坑爹表現, 當然就需要異步操作來解決了。
依然是那個例子,同樣是構造一個FileStream去讀取文件,然而現在傳遞一個FileOptions.Asynchronous標志,告訴Windows希望用異步方式進行文件讀寫。
並且現在不是調用Read而是ReadAsync來讀取數據。
ReadAsync內部分配一個Task<Int>來代表用於完成讀取操作的代碼。
然後ReadAsync調用Win32 ReadFile函數。
ReadFile分配IRP,和前面同步操作一樣初始化它,然後傳遞給windows內核。
Windows內核將IRP放到驅動程序隊列中,但線程不再阻塞,而允許返回至你的代碼。(這就是異步的好處了)
所以線程能立即從ReadAsync調用中返回。當然此時IRP尚未處理好,所以不能在ReadAsync之後的代碼中訪問傳遞的Byte[]中的字節。
ReadAsync之前在內部創建的Task<Int>對象會返回給用戶。
可在該對象上調用ContinueWith來登記任務完成時執行的回調方法,然後在回調函數中處理數據。當然也可以用C#的異步函數功能簡化代碼,以順序方式寫代碼(感覺就像是執行同步I/O)。
硬件設備處理好IRP後,會將IRP放到CLR的線程池隊列,將來某個時候一個線程池線程會提取完成的IRP並/ 成任務的代碼,最終要麼設置異常(如果發生錯誤),要麼返回結果(本例代表成功讀取字節數的一個Int32)
這樣一來,Task對象就知道操作在什麼時候完成,代碼可以開始運行並安全地訪問Byte[]中的數據。
這樣不阻塞線程使得資源不至於被過度浪費,同時提高了I/O效率。
C#異步函數
在我寫WEB的經歷中從來沒用過異步函數,倒是以前玩了一段事件Unity3D的時候用過。
實際上在上一章執行定時計算限制操作那個小節就已經用過了,把那個例子粘貼過來了:
static void Main(string[] args) { asyncDoSomething(); Console.Read(); } private static async void asyncDoSomething() { while (true) { Console.WriteLine("time is {0}", DateTime.Now); //不阻塞線程的前提下延遲兩秒 await Task.Delay(2000);//await允許線程返回 //2秒後某個線程會在await後介入並繼續循環 } }
這裡的asyncDoSomething這個函數就是異步函數。
它有一個很明顯的標志,就是用async聲明了一下。
異步函數的內部實際上就是使用了Task來實現異步,而且用了一個以前沒有提過的概念:狀態機。
異步函數,顧名思義會異步執行,而且在await後面的操作A一般也是異步執行,且等操作A執行完了,才會繼續執行await那一行語句後面的語句。
寫法上像一個正常函數,實際上在其內部用Task的ContinueWith去運行恢復狀態機的方法。使Task.Delay(2000)這個線程執行完後,又有一個線程來調用await那行代碼之後的代碼。
使用異步函數要注意以下幾點:
異步函數的返回類型一般是Task或者Task<某類型>,它們代表函數的狀態機完成。(不過也可以像我們上面的例子一樣返回void)
事實上,如果異步函數最後return的一個int值,那麼異步函數的返回類型就應該是Task<int>。
一般來講,異步函數都會按規范要求在方法名後附加Async後綴。支持I/O操作的很多類型都提供了Async方法。
在早期版本中,有一個編程模型是使用BeginXxx/EndXxx方法和IAsyncResult接口。
還有一個基於事件的編程模型,提供了XxxAsync方法(不返回Task對象,因為事件都是void)
現在這兩個編程模型都已經過時了,建議用新的以Async結尾的函數的編程模型。(不過還是有一些類因為微軟沒時間更新,所以這些類只有BeginXxx這種方法)
對於只有BeginXxx和EndXxx的編程模型的類,可以用Task.Factory.FromAsync方法,將BeginXxx和EndXxx分別作為參數傳給FromAsync,然後就可以await Task.Factory.FromAsync(BeginXxx,EndXxx,null)的方式,用新得編程模型了。
應用程序與線程處理模型
.NET支持幾種不同的應用程序模型,而每種模型可能引入了它自己的線程處理模型。
控制台應用程序和Windows服務(實際上也是控制台應用程序,只是看不到控制台)沒有引入任何線程處理模型。
而GUI應用程序引入了一個線程處理模型。在此模型中,UI元素只能由創建它的線程更新。
在GUI線程中,經常都需要生成一個異步操作,使GUI線程不至於阻塞並停止響應用戶輸入。但當異步操作完成時,是由一個線程池線程完成Task對象並恢復狀態機。
但是當這個線程池線程一旦更新UI元素就會拋出異常,所以線程池線程只能呢個以某種方式告訴GUI線程更新UI元素。
然而FCL定義了一個SynchronizationContext類(同步上下文類)來解決這個問題,簡單來說此類的對象將應用程序模型和線程處理模型連接起來。
作為開發人員通常不需要了解這個類,等待一個Task時會獲取調用線程的SynchronizationContext對象,線程池完成Task後,會使用該SynchronizationContext對象,確保為應用程序模型使用正確的線程處理模型。
所以當GUI線程等待一個Task時,await操作符後面的代碼保證在GUI線程上執行,使代碼能正確執行。
Task提供了一個ConfigureAwait方法,向其傳遞true就相當於沒有調用方法,傳遞false則await操作符就不查詢調用線程的SynchronizationContext對象。當線程池結束Task時會直接完成,await操作符後面的代碼通過線程池線程執行。
以異步方式進行I/O操作
之前雖然介紹了異步方式進行I/O操作,實際上那些操作是在內部用另一個線程模擬異步操作。這個額外的線程也會影響到性能。
如果在創建FileStream對象時,指定FileOptions.Asynchronous標志,表示以同步還是異步方式來通信。
在這個模式下,調用Read,實際上內部也是用異步方式來模擬同步實現。(而實際上如果指定了異步,那麼就用ReadAsync,如果是同步,就用Read,這樣才能得到最好的性能)
PS:
本章實際上的含金量比我寫的這些多不少,能力有限沒法完全寫出來。(信息量較大,我自己都有點迷糊,估計搞完這一輪,還要回過頭來再看看多線程這塊)
特別是在異步函數的狀態機那裡,本書介紹的很詳細,然而我並沒有寫太多。
主要是作者用了一大片的代碼來解釋,而本人實在懶得抄。
不過相信中心思想還是提煉出來了,實際上使用了任務,然後功能也相當於把await後面的代碼ContinueWith了。