一個IOCP 例子

IOCP 例子

翻譯人: Kevin Chen. 

原文鏈接:http://www.codeproject.com/KB/IP/iocp_server_client.aspx 

1. 簡介

 

 

2. IOCP

2.1. 異步完成端口(IOCP)簡介

         一個服務器程序如果不能同時服務多個客戶端,則不能稱之爲服務程序,通常,異步I/O調用和多線程被用來實現同時服務的目標。 在定義上,一個異步I/O調用立即返回, 留下I/O調用的等待。在一些時間點上,I/O異步調用的結果必須和主線程進行同步。這個可以用不同的方式來實現。這種同步可以用以下方式來實現:

·       使用事件 --- 當異步事件完成時設置一個信號。這種方式的缺點是線程需要檢測或者等待事件被設置。

·       使用GetOverlappedResult函數 --- 這種方式和上一種方式有着同樣的缺點。

·       使用異步程序調用 (APC) --- 這個方式有幾種缺點。 第一: APC經常在調用線程的上下文中被調用。第二:爲了能夠執行APCs,調用線程需要在所謂的“可變等在狀態”中暫停

·       使用IOCP --- 這種方式的缺點是有許多編程問題需要解決。編寫IOCP會有一點困難。

2.1.1. 爲什麼使用IOCP

       通過使用IOCP, 我們可以克服“一個線程 -- 一個客戶端”的問題。衆所周知的是,當軟件不是運行在一個真正的多處理器的機器上的時候,性能將大幅降低。線程是系統資源,既不是無限制的也不是便宜的。

       IOCP提供了一種用少量線程處理多客戶“Input / Output” 的方式。這些線程會被暫停,並且不使用CPU週期直到有事可做。

2.2. 什麼是IOCP

      我們已經說明了,IOCP只是一個線程同步對象,類似於信號量,因此,IOCP不是一個老的概念。一個IOCP對象關聯到幾個支持異步等待調用的I/O對象。一個已經訪問IOCP的線程會暫停直到等待的異步I/O調用完成。

3. IOCP如何工作

         爲了獲得更多的關於這章的信息,我參考了其他的文章。

         當使用IOCP時,你需要做3件事,關聯一個套接字到完成端口,執行一個異步I/O調用,與線程同步。爲了獲得異步I/O調用的結果並且知道,例如,那一個客戶端執行了調用,你需要傳遞2個參數: 參數CompletionKey,和OVERLAPPED結構。

       

3.1. 完成鍵參數 (CompletionKey)

       第一個參數,CompletionKey,僅僅是一個類型爲DWORD的變量。你可以傳輸任何唯一的值,這個值經常被關聯到對象。通常,一個出賦值給這個參數的指針指向一個結構或類,這個指針可以包含一些客戶端特定的對象。在源代碼中,指向結構ClientContext的指針被賦值給CompletionKey參數。

3.2. OVERLAPPED參數

       這個參數通常被用於傳輸被異步調用使用的內存Buffer。需要指出的是:這個數據將被鎖住並且不被換頁出物理內存。後面將討論它。

3.3. 關聯一個套接字到完成端口

       一旦一個完成端口被創建, 通過調用函數CreateIoCompletionPort可以關聯一個套接字到完成端口。方法如下:

 

3.4. 發起一個異步I/O調用

        爲了發起一個真正的異步調用,將調用函數WSASend, WSARecv。它們同樣需要參數WSABUF,WSABUF中包含了指向將被使用的緩存的指針。一個首要法則是:當服務/客戶端想要調用一個I/O操作的時候,I/O操作不能直接調用,只需要將操作傳遞到完成端口中,並且I/O操作將被I/O工作線程執行。這樣做的原因是:我們想要CPU週期被公平的平分利用。I/O調用是通過傳遞一個狀態到完成端口中來完成的,如下所示:

 

3.5. 和線程同步

       和I/O工作線程同步是通過調用GetQueuedCompletionStatus函數來完成的。這個函數也提供了CompletionKey參數和OVERLAPPED參數。

 

