前言
传统socket编程中服务端一般为每一个客户端创建一个线程(一对一)。这样虽然可以使程序的结构简单明了并且方便对数据处理,但是这些都是建立在创建多个线程的基础上,也就是以牺牲线程为代价。一旦有大量数量了客户端连接服务端,我们的服务端需要创建很多线程,这样会造成很大的系统开销这显然是不能被我们所接受的。那么为了解决这个问题就必须采用一种方法令有限的线程去处理所有的客户端连接,利用windows的IOCP完成端口配合线程池就可以帮助我们完成这个操作。
IOCP实现高并发整体思路
IOCP实现高并发原理
我们先通过CreateIoCompletionPort()函数创建一个IOCP完成端口对象,然后每次有客户端连接时在调用CreateIoCompletionPort()函数将请求连接的客户端与IOCP完成端口对象绑定,并且设置完成键为客户端的socket。实际IOCP完成端口对象内部维护者一张设备列表,此列表记录着各个设备的句柄和对应的完成键,对于我们来说就是各个客户端的句柄和各个客户端的完成键(socket)。
然后我们会利用异步I/O函数WASRecv()接收客户端的数据包,当设备驱动程序将异步I/O完成后会这个完成的I/O请求追加到IOCP完成队列中。IOCP完成队列每一项都包含了已完成异步I/O的详细信息,如已传输字节数,完成键值,指向此次I/O的Overlapped结构的地址,错误码。线程池中等待队列中的线程会从IOCP完成队列中取出一项并将此项删除。
现在来看一下线程池是如何工作的,线程池中的线程都使用同一线程函数。首先线程池有三个队列:等待线程队列,已释放线程队列,已暂停线程队列。
我们一开始调用GetQueuedCompletionStatus时会让调用线程进入等待线程队列,当等待线程队列中的线程的GetQueuedCompletionStatus返回时会从IOCP队列中取出一项并将其从IOCP队列中删除,接着其线程就会从等待队列转移到已释放队列中。然后处理完之后为了继续接收客户端的数据包需要再次调用一下异步函数WASRecv(),这时线程会从已释放队列转移到已暂定队列中,这样做IOCP完成端口对象会发现此线程在已暂停队列中,所以会使IOCP完成队列中的其他项利用线程池中等待队列中的其他线程处理,而不会继续使用此线程。接着当此线程WASRecv()函数调用后其又会从已暂停队列回到已释放队列中,然后循环重新开始继续调用GetQueuedCompletionStatus,此线程又从已释放队列中移动到等待队列中。
相关函数
-
WSARecv/WSASend
WSARecv/WSASend与函数recv/send函数相对应,前者为异步函数,后者为同步函数。以WSARecv( )函数为例,此函数会产生一个异步I/O请求,然后立刻返回而此时并未真正收到数据包,等服务端接收到来自客户端的数据包异步I/O完成时会向IOCP对象的完成队列中添加一项,接着IOCP对象会从线程池中等待队列的线程中选择一个线程来进一步处理。 -
CreateIoCompletionPort(HANDLE FileHandle, HANDLE ExistingCompletionPort, ULONG_PTR CompletionKey, DWORD NumberOfConcurrentThreads);
此函数为IOCP对象的创建或绑定函数,一开始我们需要借助此函数创建一个IOCP完成端口内核对象,然后每当一个客户端与服务端连接时我们都要将IOCP对象与此客户端的socket绑定。在客户端绑定IOCP完成端口对象时注意第三个参数CompletionKey,此参数为完成键。因为我们客户端的数据包最后都到数量受限的线程池中处理,为了区分是哪个客户端的数据包需要为每个不同的客户端指定不同的完成键,从而标志不同的客户端(通常用客户端的套接字作为完成键)。 -
GetQueuedCompletionStatus()
哪个线程调用GetQueuedCompletionStatus()函数,其就会被IOCP完成对象认为是线程池中的一个线程。此函数从IOCP完成队列中取出一项,如果最后一个参数指定为INFINITE,则只有当异步I/O完成时也就是IOCP完成队列中有非空项时其会返回,否则一直等待。 -
GetQueuedCompletionStatusEx()
此函数可以从IOCP完成队列中取出所有的项,这样我们可以避免开启多个线程并调用GetQueuedCompletionStatus()等待增加系统开销。 -
PostQueuedCompletionStatus()
此函数可以向线程池中每一个工作线程都发送—个特殊的完成数据包,在退出程序的时候可以通过此函数来向线程池中的每一个线程发送一个特定的数据包使其线程安全退出
在利用IOCP完成端口对象时遇到的一些问题
- 因为WSARecv()在异步接受数据时会指定接受数据的内存,为了通过重叠结构传递这块内存,需要new[]出来这块内存放到堆中,所以记得delete[]。
因为重叠结构是IOCP完成对象与线程池中线程交互进一步传递来自客户端的数据的,所以这块内存也需要放到堆中new出来最后也要delete。
DWORD dwSize;
DWORD dwFlag = 0;
WSABUF stBuffer;
stBuffer.buf = new char[0x1000]();
stBuffer.len = 0x1000;
MYOVERLAPPED* lpMyOVERLAPPED = new MYOVERLAPPED;
memset(lpMyOVERLAPPED, 0, sizeof(MYOVERLAPPED));
lpMyOVERLAPPED->nTypr = TYPE_RECV; //表示发送一个收包请求任务
lpMyOVERLAPPED->pBuf = (BYTE *)stBuffer.buf; //使接收到的数据通过重叠结构传递
//异步接受消息(向任务队列投递一个接受请求)
WSARecv(
h,
&stBuffer, //缓冲区结构
1, //缓冲区数组数量
&dwSize,
&dwFlag,
&(lpMyOVERLAPPED->ol), //标准重叠结构
NULL);
- 因为我们客户端发送消息的形式一般是先发送包头,在发送包尾。所以 GetQueuedCompletionStatus()在从任务队列中取数据也是先获得包头数据再获得包尾数据。然后将其拼接成完整的包后处理。
- 我们在处理完一个包后需要往任务队列中再发送接收任务,等待下一次客户端的数据到达。这时我们需要在调用WSARecv()异步接受数据时需要重新指定新的内存给IOCP存放接受到的数据使用。所以需要重新new,至于new的大小取决于我们是接下来是接收包头还是接收包尾。接收包头就是new 包头大小,包尾就是new对应的包尾大小。(一个包分两次接收)。
注意不要采用每次new固定的大小内存来接收,这样会使GetQueuedCompletionStatus()一次获取不完包中数据从而进行多次调用,而在如果在获取包的最后的数据不足new的大小的话,其会把下一个包的数据一起放进来给我们带来不必要的麻烦。
DWORD dwSize;
DWORD dwFlag = 0;
WSABUF stBuffer;
if (pClient->stWrap.dwLength == 0) //如果刚收完包尾,继续收下一个包的包头
{
stBuffer.buf = new char[8]();
stBuffer.len = 8;
}
else //如果刚收完包头,则收对应大小的包尾
{
stBuffer.buf = new char[pClient->stWrap.dwLength]();
stBuffer.len = pClient->stWrap.dwLength;
}
memset(pMyOVERLAPPED, 0, sizeof(MYOVERLAPPED));
pMyOVERLAPPED->nTypr = TYPE_RECV; //表示发送一个收包请求任务
pMyOVERLAPPED->pBuf = (BYTE*)stBuffer.buf; //使接收到的数据通过重叠结构传递
//异步接受消息(向任务队列投递一个接受请求)
WSARecv(pClient->hSocketClient, &stBuffer, 1, &dwSize, &dwFlag, &(pMyOVERLAPPED->ol), NULL);
参考:《windows核心编程》