採用完成埠(IOCP)實現高效能網路伺服器(Windows c++版)
前言
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: UINT64 Index; 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低端。
兩臺機器測試,一個傳送,一個接收:頻寬佔用40M,整體cpu佔用10%,程式佔用cpu不超過3%。
單臺機器,兩個程式互發:收發資料達到30M位元組,相當於300M頻寬,cpu佔用大概25%。
總結:考慮1G頻寬佔滿情況,cpu佔用大概80%。如果cpu為i5七代,估計cpu佔用30%。對這樣的效能,應該能滿足大部分場景。
網路庫設計思路
伺服器要啟動監聽,當有客戶端連線時,生成新的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; } }
總結:開發一個好的封裝庫必須有的好的思路。對複雜問題要學會分解,每個模組功能合理,適應性要強;要有模組化、層次化處理思路。如果網路庫也處理業務邏輯,處理具體包協議,它就無法做到通用性。一個通用性好的庫,才值得我們花費大氣力去做好。我設計的這個庫,用在了公司多個系統上;以後無論遇到任何網路協議,這個庫都可以用得上,一勞永逸的解決網路庫封裝問題。