一、Winsock簡介
對於衆多底層網絡協議,Winsock是訪問它們的首選接口。而且在每個Win32平臺上,Winsock都以不同的形式存在着。Winsock是網絡編程接口,而不是協議。在Win32平臺上,Winsock接口最終成爲一個真正的“與協議無關”接口,尤其是在Winsock 2發佈之後。
Win32平臺提供的最有用的特徵之一是能夠同步支持多種不同的網絡協議。Windows重定向器保證將網絡請求路由到恰當的協議和子系統;但是,有了Winsock,就可以編寫可直接使用任何一種協議的網絡應用程序了。
在廣泛使用的windows平臺下,winsock2被簡單包裝爲一組龐大的Api庫,通過WSA Start up加載的關於Winsock版本的信息,初始了winsock相關的dll和lib,在成功調用了WSA Startup之後,即可設計自主的與通信有關的行爲,當確認了執行完操作後,調用相應的WSA Cleanup,釋放對winsock DLL的引用次數。幾乎所有在windows平臺下可使用的通信框架都是由Winsock擴展而來的。
這裏,之所以要再提windows下的winsock Api編程,並不多餘,雖然也可以使用CSocket或ACE(ADAPTIVE Communication Environment)框架,但直接對較底層的本地操作系統API,會使我們更深的理解隱藏在框架下的實現,有時也可以解決一些實用問題。
本文涉及的主要是Winsock中面向連接(TCP)部分。
二、阻塞和非阻塞
阻塞socket,又被稱爲鎖定狀態的socket。非阻塞socket,又被稱爲非鎖定狀態的socket。當調用一個Winsock Api時, Api的調用會耗費一定的CPU時間。當一個操作完成之後才返回到用戶態,稱其爲阻塞,反之,則爲非阻塞。當調用Api產生一個基於流協議(TCP)的socket時,系統開闢了接收和發送兩個緩衝區,所以操作實際都是用戶緩衝區和系統緩衝區的數據交互,並不是實際操作的完成,阻塞也是如此。如果綜合被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綁定調用(如send或recv)過程中,被迫進入“鎖定”狀態。
對select不再贅述,msdn已經給出了詳細的解釋。這裏要討論下面兩個問題。
1.Select的fd_set數組可承受的最大數
細心的coder可能都注意到了msdn中對FD_SETSIZE(winsock2.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()。如果程序要完成多於一個端口的偵聽,自然,建立數個線程也是一個辦法,但這裏最好使用select。Msdn中解釋了這種應用:readfds可以檢測出當前listening的socket句柄上是否有有效的connect發生。把本地listening的socket句柄置入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)當調用WsaRecv和WsaSend,會提供一個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時,completionkey是PER_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缺省的出站連接的臨時端口爲1024-5000,要想使用更多的出站端口需要修改註冊表,修改方式:
HKEY_LOCAL_MACHINE/SYSTEM/CurrentControlSet/Services/Tcpip/Parameters/項下建一個MaxUserPort(雙字節值),例如:取值爲10000時,大約有9000個端口可用。
(2)在intel x86上,非頁面內存池僅佔物理內存的1/8,windows將socket正是分配在這一區域上,根據“Busy-ness”原則,爲每個socket分配的內存將隨着socket的使用方式發生變化,但最少在2k以上,overlap也正是在申請非頁面內存池,在NT下分塊的大小是4k,接收發送共需要8k,這樣一個socket大約需要10k的空間。服務器如果有1G內存,那麼能支撐的socket數也在12,000左右。所以,所謂接受幾萬個連接完全不可能。真正這樣的需求都是通過集羣實現的。
(3)欲知socket的狀態,一般要使用iphelper.h中GetTcpTable()實現,當然枚舉屬性爲0x1a的handle也可以實現。
參考文獻
[1]<<Network Programming for Microsoft Windows 2nd>> Microsoft press
[2]Platform SDK Documentation Microsoft■