一、 概述
学习完网络基础,在写C/S应用程序时,大多童靴写服务器基本都没有用到io模型,基本都是采用“accept同步拥塞通讯和多线程方式”与客户端通讯。但当有成千上万客户端请求连接并与服务器通讯时,多线程的创建与CPU上下文的切换,服务器端压力可想而知,在资源有限的情况在,选择一个好的io模型才能搭建高性能服务器。其中IOCP广泛运用于个高性能服务器程序,apache服务器就是IOCP实现。
同步通讯和异步通信
在写网络程序时,我们知道CPU运行速度非常快,而在与IO设备进行数据交换时速度简直不忍直视。在程序请求一个网络操作,如accept,recv等时,若应用程序一直拥塞等待这些网络操作结束返回结果后才接着执行后面的代码,则这个过程就是同步通讯方式,反之,在提交网络操作请求后,由系统去执行该请求,程序继续往下走(该干嘛干嘛),待网络请求操作完成后,系统在通知程序来处理结果,则就是异步通信方式。
所以异步通讯比同步通讯(accept+多线程)方式高效的多,IOCP通讯模型是比较好的网络通讯模型。一起来学习吧!
二、IOCP执行流程
三、重要的数据结构
1.单句柄数据:该结构体用于管理具体哪个socket在进行io请求操作。
typedef struct PER_HANDLE_DATA{
SOCKET socket;
SOCKADDR_IN addr;
}*pPER_HANDLE_DATA,PER_HANDLE_DATA;
2.单IO数据:该结构体用于每一次客户端socket向IOCP提交请求时提交给系统,在其结构内可定义任意参数(overlapped必须在第一个),当请求完成后,IOCP**“原封不动”**的返回到工作线程,但给相应参数进行了赋值,如用于接收数据的buffer数组。
typedef struct PER_IO_DATA{
OVERLAPPED overlapped; //类似id,每个io都必须有一个
SOCKET socket; //io请求的套接字
WSABUF wsabuf; //用于从缓冲区获取数据的结构
char buffer[LENGTH]; //保存获得的数据
OPT_TYPE opt_type; //这次io请求的类型,如ACCEPT,RECV,SEND等
}*pPER_IO_DATA,PER_IO_DATA;
解释一下WSABUF结构体,在ws2def.h中定义:
typedef struct _WSABUF{
ULONG len; /*the length of buffer*/
__field_bcount(len) CHAR FAR *buf; /* the pointer to the buffer */
};
四、IOCP详细实现
1、初始化listenSocket等相应库、创建完成端口、创建工作线程。
(1) 创建listenSocket
既然我们使用的是IOCP模型,那么创建的socket必须这样创建,用于异步请求:
SOCKET socket = WSASocket(AF_INET,SOCK_STREAM,0,NULL,0,WSA_FLAG_OVERLAPPED);
//socket只需要在服务端创建,客户端不需要
(2) 创建完成端口
对,就是这么简单一句代码就将我们完成端口创建完毕了,参数一个-1三个0,简单吧!不过,你懂得,操作系统为我们做了很多。。。
HANDLE completionPort = CreateIoCompletionPort(INVALID_HANDLE,NULL,0,0);
//最后一个0比较有讨论价值,它表示为了避免CPU上下文切换,创建的工作线程最理想的状态就是一个处理器一个线程。
(3)创建工作线程
完成端口之所以比较高效,能很好处理高并发访问的原因之一就是合理的创建处理请求连接、收发数据等工作线程。
//工作线程数 = CPU数 * 2 + 1
//上面不是说理想状态有多少个处理器创建多少工作线程么,但现实是创建两倍的线程比较高效
//因为万一有个线程被挂起了嘞,那不是有个处理器没有被充分的利用了么,得有个“备胎”啊,哈哈
//获得cpu数
SYSTEM_INFO si;
int processors = si.dwNumerOfProcessors;
//创建工作线程
HANDLE * workThreadH = new HANDLE[processors];
for(int number = 0 ; number < processors;number++){
workThreadH[number] = (HANDLE) __beginthreadex(NULL,0,workThread,传值,0,NULL);
}
2、将listenSocket与完成端口绑定,并投递第一个accept请求,这样完成端口就可以为我们处理请求了。
//结构原型
HANDLE WINAPI CreateIoCompletionPort(
__in HANDLE FileHandle, // 监听套接字句柄
__in_opt HANDLE ExistingCompletionPort, // 前面创建的完成端口
__in ULONG_PTR CompletionKey,
// 绑定自定义的结构体PRE_IO_DATA,传递到Worker线程中,相当于参数的传递
__in DWORD NumberOfConcurrentThreads // 置0
);
//绑定
//投递第一个acceptEx请求
acceptEx(...);
3、投递AcceptEx
完成端口使用的是AcceptEx接收客户端连接,而不是accept。但AcceptEx比较特别,是微软为重叠I/0机制提供的函数。在mswsock.dll中提供,所以可以通过mswsock.lib静态链接库获得AcceptEx。但是 但是 但是不推荐这样获取,而是应该用WSAIoctl来获取AcceptEx指针,因为没有获得指针的情况下就是用AcceptEx的开销很大,并且不适用所用平台。
//1、获取AcceptEx
LPFN_ACCEPTEX pAcceptEx; //AcceptEx指针
GUID GuidAcceptEx = WSAID_ACCEPTEX;
DWORD dwBytes = 0;
WSAIoctl(
socket,//只要有效的socket就可以
SIO_GET_EXTENSION_FUNCTION_POINTER,
&GuidAcceptEx,
sizeof(GuidAcceptEx),
&pAcceptEx,
sizeof(pAcceptEx),
&dwBytes,
NULL,
NULL
);
//2调用AcceptEx,投递请求
//这里向IOCP投递的AcceptEx请求,需要绑定刚才的PER_IO_DATA数据结构,当IOCP处理好请求返回后,我们将在work线程中获得这个已经赋值好了的结构体,并且最重要的是在那么多的返回请求中是通过overlapped来标记的,要不你怎么去找到你现在投递的结构体^_^。
bool AcceptEx(
SOCKET listenSocket,//监听socket
SOCKET clientSocket,//提前创建好的接收连接的socket,这是AcceptEx高效性的关键
PVOID lpOutputBuffer,//接收缓冲区,第一部分是client发来的第一组数据,第二是服务端地址,第三是客户端地址
DWORD dwReceiveDataLength,//lpOutputBuffer的长度,若不为0,则表示等待客户端第一组数组发过来后才返回,否则阻塞在这里,若为0,则表示不用等待,直接返回。
DWORD dwLocalAddressLength,//存放本地地址的大小
DWORD dwRemoteAddressLength,//存放远程地址的大小
LPVOID lpdwBytesReceived,//不用管
LPOVERLAPPED lpOverlapped//I/O重叠结构
);
4、投递WSARecv请求
//传递参数,调用就行
int WSARecv(
SOCKET socket, // 投递的套接字
LPWSABUF lpBuffers, // 接收缓冲区WSABUF结构构成的数组
DWORD dwBufferCount, // 数组中WSABUF结构的数量,设置为1
LPDWORD lpNumberOfBytesRecvd, // 返回函数调用所接收到的字节数
LPDWORD lpFlags, // 设置为0
LPWSAOVERLAPPED lpOverlapped, // Socket对应的重叠结构
NULL // 设置完成例程模式,这里设置为NULL
);
5、投递WSASend请求
//和WSARecv类似,不解释
int WSASend(
_In_ SOCKET socket,
_In_ LPWSABUF lpBuffers,
_In_ DWORD dwBufferCount,
_Out_ LPDWORD lpNumberOfBytesSent,
_In_ DWORD dwFlags,
_In_ LPWSAOVERLAPPED lpOverlapped,
_In_ LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
6、work线程工作机制
work线程主要监听完成端口的状态(GetQueuedCompletionStatus),若完成端口有返回请求,则处理,若没有则挂起。
函数原型如下:
BOOL WINAPI GetQueuedCompletionStatus(
__in HANDLE CompletionPort, // 建立的完成端口
__out LPDWORD lpNumberOfBytes, //返回的字节数
__out PULONG_PTR lpCompletionKey, // 与完成端口的时候绑定的那个socket对应的自定义结构体PER_HANDLE_DATA
__out LPOVERLAPPED *lpOverlapped, // 重叠结构
__in DWORD dwMilliseconds // 等待完成端口的超时时间,一般设置INFINITE
);
(1)监听GetQueuedCompletionStatus
pPER_HANDLE_DATA *pPerHandleData = NULL;
OVERLAPPED *pOverlapped = NULL;
DWORD dwBytesTransfered = 0;
BOOL bReturn = GetQueuedCompletionStatus(
pIOCPModel->m_hIOCompletionPort,
&dwBytesTransfered,
(LPDWORD)&pPerHandleData,
&pOverlapped,
INFINITE );
(2)获得PPER_IO_DATA
通过宏CONTAINING_RECORD获得,将lpOverlapped变量传到宏中,找到和结构体PER_IO_DATA中overlapped成员对应的数据,就是刚才投递PER_IO_DATA结构体,现在请求IO操作完成,返回给work线程的。
PPER_IO_DATA pPerIoData = CONTAINING_RECORD(lpOverlapped, PER_IO_DATA , overlapped);
(3)switch判断完成请求的类型,然后做相应的处理
大概逻辑代码如下:是不是太抽象了,具体代码看后面更新的整体工程代码
switch(pPerIoData.opt_type){
case OPT_ACCEPT:
//这里大概做两件是
//一是将连接的socket与IOCP绑定
//二是在接着投递下一个AcceptEx,这样AcceptEx接循环起来了,可以不断的监听。
break;
case OPT_RECV:
//处理接收到的来自客户端的数据,然后在投递WSARecv请求
//当然若还没有完全接收完客户端的一组数据,那么得在投递WASRecv请求,而不做任何数据处理。
//比如若客户端发送了1000个字节,但是现在dwBytesTransfered参数值为800,则说明还有200字节还在缓冲区中,得继续去缓冲区中取数据
break;
case OPT_SEND:
//这里主要是服务端向客户端发送数据返回后,处理各种情况
//1、若向客户端发送的数据全部发送完毕,那么释放PER_IO_DATA结构所占内存,服务器长时间运行,不可能让内存溢出崩溃,是不。
//2、若向客户端发送的数据部分发送完毕,则需要在投递WSAsend请求,将数据发送完毕
break;
}
7、关闭完成端口
work线程创建后一直在while循环中监听完成端口状态,要么处于挂起状态,要么处于处理数据状态,那么当要关闭服务器时,如何让work线程温文尔雅的退出嘞?我们知道让线程安全退出的最好方法是让线程自己退出,即return。
使用PostQueuedCompletionStatus通知线程退出:
BOOL WINAPI PostQueuedCompletionStatus(
__in HANDLE CompletionPort, //当初创建的完成端口
__in DWORD dwNumberOfBytesTransferred, //可做为通知线程退出的一个标示码,其对应于GetQueuedCompletionStatus中的参数lpNumberOfBytes,所以可做文章
__in ULONG_PTR dwCompletionKey, //PER_HANDLE_DATA结构体
__in_opt LPOVERLAPPED lpOverlapped
);
这是我对IOCP简单的理解,若有不对的地方,希望各位指正,相互交流,共同成长_