3.6. 四個痛苦的IOCP編程困難以及它們的解決方案

     當只用IOCP的時候會出現一些問題,有一些是直覺問題。在一個使用IOCP的多線程方案中,一個線程函數的控制流並不是易懂的,因爲在線程和通訊之間沒有聯繫。在這節中,我們將描繪在使用IOCP開發C/S程序會出現的4個不同的問題。它們是:

·        WSAENOBUFS錯誤問題

·       包重新排序問題

·       異步等待讀和數據塊處理問題

·       違規訪問問題

3.6.1. WSAENOBUFS錯誤問題

      這個問題並不直觀並且難以探測,因爲,乍一看,它像是一個普通的死鎖或者內存泄漏。假設你已經開發出你的服務程序並且一切運行正常。當你壓力測試你的服務程序時,它突然停止工作。如果你足夠幸運,你會發現這與WSAENOBUFS錯誤有關。

     對於每一個重疊發送/接受操作,提交的數據緩衝區都有可能被鎖住。當內存被鎖住後,它不能被換頁出物理內存。操作系統對於可以被鎖住的內存數量強加了一個限制。當達到上限後,重疊操作會失敗並返回WSAENOBUFS錯誤。

     如果一個服務在每個連接上分發了太多的重疊接收操作, 當連接數量增大時內存數量就會達到上限。如果一個服務器預期會有非常大量的併發客戶端需要處理,服務器會傳遞一個0字節接收請求到每個連接上。因爲沒有內存用於接收操作,所以也沒有內存需要被鎖住。通過這種方式,每一個套接字的接受緩衝區會被完整的保留,因爲一旦0-字節接受操作完成,服務器可以簡單的執行一個非阻塞的接收操作以得到緩衝在套接字接收緩衝區中所有的數據。當非阻塞接受操作失敗並返回WSAEWOULDBLOCK時,表示沒有數據在等待接收了。這種設計可以被用來處理需要最大併發連接而犧牲每個連接的數據吞吐量的情況。當然,你越瞭解客戶端如何與服務端交互的情況越好。在上一個例子中,一個非阻塞接收操作在0-字節接收完成獲取被緩衝的數據後被執行。如果服務器知道客戶端瞬間發送數據,一旦0-字節接收操作完成,將傳遞一個或多個重疊接收操作如果客戶端發送大量數據(數據量大於默認值爲8KB的接收緩衝區)。

     對於WSAENOBUFS問題,一個簡單的使用解決方案已在源碼中提供。當執行一個0-字節的異步WSAReas(...)  (FYI OnZeroByteRead)。當調用完成時,我們知道有數據在在TCP/IP棧中。我們通過執行幾個BUF長度爲MAXIMUMPACKAGESIZE的異步WSARead(...) 。這種解決方案僅在有數據到達時鎖住物理內存,這就解決了WSAENOBUFS問題。但是這種方案降低了服務器的吞吐量。

3.6.2. 包重新排序

      儘管使用IO完成端口的委託操作總是按照它們被提交的順序完成,線程調度問題意味着實際關聯到完成端口的工作是在不確定的順序下被執行。例如,如果你有兩個工作線程,並且你需要接收“數據塊1,數據塊2,數據塊3...”,你也許會在錯誤的順序下處理數據塊,即,“數據塊2,數據塊1,數據塊3”。這就是說,當你通過投遞請求到完成端口的方式發送數據時,數據實際上會在重新排序後被髮送。

      一個使用的解決方案是添加序列號到buffer類中,如果buffer序號是有序的時候直接處理buffer中的數據。也就是說,錯誤序號的buffer需要被緩存起來以稍後處理,因爲性能原因,我們將緩存buffers到哈希表中。(m_SendBufferMap和m_ReadBufferMap)。

     如果需要獲得更多關於這個解決方案的信息,請瀏覽源碼,並檢查IOPCS類中的如下函數:

      (a). GetNextSendBuffer(...) 和 GetNextReadBuffer(...), 用於獲取有序的接收/發送緩衝區。

      (b). IncreaseReadSequenceNumber(...) 和 IncreaseSendSequenceNumber(..),用以增加序列號

3.6.3. 異步等待讀和數據塊處理問題。

      大部分服務端協議都是基於包的協議,這些協議第X個字節代表頭部,頭部包含了一個完整的包的長度的詳細信息。服務端可以讀取包頭,計算出還需要多少數據,並且持續讀取數據直到獲取一個完整的包。當服務端在同一時刻僅執行一個異步調用,這種方法將會工作得很好。但是,如果我們想使用IOCP的最大潛能,我們應該有幾個異步讀等待請求。這就意味着幾個異步讀將無次序的完成(如同在3.6.2節討論的那樣),並且等待讀返回的數據塊流不能被有序的處理。此外,一個數據塊流會包含一個或多個包或者包的部分,就如下圖所示:

 

     這就意味着我們需要處理字節流塊以讀取一個完整的包。此外,我們需要處理不完全的包(如上圖所示)。這就使得字節塊處理更加困難。完整的解決方案可以在IOCPS類中的ProcessPackage 函數中發現。

 

3.6.4. 訪問異常問題

這是一個小問題,並且是代碼設計的結果而不是IOCP特有的問題。假設一個客戶端連接丟失並且I/O調用返回一個錯誤的標記,然後服務端知道這個客戶端已經斷開。我們傳遞了一個指針給參數CompletionKey,這個指針指向包含客戶端特有數據的ClientContext結構體。如果我們釋放被ClientContext佔住的內存會怎麼樣呢,如果被同樣的客戶端(ClientContext)所執行的I/O請求返回一個錯誤碼又會怎麼樣呢?如果我們將DWORD類型的CompletionKey轉換成一個指向ClientContext的指針,並且試圖訪問或者刪除它又會怎麼呢?此時,一個訪問違規發生了!

  解決這個問題的方法是:添加一個變量(m_nNumberOfPendingIO)用於記錄這個結構(ClientContext)中包含了多少個等待I/O調用,當這個結構中沒有任何I/O調用時侯纔可以刪除它。這個方式通過EnterToLoop() 函數和ReleaseClientContext()完成。

 

3.7. 源代碼概述

這個源碼的目的是提供一系列簡單的類用以處理所有關於IOCP的難點。源碼同樣也提供了一組頻繁使用的函數用以處理通訊和C/S軟件的接收/傳輸功能,邏輯線程池處理等。。。

    我們有幾個通過IOCP來處理異步I/O調用的工作線程,這些工作者調用相同的虛函數以可以提交需要在一個工作隊列中進行大量計算的請求(???)。邏輯工作者從隊列中獲取任務,並處理任務,然後送回通過類中部分函數處理後的結果。GUI通常和使用Windows消息、調用函數或者共享變量的主類進行通訊。

 

 在上圖中出現的類是:

v       CIOCPBuffer:管理被異步I/O調用使用的緩衝區。

v       IOCPS: 處理所有的通訊

v       JobItem:被邏輯工作者線程所處理的結構體,結構中包含任務。

v       ClientContext:保存客戶特定信息的結構體。

 

3.7.1. 緩衝區設計 --- CIOCPBuffer類

     當我們使用異步I/O調用的時候,我們需要提供一個被I/O操作使用的私有緩存。當分配緩衝時,需要考慮一些因素:

       分配和釋放緩衝區是代價昂貴的,因此,我們應該重用已分配的緩衝區。所以緩衝區被存放在如下所示的鏈表結構中:

 

v       有時候,當一個異步I/O調用結束,緩衝區中可能會有不完整的包,因此我們需要將緩衝區分片以獲取一個完整的包。這項工作通過CIOPS類中的SplitBuffer函數完成。我們有時候也需要在緩衝區之間拷貝數據,這項功能通過CIOPS中的AddAndFlush函數完成。

