I/O完成端口模型详解

简答粗暴——完成端口例子:http://download.csdn.net/download/fzuim/9968393

首先摘抄一段《Windows核心编程》I/O完成端口的一段话:Windows的设计目标是一个安全的、健壮的操作系统,能够运行各种各样的应用程序来为成千上万的用户服务。回顾历史,我们能够采用以下两种模型之一来架构一个服务应用程序。

串行模型:一个线程等待一个客户发出的请求。当请求到达的时候,线程会被唤醒并对客户请求进行处理。

并行模型:一个线程等待一个客户请求,并创建一个新的线程来处理请求。当新线程正在处理客户请求的时候,原来的线程会进入下一个循环并等待另一个客户请求。当处理客户请求的线程完成整个处理过程的时候,改线程就会终止。

说的通俗一点就是,并发模型每来一个客户我们就要创建一个线程处理,如果有500个客户请求到来,我们就得创建500个线程。

使用并发模型的服务器程序性能并不能比预期的高,许多线程同时并发执行,Windows内核在各可运行的线程之间进行上下文切换花费太多的时间,以至于各线程没有多少CPU时间来完成任务。Microsoft为了解决这个问题,创建了I/O完成端口内核对象。

创建一个I/O完成端口:

HANDLE
WINAPI
CreateIoCompletionPort(
    __in     HANDLE FileHandle,
    __in_opt HANDLE ExistingCompletionPort,
    __in     ULONG_PTR CompletionKey,
    __in     DWORD NumberOfConcurrentThreads
    );

	//!<创建唯一一个完成端口
	HANDLE hCompletionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, NULL, 0);

当我们要创建一个完成端口的时候,这个函数的前三个参数我们不需要用到,最后一个参数代表:I/O完成端口在同一实际最多有多少线程处于可运行状态,0默认代表:CPU数量的线程数。
那么问题出现了:使用I/O完成端口理论的并发线程应该为多少呢?标准的经验就是,CPU数量*2个线程,那么为什么创建的时候规定了最大只能是CPU数量的线程,而这边确实*2呢,这边是重点圈起来,我们待会再进行解释。

扫描二维码关注公众号,回复: 1687150 查看本文章

我们创建完成一个完成端口后,接着就是将其与设备(文件,套接字,邮件槽,管道等)关联起来,此时我们依然调用CreateIoCompletionPort。

		//!<将接入的客户端和完成端口绑定
		//!<这个在多客户端接入时,会将完成端口绑定的客户端覆盖
		PerHandleData.Socket = _SockClient;
		CreateIoCompletionPort((HANDLE)_SockClient, hCompletionPort, (DWORD)&PerHandleData, 0);

重点关注下,完成键:PerHandleData 这个值呢,对我们有意义,但操作系统并不关心我们传入的是什么值。也就是说,这边你传任何类型的值都可以,基本类型啊,结构体啊。我这边传的是个结构体:

typedef struct _Per_Handle_Data
{
	SOCKET Socket;
	_Per_Handle_Data()
	{
		Socket = INVALID_SOCKET;
	}
} PER_HANDLE_DATA, * LPPER_HANDLE_DATA;

到这里,我们的设备句柄已经和完成端口关联起来,接下来我们看看,我们创建的线程需要做什么操作:

//!<工作者线程
DWORD WINAPI WorkerProc(LPVOID v_lpParam)
{
	HANDLE hCompletionPort = (HANDLE)v_lpParam;
	DWORD dwBytesTransferred;
	LPPER_HANDLE_DATA pPerHandleData = NULL;
	LPPER_IO_OPERATION_DATA pPerIoData = NULL;
	while(TRUE)
	{
		//!<成功返回非0
		if (0 == GetQueuedCompletionStatus(hCompletionPort, //!<这个就是我们建立的那个唯一的完成端口
			&dwBytesTransferred, //!<这个是操作完成后返回的字节数
			(LPDWORD)&pPerHandleData, //!<这个是我们建立完成端口的时候绑定的那个自定义结构体参数
			(LPOVERLAPPED*)&pPerIoData, //!<这个是我们在连入Socket的时候一起建立的那个重叠结构
			INFINITE)) //!<让线程进入休眠等待状态,直到有设备I/O请求完成并进入完成端口
		{
			if ((GetLastError() == WAIT_TIMEOUT) || (GetLastError() == ERROR_NETNAME_DELETED))
			{
				printf("Time Out CloseSocket:%d\n", pPerHandleData->Socket);
				closesocket(pPerHandleData->Socket);
				delete pPerHandleData;
				delete pPerIoData;
				continue;
			}
			else
			{
				printf("GetQueuedCompletionStatus Failed.\n");
				break;
			}
		}
	
		//!<代表客户端断开连接
		if (dwBytesTransferred == 0)
		{
			printf("Client Quit CloseSocket:%d\n", pPerHandleData->Socket);
			closesocket(pPerHandleData->Socket);
			delete pPerHandleData;
			delete pPerIoData;
			continue;
		}

		printf("Recv Socket:%d Msg:%s\n", pPerHandleData->Socket, pPerIoData->Buffer);
		DWORD dwFlags = 0;
		DWORD dwRecv = 0;
		if (SOCKET_ERROR == WSARecv(pPerHandleData->Socket, //!<投递这个操作的套接字
			&(pPerIoData->DataBuf), //!<接收缓冲区,这里需要一个由WSABUF结构构成的数组
			1, //!<数组中WSABUF结构的数量,设置为1即可
			&dwRecv, //!<如果接收操作立即完成,这里会返回函数调用所接收到的字节数
			&dwFlags, //!<我们这里设置为0 即可
			&(pPerIoData->Overlapped), //!<这个Socket对应的重叠结构
			NULL //!<这个参数只有完成例程模式才会用到
			))
		{
			if (WSAGetLastError() != WSA_IO_PENDING)
			{
				printf("Throw WSARecv Failed.Error:%d\n", WSAGetLastError());
				closesocket(pPerHandleData->Socket);
				continue;
			}
		}

	}
	return 0;
}

我们的工作线程,调用GetQueuedCompletionStatus进入休眠等待状态,直到设备I/O请求完成并进入完成端口才被唤醒,这边就涉及到一个问题:同时有多个线程在休眠等待,那么到底唤醒哪一个呢? 唤醒调用了GetQueuedCompletionStatus的线程是以后入先出的方式,举个例子,假设有4个线程在等待,如果出现一个已完成的I/O项,那么最后一个调用的GetQueuedCompletionStatus的线程会被唤醒进行处理,当然这个线程完成后,又会再次调用GetQueuedCompletionStatus来进入等待,如果此时又有一个完成I/O项,那么同样还是唤醒同一个线程。

此时你可能会想,那么我其他线程干嘛用了。。。其实呢这有个好处就是,如果我们的I/O请求完成的足够慢,我们会一致唤醒同一个线程,而其他线程一致休眠。通过这种后入先出的算法,系统可以将那些未被调度的线程内存资源换出到磁盘,并将它们从处理器的高速缓存中清除,这也意味着让许多线程等待一个完成端口并不是坏事,如果我们等待的线程数大于已完成的I/O请求数,那么多余线程的资源将被换出内存。

到这边基本一个完成端口的创建,关联,运行处理都说清楚了。接着就是解释下:为什么创建的线程数要是CPU数量的2倍呢?

当我们创建一个I/O完成端口的时候,系统内核实际上回创建5个不同的数据结构:设备列表、I/O完成队列、等待线程队列、已释放线程列表、已暂停线程列表。从上面的解释我们已经清楚,当有多个线程等待I/O完成并不是坏事,也不会浪费资源。但是呢如果我在一个双核计算机上运行,我们创建一个I/O完成端口,并最后个参数默认传0,代表最多只有2个来处理已完成的项,那么我们创建4个线程多余的2个干嘛用呢,并且似乎永远不会被唤醒。。。

但其实呢,我们的I/O完成端口还是相当智能,当完成端口唤醒一个线程处理的时候,会将改线程的标识保存在已释放线程列表中,这使得完成端口能够记住哪些线程被唤醒了,并同时监视它们的执行情况,如果已被唤醒的线程进入了等待(等待信号量、sleep),完成端口检测到这一情况,则将其添加到已暂停线程列表中。

完成端口尽可能的保证最大的线程数在已释放的列表中,双核机子上就是保证2个线程同时在运行处理I/O完成项,如果一个已释放的线程进入等待,那么已释放线程列表会缩减,此时完成端口就可以释放另一个正在等待的线程,保证已释放线程列表最大。完成端口的目标就是使CPU保持在满负荷的状态下工作,这也就解释了为什么线程池中的数量要大于完成端口设置的并发线程数量。

猜你喜欢

转载自blog.csdn.net/fzuim/article/details/77879450