深度探索I/O完成端口

http://blog.csdn.net/phunxm/article/details/5085933

引言

要想編寫一個高性能的服務器應用程序,必須實現一個高效的線程模型。讓太少或者太多的服務器線程來處理客戶的請求,都可能導致性能問題。例如,如果一個服務器創建單個線程來處理所有的請求,那麼客戶端可能長期等待而得不到響應,因爲服務器同一時刻只能忙於處理一個請求。當然單個線程也能併發處理多個請求,當I/O操作被啓動時,它可以從一個請求切換到另一個請求,但是這種結構相當複雜,並且不能充分利用多處理器的優勢。在另一個極端,服務器可以創建一個大規模的線程池,這樣幾乎每一個客戶請求都可以由一個專門的線程來處理。這種情形通常會導致線程頻繁切換:大量線程被喚醒,執行CPU處理,阻塞等待I/O,然後在請求完成之後又一次阻塞以等待新的請求。如果沒有別的情況,太多的線程將導致過多的上下文切換,因爲調度程序不得不將處理器時間在多個活動線程之間分割。

服務器的目標是使線程避免不必要的阻塞,儘量減少上下文切換。同時,還要使用多線程來發揮最大限度的並行。理想的情況是在每一個處理器上運行一個線程來處理一個客戶請求,當處理器上的活動線程完成一個請求時,如果還有其他的請求正在等待,則不阻塞。爲了使這一優化處理可以有效的進行,應用程序必須有一種可行的方法,使得一個正在處理客戶請求的線程在I/O上阻塞時(例如它在處理過程中需要讀取一個文件時)另外一個等待線程被激活。

Windows NT 3.5引進了一系列API使得這個目標的實現變得相對容易。這些API主要聚焦在一個叫完成端口的對象上。在本文中,首先我將講解完成端口的使用,然後再深入其內部,向你展示Windows NT中完成端口的實現機制。

 

使用I/O完成端口

應用程序將IoCompletion執行體對象當作與多個文件句柄相關的I/O完成的核心。一旦一個文件與一個完成端口相關聯,任何在此文件上異步I/O操作的完成都會導致一個完成通知包(completion notification packet)加入到完成端口隊列。一個線程只需簡單的等待一個完成通知包被排隊到此完成端口上,就可以等待在多個文件上的所有正在進行之中的I/O操作的完成事件。Windows API中的WaitForMultipleObjects 提供了類似的功能,但完成端口的優點在於在系統的協助下發揮高效的併發性。這裏的併發性可以理解爲應用程序主動處理客戶請求的線程的數量的多少。

當應用程序創建一個完成端口時,需要設定併發量。該數值指示了在任何給定時候正在運行的與該端口相關聯的線程的最大數量。正如前面所提到的,理想情況是在任何給定的時刻,系統中每個處理器都有一個線程在運行。Windows利用與一個端口相關聯的併發值參數來控制一個應用程序中活動線程的數量。如果與一個端口相關的活動線程數達到併發值,那麼,在這個端口上等待的線程將不允許再運行了。相反,它將等待某個活動線程處理完當前操作並檢查是否有別的包正在該端口上等待。如果有的話,該線程只是簡單的抓獲該包然後處理。在這個過程中,沒有上下文切換,CPU得到最大限度的利用。

下圖1顯示了一個完成端口操作流程的高度圖解。客戶請求將導致一個I/O包(IRP)被排隊到完成端口。操作系統允許不超過併發量上限(即上面提到的那個併發值)的多個線程併發地處理客戶端請求。直到一些活動線程因I/O請求而阻塞,等待線程才能被激活。下面我們將做進一步的探討。

1 I/O完成端口操作流程 

創建完成端口需要調用Windows API CreateIoCompletionPort

HANDLE CreateIoCompletionPort(
  HANDLE FileHandle,
  HANDLE ExistingCompletionPort,
  DWORD CompletionKey,
  DWORD NumberOfConcurrentThreads
);

