采用完成端口(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:
    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;
    }
}

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM