Windows网络异步IO模型
话不多说,直接进入主题。
编写一个TCP\UDP网络服务器,自然就想到最简单的api,通过socket、bind、listen、accept、send、recv可以搭建一个阻塞的服务器。
一分钱一分货,简单的搭建,必然带来简陋的性能,或者说,可以通过一些多线程、事件通知等工具,亲手打造一个更优的服务器,但毕竟这些工作,已经有现成的模型,何必呢。
现成的模型分为以下几类:
- SELECT
- WSAAsynsSelect,基于windows窗体的异步IO
- WSAEventSelect,基于windows事件的异步IO
- 重叠IO
其中重叠IO,也就是Overlapped IO,又分为下面三种:
- 基于事件的重叠IO
- 完成例程
- 完成端口
以上提到的模型,复杂程度递增,当然,性能也是递增的。
下面一个个的讲出重点,本文是为了记录每个网络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; }
重点部分已经结构,而后就是些优雅退出的过程了,这里先不赘述了。