<a href="http://gamebabyrocksun.blog.163.com/blog/static/57153463201021554716831/">http://gamebabyrocksun.blog.163.com/blog/static/57153463201021554716831/</a>
首先這裡要重點重申一下就是,SOCKET池主要指的是使用面向連接配接的協定的情況下,最常用的就是需要管理大量的TCP連接配接的時候。常見的就是Web伺服器、FTP伺服器等。
下面就分步驟的詳細介紹如何最終實作SOCKET池。
一、WinSock2環境的初始化:
要使用WinSock2就需要先初始化Socket2.0的環境,不廢話,上代碼:
WSADATA wd = {0};
int iError = WSAStartup(MAKEWORD(2,0), &wd);
if( 0 != iError )
{//出現錯誤,最好跟蹤看下錯誤碼是多少
return FALSE;
}
if ( LOBYTE(lpwsaData->wVersion) != 2 )
{//非2.0以上環境 退出了事 可能是可憐的WinCE系統
WSACleanup();
最後再不使用WinSock之後都要記得調用一下WSACleanup()這個函數;
二、裝載WinSock2函數:
上一篇文章中給出了一個裝載WinSock2函數的類,這裡分解介紹下裝載的具體過程,要提醒的就是,凡是類裡面示範了動态裝載的函數,最好都像那樣動态載入,然後再調用。以免出現上網發帖跪求高手賜教為什麼AcceptEx函數無法編譯通過等問題。看完這篇文章詳細你不會再去發帖找答案了,呵呵呵,好了,上代碼:
//定義一個好用的載入函數 摘自CGRSMsSockFun 類
BOOL LoadWSAFun(GUID&funGuid,void*& pFun)
{//本函數利用參數傳回函數指針
DWORD dwBytes = 0;
pFun = NULL;
//随便建立一個SOCKET供WSAIoctl使用 并不一定要像下面這樣建立
SOCKET skTemp = ::WSASocket(AF_INET,
SOCK_STREAM, IPPROTO_TCP, NULL,
0, WSA_FLAG_OVERLAPPED);
if(INVALID_SOCKET == skTemp)
{//通常表示沒有正常的初始化WinSock環境
return FALSE;
}
::WSAIoctl(skTemp, SIO_GET_EXTENSION_FUNCTION_POINTER,
&funGuid,sizeof(funGuid),&pFun,
sizeof(pFun), &dwBytes, NULL,NULL);
::closesocket(skTemp);
return NULL != pFun;
//示範如何動态載入AcceptEx函數
......
LPFN_ACCEPTEX pfnAcceptEx; //首先聲明函數指針
GUID GuidAcceptEx = WSAID_ACCEPTEX;
LoadWSAFun(GuidAcceptEx,(void*&)pfnAcceptEx); //載入
//使用豐富的參數調用
pfnAcceptEx(sListenSocket,sAcceptSocket,lpOutputBuffer,
dwReceiveDataLength,dwLocalAddressLength,dwRemoteAddressLength,
lpdwBytesReceived,lpOverlapped);
//或者:
SOCKET skAccept = ::WSASocket(AF_INET,SOCK_STREAM,IPPROTO_TCP,
NULL, 0,WSA_FLAG_OVERLAPPED);
PVOID pBuf = new BYTE[sizeof(sockaddr_in) + 16];
pfnAcceptEx(skServer, skAccept,pBuf,
0,//将接收緩沖置為0,令AcceptEx直接傳回,防止拒絕服務攻擊
sizeof(sockaddr_in) + 16, sizeof(sockaddr_in) + 16, NULL,
(LPOVERLAPPED)pAcceptOL);
以上是一個簡單的示範,如何動态載入一個WinSock2擴充函數,并調用之,其它函數的詳細例子可以看前一篇文章中CGRSMsSockFun類的實作部分。如果使用CGRSMsSockFun 類的話當然更簡單,像下面這樣調用即可:
CGRSMsSockFun MsSockFun;
MsSockFun.AcceptEx(skServer, skAccept,pBuf,
(LPOVERLAPPED)pAcceptOL);
如果要使用這個類,那麼需要一些修改,主要是異常處理部分,自己注釋掉,或者用其它異常代替掉即可,這個對于有基礎的讀者來說不是什麼難事。
三、定義OVERLAPPED結構:
要想“IOCP”就要自定義OVERLAPPED,這是徹底玩轉IOCP的不二法門,可以這麼說:“江湖上有多少種自定義的OVERLAPPED派生結構體,就有多少種IOCP的封裝!”
OVERLAPPED本身是Windows IOCP機制内部需要的一個結構體,主要用于記錄每個IO操作的“完成狀态”,其内容對于調用者來說是沒有意義的,但是很多時候我們把它當做一個“火車頭”,因為它可以友善的把每個IO操作的相關資料簡單的“從調用處運輸到完成回調函數中”,這是一個非常有用的特性,哪麼如何讓這個火車頭發揮運輸的作用呢?其實很簡單:讓它成為一個自定義的更大結構體的第一個成員。然後用強制類型轉換,将自定義的結構體轉換成OVERLAPPED指針即可。當然不一定非要是新結構體的第一個成員,也可以是任何第n個成員,這時使用VC頭檔案中預定義的一個宏CONTAINING_RECORD再反轉回來即可。
說到這裡一些C++基礎差一點的讀者估計已經很頭暈了,更不知道我再說什麼,那麼我就将好人做到底吧,來解釋下這個來龍去脈。
首先就以我們将要使用的AcceptEx函數為例子看看它的原型吧(知道孫悟空的火眼金睛用來幹嘛的嗎?就是用來看原型的,哈哈哈):
BOOL AcceptEx(
__in SOCKET sListenSocket,
__in SOCKET sAcceptSocket,
__in PVOID lpOutputBuffer,
__in DWORD dwReceiveDataLength,
__in DWORD dwLocalAddressLength,
__in DWORD dwRemoteAddressLength,
__out LPDWORD lpdwBytesReceived,
__in LPOVERLAPPED lpOverlapped
);
注意最後一個參數,是一個OVERLAPPED結構體的指針(LP的意思是Long Pointer,即指向32位位址長指針,注意不是“老婆”拼音的縮寫),本身這個參數的意思就是配置設定一塊OVERLAPPED大小的記憶體,在IOCP調用方式下傳遞給AcceptEx函數用,調用者不用去關心裡面的任何内容,而在完成過程中(很多時候是另一個線程中的事情了),通常調用GetQueuedCompletionStatus函數後,會再次得到這個指針,接着讓我們也看看它的原型:
BOOL WINAPI GetQueuedCompletionStatus(
__in HANDLE CompletionPort,
__out LPDWORD lpNumberOfBytes,
__out PULONG_PTR lpCompletionKey,
__out LPOVERLAPPED* lpOverlapped,
__in DWORD dwMilliseconds
注意這裡的LPOVERLAPPED多了一個*變成了指針的指針,并且前面的說明很清楚Out!很明白了吧,不明白就真的Out了。這裡就可以重新得到調用AcceptEx傳入的LPOVERLAPPED指針,也就是得到了這個“火車頭”,因為隻是一個指針,并沒有詳細的限定能有多大,是以可以在火車頭的後面放很多東西。
再仔細觀察GetQueuedCompletionStatus函數的參數,會發現,這時隻能知道一個IO操作結束了,但是究竟是哪個操作結束了,或者是哪個SOCKET句柄上的操作結束了,并沒有辦法知道。通常這個資訊非常重要,因為隻有在IO操作實際完成之後才能釋放發送或接收等操作的緩沖區。
這些資訊可以定義成如下的一個擴充OVERLAPPED結構:
struct MYOVERLAPPED
{
OVERLAPPED m_ol;
int m_iOpType;
//操作類型 0=AcceptEx 1=DisconnectEx 2=ConnectEx 3=WSARecv等等
SOCKET m_skServer; //服務端SOCKET
SOCKET m_skClient; //用戶端SOCKET
LPVOID m_pBuf; //本次IO操作的緩沖指針
...... //其它需要的資訊
};
使用時:
MYOVERLAPPED* pMyOL = new MYOVERLAPPED;
ZeroMemory(pMyOL,sizeof(MYOVERLAPPED));
pMyOL->m_iOpType = 0; //AcceptEx操作
pMyOL->m_skServer = skServer;
pMyOL->m_skClient = skClient;
BYTE* pBuf = new BYTE[256];//一個緩沖
.................. //朝緩沖中寫入東西
pMyOL->m_pBuf = pBuf;
...............//其它的代碼
AcceptEx(skServer, skClient,pBuf,
0,//将接收緩沖置為0,令AcceptEx直接傳回
256,256,NULL,(LPOVERLAPPED)pMyOL));//注意最後這個強制類型轉換
在完成過程回調線程函數中,這樣使用:
UINT CALLBACK Client_IOCPThread(void* pParam)
{//IOCP線程函數
.....................
DWORD dwBytesTrans = 0;
DWORD dwPerData = 0;
LPOVERLAPPED lpOverlapped = NULL;
while(1)
{//又見死循環 呵呵呵
BOOL bRet = GetQueuedCompletionStatus(
pThis->m_IOCP,&dwBytesTrans,&dwPerData,
&lpOverlapped,INFINITE);
if( NULL == lpOverlapped )
{//沒有真正的完成
SleepEx(20,TRUE);//故意置成可警告狀态
continue;
}
//找回“火車頭”以及後面的所有東西
MYOVERLAPPED* pOL = CONTAINING_RECORD(lpOverlapped
, MYOVERLAPPED, m_ol);
switch(pOL->m_iOpType)
case 0: //AcceptEx結束
{//有連結進來了 SOCKET句柄就是 pMyOL->m_skClient
break;
............................
........................
} //end while
}//end fun
至此,關于這個“火車頭”如何使用,應該是看明白了,其實就是從函數傳入,又由函數傳回。隻不過其間可能已經轉換了線程環境,是不同的線程了。
這裡再補充一個AcceptEx容易被遺漏的一個細節問題,那就是在AcceptEx完成傳回之後,如下在那個連入的用戶端SOCKET上調用一下:
int nRet = ::setsockopt(
pOL->m_skClient,SOL_SOCKET,SO_UPDATE_ACCEPT_CONTEXT,
(char *)&pOL->m_skServer,sizeof(SOCKET));
這樣才可以繼續在這個代表用戶端連接配接的pOL->m_skClient上繼續調用WSARecv和WSASend。
另外,在AcceptEx完成之後,通常可以用:
LPSOCKADDR addrHost = NULL; //服務端位址
LPSOCKADDR addrClient = NULL; //用戶端位址
int lenHost = 0;
int lenClient = 0;
GetAcceptExSockaddrs(
pOL->m_pBuf,0,sizeof(sockaddr_in) + 16,sizeof(sockaddr_in) + 16,
(LPSOCKADDR*) &addrHost,&lenHost,(LPSOCKADDR*) &addrClient,&lenClient);
這樣來得到連入的用戶端位址,以及連入的服務端位址,通常這個位址可以和這個用戶端的SOCKET綁定在一起用map或hash表儲存,友善查詢,就不用再調用那個getpeername得到用戶端的位址了。要注意的是GetAcceptExSockaddrs也是一個WinSock2擴充函數,專門配合AcceptEx使用的,需要像AcceptEx那樣動态載入一下,然後再調用,詳情請見前一篇文章中的CGRSMsSockFun類。
至此AcceptEx算讨論完整了,OVERLAPPED的派生定義也講完了,讓我們繼續下一步。
四、編寫線程池回調函數:
在讨論擴充定義OVERLAPPED結構體時,給出了非線程池版的線程函數的大概架構,也就是傳統IOCP使用的自建線程使用方式,這種方式要自己建立完成端口句柄,自己将SOCKET句柄綁定到完成端口,這裡就不在贅述,主要介紹下調用BindIoCompletionCallback函數時,應如何編寫這個線程池的回調函數,其實它與前面那個線程函數是很類似的。先來看看回調函數長個什麼樣子:
VOID CALLBACK FileIOCompletionRoutine(
[in] DWORD dwErrorCode,
[in] DWORD dwNumberOfBytesTransfered,
[in] LPOVERLAPPED lpOverlapped
第一個參數就是一個錯誤碼,如果是0恭喜你,操作一切ok,如果有錯也不要慌張,前一篇文章中已經介紹了如何翻譯和看懂這個錯誤碼。照着做就是了。
第二個參數就是說這次IO操作一共完成了多少位元組的資料傳輸任務,這個字段有個特殊含義,如果你發現一個Recv操作結束了,并且這個參數為0,那麼就是說,用戶端斷開了連接配接(注意針對的是TCP方式,整個SOCKET池就是為TCP方式設計的)。如果這個情況發生了,在SOCKET池中就該回收這個SOCKET句柄。
第三個參數現在不用多說了,立刻就知道怎麼用它了。跟剛才調用GetQueuedCompletionStatus函數得到的指針是一個含義。
下面就來看一個實作這個回調的例子:
VOID CALLBACK MyIOCPThread(DWORD dwErrorCode
,DWORD dwBytesTrans,LPOVERLAPPED lpOverlapped)
{//IOCP回調函數
if( NULL == lpOverlapped )
{//沒有真正的完成
SleepEx(20,TRUE);//故意置成可警告狀态
return;
}
//找回“火車頭”以及後面的所有東西
MYOVERLAPPED* pOL = CONTAINING_RECORD(lpOverlapped
switch(pOL->m_iOpType)
看起來很簡單吧?好像少了什麼?對了那個該死的循環,這裡不用了,因為這個是由線程池回調的一個函數而已,線程的活動狀态完全由系統内部控制,隻管認為隻要有IO操作完成了,此函數就會被調用。這裡關注的焦點就完全的放到了完成之後的操作上,而什麼線程啊,完成端口句柄啊什麼的就都不需要了(甚至可以忘記)。
這裡要注意一個問題,正如在《IOCP程式設計之“雙節棍”》中提到的,這個函數執行時間不要過長,否則會出現掉線啊,連接配接不進來啊等等奇怪的事情。
另一個要注意的問題就是,這個函數最好套上結構化異常處理,盡可能的多攔截和處理異常,防止系統線程池的線程因為你糟糕的回調函數而壯烈犧牲,如果加入了并發控制,還要注意防止死鎖,不然你的伺服器會“死”的很難看。
理論上來說,你盡可以把這個函數看做一個與線程池函數等價的函數,隻是他要盡可能的“短”(指執行時間)而緊湊(結構清晰少出錯)。
最後,回調函數定義好了,就可以調用BindIoCompletionCallback函數,将一個SOCKET句柄丢進完成端口的線程池了:
BindIoCompletionCallback((HANDLE)skClient,MyIOCPThread,0);
注意最後一個參數到目前為止,你就傳入0吧。這個函數的神奇就是不見了CreateIoCompletionPort的調用,不見了CreateThread的調用,不見了GetQueuedCompletionStatus等等的調用,省去了n多繁瑣且容易出錯的步驟,一個函數就全部搞定了。
五、服務端調用:
以上的所有步驟在完全了解後,最終讓我們看看SOCKET池如何實作之。
1、按照傳統,要先監聽到某個IP的指定端口上:
SOCKADDR_IN saServer = {0};
//建立監聽Socket
SOCKET skServer = ::WSASocket(AF_INET, SOCK_STREAM, IPPROTO_IP
, NULL, 0, WSA_FLAG_OVERLAPPED);
//把監聽SOCKET扔進線程池,這個可以省略 ::BindIoCompletionCallback((HANDLE)skServer,MyIOCPThread, 0);
//必要時打開SO_REUSEADDR屬性,重新綁定到這個監聽位址
BOOL bReuse=TRUE; ::setsockopt(m_skServer,SOL_SOCKET,SO_REUSEADDR
,(LPCSTR)&bReuse,sizeof(BOOL));
saServer.sin_family = AF_INET;
saServer.sin_addr.s_addr = INADDR_ANY;
// INADDR_ANY這個值的魅力是監聽所有本地IP的相同端口
saServer.sin_port = htons(80); //用80得永生
::bind(skServer,(LPSOCKADDR)&saServer,sizeof(SOCKADDR_IN));
//監聽,隊列長為預設最大連接配接SOMAXCONN
listen(skServer, SOMAXCONN);
2、就是發出一大堆的AcceptEx調用:
for(UINT i = 0; i < 1000; i++)
{//調用1000次
//建立與用戶端通訊的SOCKET,注意SOCKET的建立方式
skAccept = ::WSASocket(AF_INET,
SOCK_STREAM,
IPPROTO_TCP,
NULL,
0,
WSA_FLAG_OVERLAPPED);
//2011-07-28:以上為原文,下面為改寫後的代碼
skClient = ::WSASocket(AF_INET,SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED );
//丢進線程池中
BindIoCompletionCallback((HANDLE)skAccept ,MyIOCPThread,0);
//2011-07-28:以上為原文寫法,下面為修改後代碼,主要為了更正變量名,友善大家了解
BindIoCompletionCallback((HANDLE)skClient ,MyIOCPThread,0);
//建立一個自定義的OVERLAPPED擴充結構,使用IOCP方式調用
pMyOL= new MYOVERLAPPED;
ZeroMemory(pBuf,256*sizeof(BYTE));
//發出AcceptEx調用
//注意将AcceptEx函數接收連接配接資料緩沖的大小設定成了0
//這将導緻此函數立即傳回,雖然與不設定成0的方式而言,
//這導緻了一個較低下的效率,但是這樣提高了安全性
//是以這種效率犧牲是必須的
//=================================================================================
//2011-07-28日修改了下面的代碼 把原來的第二個參數skAccept 改為 skClient 為友善大家閱讀和了解
0,//将接收緩沖置為0,令AcceptEx直接傳回,防止拒絕服務攻擊
256,256,NULL,(LPOVERLAPPED)pMyOL);
這樣就有1000個AcceptEx在提前等着用戶端的連接配接了,即使1000個并發連接配接也不怕了,當然如果再BT點那麼就放1w個,什麼你要放2w個?那就要看看你的這個IP段的端口還夠不夠了,還有你的系統記憶體夠不夠用。一定要注意同一個IP位址上理論上端口最大值是65535,也就是6w多個,這個要合理的分派,如果并發管理超過6w個以上的連接配接時,怎麼辦呢?那就再插塊網卡租個新的IP,然後再朝那個IP端綁定并監聽即可。因為使用了INADDR_ANY,是以一監聽就是所有本地IP的相同端口,如果伺服器的IP有内外網之分,為了安全和差別起見可以明确指定監聽哪個IP,單IP時就要注意本IP空閑端口的數量問題了。
3、AcceptEx傳回後,也就是線程函數中,判定是AcceptEx操作傳回後,首先需要的調用就是:
GetAcceptExSockaddrs(pBuf,0,sizeof(sockaddr_in) + 16,
sizeof(sockaddr_in) + 16,(LPSOCKADDR*) &addrHost,&lenHost,
(LPSOCKADDR*) &addrClient,&lenClient);
int nRet = ::setsockopt(pOL->m_skClient, SOL_SOCKET,
SO_UPDATE_ACCEPT_CONTEXT,(char *)&m_skServer,sizeof(m_skServer));
之後就可以WSASend或者WSARecv了。
4、這些調用完後,就可以在這個m_skClient上收發資料了,如果收發資料結束或者IO錯誤,那麼就回收SOCKET進入SOCKET池:
DisconnectEx(m_skClient,&pData->m_ol, TF_REUSE_SOCKET, 0);
5、當DisconnectEx函數完成操作之後,在回調的線程函數中,像下面這樣重新讓這個SOCKET進入監聽狀态,等待下一個使用者連接配接進來,至此組建SOCKET池的目的就真正達到了:
AcceptEx(skServer, skClient,pBuf , 0,256,256,NULL,
(LPOVERLAPPED)pMyOL);
//注意在這個SOCKET被重新利用後,後面的再次捆綁到完成端口的操作會傳回一個已設定//的錯誤,這個錯誤直接被忽略即可
::BindIoCompletionCallback((HANDLE)skClient,Server_IOCPThread, 0);
至此服務端的線程池就算搭建完成了,這個SOCKET池也就是圍繞AcceptEx和DisconnectEx展開的,而建立操作就全部都在服務啟動的瞬間完成,一次性投遞一定數量的SOCKET進入SOCKET池即可,這個數量也就是通常所說的最大并發連接配接數,你喜歡多少就設定多少吧,如果連接配接多數量就大些,如果IO操作多,連接配接斷開請求不多就少點,剩下就是調試了。
六、用戶端調用:
1、 主要是圍繞利用ConnectEx開始調用:
SOCKET skConnect = ::WSASocket(AF_INET,SOCK_STREAM,IPPROTO_IP,
NULL,0,WSA_FLAG_OVERLAPPED);
//把SOCKET扔進IOCP
BindIoCompletionCallback((HANDLE)skConnect,MyIOCPThread,0);
//本地随便綁個端口
SOCKADDR_IN LocalIP = {};
LocalIP.sin_family = AF_INET;
LocalIP.sin_addr.s_addr = INADDR_ANY;
LocalIP.sin_port = htons( (short)0 ); //使用0讓系統自動配置設定
int result =::bind(skConnect,(LPSOCKADDR)&LocalIP,sizeof(SOCKADDR_IN));
pMyOL->m_iOpType = 2; //ConnectEx操作
pMyOL->m_skServer = NULL; //沒有服務端的SOCKET
pMyOL->m_skClient = skConnect;
ConnectEx(skConnect,(const sockaddr*)pRemoteAddr,sizeof(SOCKADDR_IN),
NULL,0,NULL,(LPOVERLAPPED)pOL) )
如果高興就可以把上面的過程放到循環裡面去,pRemoteAddr就是遠端伺服器的IP和端口,你可以重複連接配接n多個,然後瘋狂下載下傳東西(别說我告訴你的哈,人家的伺服器當機了找你負責)。注意那個綁定一定要有,不然調用會失敗的。
2、 接下來就線上程函數中判定是ConnectEx操作,通過判定m_iOpType == 2就可以知道,然後這樣做:
setsockopt( pOL->m_skClient, SOL_SOCKET, SO_UPDATE_CONNECT_CONTEXT,
NULL, 0 );
然後就是自由的按照需要調用WSASend或者WSARecv。
3、 最後使用和服務端相似的邏輯調用DisconnectEx函數,收回SOCKET并直接再次調用ConnectEx連接配接到另一伺服器或相同的同一伺服器即可。
至此用戶端的SOCKET池也搭建完成了,建立SOCKET的工作也是在一開始的一次性就完成了,後面都是利用ConnectEx和DisconnectEx函數不斷的連接配接-收發資料-回收-再連接配接來進行的。用戶端的這個SOCKET池可以用于HTTP下載下傳檔案的用戶端或者FTP下載下傳的服務端(反向服務端)或者用戶端,甚至可以用作一個網遊的機器人系統,也可以作為一個壓力測試的用戶端核心的模型。
七、總結和提高:
以上就是比較完整的如何具體實作SOCKET池的全部内容,因為篇幅的原因就不貼全部的代碼了,我相信各位看客看完之後心中應該有個大概的架構,并且也可以進行實際的代碼編寫工作了。可以用純c來實作也可以用C++來實作。但是這裡要說明一點就是DisconnectEx函數和ConnectEx函數似乎隻能在XP SP2以上和2003Server以上的平台上使用,對于服務端來說這不是什麼問題,但是對于用戶端來說,使用SOCKET池時還要考慮一個相容性問題,不得已還是要放棄在用戶端使用SOCKET池。
SOCKET池的全部精髓就在于提前建立一批SOCKET,然後就是不斷的重複回收再利用,比起傳統的非SOCKET池方式,節省了大量的不斷建立和銷毀SOCKET對象的核心操作,同時借用IOCP函數AcceptEx、ConnectEx和DisconnectEx等的異步IO完成特性提升了整體性能,非常适合用于一些需要大規模TCP連接配接管理的場景,如:HTTP Server FTP Server和遊戲伺服器等。
SOCKET池的本質就是充分的利用了IOCP模型的幾乎所有優勢,是以要用好SOCKET池就要深入的了解IOCP模型,這是前提。有問題請跟帖讨論。