本文对于初学网络编程的极为友好,文中所有代码全部基于C语言实现,文中见解仅限于作者对于完成端口的初步认识,由于作者才疏学浅,出现的错误和纰漏,麻烦您一定要指出来,咱们共同进步。谢谢!!!
完成端口(completion Port)
前言:
网络通信分为两种:同步和异步。
在同步通信中,每一次接受数据都会导致主线程的挂起,从而阻塞住了其他操作。为了解决这一问题,我们通常会采取同步通信+多线程的策略,即为每一个连入的Socket分配一个线程。然而随着连入的Socket的数量的增加,线程的数量也在增加,这样CPU则需要不停地进行线程的切换,因此难以成为高性能的服务器程序。
异步通信则可以把接收数据这一操作交给内核,即在内核接收数据的时候,主线程可以不用被阻塞并且继续执行其他操作,而一旦接收数据完成以后,再由内核通知主线程。而如何通知主线程是一个关键,不同的异步通信策略有着不同的通知方式。
在这样的情况下,完成端口这一I/O模型被提出,成为目前Windows下性能最好的I/O模型之一。
(注:文中所有函数参数均已MSDN上的为标准,文中观点仅代表个人理解,如有错误,还请多多包涵并及时留言,我会第一时间改正,谢谢!!!)
完成端口模型简介:
上面所说的“初学”指你已经熟悉Socket进行TCP/IP编程的基本原理,前期基本的概念我这里就略过不提了,直入主题。
嗯~~!怎么说呢,完成端口是Windows的一种机制,这种机制是在重叠IO上的优化,所以说完成端口也是基于重叠结构的,换句话说如果对于重叠IO结构特别熟悉的话,那么完成端口对于你来说就特别简单。为什么说完成端口是在重叠IO上的一种优化呢?对比一下下面第一张和第二转张结构图,一定会有人好奇,为什么两张图差不多一样呢?仔细看会发现完成端口结构图里面操作系统有一步操作是将通知放进队列(第三张结构图,模仿消息队列原理系统会创建一个通知队列)。到这就可以说明完成端口在重叠IO具体优化的是什么了,熟悉重叠IO的都知道,重叠IO最严重的问题就是线程数量,有多少的客户端,那就得有多少根线程。肯定会有人说线程多了不是更好吗?速度跟快吗?程序执行时间更短码?那就错了,恰恰是相反的,上面我也大致提到了线程太多的问题。了解操作系统的都知道,线程在一个周期内分得的时间越多,那么执行就越快。换而言之如果线程数量增加,那么每根线程上所分得的时间就会变短,再加上切换线程的时间,这样一来反而时间更久。而理论上最优的线程数就是和CPU核数一样(还有其他的几种:CPU核数*2、CPU核数*2+2。为什么会有这几种情况,这里就不多多介绍了。)这样以来就可以充分的利用CPU资源。不过这也要求线程函数中没有调用诸如Sleep(),WSAWaitForMultipleEvents()...这类函数,这类函数会使线程挂起(但不占cpu时间片),从而使得CPU某个核空闲了,这就不好了,所以一般我们多建个两三根,以解决此类情况,让CPU不停歇,从而在整体上保证程序执行效率。本文采取的是和CPU核数一样多。而对于重叠IO中的无序性问题,完成端口采用了上述所说的创建一个通知队列(第三张结构图)来进行管理,从而达到有序。所以说完成端口是对重叠IO的改进也不为过。
完成端口原理以及部分函数用法:
1.用CreateIoCompletionPort()函数创建一个完成端口。
对于 CreateIoCompletionPort()函数它有两个功能一个功能是创建完成端口,另一个功能就是将SOCKET与完成端口进行绑定,在这里就是创建完成端口。至于说功能不一样,也就是参数不同而已。
HANDLE WINAPI CreateIoCompletionPort(
__in HANDLE FileHandle,
__in_opt HANDLE ExistingCompletionPort,
__in ULONG_PTR CompletionKey,
__in DWORD NumberOfConcurrentThreads
);
参数(Parameters):
此函数若要是在不关联I/O完成端口的情况下创建I/O完成端口,如果指定了参数FileHandle为INVALID_HANDLE_VALUE,在这种情况下,ExistingCompletionPort参数必须为NULL,而CompletionKey参数则被忽略可填0;那么参数NumberOfConcurrentThreads是允许此端口上最多同时运行的线程数量,一般设置为零(这里的零并不是参数3中忽略的意思,而是自动获取CPU核数。当然你也可以不用自动获取自己去指定通过函数GetSystemInfo())。
(注:这里简单介绍一下GetSystemInfo()函数的用法。这个函数也特别简单,参数也就一个SYSTEM_INFO类型的结构体,在这里我们只需要专注这个结构体里面的DWORD dwNumberOfProcessors成员即可; )
返回值(Return value):
函数执行成功会返回一个可用的端口变量,否则返回0;这里可以用GetLastError()获取错误码。
(注意:这里为什么不用WSAGetLastError()获取错误码?创建完成端口是Windows的一种机制,不是专门用于网络的,和网络是无关的。完成端口的模型只是利用了这种机制。)
2.用 CreateIoCompletionPort()函数将重叠套接字(客户端SOCKET+服务器SOCKET)与完成端口进行绑定。
毋庸置疑这就是CreateIoCompletionPort()函数的第二个功能:绑定重叠套接字与完成端口
HANDLE WINAPI CreateIoCompletionPort(
__in HANDLE FileHandle,
__in_opt HANDLE ExistingCompletionPort,
__in ULONG_PTR CompletionKey,
__in DWORD NumberOfConcurrentThreads
);
参数(Parameters):
FileHandle:要绑定的SOCKET。
ExistingCompletionPort:创建完成端口时返回的变量。
CompletionKey:这个参数就要和下面即将讲到的一个函数GetQueuedCompletionStatus()的参数3关联在一起比较着看,会很清楚。
先大概说一下GetQueuedCompletionStatus()这个函数,上面我也提到过系统会把所有SOCKET上的通知放进通知队列里面,而GetQueuedCompletionStatus()
函数就是从这个队列里面依次往外拿出通知然后进行分类处理,而CreateIoCompletionPort()函数的参数3就是告知函数GetQueuedCompletionStatus()从
队列里面拿出的事件通知具体是哪一个SOCKET上的发生的。
所以这里的参数就是要传入具体发生事件通知的SOCKET(如果是把所有的SOCKET装进数组里面的话,这里也可以传具体SOCKET的下标)。
NumberOfConcurrentThreads:如果参数ExistingCompletionPort不是NULL,则忽略此参数。可填0。
返回值(Return value):
函数执行成功返回自己,也就是再返回参数2;如果执行不成功那肯定就不等于参数2了啊!
3.使用AcceptEx(),WSARecv(),WSASend()函数投递请求。(这三个异步函数就偷个懒这里不过多的介绍了,因为是直接拿的重叠IO里面的函数,哈哈哈)
4.使用CreateThread()函数创建线程,使用GetSystemInfo()获得操作系统相关信息,比如获取CPU核数。
(GetSystemInfo()函数上文已经大致介绍了一下,和网络也没有太大的关系这里就不详细介绍了,想了解的可以看一下MSDN)
创建线程函数CreateThread()的功能就是一次创建一根线程,如果要创建多根线程,可以用循环
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
__drv_aliasesMem LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);
参数(Parameters):
lpThreadAttributes:线程句柄是否被继承,不继承就填NULL。如果不继承就是子线程与父线程共享一份线程句柄,相当于全局变量;
如果继承的话子类复制一份父类的此时就会有两份,相当于局部变量自己用自己的;
还有一个功能就是指定线程的权限,默认权限就填NULL。
所以此参数填NULL就好。
dwStackSize:线程大小(栈区大小),填0,默认大小为1M。可以指定大小以字节为单位。
lpStartAddress:线程函数地址;
线程函数函数头:DWORD WINAPI ThreadProc(LPVOID lpParameter); 这个函数的参数由函数CreateThread()的参数4传入
lpParameter:外部给线程传递数据,把传递进来的数据传递给参数3中的线程函数中;
dwCreationFlags:线程创建出来的一种执行状态;
立即执行填0,也就是立即获得时间片分得的时间;
挂起状态填CREATE_SUSPENDED(不占用时间周期)。调用ResumeThread()函数,激活挂起状态的线程。
如果填STACK_SIZE_PARAM_IS_A_RESERVATION,这个宏是和参数2关联在一起的。如果想修改栈区大小,
设置了这个宏,参数2就是修改的栈保留大小,即虚拟内存上栈得大小;如果没有设置修改的就是栈提交大小,即物理内存上的大小。
lpThreadId:线程ID,每根线程的ID都不一样。不用就填NULL。
返回值(Return value):
函数执行成功返回线程句柄,失败返回NULL。可以用GetLastError()获得错误码。
线程句柄是内核对象,用完要释放用CloseHandle()函数。
5.当系统异步处理完成后,会生成一个通知,这个通知就会放进通知队列里面,而完成端口就可以理解为通知队列的头。该队列由操作系统系统创建,维护。
6.通过GetQueuedCompletionStatus()函数从队列头一个一个往外拿,进行处理。
如果通知队列里没有通知,那么会使线程处于挂起状态,这样就不会占用CPU时间。
BOOL GetQueuedCompletionStatus(
HANDLE CompletionPort,
LPDWORD lpNumberOfBytesTransferred,
PULONG_PTR lpCompletionKey,
LPOVERLAPPED *lpOverlapped,
DWORD dwMilliseconds
);
参数(Parameters):
CompletionPort:创建完成端口时返回的变量。
lpNumberOfBytesTransferred:收到或发送的字节数。如果是客户端SOKCET发生事件通知并且此参数返回的是0,那就说明是客户端退出。
lpCompletionKey:在上面写绑定重叠套接字与完成端口的时候已经介绍到了此参数,这里就不过多说了。它就是接收绑定完成端口的时候传进来的SOCKET。
lpOverlapped:返回一个发生事件通知的SOCKET上所绑定的那个重叠结构的地址。
dwMilliseconds:等待时间。可以是具体的等待时间以毫秒为单位;也可以一直等到有事件通知为止,一直等填INFINITE。
返回值(Return value):
函数执行成功返回TRUE,失败返回FALSE,可以用GetLastError()获取错误码。
完成端口代码逻辑:
1.打开网络库(WSAStartup())
2.校验版本(副版本:HIBYTE()、主版本:LOBYTE())
3.创建SOCKET(WSASocket())
4.绑定地址与端口号(bind())
5.创建完成端口(CreateIoCompletionPort())
6.将重叠套接字(客户端SOCKET+服务器SOCKET)与完成端口进行绑定(CreateIoCompletionPort())
7.开始监听(listen())
8.创建线程(CreteThread())
9.获取事件通知(GetQueuedCompletionPort())进行分类处理
10.释放