I/O 完成端口( Windows核心編程 )

I/O 完成端口( Windows核心編程 )

  一個服務應用程序的結構可以有兩種方式:


  •   在串行模式下,單個線程等待一個客戶發出請求(通常是通過網絡)。當來了請求後,線程醒來處理客戶的請求。
  •   在併發模型下,單個線程等待客戶發出請求,而後創建新線程來處理請求。當新線程處理客戶請求時,起初的線程循環回去等待另一個客戶請求。處理客戶請求的線程處理完畢後終結。

 

  串行模型的問題在於它不能很好地處理好多個同時的請求,只適用於最簡單的服務程序。Ping服務器是串行服務器的一個很好的例子。

 

  因此併發模型就是最普通的了。它爲每個請求都創建了一個新線程。而且通過增加硬件能力,會很容易使它的性能提高。

 

  當併發模型實現在 NT 上時,微軟 NT 小組注意到這些應用程序的性能沒有預料得那麼高。特別是有很多線程運行着的時候。因爲所有這些線程都是可運行的(沒有被掛起或等待什麼事),微軟意識到NT內核花了太多的時間來轉換運行線程的上下文(context),而真正留給線程來做它們自己的工作的時間卻被壓縮了。

 

  // 這個情況可以以我的一個例子來說明,我曾經花了一個下午去兵馬俑,結果來去花在路上的時間有4個小時,而在兵馬俑只呆了40分鐘。這個例子有點誇張,不過誇張有助於理解 ;)

 

  要使NT成爲一個強大的服務器環境,微軟就需要解決這個問題。解決的方法是一個稱爲I/O完成端口的內核對象,它首次在NT3.5中被引入。I/O完成端口的理論基礎是並行運行的線程的數目必須有一個上限。500個同時的客戶請求,並不意味着500個運行的線程。但併發運行的合適的線程數是多少呢?只要可運行的線程數多於CPU數,操作系統一定要花時間來進行線程上下文的切換的。

 

  並行模型的一個低效之處是爲每一個客戶請求創建了一個新線程。創建線程比起創建進程來開銷要小,但也遠不是沒有開銷。如果當應用程序初始化時創建了一個線程池,而這些線程在應用程序執行期間是空閒的,程序的性能就能進一步提高。I/O完成端口就使用線程池。
  I/O完成端口可能是Win32提供的最複雜的內核對象。要創建I/O完成端口,應調用 CreateIoCompletionPort:

 

  HANDLE CreateIoCompletionPort(HANDLE hFileHandle, HANDLE hExistingCompletionPort, DWORD dwCompletionKey, DWORD dwNumberOfConcurrentThreads);

 

  前三個參數只在把完成端口同設備相關聯的時候纔有用。如果不關聯設備,只創建完成端口,那麼前三個參數可以爲:INVALID_HANDLE_VALUE,NULL,0。最後一個參數指示I/O完成端口同時能運行的最多線程數。如果爲0,那麼默認爲機器上的CPU數。不過你可以用幾個不同的值做實驗來確定哪個值有最佳的性能。順便說一句,這個函數是唯一一個創建了內核對象,而沒有 LPSECURITY_ATTRIBUTES 參數的 Win32 函數。這是因爲完成端口只應用於一個進程內。

  當你創建一個I/O完成端口時,內核實際上創建了5個不同的數據結構。

 

  第一個是設備列表。所有與完成端口相關聯的設備都會出現在這個列表裏,結構就是:

 

hDevice dwCompletionKey

  當調用 CreateIoCompletionPort 關聯設備時,表項就增加;當設備句柄被關閉時,表項被刪除。

  設備可以是:一個文件,socket,郵件槽或管道等等。完成鍵可以自定義。

 

  第二個數據結構是一個I/O完成隊列。當一個設備的異步I/O請求完成時,系統檢查該設備是否關聯了一個完成端口。如果是,系統就向該完成端口的I/O完成隊列里加入完成的I/O請求項。該隊列中的每條表項給出了傳輸的字節數,32位完成鍵,I/O請求的OVERLAPPED結構的指針和一個錯誤碼。

