WinSock 重疊IO模型

weixin_33912445發表於2018-06-29

之前介紹的WSAAsyncSelect和WSAEvent模型解決了收發資料的時機問題,但是網路卡這種裝置相比於CPU和記憶體來說仍然是慢速裝置,而呼叫send和recv進行資料收發操作仍然是同步的操作,即使我們能夠在恰當的時機呼叫對應的函式進行收發操作,但是仍然需要快速的CPU等待慢速的網路卡。這樣仍然存在等待的問題,這篇博文介紹的重疊IO模型將解決這個等待的問題

重疊IO簡介

一般接觸重疊IO最早是在讀寫磁碟時提出的一種非同步操作模型,它主要思想是CPU只管傳送讀寫的命令,而不用等待讀寫完成,CPU傳送命令後接著去執行自己後面的命令,至於具體的讀寫操作由硬體的DMA來控制,當讀寫完成時會向CPU傳送一個終端訊號,此時CPU中斷當前的工作轉而去進行IO完成的處理。
這是在磁碟操作中的一種高效工作的方式,為什麼在網路中又拿出來說呢?仔細想想,前面的模型解決了接收資料的時機問題,現在擺在面前的就是如何高效的讀寫資料,與磁碟操作做類比,當接收到WSAAsyncSelect對應的訊息或者WSAEvent返回時就是執行讀寫操作的時機,下面緊接著就是呼叫對應的讀寫函式來進行讀寫資料了,而聯想到linux中的一切皆檔案的思想,我們是不是可以認為操作網路卡也是在操作檔案?這也是在WinSock1中,使用WriteFile和ReadFile來進行網路資料讀寫的原因。既然它本質上也是CPU需要等待慢速的裝置,那麼為了效率它必定可以支援非同步操作,也就可以使用重疊IO。

建立重疊IO的socket

要想使用重疊IO,就不能在像之前那樣使用socket函式來建立SOCKET, 這函式最多隻能建立一個普通SOCKET然後設定它為非阻塞(請注意非阻塞與非同步的區別)。要建立非同步的SOCKET需要使用WinSock2.0函式 WSASocket

SOCKET WSASocket(
    int af,
    int type,
    int protocol,
    LPWSAPROTOCOL_INFO lpProtocolInfo,
    GROUP g,
    DWORD dwFlags
);

該函式的前3個引數與socket的引數含義相同,第4個引數是一個協議的具體資訊,配合WSAEnumProtocols 使用可以將列舉出來的網路協議資訊傳入,這樣不通過前三個引數就可以建立一個針對具體協議的SOCKET。第5個引數目前不受支援簡單的傳入0即可。第6個引數是一個標誌,如果要建立重疊IO的SOCKET,需要將這個引數設定為WSA_FLAG_OVERLAPPED。否則普通的SOCKET直接傳入0即可

使用重疊IO除了要將SOCKET設定為支援重疊IO外,還需要使用對應的支援重疊IO的函式,之前瞭解的巴克利套接字函式最多隻能算是支援非阻塞而不支援非同步。在WinSock1.0 中可以使用ReadFile和WriteFile來支援重疊IO,但是WinSock2.0 中重新設計的一套函式來支援重疊IO

  • WSASend (send的等價函式)
  • WSASendTo (sendto的等價函式)
  • WSARecv (recv的等價函式)
  • WSARecvFrom (recvfrom的等價函式)
  • WSAIoctl (ioctlsocket的等價函式)
  • WSARecvMsg (recv OOB版的等價函式)
  • AcceptEx (accept 等價函式)
  • ConnectEx (connect 等價函式)
  • TransmitFile (專門用於高效傳送檔案的擴充套件API)
  • TransmitPackets (專門用於高效傳送大規模資料包的擴充套件API)
  • DisconnectEx (擴充套件的斷開連線的Winsock API)
  • WSANSPIoctl (用於操作名字空間的重疊I/O版擴充套件控制API)

那麼如果使用上述函式但是傳入一個非阻塞的SOCKET會怎麼樣呢,這些函式只看是否傳入OVERLAPPED結構而不管SOCKET是否是阻塞的,一律按重疊IO的方式來執行。這也就是說,要使用重疊I/O方式來操作SOCKET,那麼不一定非要一開初就建立一個重疊I/O方式的SOCKET物件(但是針對AcceptEx 來說如果傳入的是普通的SOCKET,它會以阻塞的方式執行。當時測試時我傳入的是使用WSASocket建立的SOCKET,我將函式的最後一個標誌設定為0,發現AcceptEx只有當客戶端連線時才會返回)

重疊IO的通知模型

與檔案的重疊IO類似,重疊IO的第一種模型就是事件通知模型.

  1. 利用該模型首先需要把一個event物件繫結到OVERLAPPED(WinSokc中一般是WSAOVERLAPPED)上,然後利用這個OVERLAPPED結構來進行IO操作.如:WSASend/WSARecv等
  2. 判斷對應IO操作的返回值,如果使用重疊IO模式,IO操作函式不會返回成功,而是會返回失敗,使用WSAGetLastError得到的錯誤碼為WSA_IO_PENDING,此時認為函式進行一種待決狀態,也就是CPU將命令傳送出去了,而任務沒有最終完成
  3. 然後CPU可以去做接下來的工作,而在需要操作結果的地方呼叫對應的等待函式來等待對應的事件物件。如果事件物件為有訊號表示操作完成
  4. 接著可以設定事件物件為無訊號,然後繼續投遞IO操作.

要等待這些事件控制程式碼,可以呼叫WSAWaitForMultipleEvents函式,該函式原型如下:

DWORD WSAWaitForMultipleEvents(
  __in  DWORD cEvents,
  __in  const WSAEVENT* lphEvents,
  __in  BOOL fWaitAll,
  __in  DWORD dwTimeout,
  __in  BOOL fAlertable
);

第一個引數是事件物件的數目;第二個引數是事件物件的陣列首地址;第三個引數是一個bool型別表示是否等待陣列中所有的物件都變為有訊號;第四個參數列示超時值;第五個引數是表示在等待的時候是否進入可警告狀態

在函式返回後我們只知道IO操作完成了,但是完成的結果是成功還是失敗是不知道的,此時可以使用WSAGetOverlappedResult來確定IO操作執行的結果,該函式原型如下:

BOOL WSAGetOverlappedResult(
  SOCKET s,
  LPWSAOVERLAPPED lpOverlapped,
  LPDWORD lpcbTransfer,
  BOOL fWait,
  LPDWORD lpdwFlags
);

第一個引數是對應的socket;第二個引數是對應的OVERLAPPED結構;第三個引數是一個輸出引數,表示完成IO操作的位元組數,通常出錯的時候返回0;第四個引數指明呼叫者是否等待一個重疊I/O操作完成,通常在成功等待到事件控制程式碼後,這個引數在這個模型中沒有意義了;第五個引數是一個輸出引數負責接收完成結果的標誌。
下面是一個事件通知模型的例子

typedef struct _tag_CLIENTCONTENT
{
    OVERLAPPED Overlapped;
    SOCKET sClient;
    WSABUF DataBuf;
    char szBuf[WSA_BUFFER_LENGHT];
    WSAEVENT hEvent;
}CLIENTCONTENT, *LPCLIENTCONTENT;

int _tmain(int argc, TCHAR *argv[])
{
    WSADATA wd = {0};
    WSAStartup(MAKEWORD(2, 2), &wd);
    CLIENTCONTENT ClientContent[WSA_MAXIMUM_WAIT_EVENTS] = {0};
    WSAEVENT Event[WSA_MAXIMUM_WAIT_EVENTS] = {0};

    int nTotal = 0;
    SOCKET skServer = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_IP, NULL, 0, WSA_FLAG_OVERLAPPED);

    SOCKADDR_IN ServerAddr = {0};
    ServerAddr.sin_family = AF_INET;
    ServerAddr.sin_port = htons(SERVER_PORT);
    ServerAddr.sin_addr.s_addr = htonl(INADDR_ANY);

    bind(skServer, (SOCKADDR*)&ServerAddr, sizeof(SOCKADDR));
    listen(skServer, 5);
    printf("開始監聽...........\n");
    Event[nTotal] = WSACreateEvent();
    ClientContent[nTotal].hEvent = Event[nTotal];
    ClientContent[nTotal].Overlapped.hEvent = Event[nTotal];
    ClientContent[nTotal].DataBuf.len = WSA_BUFFER_LENGHT;
    ClientContent[nTotal].sClient = skServer;

    //針對監聽套接字做特殊的處理
    WSAEventSelect(skServer, Event[0], FD_ACCEPT | FD_CLOSE);
    nTotal++;

    while (TRUE)
    {
        DWORD dwTransfer = 0;
        DWORD dwFlags = 0;
        DWORD dwNumberOfBytesRecv = 0;
        int nIndex = WSAWaitForMultipleEvents(nTotal, Event, FALSE, WSA_INFINITE, FALSE);
        WSAResetEvent(Event[nIndex - WSA_WAIT_EVENT_0]);
        //監聽socket返回
        if (nIndex - WSA_WAIT_EVENT_0 == 0)
        {
            SOCKADDR_IN ClientAddr = {AF_INET};
            int nClientAddrSize = sizeof(SOCKADDR);
            SOCKET skClient = WSAAccept(skServer, (SOCKADDR*)&ClientAddr, &nClientAddrSize, NULL, NULL);
            if (SOCKET_ERROR == skClient)
            {
                printf("接受客戶端連線請求失敗,錯誤碼為:%08x\n", WSAGetLastError());
                continue;
            }
            printf("有客戶端連線進來[%s:%u]\n", inet_ntoa(ClientAddr.sin_addr), ntohs(ClientAddr.sin_port));

            Event[nTotal] = WSACreateEvent();
            ClientContent[nTotal].hEvent = Event[nTotal];
            ClientContent[nTotal].Overlapped.hEvent = Event[nTotal];
            ClientContent[nTotal].DataBuf.len = WSA_BUFFER_LENGHT;
            ClientContent[nTotal].DataBuf.buf = ClientContent[nTotal].szBuf;
            ClientContent[nTotal].sClient = skClient;

            //獲取客戶端傳送資料,這是為了觸發後面的等待
            WSARecv(ClientContent[nTotal].sClient, &ClientContent[nTotal].DataBuf, 1, &dwNumberOfBytesRecv, &dwFlags, &ClientContent[nTotal].Overlapped, NULL);
            nTotal++;
            continue;

        }else
        {
            //等待傳送完成
            WSAGetOverlappedResult(ClientContent[nIndex - WSA_WAIT_EVENT_0].sClient, &ClientContent[nIndex - WSA_WAIT_EVENT_0].Overlapped, &dwTransfer, TRUE, &dwFlags);
            if (dwTransfer == 0)
            {
                printf("接受資料失敗:%08x\n", WSAGetLastError());
                closesocket(ClientContent[nIndex - WSA_WAIT_EVENT_0].sClient);
                WSACloseEvent(ClientContent[nIndex - WSA_WAIT_EVENT_0].hEvent);

                for (int i = nIndex - WSA_WAIT_EVENT_0; i < nTotal; i++)
                {
                    ClientContent[i] = ClientContent[i];
                    Event[i] = Event[i];
                    nTotal--;
                }
            }

            if (strcmp("exit", ClientContent[nIndex - WSA_WAIT_EVENT_0].DataBuf.buf) == 0)
            {
                closesocket(ClientContent[nIndex - WSA_WAIT_EVENT_0].sClient);
                WSACloseEvent(ClientContent[nIndex - WSA_WAIT_EVENT_0].hEvent);

                for (int i = nIndex - WSA_WAIT_EVENT_0; i < nTotal; i++)
                {
                    ClientContent[i] = ClientContent[i];
                    Event[i] = Event[i];
                    nTotal--;
                }

                continue;
            }

            send(ClientContent[nIndex - WSA_WAIT_EVENT_0].sClient, ClientContent[nIndex - WSA_WAIT_EVENT_0].DataBuf.buf, dwTransfer, 0);
            WSARecv(ClientContent[nIndex - WSA_WAIT_EVENT_0].sClient, &ClientContent[nIndex - WSA_WAIT_EVENT_0].DataBuf, 1, &dwNumberOfBytesRecv, &dwFlags, &ClientContent[nIndex - WSA_WAIT_EVENT_0].Overlapped, NULL);
        }
    }

    WSACleanup();
    return 0;
}

上述程式碼中定義了一個結構,方便我們根據事件物件獲取一些重要資訊。
在main函式中首先完成了WinSock環境的初始化然後建立監聽套接字,繫結,監聽。然後定義一個事件物件讓他與對應的WSAOVERLAPPED繫結,然後WSAEventSelect來投遞監聽SOCKET以便獲取到客戶端的連線請求(這裡沒有使用AcceptEx,因為它需要特殊的載入方式)
接著在迴圈中首先呼叫WSAWaitForMultipleEvents等待所有訊號,當函式返回時判斷當前是否為監聽套接字,如果是那麼呼叫WSAAccept函式接收連線,並準備對應的事件和WSAOVERLAPPED結構,接著呼叫WSARecv接收客戶端傳入資料
如果不是監聽套接字則表明客戶端傳送資料過來,此時呼叫WSAGetOverlappedResult獲取重疊IO執行的結果,如果成功則判斷是否為exit,如果是exit關閉當前與客戶端的連結,否則呼叫send函式原樣返回資料接著呼叫WSARecv再次等待客戶端傳送資料。

完成過程模型

對於重疊I/O模型來說,前面的事件通知模型在資源的消耗上有時是驚人的。這主要是因為對於每個重疊I/O操作(WSASend/WSARecv等)來說,都必須額外建立一個Event物件。對於一個I/O密集型SOCKET應用來說,這種消耗會造成資源的嚴重浪費。由於Event物件是一個核心物件,它在應用層表現為一個4位元組的控制程式碼值,但是在核心中它對應的是一個具體的結構,而且所有的程式共享同一塊核心的記憶體,因此某幾個程式建立大量的核心物件的話,會影響整個系統的效能。

為此重疊I/O又提供了一種稱之為完成過程方式的模型。該模型不需要像前面那樣提供對應的事件控制程式碼。它需要為每個I/O操作提供一個完成之後回撥處理的函式。

完成歷程的本質是一個歷程它仍然是使用當前執行緒的環境。它主要向系統註冊一些完成函式,當對應的IO操作完成時,系統會將函式放入到執行緒的APC佇列,當執行緒陷入可警告狀態時,它利用執行緒的環境來依次執行佇列中的APC函式、
要使用重疊I/O完成過程模型,那麼也需要為每個I/O操作提供WSAOVERLAPPED結構體,只是此時不需要Event物件了。取而代之的是提供一個完成過程的函式

完成歷程的原型如下:

void CALLBACK CompletionROUTINE(DWORD dwError, DWORD cbTransferred,LPWSAOVERLAPPED lpOverlapped, DWORD dwFlags);

要使對應的完成函式能夠執行需要在恰當的時機讓對應執行緒進入可警告狀態,一般的方式是呼叫SleepEx函式,還有就是呼叫Wait家族的相關Ex函式,但是如果使用Wait函式就需要使用一個核心物件進行等待,如果使用Event物件這樣就與之前的事件通知模式有相同的資源消耗大的問問題了。此時我們可以考慮使用執行緒的控制程式碼來進行等待,但是等待執行緒控制程式碼時必須設定一個超時值而不能直接使用INFINIT了,因為等待執行緒就是要等到執行緒結束,而如果使用INFINIT,這樣Wait函式永遠不會返回,執行緒永遠不會結束,此時就造成了死鎖。

下面是一個使用完成過程的模型

typedef struct _tag_OVERLAPPED_COMPILE
{
    WSAOVERLAPPED overlapped;
    LONG lNetworks;
    SOCKET sClient;
    WSABUF pszBuf;
    DWORD dwTransfer;
    DWORD dwFlags;
    DWORD dwNumberOfBytesRecv;
    DWORD dwNumberOfBytesSend;
}OVERLAPPED_COMPILE, *LPOVERLAPPED_COMPILE;

void CALLBACK CompletionROUTINE(DWORD dwError, DWORD cbTransferred, LPWSAOVERLAPPED lpOverlapped, DWORD dwFlags);

int _tmain(int argc, TCHAR *argv[])
{
    WSADATA wd = {0};
    WSAStartup(MAKEWORD(2, 2), &wd);

    SOCKET skServer = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_IP, NULL, 0, WSA_FLAG_OVERLAPPED);
    SOCKADDR_IN ServerClient = {0};
    ServerClient.sin_family = AF_INET;
    ServerClient.sin_port = htons(SERVER_PORT);
    ServerClient.sin_addr.s_addr = htonl(INADDR_ANY);

    bind(skServer, (SOCKADDR*)&ServerClient, sizeof(SOCKADDR));

    listen(skServer, 0);

    while (TRUE)
    {
        SOCKADDR_IN AddrClient = {0};
        int AddrSize = sizeof(SOCKADDR);
        SOCKET skClient = WSAAccept(skServer, (SOCKADDR*)&AddrClient, &AddrSize, NULL, NULL);
        printf("有客戶端[%s:%u]連線進來....\n", inet_ntoa(AddrClient.sin_addr), ntohs(AddrClient.sin_port));

        LPOVERLAPPED_COMPILE lpOc = new OVERLAPPED_COMPILE;
        ZeroMemory(lpOc, sizeof(OVERLAPPED_COMPILE));
        lpOc->dwFlags = 0;
        lpOc->dwTransfer = 0;
        lpOc->lNetworks = FD_READ;
        lpOc->pszBuf.buf = new char[1024];
        ZeroMemory(lpOc->pszBuf.buf, 1024);
        lpOc->pszBuf.len = 1024;
        lpOc->sClient = skClient;
        lpOc->dwNumberOfBytesRecv = 0;
        WSARecv(skClient, &(lpOc->pszBuf), 1, &(lpOc->dwNumberOfBytesRecv), &(lpOc->dwFlags), &(lpOc->overlapped), CompletionROUTINE);

        SleepEx(2000, TRUE);
    }
    WSACleanup();
    return 0;
}

void CALLBACK CompletionROUTINE(DWORD dwError, DWORD cbTransferred, LPWSAOVERLAPPED lpOverlapped, DWORD dwFlags)
{
    LPOVERLAPPED_COMPILE lpOc = (LPOVERLAPPED_COMPILE)lpOverlapped;
    if (0 != dwError || 0 == cbTransferred)
    {
        printf("與客戶端通訊發生錯誤,錯誤碼為:%08x\n", WSAGetLastError());
        closesocket(lpOc->sClient);
        delete[] lpOc->pszBuf.buf;
        delete lpOc;
        return;
    }

    if (lpOc->lNetworks == FD_READ)
    {
        if (0 == strcmp(lpOc->pszBuf.buf, "exit"))
        {
            closesocket(lpOc->sClient);
            delete[] lpOc->pszBuf.buf;
            delete lpOc;
            return;
        }

        send(lpOc->sClient, lpOc->pszBuf.buf, cbTransferred, 0);
    lpOc->dwNumberOfBytesRecv = 0;
    ZeroMemory(lpOc->pszBuf.buf, 1024);
    lpOc->dwFlags = 0;
        lpOc->dwTransfer = 0;
        lpOc->lNetworks = FD_READ;
    WSARecv(lpOc->sClient, &(lpOc->pszBuf), 1, &(lpOc->dwNumberOfBytesRecv), &(lpOc->dwFlags), &(lpOc->overlapped), CompletionROUTINE);
    }
}

主函式的寫法與之前的例子中的寫法類似。也是先初始化環境,繫結,監聽等等。在迴圈中接收連線,當有新客戶端連線進來時建立對應的客戶端結構,然後呼叫WSARecv函式接收資料,接下來就是使用SleepEx進入可警告狀態,以便讓完成歷程有機會執行。
在完成歷程中就不需要像之前那樣呼叫WSAGetOverlappedResult了,因為呼叫完成歷程就一定意味著重疊IO操作已經完成了。在完成歷程中根據第一個引數來判斷IO操作執行是否成功。如果失敗則會直接斷開與客戶端的連線然後清理對應的結構。如果成功則直接獲取獲取IO操作得到的資料,如果是exit則需要關閉連線,否則原樣返回並準備下一次接收資料

相關文章