完成端口(Completion Port)詳解

完成端口(Completion Port)詳解

                                                             ----- By PiggyXP(小豬) 程序員

前 言 編程

        本系列裏完成端口的代碼在兩年前就已經寫好了,可是因爲許久沒有寫東西了,不知該如何提筆,因此這篇文檔老是在醞釀之中……醞釀了兩年以後,終於決定開始動筆了,希望還不算晚….. 數組

        這篇文檔我很是詳細而且圖文並茂的介紹了關於網絡編程模型中完成端口的方方面面的信息,從API的用法到使用的步驟,從完成端口的實現機理到實際使用的注意事項,都有所涉及,而且爲了讓朋友們更直觀的體會完成端口的用法,本文附帶了有詳盡註釋的使用MFC編寫的圖形界面的示例代碼。 安全

        個人初衷是但願寫一份互聯網上能找到的最詳盡的關於完成端口的教學文檔,並且讓對Socket編程略有了解的人都可以看得懂,都能學會如何來使用完成端口這麼優異的網絡編程模型,可是因爲本人水平所限,不知道個人初衷是否實現了,但仍是但願各位須要的朋友可以喜歡。 服務器

        因爲篇幅緣由,本文假設你已經熟悉了利用Socket進行TCP/IP編程的基本原理,而且也熟練的掌握了多線程編程技術,太基本的概念我這裏就略過不提了,網上的資料應該遍地都是。 網絡

        本文檔凝聚着筆者心血,如要轉載,請指明原做者及出處,謝謝!不過代碼沒有版權,能夠隨便散播使用,歡迎改進,特別是很是歡迎可以幫助我發現Bug的朋友,以更好的造福你們。^_^ 數據結構

        本文配套的示例源碼下載地址(在個人下載空間裏,已經補充上了客戶端的代碼) 多線程

        http://piggyxp.download.csdn.net/ 併發

       (裏面的代碼包括VC++2008/VC++2010編寫的完成端口服務器端和客戶端的代碼,還包括一個對服務器端進行壓力測試的客戶端,都是通過我精心調試過,而且帶有很是詳盡的代碼註釋的。固然,做爲教學代碼,爲了可以使得代碼結構清晰明瞭,我仍是對代碼有所簡化,若是想要用於產品開發,最好仍是須要本身再完善一下,另外個人工程是用2010編寫的,附帶的2008工程不知道有沒有問題,可是其中代碼都是同樣的,暫未測試) app

        忘了囑咐一下了,文章篇幅很長很長,基本涉及到了與完成端口有關的方方面面,一次看不完能夠分好幾回,中間注意休息,好身體纔是我們程序員最大的本錢!

       對了,還忘了囑咐一下,由於本人的水平有限,雖然我反覆修正了數遍,但文章和示例代碼裏確定還有我沒發現的錯誤和紕漏,但願各位必定要指出來,拍磚、噴我,我都能Hold住,可是必定要指出來,我會及時修正,由於我不想讓文中的錯誤傳遍互聯網,禍害你們。

      OK, Let’s go ! Have fun !

 

目錄:

1. 完成端口的優勢

2. 完成端口程序的運行演示

3. 完成端口的相關概念

4. 完成端口的基本流程

5. 完成端口的使用詳解

6. 實際應用中應該要注意的地方

 

一. 完成端口的優勢

        1. 我想只要是寫過或者想要寫C/S模式網絡服務器端的朋友,都應該或多或少的聽過完成端口的大名吧,完成端口會充分利用Windows內核來進行I/O的調度,是用於C/S通訊模式中性能最好的網絡通訊模型,沒有之一;甚至連和它性能接近的通訊模型都沒有。

        2. 完成端口和其餘網絡通訊方式最大的區別在哪裏呢?

        (1) 首先,若是使用「同步」的方式來通訊的話,這裏說的同步的方式就是說全部的操做都在一個線程內順序執行完成,這麼作缺點是很明顯的:由於同步的通訊操做會阻塞住來自同一個線程的任何其餘操做,只有這個操做完成了以後,後續的操做才能夠完成;一個最明顯的例子就是我們在MFC的界面代碼中,直接使用阻塞Socket調用的代碼,整個界面都會所以而阻塞住沒有響應!因此咱們不得不爲每個通訊的Socket都要創建一個線程,多麻煩?這不坑爹呢麼?因此要寫高性能的服務器程序,要求通訊必定要是異步的。

        (2) 各位讀者確定知道,能夠使用使用「同步通訊(阻塞通訊)+多線程」的方式來改善(1)的狀況,那麼好,想一下,咱們好不容易實現了讓服務器端在每個客戶端連入以後,都要啓動一個新的Thread和客戶端進行通訊,有多少個客戶端,就須要啓動多少個線程,對吧;可是因爲這些線程都是處於運行狀態,因此係統不得不在全部可運行的線程之間進行上下文的切換,咱們本身是沒啥感受,可是CPU卻痛苦不堪了,由於線程切換是至關浪費CPU時間的,若是客戶端的連入線程過多,這就會弄得CPU都忙着去切換線程了,根本沒有多少時間去執行線程體了,因此效率是很是低下的,認可坑爹了不?

        (3) 而微軟提出完成端口模型的初衷,就是爲了解決這種"one-thread-per-client"的缺點的,它充分利用內核對象的調度,只使用少許的幾個線程來處理和客戶端的全部通訊,消除了無謂的線程上下文切換,最大限度的提升了網絡通訊的性能,這種神奇的效果具體是如何實現的請看下文。

        3. 完成端口被普遍的應用於各個高性能服務器程序上,例如著名的Apache….若是你想要編寫的服務器端須要同時處理的併發客戶端鏈接數量有數百上千個的話,那不用糾結了,就是它了。

 

二. 完成端口程序的運行演示

        首先,咱們先來看一下完成端口在筆者的PC機上的運行表現,筆者的PC配置以下:

                        

        大致就是i7 2600 + 16GB內存,我以這臺PC做爲服務器,簡單的進行了以下的測試,經過Client生成3萬個併發線程同時鏈接至Server,而後每一個線程每隔3秒鐘發送一次數據,一共發送3次,而後觀察服務器端的CPU和內存的佔用狀況。

        如圖2所示,是客戶端3萬個併發線程發送共發送9萬條數據的log截圖

                             

        圖3是服務器端接收完畢3萬個併發線程和每一個線程的3份數據後的log截圖

                               

        最關鍵是圖4,圖4是服務器端在接收到28000個併發線程的時候,CPU佔用率的截圖,使用的軟件是大名鼎鼎的Process Explorer,由於相對來說這個比自帶的任務管理器要準確和精確一些。

                                   

         咱們能夠發現一個使人驚訝的結果,採用了完成端口的Server程序(藍色橫線所示)所佔用的CPU才爲 3.82%,整個運行過程當中的峯值也沒有超過4%,是至關氣定神閒的……哦,對了,這仍是在Debug環境下運行的狀況,若是採用Release方式執行,性能確定還會更高一些,除此之外,在UI上顯示信息也很大成都上影響了性能。

         相反採用了多個併發線程的Client程序(紫色橫線所示)竟然佔用的CPU高達11.53%,甚至超過了Server程序的數倍……

         其實不管是哪一種網絡操模型,對於內存佔用都是差很少的,真正的差異就在於CPU的佔用其餘的網絡模型都須要更多的CPU動力來支撐一樣的鏈接數據。

         雖然這遠遠算不上服務器極限壓力測試,可是從中也能夠看出來完成端口的實力,並且這種方式比純粹靠多線程的方式實現併發資源佔用率要低得多。

 

