Windows网络异步IO模型

话不多说,直接进入主题。

编写一个TCP\UDP网络服务器,自然就想到最简单的api,通过socket、bind、listen、accept、send、recv可以搭建一个阻塞的服务器。

一分钱一分货,简单的搭建,必然带来简陋的性能,或者说,可以通过一些多线程、事件通知等工具,亲手打造一个更优的服务器,但毕竟这些工作,已经有现成的模型,何必呢。

现成的模型分为以下几类:

  1. SELECT
  2. WSAAsynsSelect,基于windows窗体的异步IO
  3. WSAEventSelect,基于windows事件的异步IO
  4. 重叠IO

其中重叠IO,也就是Overlapped IO,又分为下面三种:

  1. 基于事件的重叠IO
  2. 完成例程
  3. 完成端口

以上提到的模型,复杂程度递增,当然,性能也是递增的。

下面一个个的讲出重点,本文是为了记录每个网络IO模型的关键点,省略了细节。必须提一句,所提到的模型,可以应用在客户端、服务端。

一、Select模型

关键在于FD_SET这个结构体,结构体如下所示,说白了,就是一个列表,保存着我们感兴趣的套接字,

typedef struct fd_set {
        u_int fd_count;               /* how many are SET? */
        SOCKET  fd_array[FD_SETSIZE];   /* an array of SOCKETs */
} fd_set;

同样关键的,当然是select这个api了,函数如下:

select(
    _In_ int nfds,//为了兼容伯克利api,方便程序移植,无用
    _Inout_opt_ fd_set FAR * readfds,//需要关注“读”的套接字
    _Inout_opt_ fd_set FAR * writefds,//需要关注“写”的套接字
    _Inout_opt_ fd_set FAR * exceptfds,//需要关注“异常”的套接字
    _In_opt_ const struct timeval FAR * timeout//超时设置结构体
    );

剩下就是一些宏定义,方便我们设置FD_SET,此处不展开,

FD_SET、FD_ZERO、FD_ISSET、FD_CLR

select模型思想的关键,其实所有的网络IO模型,都可以理解成事件触发,至于是通过何种形式触发,是通过回调函数、事件通知,whatever,无非是把我们关注的套接字,套到一个loop里面去,通过操作系统内核,告诉我们读和写的时机。

20190628 补充一个select demo

#include <WinSock2.h>  
#include <Windows.h>  
#include <MSWSock.h>  
#include <stdio.h>  
#include <map>  
using namespace std;


#pragma comment(lib,"Ws2_32.lib")  
#pragma comment(lib,"Mswsock.lib")  