創建一個完成端口時,通常對參數ExistingCompletionPort賦值NULL NumberOfConcurrentThreads參數定義了在完成端口上同時允許執行的線程數量。如果有文件句柄傳遞給FileHandle參數,則該文件與完成端口關聯在了一起。當這個文件上的I/O請求完成時,一個完成通知包將被投遞到完成端口消息隊列中。另外一個API GetQueuedCompletionStatus是用來獲取排隊完成狀態,它使調用線程掛起,直到收到一個完成通知包。

BOOL GetQueuedCompletionStatus(
  HANDLE CompletionPort,
  LPDWORD lpNumberOfBytesTransferred,
  LPDWORD CompletionKey,
  LPOVERLAPPED* lpOverlapped,
  DWORD dwMiillisecondTimeout
);

完成端口實際上是在管理一個線程池,它會記錄當前活動(即沒有被I/O等事件阻塞)的線程數。當有完成通知包到達該端口時,在該端口上等待的線程按照後進先出(LIFO)的次序被喚醒,因此最近(most recently)被阻塞的線程就是獲得下一個完成通知包的線程。那些長時間得不到響應的的線程的堆棧將會被從內存調到磁盤交換區去等待,當與一個端口關聯的線程太多超過了當前的處理能力時,就可以將長時間阻塞的線程佔用的內存減到最少。

服務器應用程序往往通過網絡端點來接受客戶請求,而這些網絡端點是由文件句柄來表示的。這樣的例子包括Windows Sockets 2(Winsock2)套接字或者命名管道。當服務器創建它的通信端點時,它將這些通信端點與一個完成端口關聯起來,並且它的線程通過調用GetQueuedCompletionStatus來等待此端口上進來的完成通知。當一個線程在此完成端口上得到一個I/O完成通知包時,它便不再等待,開始處理I/O結果數據,從而變成一個活動的線程。一個線程在處理過程中可能將阻塞很多次,比如當它需要從磁盤上的文件讀取數據時,或者當它需要與其他的線程同步時。Windows NT檢測到這些活動,並且識別出該完成端口上至少已經有一個活動線程。因此,當活動線程由於I/O請求而阻塞時,如果在隊列中存在一個包,則喚醒另一個正在此完成端口上等待的線程提供處理服務。

微軟的指導原則是,將併發值設置成大約等於該系統中處理器的數目。但是要注意,一個完成端口上實際活動線程數量有可能超過設置的併發值。考慮併發值被設置爲1的情況,一個客戶請求進來了,某個線程因爲被調度來處理該請求而變成活動的。下一個請求到達時,正在該端口上等待的另一個線程卻不允許執行,因爲活動的線程數已經達到了設置的併發上限值。然後,當活動線程需要等待I/O而阻塞時,等待的線程將被激活,當它尚在活動時,上一個線程的I/O完成了,這使得它繼續保持活動狀態(繼續執行數據處理服務)。此刻,一直到兩個線程中有一個被阻塞,併發值始終是2,高於設置的併發上限值1。大多數時候,活動線程數將維持在設置的併發限制值上,或者超過一點。

應用程序通過調用PostQueuedCompletionStatus這個API向完成端口投遞一個自定義的完成通知包。服務器一般通過該函數發送消息通知線程有外部事件發生,例如需要溫和的關機。

 

完成端口內部機制

當傳遞NULL值給ExistingCompletionPort參數來調用CreateIoCompletionPort來創建完成端口時,將調用同名的NtCreateIoCompletion系統服務。實質上,IoCompletion對象是建立在一個稱爲隊列的內核同步對象基礎上。系統創建一個完成端口的同時,在完成端口所分配到的內存中初始化一個隊列對象(指向完成端口的指針同時指向了此隊列對象,因爲隊列對象位於完成端口對象內存的開始處)。當一個線程調用CreateIoCompletionPort來創建完成端口時,第四個參數NumberOfConcurrentThreads即爲隊列的併發值。NtCreateIoCompletion函數將調用KeInitializeQueue系統服務來初始化該端口的消息隊列。