三. 完成端口的相關概念

         在開始編碼以前,咱們先來討論一下和完成端口相關的一些概念,若是你沒有耐心看完這段大段的文字的話,也能夠跳過這一節直接去看下下一節的具體實現部分,可是這一節中涉及到的基本概念你仍是有必要了解一下的,並且你也更能知道爲何有那麼多的網絡編程模式不用,非得要用這麼又複雜又難以理解的完成端口呢??也會堅決你繼續學習下去的信心^_^

         3.1 異步通訊機制及其幾種實現方式的比較

         咱們從前面的文字中瞭解到,高性能服務器程序使用異步通訊機制是必須的。

         而對於異步的概念,爲了方便後面文字的理解,這裏仍是再次簡單的描述一下:

         異步通訊就是在我們與外部的I/O設備進行打交道的時候,咱們都知道外部設備的I/O和CPU比起來簡直是龜速,好比硬盤讀寫、網絡通訊等等,咱們沒有必要在我們本身的線程裏面等待着I/O操做完成再執行後續的代碼,而是將這個請求交給設備的驅動程序本身去處理,咱們的線程能夠繼續作其餘更重要的事情,大致的流程以下圖所示:

                        

        我能夠從圖中看到一個很明顯的並行操做的過程,而「同步」的通訊方式是在進行網絡操做的時候,主線程就掛起了,主線程要等待網絡操做完成以後,才能繼續執行後續的代碼,就是說要末執行主線程,要末執行網絡操做,是無法這樣並行的;

        「異步」方式無疑比 「阻塞模式+多線程」的方式效率要高的多,這也是前者爲何叫「異步」,後者爲何叫「同步」的緣由了,由於不須要等待網絡操做完成再執行別的操做。

        而在Windows中實現異步的機制一樣有好幾種,而這其中的區別,關鍵就在於圖1中的最後一步「通知應用程序處理網絡數據」上了由於實現操做系統調用設備驅動程序去接收數據的操做都是同樣的,關鍵就是在於如何去通知應用程序來拿數據。它們之間的具體區別我這裏多講幾點,文字有點多,若是沒興趣深刻研究的朋友能夠跳過下一面的這一段,不影響的:)

        (1) 設備內核對象,使用設備內核對象來協調數據的發送請求和接收數據協調,也就是說經過設置設備內核對象的狀態,在設備接收數據完成後,立刻觸發這個內核對象,而後讓接收數據的線程收到通知,可是這種方式太原始了,接收數據的線程爲了可以知道內核對象是否被觸發了,仍是得不停的掛起等待,這簡直是根本就沒有用嘛,過低級了,有木有?因此在這裏就略過不提了,各位讀者要是沒明白是怎麼回事也不用深究了,總之沒有什麼用。

        (2) 事件內核對象,利用事件內核對象來實現I/O操做完成的通知,其實這種方式其實就是我之前寫文章的時候提到的《基於事件通知的重疊I/O模型》,連接在這裏,這種機制就先進得多,能夠同時等待多個I/O操做的完成,實現真正的異步,可是缺點也是很明顯的,既然用WaitForMultipleObjects()來等待Event的話,就會受到64個Event等待上限的限制,可是這可不是說咱們只能處理來自於64個客戶端的Socket,而是這是屬於在一個設備內核對象上等待的64個事件內核對象,也就是說,咱們在一個線程內,能夠同時監控64個重疊I/O操做的完成狀態,固然咱們一樣能夠使用多個線程的方式來知足無限多個重疊I/O的需求,好比若是想要支持3萬個鏈接,就得須要500多個線程…用起來太麻煩讓人感受不爽;

        (3) 使用APC( Asynchronous Procedure Call,異步過程調用)來完成,這個也就是我之前在文章裏提到的《基於完成例程的重疊I/O模型》,連接在這裏,這種方式的好處就是在於擺脫了基於事件通知方式的64個事件上限的限制,可是缺點也是有的,就是發出請求的線程必須得要本身去處理接收請求,哪怕是這個線程發出了不少發送或者接收數據的請求,可是其餘的線程都閒着…,這個線程也仍是得本身來處理本身發出去的這些請求,沒有人來幫忙…這就有一個負載均衡問題,顯然性能沒有達到最優化。

        (4) 完成端口,不用說你們也知道了,最後的壓軸戲就是使用完成端口,對比上面幾種機制,完成端口的作法是這樣的:事先開好幾個線程,你有幾個CPU我就開幾個,首先是避免了線程的上下文切換,由於線程想要執行的時候,總有CPU資源可用,而後讓這幾個線程等着,等到有用戶請求來到的時候,就把這些請求都加入到一個公共消息隊列中去,而後這幾個開好的線程就排隊逐一去從消息隊列中取出消息並加以處理,這種方式就很優雅的實現了異步通訊和負載均衡的問題,因爲它提供了一種機制來使用幾個線程「公平的」處理來自於多個客戶端的輸入/輸出,而且線程若是沒事幹的時候也會被系統掛起,不會佔用CPU週期,挺完美的一個解決方案,不是嗎?哦,對了,這個關鍵的做爲交換的消息隊列,就是完成端口。

        比較完畢以後,熟悉網絡編程的朋友可能會問到,爲何沒有提到WSAAsyncSelect或者是WSAEventSelect這兩個異步模型呢,對於這兩個模型,我不知道其內部是如何實現的,可是這其中必定沒有用到Overlapped機制,就不能算做是真正的異步,多是其內部本身在維護一個消息隊列吧,總之這兩個模式雖然實現了異步的接收,可是卻不能進行異步的發送,這就很明顯說明問題了,我想其內部的實現必定和完成端口是迥異的,而且,完成端口很是厚道,由於它是先把用戶數據接收回來以後再通知用戶直接來取就行了,而WSAAsyncSelect和WSAEventSelect之流只是會接收到數據到達的通知,而只能由應用程序本身再另外去recv數據,性能上的差距就更明顯了。

        最後,個人建議是,想要使用 基於事件通知的重疊I/O和基於完成例程的重疊I/O的朋友,若是不是特別必要,就不要去使用了,由於這兩種方式不只使用和理解起來也不算簡單,並且還有性能上的明顯瓶頸,何不就再努力一下使用完成端口呢?

        3.2 重疊結構(OVERLAPPED)

         咱們從上一小節中得知,要實現異步通訊,必需要用到一個很風騷的I/O數據結構,叫重疊結構「Overlapped」,Windows裏全部的異步通訊都是基於它的,完成端口也不例外。

         至於爲何叫Overlapped?Jeffrey Richter的解釋是由於「執行I/O請求的時間與線程執行其餘任務的時間是重疊(overlapped)的」,從這個名字咱們也可能看得出來重疊結構發明的初衷了,對於重疊結構的內部細節我這裏就不過多的解釋了,就把它當成和其餘內核對象同樣,不須要深究其實現機制,只要會使用就能夠了,想要了解更多重疊結構內部的朋友,請去翻閱Jeffrey Richter的《Windows via C/C++》 5th 的292頁,若是沒有機會的話,也能夠隨便翻翻我之前寫的Overlapped的東西,不過寫得比較淺顯……

         這裏我想要解釋的是,這個重疊結構是異步通訊機制實現的一個核心數據結構,由於你看到後面的代碼你會發現,幾乎全部的網絡操做例如發送/接收之類的,都會用WSASend()和WSARecv()代替,參數裏面都會附帶一個重疊結構,這是爲何呢?由於重疊結構咱們就能夠理解成爲是一個網絡操做的ID號,也就是說咱們要利用重疊I/O提供的異步機制的話,每個網絡操做都要有一個惟一的ID號,由於進了系統內核,裏面黑燈瞎火的,也不瞭解上面出了什麼情況,一看到有重疊I/O的調用進來了,就會使用其異步機制,而且操做系統就只能靠這個重疊結構帶有的ID號來區分是哪個網絡操做了,而後內核裏面處理完畢以後,根據這個ID號,把對應的數據傳上去。

         你要是實在不理解這是個什麼玩意,那就直接看後面的代碼吧,慢慢就明白了……

         3.3 完成端口(CompletionPort)

        對於完成端口這個概念,我一直不知道爲何它的名字是叫「完成端口」,我我的的感受應該叫它「完成隊列」彷佛更合適一些,總之這個「端口」和咱們日常所說的用於網絡通訊的「端口」徹底不是一個東西,咱們不要混淆了。

        首先,它之因此叫「完成」端口,就是說系統會在網絡I/O操做「完成」以後纔會通知咱們,也就是說,咱們在接到系統的通知的時候,其實網絡操做已經完成了,就是好比說在系統通知咱們的時候,並不是是有數據從網絡上到來,而是來自於網絡上的數據已經接收完畢了;或者是客戶端的連入請求已經被系統接入完畢了等等,咱們只須要處理後面的事情就行了。

        各位朋友可能會很開心,什麼?已經處理完畢了才通知咱們,那豈不是很爽?其實也沒什麼爽的,那是由於咱們在以前給系統分派工做的時候,都囑咐好了,咱們會經過代碼告訴系統「你給我作這個作那個,等待作完了再通知我」,只是這些工做是作在以前仍是以後的區別而已。

        其次,咱們須要知道,所謂的完成端口,其實和HANDLE同樣,也是一個內核對象雖然Jeff Richter嚇唬咱們說:「完成端口多是最爲複雜的內核對象了」,可是咱們也不用去管他,由於它具體的內部如何實現的和咱們無關,只要咱們可以學會用它相關的API把這個完成端口的框架搭建起來就能夠了。咱們暫時只用把它大致理解爲一個容納網絡通訊操做的隊列就行了,它會把網絡操做完成的通知,都放在這個隊列裏面,我們只用從這個隊列裏面取就好了,取走一個就少一個…。

        關於完成端口內核對象的具體更多內部細節我會在後面的「完成端口的基本原理」一節更詳細的和朋友們一塊兒來研究,固然,要是大家在文章中沒有看到這一節的話,就是說明我又犯懶了沒寫…在後續的文章裏我會補上。這裏就暫時說這麼多了,到時候咱們也能夠看到它的機制也並不是有那麼的複雜,可能只是由於操做系統其餘的內核對象相比較而言實現起來太容易了吧^_^

 

四. 使用完成端口的基本流程

         說了這麼多的廢話,你們都等不及了吧,咱們終於到了具體編碼的時候了。

        使用完成端口,說難也難,可是說簡單,其實也簡單 ---- 又說了一句廢話=。=

        大致上來說,使用完成端口只用遵循以下幾個步驟:

        (1) 調用 CreateIoCompletionPort() 函數建立一個完成端口,並且在通常狀況下,咱們須要且只須要創建這一個完成端口,把它的句柄保存好,咱們從此會常常用到它……

        (2) 根據系統中有多少個處理器,就創建多少個工做者(爲了醒目起見,下面直接說Worker)線程,這幾個線程是專門用來和客戶端進行通訊的,目前暫時沒什麼工做;

        (3) 下面就是接收連入的Socket鏈接了,這裏有兩種實現方式:一是和別的編程模型同樣,還須要啓動一個獨立的線程,專門用來accept客戶端的鏈接請求;二是用性能更高更好的異步AcceptEx()請求,由於各位對accept用法應該很是熟悉了,並且網上資料也會不少,因此爲了更全面起見,本文采用的是性能更好的AcceptEx,至於二者代碼編寫上的區別,我接下來會詳細的講。

        (4) 每當有客戶端連入的時候,咱們就仍是得調用CreateIoCompletionPort()函數,這裏卻不是新創建完成端口了,而是把新連入的Socket(也就是前面所謂的設備句柄),與目前的完成端口綁定在一塊兒。

        至此,咱們其實就已經完成了完成端口的相關部署工做了,嗯,是的,完事了,後面的代碼裏咱們就能夠充分享受完成端口帶給咱們的巨大優點,不勞而獲了,是否是很簡單呢?

       (5) 例如,客戶端連入以後,咱們能夠在這個Socket上提交一個網絡請求,例如WSARecv(),而後系統就會幫我們乖乖的去執行接收數據的操做,咱們大能夠放心的去幹別的事情了;

       (6) 而此時,咱們預先準備的那幾個Worker線程就不能閒着了, 咱們在前面創建的幾個Worker就要忙活起來了,都須要分別調用GetQueuedCompletionStatus() 函數在掃描完成端口的隊列裏是否有網絡通訊的請求存在(例如讀取數據,發送數據等),一旦有的話,就將這個請求從完成端口的隊列中取回來,繼續執行本線程中後面的處理代碼,處理完畢以後,咱們再繼續投遞下一個網絡通訊的請求就OK了,如此循環。

        關於完成端口的使用步驟,用文字來表述就是這麼多了,很簡單吧?若是你仍是不理解,我再配合一個流程圖來表示一下:

        固然,我這裏假設你已經對網絡編程的基本套路有了解了,因此略去了不少基本的細節,而且爲了配合朋友們更好的理解個人代碼,在流程圖我標出了一些函數的名字,而且畫得很是詳細。

        另外須要注意的是因爲對於客戶端的連入有兩種方式,一種是普通阻塞的accept,另一種是性能更好的AcceptEx,爲了可以方面朋友們從別的網絡編程的方式中過渡,我這裏畫了兩種方式的流程圖,方便朋友們對比學習,圖a是使用accept的方式,固然配套的源代碼我默認就不提供了,若是須要的話,我卻是也能夠發上來;圖b是使用AcceptEx的,並配有配套的源碼。

        採用accept方式的流程示意圖以下:

                          

         採用AcceptEx方式的流程示意圖以下:

                           

        

         兩個圖中最大的相同點是什麼?是的,最大的相同點就是主線程無所事事,閒得蛋疼……

         爲何呢?由於咱們使用了異步的通訊機制,這些瑣碎重複的事情徹底沒有必要交給主線程本身來作了,只用在初始化的時候和Worker線程交待好就能夠了,用一句話來形容就是,主線程永遠也體會不到Worker線程有多忙,而Worker線程也永遠體會不到主線程在初始化創建起這個通訊框架的時候操了多少的心……

         圖a中是由 _AcceptThread()負責接入鏈接,並把連入的Socket和完成端口綁定,另外的多個_WorkerThread()就負責監控完成端口上的狀況,一旦有狀況了,就取出來處理,若是CPU有多核的話,就能夠多個線程輪着來處理完成端口上的信息,很明顯效率就提升了。

         圖b中最明顯的區別,也就是AcceptEx和傳統的accept之間最大的區別,就是取消了阻塞方式的accept調用,也就是說,AcceptEx也是經過完成端口來異步完成的,因此就取消了專門用於accept鏈接的線程,用了完成端口來進行異步的AcceptEx調用;而後在檢索完成端口隊列的Worker函數中,根據用戶投遞的完成操做的類型,再來找出其中的投遞的Accept請求,加以對應的處理。

         讀者必定會問,這樣作的好處在哪裏?爲何還要異步的投遞AcceptEx鏈接的操做呢?

         首先,我能夠很明確的告訴各位,若是短期內客戶端的併發鏈接請求不是特別多的話,用accept和AcceptEx在性能上來說是沒什麼區別的。

        按照咱們目前主流的PC來說,若是客戶端只進行鏈接請求,而什麼都不作的話,咱們的Server只能接收大約3萬-4萬個左右的併發鏈接,而後客戶端其他的連入請求就只能收到WSAENOBUFS (10055)了,由於系統來不及爲新連入的客戶端準備資源了。

        須要準備什麼資源?固然是準備Socket了……雖然咱們建立Socket只用一行SOCKET s= socket(…) 這麼一行的代碼就OK了,可是系統內部創建一個Socket是至關耗費資源的,由於Winsock2是分層的機構體系,建立一個Socket須要到多個Provider之間進行處理,最終造成一個可用的套接字。總之,系統建立一個Socket的開銷是至關高的,因此用accept的話,系統可能來不及爲更多的併發客戶端現場準備Socket了。

        而AcceptEx比Accept又強大在哪裏呢?是有三點:

         (1) 這個好處是最關鍵的,是由於AcceptEx是在客戶端連入以前,就把客戶端的Socket創建好了,也就是說,AcceptEx是先創建的Socket,而後才發出的AcceptEx調用,也就是說,在進行客戶端的通訊以前,不管是否有客戶端連入,Socket都是提早創建好了;而不須要像accept是在客戶端連入了以後,再現場去花費時間創建Socket。若是各位不清楚是如何實現的,請看後面的實現部分。

         (2) 相比accept只能阻塞方式創建一個連入的入口,對於大量的併發客戶端來說,入口實在是有點擠;而AcceptEx能夠同時在完成端口上投遞多個請求,這樣有客戶端連入的時候,就很是優雅並且從容不迫的邊喝茶邊處理連入請求了。

         (3) AcceptEx還有一個很是體貼的優勢,就是在投遞AcceptEx的時候,咱們還能夠順便在AcceptEx的同時,收取客戶端發來的第一組數據,這個是同時進行的,也就是說,在咱們收到AcceptEx完成的通知的時候,咱們就已經把這第一組數據接完畢了;可是這也意味着,若是客戶端只是連入可是不發送數據的話,咱們就不會收到這個AcceptEx完成的通知……這個咱們在後面的實現部分,也能夠詳細看到。

         最後,各位要有一個內心準備,相比accept,異步的AcceptEx使用起來要麻煩得多……

 

五. 完成端口的實現詳解

        又說了一節的廢話,終於到了該動手實現的時候了……

        這裏我把完成端口的詳細實現步驟以及會涉及到的函數,按照出現的前後步驟,都和你們詳細的說明解釋一下,固然,文檔中爲了讓你們便於閱讀,這裏去掉了其中的錯誤處理的內容,固然,這些內容在示例代碼中是會有的。

       【第一步】建立一個完成端口

         首先,咱們先把完成端口建好再說。

        咱們正常狀況下,咱們須要且只須要創建這一個完成端口,代碼很簡單:

[cpp]  view plain copy
  1. HANDLE m_hIOCompletionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0 );  

        呵呵,看到CreateIoCompletionPort()的參數不要奇怪,參數就是一個INVALID,一個NULL,兩個0…,說白了就是一個-1,三個0……簡直就和什麼都沒傳同樣,可是Windows系統內部倒是好一頓忙活,把完成端口相關的資源和數據結構都已經定義好了(在後面的原理部分咱們會看到,完成端口相關的數據結構大部分都是一些用來協調各類網絡I/O的隊列),而後系統會給咱們返回一個有意義的HANDLE,只要返回值不是NULL,就說明創建完成端口成功了,就這麼簡單,不是嗎?

        有的時候我真的很讚歎Windows API的封裝,把不少實際上是很複雜的事整得這麼簡單……

        至於裏面各個參數的具體含義,我會放到後面的步驟中去講,反正這裏只要知道建立咱們惟一的這個完成端口,就只是須要這麼幾個參數。

        可是對於最後一個參數 0,我這裏要簡單的說兩句,這個0可不是一個普通的0,它表明的是NumberOfConcurrentThreads,也就是說,容許應用程序同時執行的線程數量。固然,咱們這裏爲了不上下文切換,最理想的狀態就是每一個處理器上只運行一個線程了,因此咱們設置爲0,就是說有多少個處理器,就容許同時多少個線程運行。

        由於好比一臺機器只有兩個CPU(或者兩個核心),若是讓系統同時運行的線程多於本機的CPU數量的話,那實際上是沒有什麼意義的事情,由於這樣CPU就不得不在多個線程之間執行上下文切換,這會浪費寶貴的CPU週期,反而下降的效率,咱們要牢記這個原則。

      【第二步】根據系統中CPU核心的數量創建對應的Worker線程

        咱們前面已經提到,這個Worker線程很重要,是用來具體處理網絡請求、具體和客戶端通訊的線程,並且對於線程數量的設置頗有意思,要等於系統中CPU的數量,那麼咱們就要首先獲取系統中CPU的數量,這個是基本功,我就很少說了,代碼以下:

[cpp]  view plain copy
  1. SYSTEM_INFO si;  
  2. GetSystemInfo(&si);  
  3.   
  4. int m_nProcessors = si.dwNumberOfProcessors;  


        這樣咱們根據系統中CPU的核心數量來創建對應的線程就行了,下圖是在個人 i7 2600k CPU上初始化的狀況,由於個人CPU是8核,一共啓動了16個Worker線程,以下圖所示

                 

         啊,等等!各位沒發現什麼問題麼?爲何我8核的CPU卻啓動了16個線程?這個不是和咱們第二步中說的原則自相矛盾了麼?

         哈哈,有個小祕密忘了告訴各位了,江湖上都流傳着這麼一個公式,就是:

        咱們最好是創建CPU核心數量*2那麼多的線程,這樣更能夠充分利用CPU資源,由於完成端口的調度是很是智能的,好比咱們的Worker線程有的時候可能會有Sleep()或者WaitForSingleObject()之類的狀況,這樣同一個CPU核心上的另外一個線程就能夠代替這個Sleep的線程執行了;由於完成端口的目標是要使得CPU滿負荷的工做。

        這裏也有人說是創建 CPU「核心數量 * 2 +2」個線程,我想這個應該沒有什麼太大的區別,我就是按照我本身的習慣來了。

        而後按照這個數量,來啓動這麼多個Worker線程就好能夠了,接下來咱們開始下一個步驟。

        什麼?Worker線程不會建?

        …囧…

       Worker線程和普通線程是同樣同樣同樣的啊~~~,代碼大體上以下:

[cpp]  view plain copy
  1. // 根據CPU數量,創建*2的線程  
  2.   m_nThreads = 2 * m_nProcessors;  
  3.  HANDLE* m_phWorkerThreads = new HANDLE[m_nThreads];  
  4.   
  5.  for (int i = 0; i < m_nThreads; i++)  
  6.  {  
  7.      m_phWorkerThreads[i] = ::CreateThread(0, 0, _WorkerThread, …);  
  8.  }  


       其中,_WorkerThread是Worker線程的線程函數,線程函數的具體內容咱們後面再講。

     【第三步】建立一個用於監聽的Socket,綁定到完成端口上,而後開始在指定的端口上監聽鏈接請求

       最重要的完成端口創建完畢了,咱們就能夠利用這個完成端口來進行網絡通訊了。

       首先,咱們須要初始化Socket,這裏和一般狀況下使用Socket初始化的步驟都是同樣的,大約就是以下的這麼幾個過程(詳情參照我代碼中的LoadSocketLib()和InitializeListenSocket(),這裏只是挑出關鍵部分):

[cpp]  view plain copy
  1. // 初始化Socket庫  
  2. WSADATA wsaData;  
  3. WSAStartup(MAKEWORD(2,2), &wsaData);  
  4. //初始化Socket  
  5. struct sockaddr_in ServerAddress;  
  6. // 這裏須要特別注意,若是要使用重疊I/O的話,這裏必需要使用WSASocket來初始化Socket  
  7. // 注意裏面有個WSA_FLAG_OVERLAPPED參數  
  8. SOCKET m_sockListen = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);  
  9. // 填充地址結構信息  
  10. ZeroMemory((char *)&ServerAddress, sizeof(ServerAddress));  
  11. ServerAddress.sin_family = AF_INET;  
  12. // 這裏能夠選擇綁定任何一個可用的地址,或者是本身指定的一個IP地址   
  13. //ServerAddress.sin_addr.s_addr = htonl(INADDR_ANY);                        
  14. ServerAddress.sin_addr.s_addr = inet_addr(「你的IP」);           
  15. ServerAddress.sin_port = htons(11111);                            
  16. // 綁定端口  
  17. if (SOCKET_ERROR == bind(m_sockListen, (struct sockaddr *) &ServerAddress, sizeof(ServerAddress)))   
  18. // 開始監聽  
  19. listen(m_sockListen,SOMAXCONN))  


        須要注意的地方有兩點:

        (1) 想要使用重疊I/O的話,初始化Socket的時候必定要使用WSASocket並帶上WSA_FLAG_OVERLAPPED參數才能夠(只有在服務器端須要這麼作,在客戶端是不須要的);

        (2) 注意到listen函數後面用的那個常量SOMAXCONN了嗎?這個是在微軟在WinSock2.h中定義的,而且還附贈了一條註釋,Maximum queue length specifiable by listen.,因此說,不用白不用咯^_^

        接下來有一個很是重要的動做:既然咱們要使用完成端口來幫咱們進行監聽工做,那麼咱們必定要把這個監聽Socket和完成端口綁定才能夠的吧:

        如何綁定呢?一樣很簡單,用 CreateIoCompletionPort()函數。

        等等!你們沒以爲這個函數很眼熟麼?是的,這個和前面那個建立完成端口用的竟然是同一個API!可是這裏這個API可不是用來創建完成端口的,而是用於將Socket和之前建立的那個完成端口綁定的,你們可要看準了,不要被迷惑了,由於他們的參數是明顯不同的,前面那個的參數是一個-1,三個0,太好記了…

        說實話,我感受微軟應該把這兩個函數分開,弄個 CreateNewCompletionPort() 多好呢?

        這裏在詳細講解一下CreateIoCompletionPort()的幾個參數:

[cpp]  view plain copy
  1.  HANDLE WINAPI CreateIoCompletionPort(  
  2.     __in      HANDLE  FileHandle,             // 這裏固然是連入的這個套接字句柄了  
  3.      __in_opt  HANDLE  ExistingCompletionPort, // 這個就是前面建立的那個完成端口  
  4.      __in      ULONG_PTR CompletionKey,        // 這個參數就是相似於線程參數同樣,在  
  5.                                                // 綁定的時候把本身定義的結構體指針傳遞  
  6.                                                // 這樣到了Worker線程中,也能夠使用這個  
  7.                                                // 結構體的數據了,至關於參數的傳遞  
  8.      __in      DWORD NumberOfConcurrentThreads // 這裏一樣置0  
  9. );  

         這些參數也沒什麼好講的吧,用處一目瞭然了。而對於其中的那個CompletionKey,咱們後面會詳細提到。

         到此纔算是Socket所有初始化完畢了。

        初始化Socket完畢以後,就能夠在這個Socket上投遞AcceptEx請求了。

      【第四步】在這個監聽Socket上投遞AcceptEx請求

        這裏的處理比較複雜。

        這個AcceptEx比較特別,並且這個是微軟專門在Windows操做系統裏面提供的擴展函數,也就是說這個不是Winsock2標準裏面提供的,是微軟爲了方便我們使用重疊I/O機制,額外提供的一些函數,因此在使用以前也仍是須要進行些準備工做。

        微軟的實現是經過mswsock.dll中提供的,因此咱們能夠經過靜態連接mswsock.lib來使用AcceptEx。可是這是一個不推薦的方式,咱們應該用WSAIoctl 配合SIO_GET_EXTENSION_FUNCTION_POINTER參數來獲取函數的指針,而後再調用AcceptEx

        這是爲何呢?由於咱們在未取得函數指針的狀況下就調用AcceptEx的開銷是很大的,由於AcceptEx 其實是存在於Winsock2結構體系以外的(由於是微軟另外提供的),因此若是咱們直接調用AcceptEx的話,首先咱們的代碼就只能在微軟的平臺上用了,沒有辦法在其餘平臺上調用到該平臺提供的AcceptEx的版本(若是有的話), 並且更糟糕的是,咱們每次調用AcceptEx時,Service Provider都得要經過WSAIoctl()獲取一次該函數指針,效率過低了,因此還不如咱們本身直接在代碼中直接去這麼獲取一下指針好了。

        獲取AcceptEx函數指針的代碼大體以下:

 