int main()
{

    WSAData wsaData;
    if (0 != WSAStartup(MAKEWORD(2, 2), &wsaData))
    {
        printf("初始化失败!%d\n", WSAGetLastError());
        Sleep(5000);
        return -1;
    }

    USHORT nport = 9995;
    SOCKET sListen = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

    u_long ul = 1;
    ioctlsocket(sListen, FIONBIO, &ul);

    sockaddr_in sin;
    sin.sin_family = AF_INET;
    sin.sin_port = htons(nport);
    sin.sin_addr.S_un.S_addr = ADDR_ANY;


    if (SOCKET_ERROR == bind(sListen, (sockaddr*)&sin, sizeof(sin)))
    {
        printf("bind failed!%d\n", WSAGetLastError());
        Sleep(5000);
        return -1;
    }


    listen(sListen, 5);


    //1)初始化一个套接字集合fdSocket,并将监听套接字放入  
    fd_set socketSet;
    FD_ZERO(&socketSet);
    FD_SET(sListen, &socketSet);

    TIMEVAL time = { 1,0 };
    char buf[4096];


    fd_set    readSet;
    FD_ZERO(&readSet);

    fd_set    writeSet;
    FD_ZERO(&writeSet);


    while (true)
    {
        //2)将fdSocket的一个拷贝fdRead传给select函数  
        readSet = socketSet;
        writeSet = socketSet;

        //同时检查套接字的可读可写性。
        int   nRetAll = select(0, &readSet, &writeSet, NULL, NULL/*&time*/);//若不设置超时则select为阻塞  
        if (nRetAll >0)   //-1
        {
            //是否存在客户端的连接请求。  
            if (FD_ISSET(sListen, &readSet))//在readset中会返回已经调用过listen的套接字。  
            {

                if (socketSet.fd_count < FD_SETSIZE)
                {
                    sockaddr_in addrRemote;
                    int nAddrLen = sizeof(addrRemote);
                    SOCKET sClient = accept(sListen, (sockaddr*)&addrRemote, &nAddrLen);
                    if (sClient != INVALID_SOCKET)
                    {
                        FD_SET(sClient, &socketSet);//新的客户端socket添加到socketSet中
                        printf("\n接收到连接:(%s)", inet_ntoa(addrRemote.sin_addr));
                    }
                }
                else
                {
                    printf("连接数量已达上限!\n");
                    continue;
                }
            }

        //此处有个需要注意的地方,如果是刚添加到socketSet的客户端socket,下面检测FD_ISSET是无效的,原因是此时该客户端套接字还未经过select
        //所以得经历下次select循环才会触发读写
for (int i = 0; i<socketSet.fd_count; i++) { if (FD_ISSET(socketSet.fd_array[i], &readSet)) { //调用recv,接收数据。 int nRecv = recv(socketSet.fd_array[i], buf, 4096, 0); if (nRecv > 0) { buf[nRecv] = 0; printf("\nrecv %d : %s", socketSet.fd_array[i], buf); } } if (FD_ISSET(socketSet.fd_array[i], &writeSet)) { //调用send,发送数据。 char buf[] = "hello!"; int nRet = send(socketSet.fd_array[i], buf, strlen(buf) + 1, 0); if (nRet <= 0) { if (GetLastError() == WSAEWOULDBLOCK) { //do nothing } else { closesocket(socketSet.fd_array[i]); FD_CLR(socketSet.fd_array[i], &socketSet); } } else { printf("\nsend hello!"); } } } } else if (nRetAll == 0) { printf("time out!\n"); } else { printf("select error!%d\n", WSAGetLastError()); Sleep(5000); break; } Sleep(1000); } closesocket(sListen); WSACleanup(); }

 

二、WSAAsynsSelect


基于win窗体的异步IO模型,顾名思义,我们需要有一个窗体,还有一个关键api如下

WSAAsyncSelect(
    _In_ SOCKET s,
    _In_ HWND hWnd,
    _In_ u_int wMsg,
    _In_ long lEvent
    );

第一个参数当然是我们感兴趣的套接字,

第二个参数就是我们需要创建的窗体句柄,

第三个参数,是我们定义的WM_USER消息,用于在窗体响应函数中辨别消息

第四个就是我们感兴趣的套接字事件了,使用逻辑或,FD_ACCEPT|FD_CLOSE|FD_RECV,可以监听我们感兴趣的所有事件。

 

三、WSAEventSelect

 

*完成端口

完成端口实在看了很多次了,或许该承认自己没天才,需要更努力。

今天开始有些理解。

IOCP的关键点,我认为是在于两个自定义的结构体,一个是CreateIoCompletionPort接口需要传入的CompletionKey,另一个则是投递AcceptEx、WSARecv、WSASend等接口时,需要传入的OVERLAPPED结构体

CreateIoCompletionPort(
    _In_ HANDLE FileHandle,
    _In_opt_ HANDLE ExistingCompletionPort,
    _In_ ULONG_PTR CompletionKey,
    _In_ DWORD NumberOfConcurrentThreads
    );

WSARecv(
_In_ SOCKET s,
_In_reads_(dwBufferCount) __out_data_source(NETWORK) LPWSABUF lpBuffers,
_In_ DWORD dwBufferCount,
_Out_opt_ LPDWORD lpNumberOfBytesRecvd,
_Inout_ LPDWORD lpFlags,
_Inout_opt_ LPWSAOVERLAPPED lpOverlapped,
_In_opt_ LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);

WSASend(
    _In_ SOCKET s,
    _In_reads_(dwBufferCount) LPWSABUF lpBuffers,
    _In_ DWORD dwBufferCount,
    _Out_opt_ LPDWORD lpNumberOfBytesSent,
    _In_ DWORD dwFlags,
    _Inout_opt_ LPWSAOVERLAPPED lpOverlapped,
    _In_opt_ LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
    );

下面详细说说这两个自定义结构体的内容。

CompletionKey:一个套接字的上下文。在IOCP中,每个套接字,都需要与完成端口进行绑定,在绑定的时候,需要指定这个Key,实际上,这个Key,就是这个套接字的上下文,上下文存放何种数据,由我们自己定义,例如我们可以存放客户端的ip地址,客户端连入服务器的时间等等。我们在绑定IOCP的时候,传入什么内容的Key,在工作线程中,就会返回什么Key。可以说这个Key,是我们丢给操作系统,Key在系统黑盒子游走了一圈,在工作线程中,又原封不动的返回给了我们。个人感觉这个Key作用不大,如果难以一时间理解,甚至可以先不管。

OverLapped结构体:一个套接字对应每次IO的上下文(下面简称OL)。重点在“每次IO”,如果把Key想象成连接到我们构建服务器的一个客户,那么OL结构体就记录着这个客户每次对服务器的操作。

typedef struct _OVERLAPPED {
    ULONG_PTR Internal;
    ULONG_PTR InternalHigh;
    union {
        struct {
            DWORD Offset;
            DWORD OffsetHigh;
        } DUMMYSTRUCTNAME;
        PVOID Pointer;
    } DUMMYUNIONNAME;

    HANDLE  hEvent;
} OVERLAPPED, *LPOVERLAPPED;

OL结构体如下所示,在windows中已经定义了结构体了,所以上文提及的“自定义结构体”说法,似乎有些矛盾。其实不然,我们可以自定义一个结构体,只要把OL结构体置于我们定义的结构体首个位置,那么就可以通过宏CONTAINING_RECORD,来提取完整的自定义结构体数据内容,毕竟通过内存地址,就可以找到相应的数据了,这点不难理解。

说了这么多,或许有些抽象,直接上伪代码

//主线程

CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0 ); //建立完成端口 简单的传参,不多余解释 listenSocket = WSASocket(...) //通过WSASocket建立监听的套接字 PER_SOCKET_CONTEXT *listen_socket_context = new PER_SOCKET_CONTEXT(); CreateIoCompletionPort(listen_socket_context) //构建Key,将监听套接字绑定到完成端口 for(..) { prepare_socket = WSASocket(...) PER_IO_CONTEXT *per_io_context=new PER_IO_CONTEXT(); per_io_context->socket = prepare_socket AcceptEx(listenSocket,prepare_socket,per_io_context) //提前构建N个socket,还有对应的OL结构体,通过AcceptEx投递到完成端口中 } //主线程工作到此结束,其余交给工作线程 StartThread(WorkerThread);//启动工作线程,又名搬砖线程。想不到写个程序都能体会到打工阶级被剥削的命运。

下面是工作线程,想到工作线程这么辛苦,就想到同样是搬砖命运的自己

GetQueuedCompletionStatus(&per_socket_context,&ol);
//通过宏,提取整个PER_IO_CONTEXT,方便方便
PER_IO_CONTEXT* pIoContext = CONTAINING_RECORD(ol);  

switch(pIoContext->operation)
{
    case ACCEPT:
        GetAcceptExSockAddrs(ol);//提取accpet后,首次读取的数据
        //如果是ACCEPT操作,传参per_socket_context是监听套接字对应的Key,此处没有任何作用的
        PER_SOCKET_CONTEXT *client_socket_context = new PER_SOCKET_CONTEXT
        client_socket_context->socket = ol->socket;//将OL结构体的套接字赋予新建的Key
        CreateIoCompletionPort(client_socket_context)//新的客户端Key,绑定到完成端口上
        
        PER_IO_CONTEXT *client_io_context = new PER_IO_CONTEXT;//新的客户端,创建一个OL结构体,传参的ol不能用!!!等下需要完整无缺重新投递一个AcceptEx
        WSARecv(client_socket_context->socket,client_io_context)//客户端套接字,投递一个“读”
        
        ol->socket = WSASocket();//重新新建一个socket,准备下一个AcceptEx投递,原来的套接字,已经提供给client_socket_context使用了。
        AcceptEx(per_socket_context->socket,ol->socket)//不厌其烦说一下,第一个参数是监听的套接字,第二个是上一行新建的套接字,提供下一次使用
    break;

    case RECV:
        Print(ol->buf );//读取的数据,很自然很高效就出现在这里了,不用再WSARecv
        WSARecv(ol->socket,ol);//再次投递,很简单
    break;
}

重点部分已经结构,而后就是些优雅退出的过程了,这里先不赘述了。

 

posted @ 2019-06-03 18:10  仙7道  阅读(627)  评论(0编辑  收藏  举报