基於winsock的阻塞和非阻塞通信模型

一、Winsock簡介

對於衆多底層網絡協議,Winsock是訪問它們的首選接口。而且在每個Win32平臺上,Winsock都以不同的形式存在着。Winsock是網絡編程接口,而不是協議。在Win32平臺上,Winsock接口最終成爲一個真正的與協議無關接口,尤其是在Winsock 2發佈之後。

Win32平臺提供的最有用的特徵之一是能夠同步支持多種不同的網絡協議。Windows重定向器保證將網絡請求路由到恰當的協議和子系統;但是,有了Winsock,就可以編寫可直接使用任何一種協議的網絡應用程序了。

在廣泛使用的windows平臺下,winsock2被簡單包裝爲一組龐大的Api庫,通過WSA Start up加載的關於Winsock版本的信息,初始了winsock相關的dlllib,在成功調用了WSA Startup之後,即可設計自主的與通信有關的行爲,當確認了執行完操作後,調用相應的WSA Cleanup,釋放對winsock DLL的引用次數。幾乎所有在windows平臺下可使用的通信框架都是由Winsock擴展而來的。

這裏,之所以要再提windows下的winsock Api編程,並不多餘,雖然也可以使用CSocketACEADAPTIVE Communication Environment)框架,但直接對較底層的本地操作系統API,會使我們更深的理解隱藏在框架下的實現,有時也可以解決一些實用問題。

本文涉及的主要是Winsock中面向連接(TCP)部分。

二、阻塞和非阻塞

阻塞socket又被稱爲鎖定狀態的socket。非阻塞socket,又被稱爲非鎖定狀態的socket。當調用一個Winsock Api時, Api的調用會耗費一定的CPU時間。當一個操作完成之後才返回到用戶態,稱其爲阻塞,反之,則爲非阻塞。當調用Api產生一個基於流協議TCPsocket時,系統開闢了接收和發送兩個緩衝區,所以操作實際都是用戶緩衝區和系統緩衝區的數據交互,並不是實際操作的完成,阻塞也是如此。如果綜合被TCP協議默認使用的nagle算法,在數據包TCP Payload比較短時,協議可能推遲其發送,就不難想象了。

很多做過Winsock通信的人,都知道非阻塞的socket,也就是異步socket的效率遠遠高於阻塞socket。可是除了直觀的認識外,底層的原理又是什麼呢?首先,阻塞的socket的最合理方式是:先知道socket句柄的可讀寫性,再發起動作。因爲如果不做檢測而直接發起操作,那麼TCP的默認超時將讓你後悔不該這麼魯莽。於是,在一次動作中,完成了兩次從用戶態到系統態的狀態轉換,異步的socket實際只告訴系統,所期望的操作,而不完成這種操作,系統會對每次提交的操作進行排隊。當指定的操作完成時,系統態跳轉至用戶態。這樣,只用了一次狀態轉換。其次,由於阻塞的特性,它比非阻塞佔用了更多的CPU時間。

當然,程序總是要適合需求,有時阻塞的socket亦可滿足要求。

三、Select模式

select模式是Winsock中最常見的I/O模型。之所以稱其爲“select模式”,是由於它的中心思想是利用select函數,實現對I/O的管理。利用select函數,判斷套接字上是否存在數據,或者能否向一個套接字寫入數據。設計這個函數,唯一的目的是防止應用程序在套接字處於鎖定模式中時,在一次I/O綁定調用(如sendrecv)過程中,被迫進入“鎖定”狀態。

select不再贅述msdn已經給出了詳細的解釋。這裏要討論下面兩個問題。

1.Selectfd_set數組可承受的最大數

細心的coder可能都注意到了msdn中對FD_SETSIZEwinsock2.h宏的說明,可以在包含winsock2.h之前重新定義這個宏,它將允許在一個select操作中處理更多的socket句柄>64。但是爲何定義就是64呢?這不僅是unix的遺留,更是select處理能力的一種衡量標準,過多的socket句柄檢測畢竟會影響到對已存在操作的socket的響應。一個最合理的建議是,當程序運行在多CPU的機器上時,可以從邏輯上將socket句柄分爲數個組,每組都小於64,用多個線程對每組socket進行select,這樣可以增加程序的響應能力。如果是單CPU,則可將FD_SETSIZE增大至256,適當放大timeout。這時每個socket上吞吐量如果還很大,CPU利用率也據高不下,那就要考慮換種模型。

2.Select在多端口偵聽中的應用

衆所周知,在winsock api中,accept()是一個典型的阻塞操作,通常是建立一個偵聽線程來單獨執行accept()。如果程序要完成多於一個端口的偵聽,自然,建立數個線程也是一個辦法,但這裏最好使用selectMsdn中解釋了這種應用:readfds可以檢測出當前listeningsocket句柄上是否有有效的connect發生。把本地listeningsocket句柄置入readfds,當某個socket有效時,對它調用accept(),此時,發現accept()會立刻成功返回,一個線程就完成了多端口的偵聽。

四、非阻塞與完成端口模式

IO完成端口:一種windows獨有的異步IO機制。它不專屬socketIO,更多的應用是file IO和串口IO。這種模式的優點是:在句柄較多時,較低的CPU利用率和較高的吞吐量。但設計上有較高的複雜性,只有在應用程序需要同時管理數百乃至上千個套接字的時候,並希望隨着系統內安裝的CPU數量的增多,應用程序的性能也可以線性提升,才應考慮採用“完成端口”模式。

windows下,IOCP已經可以說是頂級的通信方式了,在網上能搜到的資料都表明:windows的高效通信 =IOCP + 多線程。IOCP server的工作過程如下,代碼在windows sdk中。

         主線程 

             ¦  

 CreateIoCompletionPort                                                                                         ¦                                                                    

CreateThread          —————————    完成端口線程  

                                                              ¦  

 ¦----  While(TRUE)                              While(TRUE)----------  ¦  

            ¦                                                 ¦                   

        Accept                              ¦------GetQueuedCompletionStatus()    ¦  

¦            ¦                                 ¦                ¦               

¦  CreateIoCompletionPort      ¦             WsaRev/WsaSend-------  ¦  

¦            ¦                                  ¦                ¦  

¦----WsaRev/WsaSend                      -------  Windows系統  

              ¦                                  ¦    

Windows系統    ---------  

應注意以下兩個事項:

1)當調用WsaRecvWsaSend,會提供一個WSADATA的數據結構,其中的指針指向的是用戶緩衝區,由於以上兩個函數在大多數情況下並不是立即就可以完成的,所以,在GetQueuedCompletionStatus沒有收到完成事件前,這個緩衝區不可修改或釋放。

2)經常會在技術論壇中見到有人提問關於IOCP的異步方式會導致亂包的問題。首先看一下IOCP的原理,其實它就相當一個由windows底層管理的有事務功能的消息隊列。看過所謂“windows泄漏代碼”的人都會注意到windows並不保證這個隊列是有序的,特別是當將完成事件通知用戶的多個GetQueuedCompletionStatus線程時,由於調度算法,並不能保證先遞交的操作會先返回結果。這樣,如果在當前隊列中對一個socket句柄有兩個recv的待完成操作,此時,socket底層緩衝區內數據爲“abcd”,第一個操作完成了“ab”,第二個操作應該完成 cd”,但第二個操作先找到了空閒線程返回了,於是,數據就亂了。解決的方法很簡單,只要從邏輯上保證一個socket句柄接收和發送操作都是同步完成的(即完成一個,才發起下一個),就可以避免亂包。

下面提出兩點技巧:

即使在網上能找到關於IOCP的代碼,幾乎都是照搬了sdk中的實例,在自己編寫代碼時,還是會碰到更多的問題。

(1)sdk代碼中存在一個沒有說明的問題。其中模擬的是一個基於一定固有消息的服務器,每收到一包消息,處理後發出一包消息,WsaRecv是常發出的,如果應用突然要發送一包消息呢?由於一個socket只存在一個異步操作使用的WSAOVERLAPPED結構,所以最常見的辦法是使用api       PostQueuedCompletionStatus()將一個已投出的WsaRecv“召回”,再投出WsaSend,這樣浪費了一次操作,在連接數較大時,這種代價是不可忽略的。更好的辦法是使用兩個WSAOVERLAPPED結構,我使用的數據結構如下:

   typedef enum _IO_OPERATION

   {

    ClientIoAccept,

    ClientIoRead,

    ClientIoWrite

   } IO_OPERATION, *PIO_OPERATION;

 

   typedef struct _PER_IO_CONTEXT

   {

    WSAOVERLAPPED               Overlapped;

    char                        *Buffer;

 

    WSABUF                      wsabuf;

    IO_OPERATION                IOOperation;

    SOCKET                      SocketAccept;

    int                         State;

 

    int                         nTotalBytes;

    int                         nSentBytes;

 

    struct _PER_IO_CONTEXT      *pIOContextForward;

   } PER_IO_CONTEXT, *PPER_IO_CONTEXT;

 

   typedef struct _PER_SOCKET_CONTEXT

   {

   int                       state;

   SOCKET                    sock;

   struct  sockaddr_in          remote;

 

   PPER_IO_CONTEXT             pRIOContext;

   PPER_IO_CONTEXT             pSIOContext;

 

   struct _PER_SOCKET_CONTEXT  *pCtxtBack;

   struct _PER_SOCKET_CONTEXT  *pCtxtForward;

   } PER_SOCKET_CONTEXT, *PPER_SOCKET_CONTEXT;

 

 

PER_IO_CONTEXT中包含了一個WSAOVERLAPPED結構,每個socket上下文PER_SOCKET_CONTEXT中包含了兩個PER_IO_CONTEXT的指針,這樣WsaRecv使用pRIOContext中的WSAOVERLAPPED,而WsaSend使用pSIOContext中的WSAOVERLAPPED,互不干擾。

(2)上述問題解決了,但又帶來了一個新的問題。在socket註冊入IOCP時,completionkeyPER_SOCKET_CONTEXT的地址。此時,如果在這個socket上有完成事件,completionkey會被返回,但如何知道是接收還是發送完成了呢?你一定會想到WSAOVERLAPPED的地址也不被返回了,和pRIOContext以及pSIOContext中的WSAOVERLAPPED地址比一下就可以了,然而,地址比較是C語言的大忌。其實,上面的數據結構也是在sdk代碼上改來的,注意在WsaRecv以及WsaSend中用的Overlapped指針,正是PER_IO_CONTEXT的地址,強制類型轉換即可,其中IO_OPERATION正是對操作類型的描述。

五、幾點補充

IOCP到底有多高效呢?<<Network  Programming  for  Microsoft  Windows  2nd>>有個技術統計,採用IOCP:  

Attempted/Connected:  50,000/49,997  

Memory  Used  (KB):  242,272  

Non-Paged  Pool:  148,192  

CPU  Usage:  55–65%  

Threads:  2  

Throughput (Send/Receive Bytes Per Second):4,326,946/4,326,496 (The server was a Pentium 4 1.7 GHz  Xeon  with 768 MB  memory)

 因此,接受幾千個連接是可能的,但要穩定處理就有賴於服務器性能和程序的健壯性。

對自己程序的測試還要注意一些windows特性:

1)連接測試時,win2k缺省的出站連接的臨時端口爲10245000,要想使用更多的出站端口需要修改註冊表,修改方式:  

HKEY_LOCAL_MACHINE/SYSTEM/CurrentControlSet/Services/Tcpip/Parameters/項下建一個MaxUserPort(雙字節值),例如:取值爲10000時,大約有9000個端口可用。

2)在intel x86上,非頁面內存池僅佔物理內存的1/8windowssocket正是分配在這一區域上,根據“Busy-ness”原則,爲每個socket分配的內存將隨着socket的使用方式發生變化,但最少在2k以上,overlap也正是在申請非頁面內存池,在NT下分塊的大小是4k,接收發送共需要8k,這樣一個socket大約需要10k的空間。服務器如果有1G內存,那麼能支撐的socket數也在12,000左右。所以,所謂接受幾萬個連接完全不可能。真正這樣的需求都是通過集羣實現的。

3)欲知socket的狀態,一般要使用iphelper.hGetTcpTable()實現,當然枚舉屬性爲0x1ahandle也可以實現。

參考文獻

[1]<<Network Programming for Microsoft Windows 2nd>>         Microsoft press

[2]Platform SDK Documentation                            Microsoft

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