[cpp]  view plain copy
  1.         
  2.        LPFN_ACCEPTEX     m_lpfnAcceptEx;         // AcceptEx函數指針  
  3.         GUID GuidAcceptEx = WSAID_ACCEPTEX;        // GUID,這個是識別AcceptEx函數必須的  
  4. DWORD dwBytes = 0;    
  5.   
  6. WSAIoctl(  
  7.     m_pListenContext->m_Socket,   
  8.     SIO_GET_EXTENSION_FUNCTION_POINTER,   
  9.     &GuidAcceptEx,   
  10.     sizeof(GuidAcceptEx),   
  11.     &m_lpfnAcceptEx,   
  12.     sizeof(m_lpfnAcceptEx),   
  13.     &dwBytes,   
  14.     NULL,   
  15.     NULL);  


   

        具體實現就沒什麼可說的了,由於都是固定的套路,那個GUID是微軟給定義好的,直接拿過來用就好了,WSAIoctl()就是經過這個找到AcceptEx的地址的,另外須要注意的是,經過WSAIoctl獲取AcceptEx函數指針時,只須要隨便傳遞給WSAIoctl()一個有效的SOCKET便可,該Socket的類型不會影響獲取的AcceptEx函數指針。

        而後,咱們就能夠經過其中的指針m_lpfnAcceptEx調用AcceptEx函數了。

       AcceptEx函數的定義以下:

[cpp]  view plain copy
  1. BOOL AcceptEx (       
  2.                SOCKET sListenSocket,   
  3.                SOCKET sAcceptSocket,   
  4.                PVOID lpOutputBuffer,   
  5.                DWORD dwReceiveDataLength,   
  6.                DWORD dwLocalAddressLength,   
  7.                DWORD dwRemoteAddressLength,   
  8.                LPDWORD lpdwBytesReceived,   
  9.                LPOVERLAPPED lpOverlapped   
  10. );  

        乍一看起來參數不少,可是實際用起來也很簡單:

  • 參數1--sListenSocket, 這個就是那個惟一的用來監聽的Socket了,沒什麼說的;
  • 參數2--sAcceptSocket, 用於接受鏈接的socket,這個就是那個須要咱們事先建好的,等有客戶端鏈接進來直接把這個Socket拿給它用的那個,是AcceptEx高性能的關鍵所在。
  • 參數3--lpOutputBuffer,接收緩衝區, 這也是AcceptEx比較有特點的地方,既然AcceptEx不是普通的accpet函數,那麼這個緩衝區也不是普通的緩衝區,這個緩衝區包含了三個信息:一是客戶端發來的第一組數據,二是server的地址,三是client地址,都是精華啊…可是讀取起來就會很麻煩,不事後面有一個更好的解決方案。
  • 參數4--dwReceiveDataLength,前面那個參數lpOutputBuffer中用於存放數據的空間大小。若是此參數=0,則Accept時將不會待數據到來,而直接返回,若是此參數不爲0,那麼必定得等接收到數據了纔會返回……因此一般當須要Accept接收數據時,就須要將該參數設成爲:sizeof(lpOutputBuffer) - 2*(sizeof sockaddr_in +16),也就是說總長度減去兩個地址空間的長度就是了,看起來複雜,其實想明白了也沒啥……
  • 參數5--dwLocalAddressLength,存放本地址地址信息的空間大小;
  • 參數6--dwRemoteAddressLength,存放本遠端地址信息的空間大小;
  • 參數7--lpdwBytesReceived,out參數,對咱們來講沒用,不用管;
  • 參數8--lpOverlapped,本次重疊I/O所要用到的重疊結構。

        這裏面的參數卻是沒什麼,看起來複雜,可是我們依舊能夠一個一個傳進去,而後在對應的IO操做完成以後,這些參數Windows內核天然就會幫我們填滿了。

        可是很是悲催的是,咱們這個是異步操做,咱們是在線程啓動的地方投遞的這個操做, 等咱們再次見到這些個變量的時候,就已是在Worker線程內部了,由於Windows會直接把操做完成的結果傳遞到Worker線程裏,這樣我們在啓動的時候投遞了那麼多的IO請求,這從Worker線程傳回來的這些結果,究竟是對應着哪一個IO請求的呢?。。。。

        聰明的你確定想到了,是的,Windows內核也幫咱們想到了:用一個標誌來綁定每個IO操做,這樣到了Worker線程內部的時候,收到網絡操做完成的通知以後,再經過這個標誌來找出這組返回的數據到底對應的是哪一個Io操做的。

        這裏的標誌就是以下這樣的結構體:

[cpp]  view plain copy
  1.    
  2. typedef struct _PER_IO_CONTEXT{  
  3.   OVERLAPPED   m_Overlapped;          // 每個重疊I/O網絡操做都要有一個                
  4.    SOCKET       m_sockAccept;          // 這個I/O操做所使用的Socket,每一個鏈接的都是同樣的  
  5.    WSABUF       m_wsaBuf;              // 存儲數據的緩衝區,用來給重疊操做傳遞參數的,關於WSABUF後面還會講  
  6.    char         m_szBuffer[MAX_BUFFER_LEN]; // 對應WSABUF裏的緩衝區  
  7.    OPERATION_TYPE  m_OpType;               // 標誌這個重疊I/O操做是作什麼的,例如Accept/Recv等  
  8.   
  9.  } PER_IO_CONTEXT, *PPER_IO_CONTEXT;  


        這個結構體的成員固然是咱們隨便定義的,裏面的成員你能夠隨意修改(除了OVERLAPPED那個以外……)。

       可是AcceptEx不是普通的accept,buffer不是普通的buffer,那麼這個結構體固然也不能是普通的結構體了……

        在完成端口的世界裏,這個結構體有個專屬的名字「單IO數據」,是什麼意思呢?也就是說每個重疊I/O都要對應的這麼一組參數,至於這個結構體怎麼定義無所謂,並且這個結構體也不是必需要定義的,可是沒它……還真是不行,咱們能夠把它理解爲線程參數,就比如你使用線程的時候,線程參數也不是必須的,可是不傳還真是不行……

        除此之外,咱們也還會想到,既然每個I/O操做都有對應的PER_IO_CONTEXT結構體,而在每個Socket上,咱們會投遞多個I/O請求的,例如咱們就能夠在監聽Socket上投遞多個AcceptEx請求,因此一樣的,咱們也還須要一個「單句柄數據」來管理這個句柄上全部的I/O請求,這裏的「句柄」固然就是指的Socket了,我在代碼中是這樣定義的:

[cpp]  view plain copy
  1.      
  2. typedef struct _PER_SOCKET_CONTEXT  
  3. {    
  4.   SOCKET                   m_Socket;              // 每個客戶端鏈接的Socket  
  5.   SOCKADDR_IN              m_ClientAddr;          // 這個客戶端的地址  
  6.   CArray<_PER_IO_CONTEXT*>  m_arrayIoContext;   // 數組,全部客戶端IO操做的參數,  
  7.                                                         // 也就是說對於每個客戶端Socket  
  8.                                                       // 是能夠在上面同時投遞多個IO請求的  
  9. } PER_SOCKET_CONTEXT, *PPER_SOCKET_CONTEXT;  

         這也是比較好理解的,也就是說咱們須要在一個Socket句柄上,管理在這個Socket上投遞的每個IO請求的_PER_IO_CONTEXT。

         固然,一樣的,各位對於這些也能夠按照本身的想法來隨便定義,只要能起到管理每個IO請求上須要傳遞的網絡參數的目的就行了,關鍵就是須要跟蹤這些參數的狀態,在必要的時候釋放這些資源,不要形成內存泄漏,由於做爲Server老是須要長時間運行的,因此若是有內存泄露的狀況那是很是可怕的,必定要杜絕一絲一毫的內存泄漏。

        至於具體這兩個結構體參數是如何在Worker線程裏大發神威的,咱們後面再看。

         以上就是咱們所有的準備工做了,具體的實現各位能夠配合個人流程圖再看一下示例代碼,相信應該會理解得比較快。

        完成端口初始化的工做比起其餘的模型來說是要更復雜一些,因此說對於主線程來說,它總以爲本身付出了不少,總以爲Worker線程是不勞而獲,可是Worker本身的苦只有本身明白,Worker線程的工做一點也不比主線程少,相反還要更復雜一些,而且具體的通訊工做所有都是Worker線程來完成的,Worker線程反而還以爲主線程是在旁邊看熱鬧,只知道發號施令而已,可是你們終究仍是誰也離不開誰,這也就和公司里老板和員工的微妙關係是同樣的吧……


        【第五步】咱們再來看看Worker線程都作了些什麼

        _Worker線程的工做都是涉及到具體的通訊事務問題,主要完成了以下的幾個工做,讓咱們一步一步的來看。

        (1) 使用 GetQueuedCompletionStatus() 監控完成端口

        首先這個工做所要作的工做你們也能猜到,無非就是幾個Worker線程哥幾個一塊兒排好隊隊來監視完成端口的隊列中是否有完成的網絡操做就行了,代碼大致以下:

[cpp]  view plain copy
  1.       
  2. void *lpContext = NULL;  
  3. OVERLAPPED        *pOverlapped = NULL;  
  4. DWORD            dwBytesTransfered = 0;  
  5.   
  6. BOOL bReturn  =  GetQueuedCompletionStatus(  
  7.                                      pIOCPModel->m_hIOCompletionPort,  
  8.                                          &dwBytesTransfered,  
  9.                              (LPDWORD)&lpContext,  
  10.                              &pOverlapped,  
  11.                              INFINITE );  


        各位留意到其中的GetQueuedCompletionStatus()函數了嗎?這個就是Worker線程裏第一件也是最重要的一件事了,這個函數的做用就是我在前面提到的,會讓Worker線程進入不佔用CPU的睡眠狀態,直到完成端口上出現了須要處理的網絡操做或者超出了等待的時間限制爲止。

        一旦完成端口上出現了已完成的I/O請求,那麼等待的線程會被馬上喚醒,而後繼續執行後續的代碼。

       至於這個神奇的函數,原型是這樣的:

[cpp]  view plain copy
  1.       
  2. BOOL WINAPI GetQueuedCompletionStatus(  
  3.     __in   HANDLE          CompletionPort,    // 這個就是咱們創建的那個惟一的完成端口  
  4.     __out  LPDWORD         lpNumberOfBytes,   //這個是操做完成後返回的字節數  
  5.     __out  PULONG_PTR      lpCompletionKey,   // 這個是咱們創建完成端口的時候綁定的那個自定義結構體參數  
  6.     __out  LPOVERLAPPED    *lpOverlapped,     // 這個是咱們在連入Socket的時候一塊兒創建的那個重疊結構  
  7.     __in   DWORD           dwMilliseconds     // 等待完成端口的超時時間,若是線程不須要作其餘的事情,那就INFINITE就好了  
  8.     );  

        因此,若是這個函數忽然返回了,那就說明有須要處理的網絡操做了 --- 固然,在沒有出現錯誤的狀況下。

        而後switch()一下,根據須要處理的操做類型,那咱們來進行相應的處理。

        可是如何知道操做是什麼類型的呢?這就須要用到從外部傳遞進來的loContext參數,也就是咱們封裝的那個參數結構體,這個參數結構體裏面會帶有咱們一開始投遞這個操做的時候設置的操做類型,而後咱們根據這個操做再來進行對應的處理。

        可是還有問題,這個參數到底是從哪裏傳進來的呢?傳進來的時候內容都有些什麼?

        這個問題問得好!

        首先,咱們要知道兩個關鍵點:

        (1) 這個參數,是在你綁定Socket到一個完成端口的時候,用的CreateIoCompletionPort()函數,傳入的那個CompletionKey參數,要是忘了的話,就翻到文檔的「第三步」看看相關的內容;咱們在這裏傳入的是定義的PER_SOCKET_CONTEXT,也就是說「單句柄數據」,由於咱們綁定的是一個Socket,這裏天然也就須要傳入Socket相關的上下文,你是怎麼傳過去的,這裏收到的就會是什麼樣子,也就是說這個lpCompletionKey就是咱們的PER_SOCKET_CONTEXT,直接把裏面的數據拿出來用就能夠了。

       (2) 另外還有一個很神奇的地方,裏面的那個lpOverlapped參數,裏面就帶有咱們的PER_IO_CONTEXT。這個參數是從哪裏來的呢?咱們去看看前面投遞AcceptEx請求的時候,是否是傳了一個重疊參數進去?這裏就是它了,而且,咱們能夠使用一個很神奇的宏,把和它存儲在一塊兒的其餘的變量,所有都讀取出來,例如:

[cpp]  view plain copy
  1. PER_IO_CONTEXT* pIoContext = CONTAINING_RECORD(lpOverlapped, PER_IO_CONTEXT, m_Overlapped);  


         這個宏的含義,就是去傳入的lpOverlapped變量裏,找到和結構體中PER_IO_CONTEXT中m_Overlapped成員相關的數據。

         你仔細想一想,其實真的很神奇……

         可是要作到這種神奇的效果,應該確保咱們在結構體PER_IO_CONTEXT定義的時候,把Overlapped變量,定義爲結構體中的第一個成員。

         只要各位能弄清楚這個GetQueuedCompletionStatus()中各類奇怪的參數,那咱們就離成功不遠了。

         既然咱們能夠得到PER_IO_CONTEXT結構體,那麼咱們就天然能夠根據其中的m_OpType參數,得知此次收到的這個完成通知,是關於哪一個Socket上的哪一個I/O操做的,這樣就分別進行對應處理就行了。

        在個人示例代碼裏,在有AcceptEx請求完成的時候,我是執行的_DoAccept()函數,在有WSARecv請求完成的時候,執行的是_DoRecv()函數,下面我就分別講解一下這兩個函數的執行流程。

       【第六步】當收到Accept通知時 _DoAccept()

        在用戶收到AcceptEx的完成通知時,須要後續代碼並很少,但倒是邏輯最爲混亂,最容易出錯的地方,這也是不少用戶爲何寧願用效率低下的accept()也不肯意去用AcceptEx的緣由吧。

       和普通的Socket通信方式同樣,在有客戶端連入的時候,咱們須要作三件事情:

       (1) 爲這個新連入的鏈接分配一個Socket;

       (2) 在這個Socket上投遞第一個異步的發送/接收請求;

       (3) 繼續監聽。

        其實都是一些很簡單的事情可是因爲「單句柄數據」和「單IO數據」的加入,事情就變得比較亂。由於是這樣的,讓咱們一塊兒縷一縷啊,最好是配合代碼一塊兒看,不然太抽象了……

        (1) 首先,_Worker線程經過GetQueuedCompletionStatus()裏會收到一個lpCompletionKey,這個也就是PER_SOCKET_CONTEXT,裏面保存了與這個I/O相關的Socket和Overlapped還有客戶端發來的第一組數據等等,對吧?可是這裏得注意,這個SOCKET的上下文數據,是關於監聽Socket的,而不是新連入的這個客戶端Socket的,千萬別弄混了……

        (2) 因此,AcceptEx不是給我們新連入的這個Socket早就建好了一個Socket嗎?因此這裏,咱們須要再用這個新Socket從新爲新客戶端創建一個PER_SOCKET_CONTEXT,以及下面一系列的新PER_IO_CONTEXT,千萬不要去動傳入的這個Listen Socket上的PER_SOCKET_CONTEXT,也不要用傳入的這個Overlapped信息,由於這個是屬於AcceptEx I/O操做的,也不是屬於你投遞的那個Recv I/O操做的……,要不你下次繼續監聽的時候就悲劇了……

        (3) 等到新的Socket準備完畢了,咱們就趕忙仍是用傳入的這個Listen Socket上的PER_SOCKET_CONTEXT和PER_IO_CONTEXT去繼續投遞下一個AcceptEx,循環起來,留在這裏太危險了,遲早得被人給改了……

        (4) 而咱們新的Socket的上下文數據和I/O操做數據都準備好了以後,咱們要作兩件事情:一件事情是把這個新的Socket和咱們惟一的那個完成端口綁定,這個就不用細說了,和前面綁定監聽Socket是同樣的;而後就是在這個Socket上投遞第一個I/O操做請求,在個人示例代碼裏投遞的是WSARecv()。由於後續的WSARecv,就不是在這裏投遞的了,這裏只負責第一個請求。

        可是,至於WSARecv請求如何來投遞的,咱們放到下一節中去講,這一節,咱們還有一個很重要的事情,我得給你們提一下,就是在客戶端連入的時候,咱們如何來獲取客戶端的連入地址信息。

         這裏咱們還須要引入另一個很高端的函數,GetAcceptExSockAddrs(),它和AcceptEx()同樣,都是微軟提供的擴展函數,因此一樣須要經過下面的方式來導入才能夠使用……

[cpp]  view plain copy
  1. WSAIoctl(  
  2.     m_pListenContext->m_Socket,   
  3.     SIO_GET_EXTENSION_FUNCTION_POINTER,   
  4.     &GuidGetAcceptExSockAddrs,  
  5.     sizeof(GuidGetAcceptExSockAddrs),   
  6.     &m_lpfnGetAcceptExSockAddrs,   
  7.     sizeof(m_lpfnGetAcceptExSockAddrs),     
  8.     &dwBytes,   
  9.     NULL,   
  10.     NULL);  


        和導出AcceptEx同樣同樣的,一樣是須要用其GUID來獲取對應的函數指針 m_lpfnGetAcceptExSockAddrs 。

        說了這麼多,這個函數到底是幹嗎用的呢?它是名副其實的「AcceptEx之友」,爲何這麼說呢?由於我前面提起過AcceptEx有個很神奇的功能,就是附帶一個神奇的緩衝區,這個緩衝區厲害了,包括了客戶端發來的第一組數據、本地的地址信息、客戶端的地址信息,三合一啊,你說神奇不神奇?

        這個函數從它字面上的意思也基本能夠看得出來,就是用來解碼這個緩衝區的,是的,它不提供別的任何功能,就是專門用來解析AcceptEx緩衝區內容的。例如以下代碼:

[cpp]  view plain copy
  1.            
  2. PER_IO_CONTEXT* pIoContext = 本次通訊用的I/O Context  
  3.   
  4. SOCKADDR_IN* ClientAddr = NULL;  
  5. SOCKADDR_IN* LocalAddr = NULL;    
  6. int remoteLen = sizeof(SOCKADDR_IN), localLen = sizeof(SOCKADDR_IN);    
  7.   
  8. m_lpfnGetAcceptExSockAddrs(pIoContext->m_wsaBuf.buf, pIoContext->m_wsaBuf.len - ((sizeof(SOCKADDR_IN)+16)*2),  sizeof(SOCKADDR_IN)+16, sizeof(SOCKADDR_IN)+16, (LPSOCKADDR*)&LocalAddr, &localLen, (LPSOCKADDR*)&ClientAddr, &remoteLen);  


        解碼完畢以後,因而,咱們就能夠從以下的結構體指針中得到不少有趣的地址信息了:

inet_ntoa(ClientAddr->sin_addr) 是客戶端IP地址

ntohs(ClientAddr->sin_port) 是客戶端連入的端口

inet_ntoa(LocalAddr ->sin_addr) 是本地IP地址

ntohs(LocalAddr ->sin_port) 是本地通信的端口

pIoContext->m_wsaBuf.buf 是存儲客戶端發來第一組數據的緩衝區

 

自從用了「AcceptEx之友」,一切都清淨了….

         【第七步】當收到Recv通知時, _DoRecv()

         在講解如何處理Recv請求以前,咱們仍是先講一下如何投遞WSARecv請求的。

         WSARecv大致的代碼以下,其實就一行,在代碼中咱們能夠很清楚的看到咱們用到了不少新建的PerIoContext的參數,這裏再強調一下,注意必定要是本身另外新建的啊,必定不能是Worker線程裏傳入的那個PerIoContext,由於那個是監聽Socket的,別給人弄壞了……:

[cpp]  view plain copy
  1. int nBytesRecv = WSARecv(pIoContext->m_Socket, pIoContext ->p_wbuf, 1, &dwBytes, 0, pIoContext->p_ol, NULL);  


        這裏,我再把WSARev函數的原型再給各位講一下

[cpp]  view plain copy
  1.       
  2. int WSARecv(  
  3.     SOCKET s,                      // 固然是投遞這個操做的套接字  
  4.      LPWSABUF lpBuffers,            // 接收緩衝區   
  5.                                         // 這裏須要一個由WSABUF結構構成的數組  
  6.      DWORD dwBufferCount,           // 數組中WSABUF結構的數量,設置爲1便可  
  7.      LPDWORD lpNumberOfBytesRecvd,  // 若是接收操做當即完成,這裏會返回函數調用所接收到的字節數  
  8.      LPDWORD lpFlags,               // 說來話長了,咱們這裏設置爲0 便可  
  9.      LPWSAOVERLAPPED lpOverlapped,  // 這個Socket對應的重疊結構  
  10.      NULL                           // 這個參數只有完成例程模式纔會用到,  
  11.                                         // 完成端口中咱們設置爲NULL便可  
  12. );  


         其實裏面的參數,若是大家熟悉或者看過我之前的重疊I/O的文章,應該都比較熟悉,只須要注意其中的兩個參數:

  • LPWSABUF lpBuffers;

        這裏是須要咱們本身new 一個 WSABUF 的結構體傳進去的;

        若是大家非要追問 WSABUF 結構體是個什麼東東?我就給各位多說兩句,就是在ws2def.h中有定義的,定義以下:

[cpp]  view plain copy
  1.          
  2. typedef struct _WSABUF {  
  3.                ULONG len; /* the length of the buffer */  
  4.                __field_bcount(len) CHAR FAR *buf; /* the pointer to the buffer */  
  5.   
  6.         } WSABUF, FAR * LPWSABUF;  


         並且好心的微軟還附贈了註釋,真不容易….

         看到了嗎?若是對於裏面的一些奇怪符號大家看不懂的話,也不用管他,只用看到一個ULONG和一個CHAR*就能夠了,這不就是一個是緩衝區長度,一個是緩衝區指針麼?至於那個什麼 FAR…..讓他見鬼去吧,如今已是32位和64位時代了……

        這裏須要注意的,咱們的應用程序接到數據到達的通知的時候,其實數據已經被我們的主機接收下來了,咱們直接經過這個WSABUF指針去系統緩衝區拿數據就行了,而不像那些沒用重疊I/O的模型,接收到有數據到達的通知的時候還得本身去另外recv,過低端了……這也是爲何重疊I/O比其餘的I/O性能要好的緣由之一。

  • LPWSAOVERLAPPED lpOverlapped

         這個參數就是咱們所謂的重疊結構了,就是這樣定義,而後在有Socket鏈接進來的時候,生成並初始化一下,而後在投遞第一個完成請求的時候,做爲參數傳遞進去就能夠,

[cpp]  view plain copy
  1. OVERLAPPED* m_pol = new OVERLAPPED;  
  2.   
  3. eroMemory(m_pol, sizeof(OVERLAPPED));  


        在第一個重疊請求完畢以後,咱們的這個OVERLAPPED 結構體裏,就會被分配有效的系統參數了,而且咱們是須要每個Socket上的每個I/O操做類型,都要有一個惟一的Overlapped結構去標識。

        這樣,投遞一個WSARecv就講完了,至於_DoRecv()須要作些什麼呢?其實就是作兩件事:

        (1) 把WSARecv裏這個緩衝區裏收到的數據顯示出來;

        (2) 發出下一個WSARecv();

        Over……

        至此,咱們終於深深的喘口氣了,完成端口的大部分工做咱們也完成了,也很是感謝各位耐心的看我這麼枯燥的文字一直看到這裏,真是一個不容易的事情!!

       【第八步】如何關閉完成端口

        休息完畢,咱們繼續……

        各位看官不要高興得太早,雖然咱們已經讓咱們的完成端口順利運做起來了,可是在退出的時候如何釋放資源我們也是要知道的,不然豈不是功虧一簣…..

        從前面的章節中,咱們已經瞭解到,Worker線程一旦進入了GetQueuedCompletionStatus()的階段,就會進入睡眠狀態,INFINITE的等待完成端口中,若是完成端口上一直都沒有已經完成的I/O請求,那麼這些線程將沒法被喚醒,這也意味着線程無法正常退出。

        熟悉或者不熟悉多線程編程的朋友,都應該知道,若是在線程睡眠的時候,簡單粗暴的就把線程關閉掉的話,那是會一個很可怕的事情,由於不少線程體內不少資源都來不及釋放掉,不管是這些資源最後是否會被操做系統回收,咱們做爲一個C++程序員來說,都不該該容許這樣的事情出現。

        因此咱們必須得有一個很優雅的,讓線程本身退出的辦法。

       這時會用到咱們此次見到的與完成端口有關的最後一個API,叫 PostQueuedCompletionStatus(),從名字上也能看得出來,這個是和 GetQueuedCompletionStatus() 函數相對的,這個函數的用途就是可讓咱們手動的添加一個完成端口I/O操做,這樣處於睡眠等待的狀態的線程就會有一個被喚醒,若是爲咱們每個Worker線程都調用一次PostQueuedCompletionStatus()的話,那麼全部的線程也就會所以而被喚醒了。

       PostQueuedCompletionStatus()函數的原型是這樣定義的:

