前言
TCP\IP已成為業界通訊標准。現在越來越多的程序需要聯網。網絡系統分為服務端和客戶端,也就是c\s模式(client \ server)。client一般有一個或少數幾個連接;server則需要處理大量連接。大部分情況下,只有服務端才特別考慮性能問題。本文主要介紹服務端處理方法,當然也可以用於客戶端。
我也發表過c#版網絡庫。其實,我最早是從事c++開發,多年前就實現了對完成端口的封裝。最近又把以前的代碼整理一下,做了測試,也和c#版網絡庫做了粗略對比。總體上,還是c++性能要好一些。c#網絡庫見文章《一個高性能異步socket封裝庫的實現思路》。
Windows平台下處理socket通訊有多種方式;大體可以分為阻塞模式和非阻塞模式。阻塞模式下send和recv都是阻塞的。簡單講一下這兩種模式處理思路。
阻塞模式:比如調用send時,把要發送的數據放到網絡發送緩沖區才返回。如果這時,網絡發送緩沖區滿了,則需要等待更久的時間。socket的收發其實也是一種IO,和讀寫硬盤數據有些類似。一般來講,IO處理速度總是慢的,不要和內存處理並列。對於調用recv,至少讀取一個字節數據,函數才會返回。所以對於recv,一般用一個單獨的線程處理。
非阻塞模式:send和recv都是非阻塞的;比如調用send,函數會立馬返回。真正的發送結果,需要等待操作系統的再次通知。阻塞模式下一步可以完成的處理,在非阻塞模式下需要兩步。就是多出的這一步,導致開發難度大大增加。高性能大並發網絡服務器必須采用非阻塞模式。完成端口(IOCP)是非阻塞模式中性能最好的一種。
作者多年以前,就開始從事winsocket開發,最開始是采用c++、后來采用c#。對高性能服務器設計的體會逐步加深。人要在一定的壓力下才能有所成就。最開始的一個項目是移動信令分析,所處理的消息量非常大;高峰期,每秒要處理30萬條信令,占用帶寬500M。無論是socket通訊還是后面的數據處理,都必須非常優化。所以從項目的開始,我就謹小慎微,對性能特別在意。項目實施后,程序的處理性能出乎意料。一台服務器可以輕松處理一個省的信令數據(項目是08年開始部署,現在的硬件性能遠超當時)。程序界面如下:
題外話 通過這個項目我也有些體會:1)不要懷疑Windows的性能,不要懷疑微軟的實力。有些人遇到性能問題,或是遇到奇怪的bug,總是把責任推給操作系統;這是不負責任的表現。應該反思自己的開發水平、設計思路。2)開發過程中,需要把業務吃透;業務是開發的基石。不了解業務,不可能開發出高性能的程序。所有的處理都有取舍,每個函數都有他的適應場合。有時候需要拿來主義,有時候需要從頭開發一個函數。
目標
開發出一個完善的IOCP程序是非常困難的。怎么才能化繁為簡?需要把IOCP封裝;同時這個封裝庫要有很好的適應性,能滿足各種應用場景。一個好的思路就能事半功倍。我就是圍繞這兩個目標展開設計。
1 程序開發接口
socket處理本質上可以分為:讀、寫、accept、socket關閉等事件。把這些事件分為兩類:a)讀、accept、socket關閉 b)寫;a類是從庫中獲取消息,b類是程序主動調用函數。對於a類消息可以調用如下函數:
//消息事件 enum Enum_MessageType :char { EN_Accept = 0, EN_Read, EN_Close, EN_Connect }; //返回的數據結構 class SocketMessage { public: SOCKET Socket; Enum_MessageType MessageType; //當MessageType為EN_Connect時,BufferLen為EasyIocpLib_Connect函數的tag參數 INT32 BufferLen; char *Buffer; }; //不停的調用此函數,返回數據 SocketMessage* EasyIocpLib_GetMessage(UINT64 handle);
對於b類,就是發送數據。當調用發送時,數據被放到庫的發送緩沖中,函數里面返回。接口如下:
enum EN_SEND_BUFFER_RESULT { en_send_buffer_ok = 0, //放入到發送緩沖 en_not_validate_socket, //無效的socket句柄 en_send_buffer_full //發送緩沖區滿 }; EN_SEND_BUFFER_RESULT EasyIocpLib_SendMessage(UINT64 handle, SOCKET socket, char* buffer, int offset, int len, BOOL mustSend = FALSE);
總的思路是接收時,放到接收緩沖;發送時,放到發送緩沖。外部接口只對內存中數據操作,沒有任何阻塞。
2)具有廣泛的適應性
如果網絡庫可以用到各種場景,所處理的邏輯必須與業務無關。所以本庫接收和發送的都是字節流。包協議一般有長度指示或有開始結束符。需要把字節流分成一個個完整的數據包。這就與業務邏輯有關了。所以要有分層處理思想:
庫性能測試
首先對庫的性能做測試,使大家對庫的性能有初步印象。這些測試都不是很嚴格,大體能反映程序的性能。IOCP是可擴展的,就是同時處理10個連接與同時處理1000個連接,性能上沒有差別。
我的機器配置不高,cup為酷睿2 雙核 E7500,相當於i3低端。
1)兩台機器測試,一個發送,一個接收:帶寬占用40M,整體cpu占用10%,程序占用cpu不超過3%。
2)單台機器,兩個程序互發:收發數據達到30M字節,相當於300M帶寬,cpu占用大概25%。
3)采用更高性能機器測試,兩個程序對發數據:cpu為:i5-7500 CPU @ 3.40GHz
收發數據總和80M字節每秒,接近1G帶寬。cpu占用25%。
測試程序下載地址 :《完成端口(IOCP)性能測試程序(c++版本 64位程序)》。只有exe程序,不包括代碼。
網絡庫設計思路
服務器要啟動監聽,當有客戶端連接時,生成新的socket句柄;該socket句柄與完成端口關聯,后續讀寫都通過完成端口完成。
1 socket監聽(Accept處理)
關於監聽處理,參考我另一篇文章《單線程實現同時監聽多個端口》。
2 數據接收
收發數據要用到類型OVERLAPPED。需要對該類型進一步擴充,這樣當從完成端口返回時,可以獲取具體的數據和操作類型。這是處理完成端口一個非常重要的技巧。
//完成端口操作類型 typedef enum { POST_READ_PKG, //讀 POST_SEND_PKG, //寫 POST_CONNECT_PKG, POST_CONNECT_RESULT }OPERATION_TYPE; struct PER_IO_OPERATION_DATA { WSAOVERLAPPED overlap; //第一個變量,必須是操作系統定義的結構 OPERATION_TYPE opType; SOCKET socket; WSABUF buf; //要讀取或發送的數據 };
發送處理:overlap包含要發送的數據。調用此函數會立馬返回;當有數據到達時,會有通知。
BOOL NetServer::PostRcvBuffer(SOCKET socket, PER_IO_OPERATION_DATA *overlap) { DWORD flags = MSG_PARTIAL; DWORD numToRecvd = 0; overlap->opType = OPERATION_TYPE::POST_READ_PKG; overlap->socket = socket; int ret = WSARecv(socket, &overlap->buf, 1, &numToRecvd, &flags, &(overlap->overlap), NULL); if (ret != 0) { if (WSAGetLastError() == WSA_IO_PENDING) { ret = NO_ERROR; } else { ret = SOCKET_ERROR; } } return (ret == NO_ERROR); }
從完成端口獲取讀數據事件通知:
DWORD NetServer::Deal_CompletionRoutine() { DWORD dwBytesTransferred; PER_IO_OPERATION_DATA *lpPerIOData = NULL; ULONG_PTR Key; BOOL rc; int error; while (m_bServerStart) { error = NO_ERROR; //從完成端口獲取事件 rc = GetQueuedCompletionStatus( m_hIocp, &dwBytesTransferred, &Key, (LPOVERLAPPED *)&lpPerIOData, INFINITE); if (rc == FALSE) { error = 123; if (lpPerIOData == NULL) { DWORD lastError = GetLastError(); if (lastError == WAIT_TIMEOUT) { continue; } else { //continue; //程序結束 assert(false); return lastError; } } else { if (GetNetResult(lpPerIOData, dwBytesTransferred) == FALSE) { error = WSAGetLastError(); } } } if (lpPerIOData != NULL) { switch (lpPerIOData->opType) { case POST_READ_PKG: //讀函數返回 { OnIocpReadOver(*lpPerIOData, dwBytesTransferred, error); } break; case POST_SEND_PKG: { OnIocpWriteOver(*lpPerIOData, dwBytesTransferred, error); } break; } } } return 0; } void NetServer::OnIocpReadOver(PER_IO_OPERATION_DATA& opData, DWORD nBytesTransfered, DWORD error) { if (error != NO_ERROR || nBytesTransfered == 0)//socket出錯 { Net_CloseSocket(opData.socket); NetPool::PutIocpData(&opData);//數據緩沖處理 } else { OnRcvBuffer(opData, nBytesTransfered);//處理接收到的數據 BOOL post = PostRcvBuffer(opData.socket, &opData); //再次讀數據 if (!post) { Net_CloseSocket(opData.socket); NetPool::PutIocpData(&opData); } } }
3 數據發送
數據發送時,先放到發送緩沖,再發送。向完成端口投遞時,每個連接同時只能有一個正在投遞的操作。
BOOL NetServer::PostSendBuffer(SOCKET socket) { if (m_clientManage.IsPostSendBuffer(socket)) //如果有正在執行的投遞,不能再次投遞 return FALSE; //獲取要發送的數據 PER_IO_OPERATION_DATA *overlap = NetPool::GetIocpData(FALSE); int sendCount = m_clientManage.GetSendBuf(socket, overlap->buf); if (sendCount == 0) { NetPool::PutIocpData(overlap); return FALSE; } overlap->socket = socket; overlap->opType = POST_SEND_PKG; BOOL post = PostSendBuffer(socket, overlap); if (!post) { Net_CloseSocket(socket); NetPool::PutIocpData(overlap); return FALSE; } else { m_clientManage.SetPostSendBuffer(socket, TRUE); return TRUE; } }
總結:開發一個好的封裝庫必須有的好的思路。對復雜問題要學會分解,每個模塊功能合理,適應性要強;要有模塊化、層次化處理思路。如果網絡庫也處理業務邏輯,處理具體包協議,它就無法做到通用性。一個通用性好的庫,才值得我們花費大氣力去做好。我設計的這個庫,用在了公司多個系統上;以后無論遇到任何網絡協議,這個庫都可以用得上,一勞永逸的解決網絡庫封裝問題。