dwBytesTransferred dwCompletionKey pOverlapped dwError

  當I/O請求完成時或當PostQueuedCompletionStatus被調用時,表項被增加;當“等待線程隊列”中刪除一條表項時,表項被刪除。

  當服務應用程序初始化時,它應該創建I/O完成端口,而後應該創建一個線程池來處理客戶請求。現在的問題在於池中應該有多少線程。這是一個很難回答的問題。一個標準的答案是將計算機上的CPU的數目乘以2。

  池中的所有線程應該執行同一個線程函數。一般說來,該線程函數執行一些初始化後進入一個循環,該循環在服務進程終止時才結束。在循環中,線程使自己睡眠來等待完成端口的設備I/O請求的完成。這是通過 GetQueuedCompletionStatus 來實現的:

 

  BOOL GetQueuedCompletionStatus(HANDLE hCompletionPort, LPDWORD lpdwNumberOfBytesTransferred, LPDWORD lpdwCompletionKey, LPOVERLAPPED* lpOverlapped, DWORD dwMilliseconds);

 

  第一個參數指出線程要監視哪個完成端口。很多服務應用程序只使用一個I/O完成端口,所有的I/O請求完成通知都發給了該端口。簡單地說,GetQueuedCompletionStatus 使調用線程進入睡眠,直到指定的完成端口的I/O完成隊列中出現了一項或直到超時。

 

  I/O完成端口的第三個數據結構是等待的線程隊列

 

dwThreadId

  當線程池中的一個線程調用 GetQueuedCompletionStatus 時,調用線程的 ID 就被放入等待線程隊列中。這樣,I/O完成端口對象總是知道哪個線程正在等待處理完成的I/O請求。當完成隊列裏出現一項時,完成端口就喚醒等待線程隊列裏的一個線程,並把所有信息通過參數傳過去。

  要注意如何處理 GetQueuedCompletionStatus 的返回:

  

代碼
 1 DWORD dwNumberOfBytesTransferred, dwCompletionKey;
 2 LPOVERLAPPED lpOverlapped;
 3 .
 4 .
 5 .
 6 BOOL fOk=GetQueuedCompletionStatus(hIOCompPort, 
 7 &dwNumberOfBytesTransferred, &dwCompletionKey, &lpOverlapped, 1000);
 8 DWORD dwError=GetLastError();
 9 if(fOk)
10 {
11   成功
12 }
13 else
14 {
15     if(lpOverlapped!=NULL)
16     {
17         I/O 請求失敗,dwError 包含錯誤碼
18     }
19     else
20     {
21         if(dwError==WAIT_TIMEOUT)
22         {
23             超時
24         }
25         else
26         {
27             錯誤調用 GetQueuedCompletionStatus, dwError 包含錯誤碼
28         }
29     }
30 }

 


 

  I/O 完成隊列裏的表項是按照先進先出(FIFO)方式刪除的。但是調用 GetQueuedCompletionStatus 的線程卻是按照後進先出(LIFO)方式被喚醒的。原因也是爲了提高性能。比如,有4個線程等在線程隊列中。如果出現了一個I/O項,最後一個調用 GetQueuedCompletionStatus 的線程被喚醒來處理這一項。當處理完後,它再次調用 GetQueuedCompletionStatus 進入等待線程隊列。這時如果出現了另一個I/O完成項,同一線程將被喚醒來處理這一新項。只要I/O請求完成的足夠慢,使得一個線程能處理它們,系統就總是喚醒同一個線程,其它三個線程將繼續休眠。通過使用LIFO算法,不被調度的線程的內存資源(如棧空間)可以被交換到磁盤上和從處理器的緩存中清除。這意味着有多個線程等待一個完成端口也沒有什麼壞處。

  現在該討論爲什麼I/O完成端口這麼有用了。首先,當你創建一個I/O完成端口時,你指定了能併發運行的線程的數目。前面說過,通常應該把該值設爲計算機上CPU的數目。當完成的I/O項進入隊列時,I/O完成端口就要喚醒等待的線程。不過,完成端口只喚醒你指定的數目的線程。所以,如果有2個I/O請求完成了,而且有2個線程等在對 GetQueuedCompletionStatus 的調用上,I/O完成端口只喚醒1個線程,另1個線程將繼續休眠。當一個線程處理完一項後,它再次調用GetQueuedCompletionStatus,看到還有表項要處理,就喚醒同一線程來處理剩下的表項。

 

  如果認真想一下,就會發現這裏有問題:如果完成端口只能允許指定數目的線程併發地醒來,那麼線程池中爲什麼要有多餘的線程等待呢?

  I/O完成端口是非常智能的。當完成端口喚醒一個線程時,它把線程的ID放在了同它相關聯的第四個數據結構——一個釋放線程列表中:

dwThreadId
 

  這使得完成端口能記住它喚醒了哪個線程並允許它監視這些線程的執行。如果一個釋放線程調用了某個函數使自己進入等待狀態,完成端口檢測到這一情況,就更新它的內部數據結構,把線程的ID從釋放線程列表移到暫停線程列表(I/O完成端口的最後一個數據結構):

dwThreadId
 

  完成端口的目標是使在釋放線程列表中的線程數與它被創建時指定的併發線程數相同。如果一個釋放線程因某種原因進入了等待狀態,釋放線程列表變小,完成端口就釋放另一個等待的線程。如果一個暫停線程醒來,它就離開暫停線程列表,重新進入釋放線程列表。這就意味着釋放線程列表中的線程數可能比允許的最大併發線程數要大。

  現在讓我們把這些合在一起。假設運行在一臺雙CPU的計算機上。我們創建了一個完成端口允許最多2個線程併發醒來,又創建了4個線程等待完成的I/O請求。如果端口隊列中有3個完成的I/O請求,只有2個線程醒來處理這些請求。這減少了可運行線程的數目,節省了上下文切換的時間。現在,如果第一個運行線程調用了 Sleep,WaitforSingleObject 等使它不能運行的函數,I/O完成端口檢測到這一點,就立刻喚醒第3個線程。

  最終,第一個線程會再次運行。這使得運行線程數目大於系統中的CPU數。不過,完成端口會再次意識到這一點,在線程數目不超過CPU數之前,不會再喚醒其它線程。假定運行線程數超過最大值的時間會很短,當線程再次循環調用 GetQueuedCompletionStatus 時,數目會降下來。這就說明了爲什麼線程池中的線程數要比完成端口的併發線程數設置要多。

現在該討論線程池中應該有多少線程。首先,當服務應用程序初始化時,你要創建一組最小數目的線程,這樣就不必在運行時創建和釋放線程了。要記住,創建和釋放線程是浪費CPU時間的,所以最好減少這類事情發生。其次,你還要設置線程的最大數目,因爲創建太多的線程會浪費系統資源。

  你可能要用不同的線程數目做實驗。IIS 服務器使用了一個相當複雜的算法來管理它的線程池。IIS 創建的最大線程數目是動態的。當IIS初始化時,對每個CPU,它至多允許創建10個線程。不過,根據客戶請求,這一最大值可能還會增加。IIS 設的最大值是計算機上的內存數量的MB數的2倍。(* Jeffrey 詢問過IIS小組他們如何得到這一最大值的公式,被告知是感覺正確。你也應該爲你的應用程序找到一個“感覺正確”的公式。)

  剛纔,我們討論的是增加池中能有的最大線程數目。當數目改變時,新線程不會立刻被加到池裏。如果一個客戶請求到達時,池中所有線程都在忙,纔會創建一個新線程。(假設現有的線程數小於現在的最大值)IIS 通過一個計數器來知道有多少線程忙。在調用 GetQueuedCompletionStatus 之前,計數器增加;在 GetQueuedCompletionStatus 返回之後,計數器減小。(* 你可以使用 InterlockedIncrement 和 InterlockedDecrement 函數來實現這一點)

要記住的很重要的一件事是,你應該使池中至少有一個線程能接受到來的客戶請求。

 

模擬完成I/O請求

  BOOL PostQueuedCompletionStatus(HANDLE hCompletionPort, DWORD dwNumberOfBytesTransferred, DWORD dwCompletionKey, LPOVERLAPPED lpOverlapped);

  該函數允許你人工地向一個完成端口的I/O完成隊列里加入一個完成I/O請求。這是非常有用的,它使你能同池中的所有線程通信。例如,如果用戶想要終止一個服務應用程序,你就要讓所有的線程乾淨地退出。但如果線程等在完成端口上,而又沒有I/O請求到來,線程就不會醒來。通過對池中的每個線程調用一次 PostQueuedCompletionStatus ,線程就會醒來查看 GetQueuedCompletionStatus 的返回值,發現應用程序正在終止,就能正確地清除和結束。

  在使用這一技術時必須小心。上面的例子行得通是因爲池中的所有線程都在終止,不會再次調用 GetQueuedCompletionStatus 。不過,如果你想要通知線程某件事後,讓它們再循環回去調用 GetQueuedCompletionStatus,就可能會有問題。這是因爲線程是按LIFO順序被喚醒的。所以你必須在應用程序中使用一些額外的線程同步技術來確保每個線程都有機會看到模擬的 I/O 表項。否則,一個線程可能會見到幾次同樣的通知。

發佈了0 篇原創文章 · 獲贊 6 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章