對於開發一款高效能伺服器程式,廣大伺服器開發人員在一直為之奮鬥和努力.其中一個影響伺服器的重要瓶頸就是伺服器的網路處理模組.如果一款伺服器程式不能及時的處理使用者的資料.則伺服器的上層商務邏輯再高效也是徒勞.所以一個伺服器程式的網路處理能力直接影響到整個伺服器的效能,
本文主要介紹在windows平台下開發高效能的網路處理模組以及自己在設計程式開發伺服器網路模組遇到的一些問題和開發心得.
本篇主要介紹TCP伺服器的設計, 下一篇將主要介紹UDP伺服器的設計。
眾所周知, 對於伺服器來說windows下網路I/O處理的最佳方式就是完成連接埠, 因此本伺服器的開發主要基於完成連接埠的模式.完成連接埠(completion port)是應用程式使用線程池處理非同步I/O請求的一種機制.將建立好的socket和完成連接埠綁定後就可以向該socket上投遞相應的I/O操作, 當操作完成後I/O系統會向完成連接埠發送一個通知包;應用程式通過GetQueuedCompletionStatus()函數擷取這些通知包並進行相應的處理.下面切入正題談談TCP伺服器的開發。
本人在開發TCP伺服器的經過了兩個階段, 第設計一次出來的TCP伺服器網路層只能支援5000 – 8000個線上使用者同時和伺服器互動, 但是會出現一些莫名其妙的系統異常.所以網路層不是很穩定.這次開發主要用到一個系統的I/O線程池函數BindIoCompletionCallback()
該函數在win2000以後都支援, BindIoCompletionCallback()是一個I/O線程池函數,其主要功能是採用系統線程池進行I/O處理,優點是使用者無需自己建立完成連接埠和線程池,完成連接埠和工作者線程池的建立和管理都由系統維護.給使用者帶了很大的方便.使用者只需要將自己建立的socket和I/O回呼函數傳遞給BindIoCompletionCallback()函數即可,
然後就可以在該socket上投遞相應的操作.當操作完成後系統會調用使用者的回呼函數通知使用者.這種方式給開發人員帶來了很大的方便, 開發人員甚至不需要去瞭解完成連接埠的工作機制就可以開發出一個較高效能的網路程式.但同時也帶來了很多麻煩,使用者無法知道在完成連接埠上到底有多少個工作者線程, 而且當串連到伺服器上的使用者量過大時會出現線程堆棧錯誤等異常,同時有1000-2000個使用者中斷連線後, 伺服器就無法讓後續使用者串連到伺服器. 在這種方式下的伺服器網路層最多隻支援4000 – 5000使用者同時串連到伺服器.使用者量再大時就會出現一些系統異常而且無法解決。
借鑒於第一次開發的經驗和教訓, 在第二次程式開發伺服器TCP層時決定自己建立完成連接埠和工作者線程池, 並對其進行維護和管理.這樣做的好處是出了問題好定位和處理.下面將我開發的代碼拿出來和大家切磋切磋, 如果什麼地方寫得問題還希望能夠指正出來,
1.首先介紹網路上下文(NET_CONTEXT)的定義:
class NET_CONTEXT
{
public:
WSAOVERLAPPED m_ol;
SOCKET m_hSock;
CHAR* m_pBuf; //接收或發送資料的緩衝區
INT m_nOperation; //在該網路上下文上進行的操作 OP_ACCEPT…
static DWORD S_PAGE_SIZE; //緩衝區的最大容量
NET_CONTEXT();
virtual ~NET_CONTEXT();
static void InitReource();
static void ReleaseReource();
private:
void* operator new (size_t nSize);
void operator delete(void* p);
static HANDLE s_hDataHeap;
static vector<char * > s_IDLQue; //無效資料緩衝區的隊列
static CRITICAL_SECTION s_IDLQueLock; //訪問s_IDLQue的互斥鎖
};
NET_CONTEXT 是所有網路內容相關的基類, 對於TCP的recv, send, accep, connect的上下文都繼承自該類.UDP的send和recv的網路上下文也繼承自該類. m_ol 必須放置在第一個位置否則當從完成封包取net_context不能得到正確的結果. S_PAGE_SIZE 為資料緩衝區m_pBuf的大小,其大小和相應的作業系統平台有關, win32下其值為4096, win64下其值為8192, 即為作業系統的一個記憶體頁的大小.設定為一個記憶體頁的原因是因為在投遞重疊操作時系統會鎖定投遞的緩衝區,
在鎖定時是按照記憶體頁的邊界來鎖定的.因此即使你只發送一個1K位元組資料系統也會鎖定整個記憶體頁(4096/8192). s_hDataHeap 為自訂的BUF申請的堆.其優點是使用者可以自己對堆進行管理和操作. s_IDLQue 為用過的BUF隊列, 當使用者用完相應的NET_CONTEXT後在執行析構操作時並不會真正把m_pBuf所佔的記憶體釋放掉而是將其放入到s_IDLQue隊列中, 當下次申請新的NET_CONTEXT時只需從s_IDLQue中取出就可以使用, 避免頻繁的new和delete操作。
2.資料包頭的定義:
struct PACKET_HEAD
{
LONG nTotalLen; //資料包的總長度
ULONG nSerialNum; //資料包的序號
WORD nCurrentLen; //當前資料包的長度
WORD nType; //資料包的類型
};
資料包頭位於每一個接收到的或待發送的資料包的首部,用於確定接收到的資料包是否合法以及該資料包是做什麼用的.使用者可以定義自己包頭。
3.TCP_CONTEXT主要用於定義接收和發送資料的緩衝區, 繼承自NET_CONTEXT
class TCP_CONTEXT : public NET_CONTEXT
{
friend class TcpServer;
protected:
DWORD m_nDataLen; //TCP模式下累計發送和接收資料的長度
TCP_CONTEXT()
: m_nDataLen(0)
{
}
virtual ~TCP_CONTEXT() {}
void* operator new(size_t nSize);
void operator delete(void* p);
enum
{
E_TCP_HEAP_SIZE = 1024 * 1024* 10,
MAX_IDL_DATA = 20000,
};
private:
static vector<TCP_CONTEXT* > s_IDLQue; //無效的資料隊列
static CRITICAL_SECTION s_IDLQueLock; //訪問s_IDLQue的互斥鎖
static HANDLE s_hHeap; //TCP_CONTEXT的資料申請堆
};
TCP_CONTEXT類主要用在網路上發送和接收資料的上下文.每個串連到伺服器的SOCKET都會有一個發送和接收資料的TCP_CONTEXT.這裡重載了new和delete函數.這樣做的優點在於當申請一個新的TCP_CONTEXT對象時會先判斷無效的資料隊列中是否有未使用的TCP_CONTEXT,若有則直接取出來使用否則從s_hHeap堆上新申請一個.new 函數的定義如下
void* TCP_CONTEXT::operator new(size_t nSize)
{
void* pContext = NULL;
try
{
if (NULL == s_hHeap)
{
throw ((long)(__LINE__));
}
//為新的TCP_CONTEXT申請記憶體, 先從無效隊列中找, 如無效隊列為空白則從堆上申請
EnterCriticalSection(&s_IDLQueLock);
vector<TCP_CONTEXT* >::iterator iter = s_IDLQue.begin();
if (iter != s_IDLQue.end())
{
pContext = *iter;
s_IDLQue.erase(iter);
}
else
{
pContext = HeapAlloc(s_hHeap, HEAP_ZERO_MEMORY | HEAP_NO_SERIALIZE, nSize);
}
LeaveCriticalSection(&s_IDLQueLock);
if (NULL == pContext)
{
throw ((long)(__LINE__));
}
}
catch (const long& iErrCode)
{
pContext = NULL;
_TRACE("/r/nExcept : %s--%ld", __FILE__, iErrCode);
}
return pContext;
}
當使用完TCP_CONTEXT時調用delete函數進行對記憶體回收, 在進行記憶體回收時先查看無效隊列已存放的資料是否達到MAX_IDL_DATA, 若沒有超過MAX_IDL_DATA則將其放入到s_IDLQue中否則將其釋放掉.delete函數的實現如下:
void TCP_CONTEXT::operator delete(void* p)
{
if (p)
{
//若空閑隊列的長度小於MAX_IDL_DATA, 則將其放入無效隊列中否則釋
//放之
EnterCriticalSection(&s_IDLQueLock);
const DWORD QUE_SIZE = (DWORD)(s_IDLQue.size());
TCP_CONTEXT* const pContext = (TCP_CONTEXT*)p;
if (QUE_SIZE <= MAX_IDL_DATA)
{
s_IDLQue.push_back(pContext);
}
else
{
HeapFree(s_hHeap, HEAP_NO_SERIALIZE, p);
}
LeaveCriticalSection(&s_IDLQueLock);
}
return;
}
4.ACCEPT_CONTEXT 主要用於投遞AcceptEx操作, 繼承自NET_CONTEXT類
class ACCEPT_CONTEXT : public NET_CONTEXT
{
friend class TcpServer;
protected:
SOCKET m_hRemoteSock; //串連本伺服器的用戶端SOCKET
ACCEPT_CONTEXT()
: m_hRemoteSock(INVALID_SOCKET)
{
}
virtual ~ACCEPT_CONTEXT() {}
void* operator new(size_t nSize);
void operator delete(void* p);
private:
static vector<ACCEPT_CONTEXT* > s_IDLQue; //無效的資料隊列
static CRITICAL_SECTION s_IDLQueLock; //訪問s_IDLQueµ互斥鎖
static HANDLE s_hHeap; //ACCEPT_CONTEXT的自訂堆
};
5.TCP_RCV_DATA, 當伺服器的某個socket從網路上收到資料後並且資料合法便為收到的資料申請一個新的TCP_RCV_DATA執行個體儲存收到的資料.其定義如下:
class DLLENTRY TCP_RCV_DATA
{
friend class TcpServer;
public:
SOCKET m_hSocket; //與該資料相關的socket
CHAR* m_pData; //資料緩衝區地址
INT m_nLen; //收到的資料的長度
TCP_RCV_DATA(SOCKET hSock, const CHAR* pBuf, INT nLen);
~TCP_RCV_DATA();
void* operator new(size_t nSize);
void operator delete(void* p);
enum
{
HEAP_SIZE = 1024 *1024* 50,
DATA_HEAP_SIZE = 1024 *1024 * 100,
MAX_IDL_DATA = 100000,
};
private:
static vector<TCP_RCV_DATA* > s_IDLQue; //無效資料隊列
static CRITICAL_SECTION s_IDLQueLock; //訪問s_IDLQue的互斥鎖
static HANDLE s_hHeap;
static HANDLE s_DataHeap;
};
6.前面講的相關的資料結構都是為下面要探討的TcpServer類服務的. TcpServer類是本文要探討的核心資料結構;主要用於啟動服務, 管理串連等操作.
class DLLENTRY TcpServer
{
public:
TcpServer();
~TcpServer();
/************************************************************************
* Desc : 初始化相關靜態資源,在申請TCP執行個體之前必須先調用該方法對相關資
* 源進行初始化
************************************************************************/
static void InitReource();
/************************************************************************
* Desc : 釋放相應的靜態資源
************************************************************************/
static void ReleaseReource();
/****************************************************
* Name : StartServer()
* Desc : 啟動TCP服務
****************************************************/
BOOL StartServer(
const char *szIp //要啟動服務的本地地址, 若為NULL則採用預設地址
, INT nPort //要啟動服務的連接埠
, LPCLOSE_ROUTINE pCloseFun //用戶端socket關閉的通知函數
, LPVOID pParam //close函數的參數
);
/****************************************************
* Name : CloseServer()
* Desc : 關閉TCP服務
****************************************************/
void CloseServer();
/****************************************************
* Name : SendData()
* Desc : 對用戶端hSock發送長度為nDataLen的資料
****************************************************/
BOOL SendData(SOCKET hSock, const CHAR* szData, INT nDataLen);
/****************************************************
* Name : GetRcvData()
* Desc : 從接收資料隊列中擷取一個接收資料包
* pQueLen 不為NULL時返回其長度
****************************************************/
TCP_RCV_DATA* GetRcvData(
DWORD* const pQueLen
);
protected:
enum
{
LISTEN_EVENTS = 2, //監聽socket的事件個數
MAX_ACCEPT = 50, //每次最多投遞的accept操作的個數
_SOCK_NO_RECV = 0xf0000000, //用戶端socket已串連上但為發送資料
_SOCK_RECV = 0xf0000001 //用戶端socket已串連上並也收到資料
};
vector<TCP_RCV_DATA* > m_RcvDataQue; //接收到的資料緩衝區隊列
CRITICAL_SECTION m_RcvQueLock; //訪問m_RcvDataQue的互斥鎖
vector<SOCKET> m_SocketQue; //串連本伺服器的用戶端socket隊列
CRITICAL_SECTION m_SockQueLock; //訪問m_SocketQue的互斥鎖
LPCLOSE_ROUTINE m_pCloseFun; //用戶端socket關閉的通知函數
LPVOID m_pCloseParam; //傳遞給m_pCloseFun的使用者參數
SOCKET m_hSock; //要進行伺服器監聽的socket
long volatile m_bThreadRun; //是否允許後台線程繼續運行
long volatile m_nAcceptCount; //當前已投遞的accept操作的個數
BOOL m_bSerRun; //服務是否正在運行
//accept的事件
HANDLE m_ListenEvents[LISTEN_EVENTS];
HANDLE *m_pThreads; //建立的後台線程的控制代碼
HANDLE m_hCompletion; //完成連接埠控制代碼
static LPFN_ACCEPTEX s_pfAcceptEx; //AcceptEx地址
// GetAcceptExSockaddrs的地址
static LPFN_GETACCEPTEXSOCKADDRS s_pfGetAddrs;
/****************************************************
* Name : AcceptCompletionProc()
* Desc : acceptEx操作完成後回呼函數
****************************************************/
void AcceptCompletionProc(BOOL bSuccess, DWORD dwNumberOfBytesTransfered, LPOVERLAPPED lpOverlapped);
/****************************************************
* Name : RecvCompletionProc()
* Desc : 接收操作完成後的回呼函數
****************************************************/
void RecvCompletionProc(BOOL bSuccess, DWORD dwNumberOfBytesTransfered, LPOVERLAPPED lpOverlapped);
/****************************************************
* Name : SendCompletionProc()
* Desc : 發送操作完成後的回呼函數
****************************************************/
void SendCompletionProc(BOOL bSuccess, DWORD dwNumberOfBytesTransfered, LPOVERLAPPED lpOverlapped);
/****************************************************
* Name : ListenThread()
* Desc : 監聽線程
****************************************************/
static UINT WINAPI ListenThread(LPVOID lpParam);
/****************************************************
* Name : WorkThread()
* Desc : 在完成連接埠上工作的後台線程
****************************************************/
static UINT WINAPI WorkThread(LPVOID lpParam);
/****************************************************
* Name : AideThread()
* Desc : 後台輔助線程
****************************************************/
static UINT WINAPI AideThread(LPVOID lpParam);
};
下面將對相關實現細節作詳細介紹。
也許您已經注意到本類只提供了用戶端socket關閉的介面, 而沒有提供用戶端串連到伺服器的相關介面;這樣做的主要原因是因為當一個用戶端串連成功需要在完成連接埠的I/O線程中進行通知, 若使用者在該介面中進行複雜的運算操作將會使I/O背景工作執行緒阻塞.所以此處沒有提供串連成功的通知介面, 其實使用者可以根據用戶端發來的特定資料包(例如登陸資料包)確定使用者是否串連到本伺服器。
當有用戶端串連伺服器投遞的accept操作就會完成, m_ListenEvents[1] 事件對象就會授信這時ListenThread線程將被喚醒並投遞一個accept操作. 若有大量的用戶端串連到本伺服器而沒有足夠的accept接受串連此時m_ListenEvents[0]事件就會受信此時ListenThread線程會再次投遞MAX_ACCEPT個accept操作已接受更多的串連。
ListenThread線程主要用來投遞aeecptex操作, 當m_ListenEvents[0]或者m_ListenEvents[1]受信時就會投遞一定量的AcceptEx操作以接受更多的用戶端串連。
WorkThread 線程工作在完成連接埠上, 當相關的操作完成時該線程組負責從完成連接埠隊列上取得相應的完成封包進行處理. AideThread線程主要用於維護串連本伺服器的socket隊列, 如果用戶端串連到伺服器但長時間沒有進行發送資料便斷開該用戶端, 防止用戶端惡意串連.當有用戶端中斷連線時也在該線程中調用關閉介面通知使用者。
由於代碼量太大無法全部詳解, 如有需要歡迎郵件聯絡我: siqiang0312@163.com, QQ: 24633959, MSN: beifangying@hotmail.com
本伺服器的測試程式在WindowsXP的32位平台下測試時可以同時接受20K個用戶端同時串連到伺服器, CPU只佔10%, 記憶體佔20M左右。