[cpp]  view plain copy
  1. BOOL WINAPI PostQueuedCompletionStatus(  
  2.                    __in      HANDLE CompletionPort,  
  3.                    __in      DWORD dwNumberOfBytesTransferred,  
  4.                    __in      ULONG_PTR dwCompletionKey,  
  5.                    __in_opt  LPOVERLAPPED lpOverlapped  
  6. );  


        咱們能夠看到,這個函數的參數幾乎和GetQueuedCompletionStatus()的如出一轍,都是須要把咱們創建的完成端口傳進去,而後後面的三個參數是 傳輸字節數、結構體參數、重疊結構的指針.

       注意,這裏也有一個很神奇的事情,正常狀況下,GetQueuedCompletionStatus()獲取回來的參數原本是應該是系統幫咱們填充的,或者是在綁定完成端口時就有的,可是咱們這裏卻能夠直接使用PostQueuedCompletionStatus()直接將後面三個參數傳遞給GetQueuedCompletionStatus(),這樣就很是方便了。

       例如,咱們爲了可以實現通知線程退出的效果,能夠本身定義一些約定,好比把這後面三個參數設置一個特殊的值,而後Worker線程接收到完成通知以後,經過判斷這3個參數中是否出現了特殊的值,來決定是不是應該退出線程了。

       例如咱們在調用的時候,就能夠這樣:

[cpp]  view plain copy
  1. for (int i = 0; i < m_nThreads; i++)  
  2. {  
  3.       PostQueuedCompletionStatus(m_hIOCompletionPort, 0, (DWORD) NULL, NULL);  
  4. }  


        爲每個線程都發送一個完成端口數據包,有幾個線程就發送幾遍,把其中的dwCompletionKey參數設置爲NULL,這樣每個Worker線程在接收到這個完成通知的時候,再本身判斷一下這個參數是否被設置成了NULL,由於正常狀況下,這個參數老是會有一個非NULL的指針傳入進來的,若是Worker發現這個參數被設置成了NULL,那麼Worker線程就會知道,這是應用程序再向Worker線程發送的退出指令,這樣Worker線程在內部就能夠本身很「優雅」的退出了……

        學會了嗎?

        可是這裏有一個很明顯的問題,聰明的朋友必定想到了,並且只有想到了這個問題的人,纔算是真正看明白了這個方法。

        咱們只是發送了m_nThreads次,咱們如何能確保每個Worker線程正好就收到一個,而後全部的線程都正好退出呢?是的,咱們沒有辦法保證,因此頗有可能一個Worker線程處理完一個完成請求以後,發生了某些事情,結果又再次去循環接收下一個完成請求了,這樣就會形成有的Worker線程沒有辦法接收到咱們發出的退出通知。

        因此,咱們在退出的時候,必定要確保Worker線程只調用一次GetQueuedCompletionStatus(),這就須要咱們本身想辦法了,各位請參考我在Worker線程中實現的代碼,我搭配了一個退出的Event,在退出的時候SetEvent一下,來確保Worker線程每次就只會調用一輪 GetQueuedCompletionStatus() ,這樣就應該比較安全了。

        另外,在Vista/Win7系統中,咱們還有一個更簡單的方式,咱們能夠直接CloseHandle關掉完成端口的句柄,這樣全部在GetQueuedCompletionStatus()的線程都會被喚醒,而且返回FALSE,這時調用GetLastError()獲取錯誤碼時,會返回ERROR_INVALID_HANDLE,這樣每個Worker線程就能夠經過這種方式輕鬆簡單的知道本身該退出了。固然,若是咱們不能保證咱們的應用程序只在Vista/Win7中,那仍是老老實實的PostQueuedCompletionStatus()吧。

        最後,在系統釋放資源的最後階段,切記,由於完成端口一樣也是一個Handle,因此也得用CloseHandle將這個句柄關閉,固然還要記得用closesocket關閉一系列的socket,還有別的各類指針什麼的,這都是做爲一個合格的C++程序員的基本功,在這裏就很少說了,若是仍是有不太清楚的朋友,請參考個人示例代碼中的 StopListen() 和DeInitialize() 函數。

 

六. 完成端口使用中的注意事項

        終於到了文章的結尾了,不知道各位朋友是基本學會了完成端口的使用了呢,仍是被完成端口以及我這麼多口水的文章折磨得不行了……

        最後再補充一些前面沒有提到了,實際應用中的一些注意事項吧。

       1. Socket的通訊緩衝區設置成多大合適?

        在x86的體系中,內存頁面是以4KB爲單位來鎖定的,也就是說,就算是你投遞WSARecv()的時候只用了1KB大小的緩衝區,系統仍是得給你分4KB的內存。爲了不這種浪費,最好是把發送和接收數據的緩衝區直接設置成4KB的倍數。

       2.  關於完成端口通知的次序問題

        這個不用想也能知道,調用GetQueuedCompletionStatus() 獲取I/O完成端口請求的時候,確定是用先入先出的方式來進行的。

        可是,我們你們可能都想不到的是,喚醒那些調用了GetQueuedCompletionStatus()的線程是之後入先出的方式來進行的。

        好比有4個線程在等待,若是出現了一個已經完成的I/O項,那麼是最後一個調用GetQueuedCompletionStatus()的線程會被喚醒。日常這個次序卻是不重要,可是在對數據包順序有要求的時候,好比傳送大塊數據的時候,是須要注意下這個前後次序的。

        -- 微軟之因此這麼作,那固然是有道理的,這樣若是反覆只有一個I/O操做而不是多個操做完成的話,內核就只須要喚醒同一個線程就能夠了,而不須要輪着喚醒多個線程,節約了資源,並且能夠把其餘長時間睡眠的線程換出內存,提到資源利用率。

       3.  若是各位想要傳輸文件…

        若是各位須要使用完成端口來傳送文件的話,這裏有個很是須要注意的地方。由於發送文件的作法,按照正常人的思路來說,都會是先打開一個文件,而後不斷的循環調用ReadFile()讀取一塊以後,而後再調用WSASend ()去發發送。

        可是咱們知道,ReadFile()的時候,是須要操做系統經過磁盤的驅動程序,到實際的物理硬盤上去讀取文件的,這就會使得操做系統從用戶態轉換到內核態去調用驅動程序,而後再把讀取的結果返回至用戶態;一樣的道理,WSARecv()也會涉及到從用戶態到內核態切換的問題 --- 這樣就使得咱們不得不頻繁的在用戶態到內核態之間轉換,效率低下……

        而一個很是好的解決方案是使用微軟提供的擴展函數TransmitFile()來傳輸文件,由於只須要傳遞給TransmitFile()一個文件的句柄和須要傳輸的字節數,程序就會整個切換至內核態,不管是讀取數據仍是發送文件,都是直接在內核態中執行的,直到文件傳輸完畢纔會返回至用戶態給主進程發送通知。這樣效率就高多了。

       4. 關於重疊結構數據釋放的問題

        咱們既然使用的是異步通信的方式,就得要習慣一點,就是咱們投遞出去的完成請求,不知道何時咱們才能收到操做完成的通知,而在這段等待通知的時間,咱們就得要千萬注意得保證咱們投遞請求的時候所使用的變量在此期間都得是有效的。

        例如咱們發送WSARecv請求時候所使用的Overlapped變量,由於在操做完成的時候,這個結構裏面會保存不少很重要的數據,對於設備驅動程序來說,指示保存着咱們這個Overlapped變量的指針,而在操做完成以後,驅動程序會將Buffer的指針、已經傳輸的字節數、錯誤碼等等信息都寫入到咱們傳遞給它的那個Overlapped指針中去。若是咱們已經不當心把Overlapped釋放了,或者是又交給別的操做使用了的話,誰知道驅動程序會把這些東西寫到哪裏去呢?豈不是很崩潰……

        暫時我想到的問題就是這麼多吧,若是各位真的是要正兒八經寫一個承受很大訪問壓力的Server的話,你慢慢就會發現,只用我附帶的這個示例代碼是不夠的,還得須要在不少細節之處進行改進,例如用更好的數據結構來管理上下文數據,而且須要很是完善的異常處理機制等等,總之,很是期待你們的批評和指正。

        謝謝你們看到這裏!!!

                                                                

                                                                                               ------ Finished in DLUT

                                                                                               ------ 2011-9-31

相關文章
相關標籤/搜索