1. 程式人生 > >採用完成埠(IOCP)實現高效能網路伺服器(Windows c++版)

採用完成埠(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;
    }
}

 總結:開發一個好的封裝庫必須有的好的思路。對複雜問題要學會分解,每個模組功能合理,適應性要強;要有模組化、層次化處理思路。如果網路庫也處理業務邏輯,處理具體包協議,它就無法做到通用性。一個通用性好的庫,才值得我們花費大氣力去做好。我設計的這個庫,用在了公司多個系統上;以後無論遇到任何網路協議,這個庫都可以用得上,一勞永逸的解決網路庫封裝問題。