v       我們也需要添加一個序號和狀態(IOType 變量,IOZeroReadCompleted,等)到緩衝區中。

v       CIOCPBuffer中的一些函數提供了在字節流和數據之間相互轉換的功能。

      針對上面談到的問題,所有的解決方案都在CIOCPBuffer類中給出。

3.8. 如何使用源代碼

   通過繼承IOCP得到自己的類,並且使用虛函數和IOCPS提供的功能,能夠僅使用少量線程就可以有效處理大量連接的服務器或者客戶端。

啓動服務: Start

關閉服務:ShutDown

3.8. 重要的變量

所有被函數使用的共享變量都需要加鎖,這是爲了防止訪問違規和重疊寫的重要方法。任何需要被鎖住的變量xxx都會有一個變量爲名 xxxLock的變量。

v       m_ContextMapLock

v       ContextMap  m_ContextMap:    保存所有的客戶端數據(socket, client data, etc…)。

v       m_NumberOfActiveConnections: 保存已經建立的連接數。

 

4. 文件傳輸

  文件傳輸是通過WinSock2.0的TransmitFile函數來完成的。TransmitFile函數通過一個已連接的套接字來傳輸數據。這個函數使用操作系統的高速緩存來世接收數據,並且提供了在套接字上高性能的數據傳輸。

v       除非TransmitFile函數返回,套接字上沒有任何其它的讀寫操作被執行,因爲這會破壞文件。因此,在調用PrepareSendFile之後,所有的ASend調用都將無效。

v       一旦操作系統有序的讀取文件數據,通過用FILE_FLAG_SEQUENTIAL_SCAN打開文件句柄可以提升cache性能。

v       當發送數據的時候(TF_USE_KERNEL_APC)時我們使用內核異步過程調用。TF_USE_KERNEL_APC可以實現顯著的性能提升。這樣做使得上下文爲TransmitFile的線程可以被用來進行大量的計算,這種情況防止APC被觸發。

文件傳輸的順序是:服務端通過調用PrepareSendFile(…)函數初始化文件傳輸。當客戶端接收到文件信息時,客戶端通過調用PrepareReceiveFile(…)來初始化,並且發送一個數據包到服務端以開始文件傳輸。當數據包到達服務端的時候,服務端調用使用高性能的TransmitFile函數的StartSendFile(…)函數來傳輸指定的數據。

5. 特殊的規則

     當你在其它類型的程序中使用此代碼的話,有一些關於此代碼和“多線程編程”的陷阱需要避免。有一些隨機的錯誤很難復現。這種類型的錯誤是最嚴重的錯誤,它們的存在是因爲源代碼的關鍵實現上出現錯誤。當服務端工作在多IO線程上時,或正在爲多個連接的客戶端服務的時候,如果在源代碼中沒能考慮多線程環境的話,隨機錯誤,如訪問違規,就會出現。

規則1:

   永遠不要在爲加鎖的情況下read/write 客戶端上下文,加鎖情況如下所示。通知函數總是線程安全的,ClientContext的成員變量可以在不加鎖的情況訪問。

   同樣,當鎖住上下文,其它的線程或者GUI就會等待它,這點值得注意。

規則2:

     避免在“上下文鎖”中嵌入其它類型的鎖或者有複雜“上下文鎖”特殊代碼,因爲這樣做會導致死鎖。

上面所示的代碼可能會導致死鎖。

規則3:

永遠不要在通知函數(如Notify* (ClientContext *pContext))之外訪問客戶端上下文,如果需要,則要用m_ContextMapLock.Lock();m_ContextMapLock.Unlock()來鎖住上下文,如下列代碼所示:

 

NOTE:
1. 以上所翻譯的只是文章部分內容,如有需要,請查閱原文。
2. 關於DEMO的程序架構,請參考文章:《A Simple IOCP Server/Client Class》整改
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章