當應用程序再次調用CreateIoCompletionPort時,將調用NtSetInformationFile服務來使參數一(文件句柄)與參數二(一個已有的完成端口)關聯起來。完成通知包FileCompletionInformation包含的信息:CreateIoCompletionPort的參數二ExistingCompletionPort(已有的完成端口句柄)和參數三CompletionKey(完成鍵)。NtSetInformationFile通過解引用操作從該文件句柄獲得對應的文件對象,並且申請一個記錄完成上下文的數據結構。這個數據結構在NTDDK.H定義如下:

typedef struct _IO_COMPLETION_CONTEXT {
  PVOID Port;
  ULONG Key;
} IO_COMPLETION_CONTEXT, *PIO_COMPLETION_CONTEXT;

最後,將調用NtSetInformationFile系統服務設置文件對象中CompletionContext域的值。當一個異步I/O在一個文件對象上完成時,系統內部執行具有I/O管理功能的IopCompleteRequest系統服務,檢查文件對象中的CompletionContext域是否爲非NULL。如果是,則I/O管理器生成一個完成通知包,通過調用KeInsertQueue系統服務將完成通知包投遞到完成端口隊列(注意,完成端口對象和隊列對象是同義的)。

當一個服務器線程調用GetQueuedCompletionStatus時,它將調用NtRemoveIoCompletion系統服務。在驗證參數後,並且將完成端口句柄轉換成一個指向該端口的指針後,NtRemoveIoCompletion調用KeRemoveQueue

正如你所看到的,KeRemoveQueueKeInsertQueue是完成端口模型的兩個引擎級函數,它們決定阻塞在完成端口上等待I/O完成通知包的線程什麼時候被喚醒。在系統內部,隊列對象維護了完成端口上當前活動線程的計數值,以及最大的併發活動線程的數量。當一個線程調用KeRemoveQueue並且當前活動線程數大於或等於併發數上限時,那麼該線程將被投放到一個阻塞線程隊列(按LIFO順序)中,等待系統調度來獲取並處理完成通知包。此線程列表掛在隊列對象的外面,線程的控制塊數據結構中有一個指針引用了一個與之相關的隊列對象;如果這個指針爲NULL,則該線程沒有與隊列關聯。

Windows依賴與線程控制塊中的隊列指針來跟蹤和記錄那些“由於被阻塞在除了完成端口之外的其他事情上而變成不活動”的線程。那些有可能會導致一個線程阻塞的調度例程(例如KeWaitForSingleObjectKeDelayExecutionThread等等)要檢查該線程的隊列指針。如果該指針不爲NULL,則這些函數調用KiActivateWaiterQueue一個與隊列相關的函數,它會遞減與該隊列相關聯的活動線程的計數值。如果計數值遞減到小於設置的併發值,並且此時至少有一個完成通知包在該隊列中,那麼處於該隊列的線程列表最前面的那個線程被喚醒,並且把最老的(the oldest)完成通知包交給它處理。相反,無論何時,與一個隊列相關聯的線程在阻塞之後被喚醒時,調度程序執行KiUnwaitThread函數來增加該隊列上活動線程的計數值。

最後,PostQueuedCompletionStatus這個Windows API將調用NtSetIoCompletion服務。該函數只是簡單的調用KeInsertQueue將自定義的完成通知包插入到完成端口的隊列中。

 

沒有公開的

     Windows NT的完成端口API提供了一種易於使用和高效的方法最大限度地發揮服務器的性能——最大限度的減少上下文切換的同時最大限度的提高系統併發量。這些API使我們能夠調用I/O管理器和內核提供的一些服務功能。隊列對象可以被設備驅動程序調用(這些接口儘管沒有公開,但還是很容易查詢到的),不過完成端口的API沒有提供相關訪問功能。但是,如果隊列接口被繼承,我們完全可以通過編寫隊列處理程序並通過手動設置CompletionContext的值來模擬完成端口模型。

 

原文

Inside I/O Completion Portshttp://technet.microsoft.com/en-us/sysinternals/bb963891.aspx

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章