----- By PiggyXP(小猪)程序员
前 言编程
本系列里完成端口的代码在两年前就已经写好了,可是因为许久没有写东西了,不知该如何提笔,因此这篇文档老是在酝酿之中……酝酿了两年以后,终于决定开始动笔了,希望还不算晚…..数组
这篇文档我很是详细而且图文并茂的介绍了关于网络编程模型中完成端口的方方面面的信息,从API的用法到使用的步骤,从完成端口的实现机理到实际使用的注意事项,都有所涉及,而且为了让朋友们更直观的体会完成端口的用法,本文附带了有详尽注释的使用MFC编写的图形界面的示例代码。安全
个人初衷是但愿写一份互联网上能找到的最详尽的关于完成端口的教学文档,并且让对Socket编程略有了解的人都可以看得懂,都能学会如何来使用完成端口这么优异的网络编程模型,可是因为本人水平所限,不知道个人初衷是否实现了,但仍是但愿各位须要的朋友可以喜欢。服务器
因为篇幅缘由,本文假设你已经熟悉了利用Socket进行TCP/IP编程的基本原理,而且也熟练的掌握了多线程编程技术,太基本的概念我这里就略过不提了,网上的资料应该遍地都是。网络
本文档凝聚着笔者心血,如要转载,请指明原做者及出处,谢谢!不过代码没有版权,能够随便散播使用,欢迎改进,特别是很是欢迎可以帮助我发现Bug的朋友,以更好的造福你们。^_^数据结构
本文配套的示例源码下载地址(在个人下载空间里,已经补充上了客户端的代码)多线程
http://piggyxp.download.csdn.net/并发
(里面的代码包括VC++2008/VC++2010编写的完成端口服务器端和客户端的代码,还包括一个对服务器端进行压力测试的客户端,都是通过我精心调试过,而且带有很是详尽的代码注释的。固然,做为教学代码,为了可以使得代码结构清晰明了,我仍是对代码有所简化,若是想要用于产品开发,最好仍是须要本身再完善一下,另外个人工程是用2010编写的,附带的2008工程不知道有没有问题,可是其中代码都是同样的,暂未测试)app
忘了嘱咐一下了,文章篇幅很长很长,基本涉及到了与完成端口有关的方方面面,一次看不完能够分好几回,中间注意休息,好身体才是我们程序员最大的本钱!
对了,还忘了嘱咐一下,由于本人的水平有限,虽然我反复修正了数遍,但文章和示例代码里确定还有我没发现的错误和纰漏,但愿各位必定要指出来,拍砖、喷我,我都能Hold住,可是必定要指出来,我会及时修正,由于我不想让文中的错误传遍互联网,祸害你们。
OK, Let’s go ! Have fun !
目录:
1. 完成端口的优势
2. 完成端口程序的运行演示
3. 完成端口的相关概念
4. 完成端口的基本流程
5. 完成端口的使用详解
6. 实际应用中应该要注意的地方
一. 完成端口的优势
1. 我想只要是写过或者想要写C/S模式网络服务器端的朋友,都应该或多或少的听过完成端口的大名吧,完成端口会充分利用Windows内核来进行I/O的调度,是用于C/S通讯模式中性能最好的网络通讯模型,没有之一;甚至连和它性能接近的通讯模型都没有。
2. 完成端口和其余网络通讯方式最大的区别在哪里呢?
(1) 首先,若是使用“同步”的方式来通讯的话,这里说的同步的方式就是说全部的操做都在一个线程内顺序执行完成,这么作缺点是很明显的:由于同步的通讯操做会阻塞住来自同一个线程的任何其余操做,只有这个操做完成了以后,后续的操做才能够完成;一个最明显的例子就是我们在MFC的界面代码中,直接使用阻塞Socket调用的代码,整个界面都会所以而阻塞住没有响应!因此咱们不得不为每个通讯的Socket都要创建一个线程,多麻烦?这不坑爹呢么?因此要写高性能的服务器程序,要求通讯必定要是异步的。
(2) 各位读者确定知道,能够使用使用“同步通讯(阻塞通讯)+多线程”的方式来改善(1)的状况,那么好,想一下,咱们好不容易实现了让服务器端在每个客户端连入以后,都要启动一个新的Thread和客户端进行通讯,有多少个客户端,就须要启动多少个线程,对吧;可是因为这些线程都是处于运行状态,因此系统不得不在全部可运行的线程之间进行上下文的切换,咱们本身是没啥感受,可是CPU却痛苦不堪了,由于线程切换是至关浪费CPU时间的,若是客户端的连入线程过多,这就会弄得CPU都忙着去切换线程了,根本没有多少时间去执行线程体了,因此效率是很是低下的,认可坑爹了不?
(3) 而微软提出完成端口模型的初衷,就是为了解决这种"one-thread-per-client"的缺点的,它充分利用内核对象的调度,只使用少许的几个线程来处理和客户端的全部通讯,消除了无谓的线程上下文切换,最大限度的提升了网络通讯的性能,这种神奇的效果具体是如何实现的请看下文。
3. 完成端口被普遍的应用于各个高性能服务器程序上,例如著名的Apache….若是你想要编写的服务器端须要同时处理的并发客户端链接数量有数百上千个的话,那不用纠结了,就是它了。
二. 完成端口程序的运行演示
首先,咱们先来看一下完成端口在笔者的PC机上的运行表现,笔者的PC配置以下:
大致就是i7 2600 + 16GB内存,我以这台PC做为服务器,简单的进行了以下的测试,经过Client生成3万个并发线程同时链接至Server,而后每一个线程每隔3秒钟发送一次数据,一共发送3次,而后观察服务器端的CPU和内存的占用状况。
如图2所示,是客户端3万个并发线程发送共发送9万条数据的log截图
图3是服务器端接收完毕3万个并发线程和每一个线程的3份数据后的log截图
最关键是图4,图4是服务器端在接收到28000个并发线程的时候,CPU占用率的截图,使用的软件是大名鼎鼎的Process Explorer,由于相对来说这个比自带的任务管理器要准确和精确一些。
咱们能够发现一个使人惊讶的结果,采用了完成端口的Server程序(蓝色横线所示)所占用的CPU才为 3.82%,整个运行过程当中的峰值也没有超过4%,是至关气定神闲的……哦,对了,这仍是在Debug环境下运行的状况,若是采用Release方式执行,性能确定还会更高一些,除此之外,在UI上显示信息也很大成都上影响了性能。
相反采用了多个并发线程的Client程序(紫色横线所示)竟然占用的CPU高达11.53%,甚至超过了Server程序的数倍……
其实不管是哪一种网络操模型,对于内存占用都是差很少的,真正的差异就在于CPU的占用,其余的网络模型都须要更多的CPU动力来支撑一样的链接数据。
虽然这远远算不上服务器极限压力测试,可是从中也能够看出来完成端口的实力,并且这种方式比纯粹靠多线程的方式实现并发资源占用率要低得多。
三. 完成端口的相关概念
在开始编码以前,咱们先来讨论一下和完成端口相关的一些概念,若是你没有耐心看完这段大段的文字的话,也能够跳过这一节直接去看下下一节的具体实现部分,可是这一节中涉及到的基本概念你仍是有必要了解一下的,并且你也更能知道为何有那么多的网络编程模式不用,非得要用这么又复杂又难以理解的完成端口呢??也会坚决你继续学习下去的信心^_^
3.1 异步通讯机制及其几种实现方式的比较
咱们从前面的文字中了解到,高性能服务器程序使用异步通讯机制是必须的。
而对于异步的概念,为了方便后面文字的理解,这里仍是再次简单的描述一下:
异步通讯就是在我们与外部的I/O设备进行打交道的时候,咱们都知道外部设备的I/O和CPU比起来简直是龟速,好比硬盘读写、网络通讯等等,咱们没有必要在我们本身的线程里面等待着I/O操做完成再执行后续的代码,而是将这个请求交给设备的驱动程序本身去处理,咱们的线程能够继续作其余更重要的事情,大致的流程以下图所示:
我能够从图中看到一个很明显的并行操做的过程,而“同步”的通讯方式是在进行网络操做的时候,主线程就挂起了,主线程要等待网络操做完成以后,才能继续执行后续的代码,就是说要末执行主线程,要末执行网络操做,是无法这样并行的;
“异步”方式无疑比 “阻塞模式+多线程”的方式效率要高的多,这也是前者为何叫“异步”,后者为何叫“同步”的缘由了,由于不须要等待网络操做完成再执行别的操做。
而在Windows中实现异步的机制一样有好几种,而这其中的区别,关键就在于图1中的最后一步“通知应用程序处理网络数据”上了,由于实现操做系统调用设备驱动程序去接收数据的操做都是同样的,关键就是在于如何去通知应用程序来拿数据。它们之间的具体区别我这里多讲几点,文字有点多,若是没兴趣深刻研究的朋友能够跳过下一面的这一段,不影响的:)
(1) 设备内核对象,使用设备内核对象来协调数据的发送请求和接收数据协调,也就是说经过设置设备内核对象的状态,在设备接收数据完成后,立刻触发这个内核对象,而后让接收数据的线程收到通知,可是这种方式太原始了,接收数据的线程为了可以知道内核对象是否被触发了,仍是得不停的挂起等待,这简直是根本就没有用嘛,过低级了,有木有?因此在这里就略过不提了,各位读者要是没明白是怎么回事也不用深究了,总之没有什么用。
(2) 事件内核对象,利用事件内核对象来实现I/O操做完成的通知,其实这种方式其实就是我之前写文章的时候提到的《基于事件通知的重叠I/O模型》,连接在这里,这种机制就先进得多,能够同时等待多个I/O操做的完成,实现真正的异步,可是缺点也是很明显的,既然用WaitForMultipleObjects()来等待Event的话,就会受到64个Event等待上限的限制,可是这可不是说咱们只能处理来自于64个客户端的Socket,而是这是属于在一个设备内核对象上等待的64个事件内核对象,也就是说,咱们在一个线程内,能够同时监控64个重叠I/O操做的完成状态,固然咱们一样能够使用多个线程的方式来知足无限多个重叠I/O的需求,好比若是想要支持3万个链接,就得须要500多个线程…用起来太麻烦让人感受不爽;
(3) 使用APC( Asynchronous Procedure Call,异步过程调用)来完成,这个也就是我之前在文章里提到的《基于完成例程的重叠I/O模型》,连接在这里,这种方式的好处就是在于摆脱了基于事件通知方式的64个事件上限的限制,可是缺点也是有的,就是发出请求的线程必须得要本身去处理接收请求,哪怕是这个线程发出了不少发送或者接收数据的请求,可是其余的线程都闲着…,这个线程也仍是得本身来处理本身发出去的这些请求,没有人来帮忙…这就有一个负载均衡问题,显然性能没有达到最优化。
(4) 完成端口,不用说你们也知道了,最后的压轴戏就是使用完成端口,对比上面几种机制,完成端口的作法是这样的:事先开好几个线程,你有几个CPU我就开几个,首先是避免了线程的上下文切换,由于线程想要执行的时候,总有CPU资源可用,而后让这几个线程等着,等到有用户请求来到的时候,就把这些请求都加入到一个公共消息队列中去,而后这几个开好的线程就排队逐一去从消息队列中取出消息并加以处理,这种方式就很优雅的实现了异步通讯和负载均衡的问题,由于它提供了一种机制来使用几个线程“公平的”处理来自于多个客户端的输入/输出,而且线程若是没事干的时候也会被系统挂起,不会占用CPU周期,挺完美的一个解决方案,不是吗?哦,对了,这个关键的做为交换的消息队列,就是完成端口。
比较完毕以后,熟悉网络编程的朋友可能会问到,为何没有提到WSAAsyncSelect或者是WSAEventSelect这两个异步模型呢,对于这两个模型,我不知道其内部是如何实现的,可是这其中必定没有用到Overlapped机制,就不能算做是真正的异步,多是其内部本身在维护一个消息队列吧,总之这两个模式虽然实现了异步的接收,可是却不能进行异步的发送,这就很明显说明问题了,我想其内部的实现必定和完成端口是迥异的,而且,完成端口很是厚道,由于它是先把用户数据接收回来以后再通知用户直接来取就行了,而WSAAsyncSelect和WSAEventSelect之流只是会接收到数据到达的通知,而只能由应用程序本身再另外去recv数据,性能上的差距就更明显了。
最后,个人建议是,想要使用 基于事件通知的重叠I/O和基于完成例程的重叠I/O的朋友,若是不是特别必要,就不要去使用了,由于这两种方式不只使用和理解起来也不算简单,并且还有性能上的明显瓶颈,何不就再努力一下使用完成端口呢?
3.2 重叠结构(OVERLAPPED)
咱们从上一小节中得知,要实现异步通讯,必需要用到一个很风骚的I/O数据结构,叫重叠结构“Overlapped”,Windows里全部的异步通讯都是基于它的,完成端口也不例外。
至于为何叫Overlapped?Jeffrey Richter的解释是由于“执行I/O请求的时间与线程执行其余任务的时间是重叠(overlapped)的”,从这个名字咱们也可能看得出来重叠结构发明的初衷了,对于重叠结构的内部细节我这里就不过多的解释了,就把它当成和其余内核对象同样,不须要深究其实现机制,只要会使用就能够了,想要了解更多重叠结构内部的朋友,请去翻阅Jeffrey Richter的《Windows via C/C++》 5th 的292页,若是没有机会的话,也能够随便翻翻我之前写的Overlapped的东西,不过写得比较浅显……
这里我想要解释的是,这个重叠结构是异步通讯机制实现的一个核心数据结构,由于你看到后面的代码你会发现,几乎全部的网络操做例如发送/接收之类的,都会用WSASend()和WSARecv()代替,参数里面都会附带一个重叠结构,这是为何呢?由于重叠结构咱们就能够理解成为是一个网络操做的ID号,也就是说咱们要利用重叠I/O提供的异步机制的话,每个网络操做都要有一个惟一的ID号,由于进了系统内核,里面黑灯瞎火的,也不了解上面出了什么情况,一看到有重叠I/O的调用进来了,就会使用其异步机制,而且操做系统就只能靠这个重叠结构带有的ID号来区分是哪个网络操做了,而后内核里面处理完毕以后,根据这个ID号,把对应的数据传上去。
你要是实在不理解这是个什么玩意,那就直接看后面的代码吧,慢慢就明白了……
3.3 完成端口(CompletionPort)
对于完成端口这个概念,我一直不知道为何它的名字是叫“完成端口”,我我的的感受应该叫它“完成队列”彷佛更合适一些,总之这个“端口”和咱们日常所说的用于网络通讯的“端口”彻底不是一个东西,咱们不要混淆了。
首先,它之因此叫“完成”端口,就是说系统会在网络I/O操做“完成”以后才会通知咱们,也就是说,咱们在接到系统的通知的时候,其实网络操做已经完成了,就是好比说在系统通知咱们的时候,并不是是有数据从网络上到来,而是来自于网络上的数据已经接收完毕了;或者是客户端的连入请求已经被系统接入完毕了等等,咱们只须要处理后面的事情就行了。
各位朋友可能会很开心,什么?已经处理完毕了才通知咱们,那岂不是很爽?其实也没什么爽的,那是由于咱们在以前给系统分派工做的时候,都嘱咐好了,咱们会经过代码告诉系统“你给我作这个作那个,等待作完了再通知我”,只是这些工做是作在以前仍是以后的区别而已。
其次,咱们须要知道,所谓的完成端口,其实和HANDLE同样,也是一个内核对象,虽然Jeff Richter吓唬咱们说:“完成端口多是最为复杂的内核对象了”,可是咱们也不用去管他,由于它具体的内部如何实现的和咱们无关,只要咱们可以学会用它相关的API把这个完成端口的框架搭建起来就能够了。咱们暂时只用把它大致理解为一个容纳网络通讯操做的队列就行了,它会把网络操做完成的通知,都放在这个队列里面,我们只用从这个队列里面取就好了,取走一个就少一个…。
关于完成端口内核对象的具体更多内部细节我会在后面的“完成端口的基本原理”一节更详细的和朋友们一块儿来研究,固然,要是大家在文章中没有看到这一节的话,就是说明我又犯懒了没写…在后续的文章里我会补上。这里就暂时说这么多了,到时候咱们也能够看到它的机制也并不是有那么的复杂,可能只是由于操做系统其余的内核对象相比较而言实现起来太容易了吧^_^
四. 使用完成端口的基本流程
说了这么多的废话,你们都等不及了吧,咱们终于到了具体编码的时候了。
使用完成端口,说难也难,可是说简单,其实也简单 ---- 又说了一句废话=。=
大致上来说,使用完成端口只用遵循以下几个步骤:
(1) 调用 CreateIoCompletionPort() 函数建立一个完成端口,并且在通常状况下,咱们须要且只须要创建这一个完成端口,把它的句柄保存好,咱们从此会常常用到它……
(2) 根据系统中有多少个处理器,就创建多少个工做者(为了醒目起见,下面直接说Worker)线程,这几个线程是专门用来和客户端进行通讯的,目前暂时没什么工做;
(3) 下面就是接收连入的Socket链接了,这里有两种实现方式:一是和别的编程模型同样,还须要启动一个独立的线程,专门用来accept客户端的链接请求;二是用性能更高更好的异步AcceptEx()请求,由于各位对accept用法应该很是熟悉了,并且网上资料也会不少,因此为了更全面起见,本文采用的是性能更好的AcceptEx,至于二者代码编写上的区别,我接下来会详细的讲。
(4) 每当有客户端连入的时候,咱们就仍是得调用CreateIoCompletionPort()函数,这里却不是新创建完成端口了,而是把新连入的Socket(也就是前面所谓的设备句柄),与目前的完成端口绑定在一块儿。
至此,咱们其实就已经完成了完成端口的相关部署工做了,嗯,是的,完事了,后面的代码里咱们就能够充分享受完成端口带给咱们的巨大优点,不劳而获了,是否是很简单呢?
(5) 例如,客户端连入以后,咱们能够在这个Socket上提交一个网络请求,例如WSARecv(),而后系统就会帮我们乖乖的去执行接收数据的操做,咱们大能够放心的去干别的事情了;
(6) 而此时,咱们预先准备的那几个Worker线程就不能闲着了, 咱们在前面创建的几个Worker就要忙活起来了,都须要分别调用GetQueuedCompletionStatus() 函数在扫描完成端口的队列里是否有网络通讯的请求存在(例如读取数据,发送数据等),一旦有的话,就将这个请求从完成端口的队列中取回来,继续执行本线程中后面的处理代码,处理完毕以后,咱们再继续投递下一个网络通讯的请求就OK了,如此循环。
关于完成端口的使用步骤,用文字来表述就是这么多了,很简单吧?若是你仍是不理解,我再配合一个流程图来表示一下:
固然,我这里假设你已经对网络编程的基本套路有了解了,因此略去了不少基本的细节,而且为了配合朋友们更好的理解个人代码,在流程图我标出了一些函数的名字,而且画得很是详细。
另外须要注意的是因为对于客户端的连入有两种方式,一种是普通阻塞的accept,另一种是性能更好的AcceptEx,为了可以方面朋友们从别的网络编程的方式中过渡,我这里画了两种方式的流程图,方便朋友们对比学习,图a是使用accept的方式,固然配套的源代码我默认就不提供了,若是须要的话,我却是也能够发上来;图b是使用AcceptEx的,并配有配套的源码。
采用accept方式的流程示意图以下:
采用AcceptEx方式的流程示意图以下:
两个图中最大的相同点是什么?是的,最大的相同点就是主线程无所事事,闲得蛋疼……
为何呢?由于咱们使用了异步的通讯机制,这些琐碎重复的事情彻底没有必要交给主线程本身来作了,只用在初始化的时候和Worker线程交待好就能够了,用一句话来形容就是,主线程永远也体会不到Worker线程有多忙,而Worker线程也永远体会不到主线程在初始化创建起这个通讯框架的时候操了多少的心……
图a中是由 _AcceptThread()负责接入链接,并把连入的Socket和完成端口绑定,另外的多个_WorkerThread()就负责监控完成端口上的状况,一旦有状况了,就取出来处理,若是CPU有多核的话,就能够多个线程轮着来处理完成端口上的信息,很明显效率就提升了。
图b中最明显的区别,也就是AcceptEx和传统的accept之间最大的区别,就是取消了阻塞方式的accept调用,也就是说,AcceptEx也是经过完成端口来异步完成的,因此就取消了专门用于accept链接的线程,用了完成端口来进行异步的AcceptEx调用;而后在检索完成端口队列的Worker函数中,根据用户投递的完成操做的类型,再来找出其中的投递的Accept请求,加以对应的处理。
读者必定会问,这样作的好处在哪里?为何还要异步的投递AcceptEx链接的操做呢?
首先,我能够很明确的告诉各位,若是短期内客户端的并发链接请求不是特别多的话,用accept和AcceptEx在性能上来说是没什么区别的。
按照咱们目前主流的PC来说,若是客户端只进行链接请求,而什么都不作的话,咱们的Server只能接收大约3万-4万个左右的并发链接,而后客户端其他的连入请求就只能收到WSAENOBUFS (10055)了,由于系统来不及为新连入的客户端准备资源了。
须要准备什么资源?固然是准备Socket了……虽然咱们建立Socket只用一行SOCKET s= socket(…) 这么一行的代码就OK了,可是系统内部创建一个Socket是至关耗费资源的,由于Winsock2是分层的机构体系,建立一个Socket须要到多个Provider之间进行处理,最终造成一个可用的套接字。总之,系统建立一个Socket的开销是至关高的,因此用accept的话,系统可能来不及为更多的并发客户端现场准备Socket了。
而AcceptEx比Accept又强大在哪里呢?是有三点:
(1) 这个好处是最关键的,是由于AcceptEx是在客户端连入以前,就把客户端的Socket创建好了,也就是说,AcceptEx是先创建的Socket,而后才发出的AcceptEx调用,也就是说,在进行客户端的通讯以前,不管是否有客户端连入,Socket都是提早创建好了;而不须要像accept是在客户端连入了以后,再现场去花费时间创建Socket。若是各位不清楚是如何实现的,请看后面的实现部分。
(2) 相比accept只能阻塞方式创建一个连入的入口,对于大量的并发客户端来说,入口实在是有点挤;而AcceptEx能够同时在完成端口上投递多个请求,这样有客户端连入的时候,就很是优雅并且从容不迫的边喝茶边处理连入请求了。
(3) AcceptEx还有一个很是体贴的优势,就是在投递AcceptEx的时候,咱们还能够顺便在AcceptEx的同时,收取客户端发来的第一组数据,这个是同时进行的,也就是说,在咱们收到AcceptEx完成的通知的时候,咱们就已经把这第一组数据接完毕了;可是这也意味着,若是客户端只是连入可是不发送数据的话,咱们就不会收到这个AcceptEx完成的通知……这个咱们在后面的实现部分,也能够详细看到。
最后,各位要有一个内心准备,相比accept,异步的AcceptEx使用起来要麻烦得多……
五. 完成端口的实现详解
又说了一节的废话,终于到了该动手实现的时候了……
这里我把完成端口的详细实现步骤以及会涉及到的函数,按照出现的前后步骤,都和你们详细的说明解释一下,固然,文档中为了让你们便于阅读,这里去掉了其中的错误处理的内容,固然,这些内容在示例代码中是会有的。
【第一步】建立一个完成端口
首先,咱们先把完成端口建好再说。
咱们正常状况下,咱们须要且只须要创建这一个完成端口,代码很简单:
HANDLE m_hIOCompletionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0 );
呵呵,看到CreateIoCompletionPort()的参数不要奇怪,参数就是一个INVALID,一个NULL,两个0…,说白了就是一个-1,三个0……简直就和什么都没传同样,可是Windows系统内部倒是好一顿忙活,把完成端口相关的资源和数据结构都已经定义好了(在后面的原理部分咱们会看到,完成端口相关的数据结构大部分都是一些用来协调各类网络I/O的队列),而后系统会给咱们返回一个有意义的HANDLE,只要返回值不是NULL,就说明创建完成端口成功了,就这么简单,不是吗?
有的时候我真的很赞叹Windows API的封装,把不少实际上是很复杂的事整得这么简单……
至于里面各个参数的具体含义,我会放到后面的步骤中去讲,反正这里只要知道建立咱们惟一的这个完成端口,就只是须要这么几个参数。
可是对于最后一个参数 0,我这里要简单的说两句,这个0可不是一个普通的0,它表明的是NumberOfConcurrentThreads,也就是说,容许应用程序同时执行的线程数量。固然,咱们这里为了不上下文切换,最理想的状态就是每一个处理器上只运行一个线程了,因此咱们设置为0,就是说有多少个处理器,就容许同时多少个线程运行。
由于好比一台机器只有两个CPU(或者两个核心),若是让系统同时运行的线程多于本机的CPU数量的话,那实际上是没有什么意义的事情,由于这样CPU就不得不在多个线程之间执行上下文切换,这会浪费宝贵的CPU周期,反而下降的效率,咱们要牢记这个原则。
【第二步】根据系统中CPU核心的数量创建对应的Worker线程
咱们前面已经提到,这个Worker线程很重要,是用来具体处理网络请求、具体和客户端通讯的线程,并且对于线程数量的设置颇有意思,要等于系统中CPU的数量,那么咱们就要首先获取系统中CPU的数量,这个是基本功,我就很少说了,代码以下:
SYSTEM_INFO si; GetSystemInfo(&si); int m_nProcessors = si.dwNumberOfProcessors;
这样咱们根据系统中CPU的核心数量来创建对应的线程就行了,下图是在个人 i7 2600k CPU上初始化的状况,由于个人CPU是8核,一共启动了16个Worker线程,以下图所示
啊,等等!各位没发现什么问题么?为何我8核的CPU却启动了16个线程?这个不是和咱们第二步中说的原则自相矛盾了么?
哈哈,有个小秘密忘了告诉各位了,江湖上都流传着这么一个公式,就是:
咱们最好是创建CPU核心数量*2那么多的线程,这样更能够充分利用CPU资源,由于完成端口的调度是很是智能的,好比咱们的Worker线程有的时候可能会有Sleep()或者WaitForSingleObject()之类的状况,这样同一个CPU核心上的另外一个线程就能够代替这个Sleep的线程执行了;由于完成端口的目标是要使得CPU满负荷的工做。
这里也有人说是创建 CPU“核心数量 * 2 +2”个线程,我想这个应该没有什么太大的区别,我就是按照我本身的习惯来了。
而后按照这个数量,来启动这么多个Worker线程就好能够了,接下来咱们开始下一个步骤。
什么?Worker线程不会建?
…囧…
Worker线程和普通线程是同样同样同样的啊~~~,代码大体上以下:
// 根据CPU数量,创建*2的线程 m_nThreads = 2 * m_nProcessors; HANDLE* m_phWorkerThreads = new HANDLE[m_nThreads]; for (int i = 0; i < m_nThreads; i++) { m_phWorkerThreads[i] = ::CreateThread(0, 0, _WorkerThread, …); }
其中,_WorkerThread是Worker线程的线程函数,线程函数的具体内容咱们后面再讲。
【第三步】建立一个用于监听的Socket,绑定到完成端口上,而后开始在指定的端口上监听链接请求
最重要的完成端口创建完毕了,咱们就能够利用这个完成端口来进行网络通讯了。
首先,咱们须要初始化Socket,这里和一般状况下使用Socket初始化的步骤都是同样的,大约就是以下的这么几个过程(详情参照我代码中的LoadSocketLib()和InitializeListenSocket(),这里只是挑出关键部分):
// 初始化Socket库 WSADATA wsaData; WSAStartup(MAKEWORD(2,2), &wsaData); //初始化Socket struct sockaddr_in ServerAddress; // 这里须要特别注意,若是要使用重叠I/O的话,这里必需要使用WSASocket来初始化Socket // 注意里面有个WSA_FLAG_OVERLAPPED参数 SOCKET m_sockListen = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED); // 填充地址结构信息 ZeroMemory((char *)&ServerAddress, sizeof(ServerAddress)); ServerAddress.sin_family = AF_INET; // 这里能够选择绑定任何一个可用的地址,或者是本身指定的一个IP地址 //ServerAddress.sin_addr.s_addr = htonl(INADDR_ANY); ServerAddress.sin_addr.s_addr = inet_addr(“你的IP”); ServerAddress.sin_port = htons(11111); // 绑定端口 if (SOCKET_ERROR == bind(m_sockListen, (struct sockaddr *) &ServerAddress, sizeof(ServerAddress))) // 开始监听 listen(m_sockListen,SOMAXCONN))
须要注意的地方有两点:
(1) 想要使用重叠I/O的话,初始化Socket的时候必定要使用WSASocket并带上WSA_FLAG_OVERLAPPED参数才能够(只有在服务器端须要这么作,在客户端是不须要的);
(2) 注意到listen函数后面用的那个常量SOMAXCONN了吗?这个是在微软在WinSock2.h中定义的,而且还附赠了一条注释,Maximum queue length specifiable by listen.,因此说,不用白不用咯^_^
接下来有一个很是重要的动做:既然咱们要使用完成端口来帮咱们进行监听工做,那么咱们必定要把这个监听Socket和完成端口绑定才能够的吧:
如何绑定呢?一样很简单,用 CreateIoCompletionPort()函数。
等等!你们没以为这个函数很眼熟么?是的,这个和前面那个建立完成端口用的竟然是同一个API!可是这里这个API可不是用来创建完成端口的,而是用于将Socket和之前建立的那个完成端口绑定的,你们可要看准了,不要被迷惑了,由于他们的参数是明显不同的,前面那个的参数是一个-1,三个0,太好记了…
说实话,我感受微软应该把这两个函数分开,弄个 CreateNewCompletionPort() 多好呢?
这里在详细讲解一下CreateIoCompletionPort()的几个参数:
HANDLE WINAPI CreateIoCompletionPort( __in HANDLE FileHandle, // 这里固然是连入的这个套接字句柄了 __in_opt HANDLE ExistingCompletionPort, // 这个就是前面建立的那个完成端口 __in ULONG_PTR CompletionKey, // 这个参数就是相似于线程参数同样,在 // 绑定的时候把本身定义的结构体指针传递 // 这样到了Worker线程中,也能够使用这个 // 结构体的数据了,至关于参数的传递 __in DWORD NumberOfConcurrentThreads // 这里一样置0 );
这些参数也没什么好讲的吧,用处一目了然了。而对于其中的那个CompletionKey,咱们后面会详细提到。
到此才算是Socket所有初始化完毕了。
初始化Socket完毕以后,就能够在这个Socket上投递AcceptEx请求了。
【第四步】在这个监听Socket上投递AcceptEx请求
这里的处理比较复杂。
这个AcceptEx比较特别,并且这个是微软专门在Windows操做系统里面提供的扩展函数,也就是说这个不是Winsock2标准里面提供的,是微软为了方便我们使用重叠I/O机制,额外提供的一些函数,因此在使用以前也仍是须要进行些准备工做。
微软的实现是经过mswsock.dll中提供的,因此咱们能够经过静态连接mswsock.lib来使用AcceptEx。可是这是一个不推荐的方式,咱们应该用WSAIoctl 配合SIO_GET_EXTENSION_FUNCTION_POINTER参数来获取函数的指针,而后再调用AcceptEx。
这是为何呢?由于咱们在未取得函数指针的状况下就调用AcceptEx的开销是很大的,由于AcceptEx 其实是存在于Winsock2结构体系以外的(由于是微软另外提供的),因此若是咱们直接调用AcceptEx的话,首先咱们的代码就只能在微软的平台上用了,没有办法在其余平台上调用到该平台提供的AcceptEx的版本(若是有的话), 并且更糟糕的是,咱们每次调用AcceptEx时,Service Provider都得要经过WSAIoctl()获取一次该函数指针,效率过低了,因此还不如咱们本身直接在代码中直接去这么获取一下指针好了。
获取AcceptEx函数指针的代码大体以下:
LPFN_ACCEPTEX m_lpfnAcceptEx; // AcceptEx函数指针 GUID GuidAcceptEx = WSAID_ACCEPTEX; // GUID,这个是识别AcceptEx函数必须的 DWORD dwBytes = 0; WSAIoctl( m_pListenContext->m_Socket, SIO_GET_EXTENSION_FUNCTION_POINTER, &GuidAcceptEx, sizeof(GuidAcceptEx), &m_lpfnAcceptEx, sizeof(m_lpfnAcceptEx), &dwBytes, NULL, NULL);
具体实现就没什么可说的了,由于都是固定的套路,那个GUID是微软给定义好的,直接拿过来用就好了,WSAIoctl()就是经过这个找到AcceptEx的地址的,另外须要注意的是,经过WSAIoctl获取AcceptEx函数指针时,只须要随便传递给WSAIoctl()一个有效的SOCKET便可,该Socket的类型不会影响获取的AcceptEx函数指针。
而后,咱们就能够经过其中的指针m_lpfnAcceptEx调用AcceptEx函数了。
AcceptEx函数的定义以下:
BOOL AcceptEx (
SOCKET sListenSocket,
SOCKET sAcceptSocket,
PVOID lpOutputBuffer,
DWORD dwReceiveDataLength,
DWORD dwLocalAddressLength,
DWORD dwRemoteAddressLength,
LPDWORD lpdwBytesReceived,
LPOVERLAPPED lpOverlapped
);
乍一看起来参数不少,可是实际用起来也很简单:
这里面的参数却是没什么,看起来复杂,可是我们依旧能够一个一个传进去,而后在对应的IO操做完成以后,这些参数Windows内核天然就会帮我们填满了。
可是很是悲催的是,咱们这个是异步操做,咱们是在线程启动的地方投递的这个操做, 等咱们再次见到这些个变量的时候,就已是在Worker线程内部了,由于Windows会直接把操做完成的结果传递到Worker线程里,这样我们在启动的时候投递了那么多的IO请求,这从Worker线程传回来的这些结果,究竟是对应着哪一个IO请求的呢?。。。。
聪明的你确定想到了,是的,Windows内核也帮咱们想到了:用一个标志来绑定每个IO操做,这样到了Worker线程内部的时候,收到网络操做完成的通知以后,再经过这个标志来找出这组返回的数据到底对应的是哪一个Io操做的。
这里的标志就是以下这样的结构体:
typedef struct _PER_IO_CONTEXT{ OVERLAPPED m_Overlapped; // 每个重叠I/O网络操做都要有一个 SOCKET m_sockAccept; // 这个I/O操做所使用的Socket,每一个链接的都是同样的 WSABUF m_wsaBuf; // 存储数据的缓冲区,用来给重叠操做传递参数的,关于WSABUF后面还会讲 char m_szBuffer[MAX_BUFFER_LEN]; // 对应WSABUF里的缓冲区 OPERATION_TYPE m_OpType; // 标志这个重叠I/O操做是作什么的,例如Accept/Recv等 } PER_IO_CONTEXT, *PPER_IO_CONTEXT;
这个结构体的成员固然是咱们随便定义的,里面的成员你能够随意修改(除了OVERLAPPED那个以外……)。
可是AcceptEx不是普通的accept,buffer不是普通的buffer,那么这个结构体固然也不能是普通的结构体了……
在完成端口的世界里,这个结构体有个专属的名字“单IO数据”,是什么意思呢?也就是说每个重叠I/O都要对应的这么一组参数,至于这个结构体怎么定义无所谓,并且这个结构体也不是必需要定义的,可是没它……还真是不行,咱们能够把它理解为线程参数,就比如你使用线程的时候,线程参数也不是必须的,可是不传还真是不行……
除此之外,咱们也还会想到,既然每个I/O操做都有对应的PER_IO_CONTEXT结构体,而在每个Socket上,咱们会投递多个I/O请求的,例如咱们就能够在监听Socket上投递多个AcceptEx请求,因此一样的,咱们也还须要一个“单句柄数据”来管理这个句柄上全部的I/O请求,这里的“句柄”固然就是指的Socket了,我在代码中是这样定义的:
typedef struct _PER_SOCKET_CONTEXT { SOCKET m_Socket; // 每个客户端链接的Socket SOCKADDR_IN m_ClientAddr; // 这个客户端的地址 CArray<_PER_IO_CONTEXT*> m_arrayIoContext; // 数组,全部客户端IO操做的参数, // 也就是说对于每个客户端Socket // 是能够在上面同时投递多个IO请求的 } PER_SOCKET_CONTEXT, *PPER_SOCKET_CONTEXT;
这也是比较好理解的,也就是说咱们须要在一个Socket句柄上,管理在这个Socket上投递的每个IO请求的_PER_IO_CONTEXT。
固然,一样的,各位对于这些也能够按照本身的想法来随便定义,只要能起到管理每个IO请求上须要传递的网络参数的目的就行了,关键就是须要跟踪这些参数的状态,在必要的时候释放这些资源,不要形成内存泄漏,由于做为Server老是须要长时间运行的,因此若是有内存泄露的状况那是很是可怕的,必定要杜绝一丝一毫的内存泄漏。
至于具体这两个结构体参数是如何在Worker线程里大发神威的,咱们后面再看。
以上就是咱们所有的准备工做了,具体的实现各位能够配合个人流程图再看一下示例代码,相信应该会理解得比较快。
完成端口初始化的工做比起其余的模型来说是要更复杂一些,因此说对于主线程来说,它总以为本身付出了不少,总以为Worker线程是不劳而获,可是Worker本身的苦只有本身明白,Worker线程的工做一点也不比主线程少,相反还要更复杂一些,而且具体的通讯工做所有都是Worker线程来完成的,Worker线程反而还以为主线程是在旁边看热闹,只知道发号施令而已,可是你们终究仍是谁也离不开谁,这也就和公司里老板和员工的微妙关系是同样的吧……
【第五步】咱们再来看看Worker线程都作了些什么
_Worker线程的工做都是涉及到具体的通讯事务问题,主要完成了以下的几个工做,让咱们一步一步的来看。
(1) 使用 GetQueuedCompletionStatus() 监控完成端口
首先这个工做所要作的工做你们也能猜到,无非就是几个Worker线程哥几个一块儿排好队队来监视完成端口的队列中是否有完成的网络操做就行了,代码大致以下:
void *lpContext = NULL; OVERLAPPED *pOverlapped = NULL; DWORD dwBytesTransfered = 0; BOOL bReturn = GetQueuedCompletionStatus( pIOCPModel->m_hIOCompletionPort, &dwBytesTransfered, (LPDWORD)&lpContext, &pOverlapped, INFINITE );
各位留意到其中的GetQueuedCompletionStatus()函数了吗?这个就是Worker线程里第一件也是最重要的一件事了,这个函数的做用就是我在前面提到的,会让Worker线程进入不占用CPU的睡眠状态,直到完成端口上出现了须要处理的网络操做或者超出了等待的时间限制为止。
一旦完成端口上出现了已完成的I/O请求,那么等待的线程会被马上唤醒,而后继续执行后续的代码。
至于这个神奇的函数,原型是这样的:
BOOL WINAPI GetQueuedCompletionStatus( __in HANDLE CompletionPort, // 这个就是咱们创建的那个惟一的完成端口 __out LPDWORD lpNumberOfBytes, //这个是操做完成后返回的字节数 __out PULONG_PTR lpCompletionKey, // 这个是咱们创建完成端口的时候绑定的那个自定义结构体参数 __out LPOVERLAPPED *lpOverlapped, // 这个是咱们在连入Socket的时候一块儿创建的那个重叠结构 __in DWORD dwMilliseconds // 等待完成端口的超时时间,若是线程不须要作其余的事情,那就INFINITE就好了 );
因此,若是这个函数忽然返回了,那就说明有须要处理的网络操做了 --- 固然,在没有出现错误的状况下。
而后switch()一下,根据须要处理的操做类型,那咱们来进行相应的处理。
可是如何知道操做是什么类型的呢?这就须要用到从外部传递进来的loContext参数,也就是咱们封装的那个参数结构体,这个参数结构体里面会带有咱们一开始投递这个操做的时候设置的操做类型,而后咱们根据这个操做再来进行对应的处理。
可是还有问题,这个参数到底是从哪里传进来的呢?传进来的时候内容都有些什么?
这个问题问得好!
首先,咱们要知道两个关键点:
(1) 这个参数,是在你绑定Socket到一个完成端口的时候,用的CreateIoCompletionPort()函数,传入的那个CompletionKey参数,要是忘了的话,就翻到文档的“第三步”看看相关的内容;咱们在这里传入的是定义的PER_SOCKET_CONTEXT,也就是说“单句柄数据”,由于咱们绑定的是一个Socket,这里天然也就须要传入Socket相关的上下文,你是怎么传过去的,这里收到的就会是什么样子,也就是说这个lpCompletionKey就是咱们的PER_SOCKET_CONTEXT,直接把里面的数据拿出来用就能够了。
(2) 另外还有一个很神奇的地方,里面的那个lpOverlapped参数,里面就带有咱们的PER_IO_CONTEXT。这个参数是从哪里来的呢?咱们去看看前面投递AcceptEx请求的时候,是否是传了一个重叠参数进去?这里就是它了,而且,咱们能够使用一个很神奇的宏,把和它存储在一块儿的其余的变量,所有都读取出来,例如:
PER_IO_CONTEXT* pIoContext = CONTAINING_RECORD(lpOverlapped, PER_IO_CONTEXT, m_Overlapped);
这个宏的含义,就是去传入的lpOverlapped变量里,找到和结构体中PER_IO_CONTEXT中m_Overlapped成员相关的数据。
你仔细想一想,其实真的很神奇……
可是要作到这种神奇的效果,应该确保咱们在结构体PER_IO_CONTEXT定义的时候,把Overlapped变量,定义为结构体中的第一个成员。
只要各位能弄清楚这个GetQueuedCompletionStatus()中各类奇怪的参数,那咱们就离成功不远了。
既然咱们能够得到PER_IO_CONTEXT结构体,那么咱们就天然能够根据其中的m_OpType参数,得知此次收到的这个完成通知,是关于哪一个Socket上的哪一个I/O操做的,这样就分别进行对应处理就行了。
在个人示例代码里,在有AcceptEx请求完成的时候,我是执行的_DoAccept()函数,在有WSARecv请求完成的时候,执行的是_DoRecv()函数,下面我就分别讲解一下这两个函数的执行流程。
【第六步】当收到Accept通知时 _DoAccept()
在用户收到AcceptEx的完成通知时,须要后续代码并很少,但倒是逻辑最为混乱,最容易出错的地方,这也是不少用户为何宁愿用效率低下的accept()也不肯意去用AcceptEx的缘由吧。
和普通的Socket通信方式同样,在有客户端连入的时候,咱们须要作三件事情:
(1) 为这个新连入的链接分配一个Socket;
(2) 在这个Socket上投递第一个异步的发送/接收请求;
(3) 继续监听。
其实都是一些很简单的事情可是因为“单句柄数据”和“单IO数据”的加入,事情就变得比较乱。由于是这样的,让咱们一块儿缕一缕啊,最好是配合代码一块儿看,不然太抽象了……
(1) 首先,_Worker线程经过GetQueuedCompletionStatus()里会收到一个lpCompletionKey,这个也就是PER_SOCKET_CONTEXT,里面保存了与这个I/O相关的Socket和Overlapped还有客户端发来的第一组数据等等,对吧?可是这里得注意,这个SOCKET的上下文数据,是关于监听Socket的,而不是新连入的这个客户端Socket的,千万别弄混了……
(2) 因此,AcceptEx不是给我们新连入的这个Socket早就建好了一个Socket吗?因此这里,咱们须要再用这个新Socket从新为新客户端创建一个PER_SOCKET_CONTEXT,以及下面一系列的新PER_IO_CONTEXT,千万不要去动传入的这个Listen Socket上的PER_SOCKET_CONTEXT,也不要用传入的这个Overlapped信息,由于这个是属于AcceptEx I/O操做的,也不是属于你投递的那个Recv I/O操做的……,要不你下次继续监听的时候就悲剧了……
(3) 等到新的Socket准备完毕了,咱们就赶忙仍是用传入的这个Listen Socket上的PER_SOCKET_CONTEXT和PER_IO_CONTEXT去继续投递下一个AcceptEx,循环起来,留在这里太危险了,迟早得被人给改了……
(4) 而咱们新的Socket的上下文数据和I/O操做数据都准备好了以后,咱们要作两件事情:一件事情是把这个新的Socket和咱们惟一的那个完成端口绑定,这个就不用细说了,和前面绑定监听Socket是同样的;而后就是在这个Socket上投递第一个I/O操做请求,在个人示例代码里投递的是WSARecv()。由于后续的WSARecv,就不是在这里投递的了,这里只负责第一个请求。
可是,至于WSARecv请求如何来投递的,咱们放到下一节中去讲,这一节,咱们还有一个很重要的事情,我得给你们提一下,就是在客户端连入的时候,咱们如何来获取客户端的连入地址信息。
这里咱们还须要引入另一个很高端的函数,GetAcceptExSockAddrs(),它和AcceptEx()同样,都是微软提供的扩展函数,因此一样须要经过下面的方式来导入才能够使用……
和导出AcceptEx同样同样的,一样是须要用其GUID来获取对应的函数指针 m_lpfnGetAcceptExSockAddrs 。
说了这么多,这个函数到底是干吗用的呢?它是名副其实的“AcceptEx之友”,为何这么说呢?由于我前面提起过AcceptEx有个很神奇的功能,就是附带一个神奇的缓冲区,这个缓冲区厉害了,包括了客户端发来的第一组数据、本地的地址信息、客户端的地址信息,三合一啊,你说神奇不神奇?
这个函数从它字面上的意思也基本能够看得出来,就是用来解码这个缓冲区的,是的,它不提供别的任何功能,就是专门用来解析AcceptEx缓冲区内容的。例如以下代码:
PER_IO_CONTEXT* pIoContext = 本次通讯用的I/O Context SOCKADDR_IN* ClientAddr = NULL; SOCKADDR_IN* LocalAddr = NULL; int remoteLen = sizeof(SOCKADDR_IN), localLen = sizeof(SOCKADDR_IN); m_lpfnGetAcceptExSockAddrs(pIoContext->m_wsaBuf.buf, pIoContext->m_wsaBuf.len - ((sizeof(SOCKADDR_IN)+16)*2), sizeof(SOCKADDR_IN)+16, sizeof(SOCKADDR_IN)+16, (LPSOCKADDR*)&LocalAddr, &localLen, (LPSOCKADDR*)&ClientAddr, &remoteLen);
解码完毕以后,因而,咱们就能够从以下的结构体指针中得到不少有趣的地址信息了:
inet_ntoa(ClientAddr->sin_addr) 是客户端IP地址
ntohs(ClientAddr->sin_port) 是客户端连入的端口
inet_ntoa(LocalAddr ->sin_addr) 是本地IP地址
ntohs(LocalAddr ->sin_port) 是本地通信的端口
pIoContext->m_wsaBuf.buf 是存储客户端发来第一组数据的缓冲区
自从用了“AcceptEx之友”,一切都清净了….
【第七步】当收到Recv通知时, _DoRecv()
在讲解如何处理Recv请求以前,咱们仍是先讲一下如何投递WSARecv请求的。
WSARecv大致的代码以下,其实就一行,在代码中咱们能够很清楚的看到咱们用到了不少新建的PerIoContext的参数,这里再强调一下,注意必定要是本身另外新建的啊,必定不能是Worker线程里传入的那个PerIoContext,由于那个是监听Socket的,别给人弄坏了……:
这里,我再把WSARev函数的原型再给各位讲一下
int WSARecv( SOCKET s, // 固然是投递这个操做的套接字 LPWSABUF lpBuffers, // 接收缓冲区 // 这里须要一个由WSABUF结构构成的数组 DWORD dwBufferCount, // 数组中WSABUF结构的数量,设置为1便可 LPDWORD lpNumberOfBytesRecvd, // 若是接收操做当即完成,这里会返回函数调用所接收到的字节数 LPDWORD lpFlags, // 说来话长了,咱们这里设置为0 便可 LPWSAOVERLAPPED lpOverlapped, // 这个Socket对应的重叠结构 NULL // 这个参数只有完成例程模式才会用到, // 完成端口中咱们设置为NULL便可 );
其实里面的参数,若是大家熟悉或者看过我之前的重叠I/O的文章,应该都比较熟悉,只须要注意其中的两个参数:
这里是须要咱们本身new 一个 WSABUF 的结构体传进去的;
若是大家非要追问 WSABUF 结构体是个什么东东?我就给各位多说两句,就是在ws2def.h中有定义的,定义以下:
并且好心的微软还附赠了注释,真不容易….
看到了吗?若是对于里面的一些奇怪符号大家看不懂的话,也不用管他,只用看到一个ULONG和一个CHAR*就能够了,这不就是一个是缓冲区长度,一个是缓冲区指针么?至于那个什么 FAR…..让他见鬼去吧,如今已是32位和64位时代了……
这里须要注意的,咱们的应用程序接到数据到达的通知的时候,其实数据已经被我们的主机接收下来了,咱们直接经过这个WSABUF指针去系统缓冲区拿数据就行了,而不像那些没用重叠I/O的模型,接收到有数据到达的通知的时候还得本身去另外recv,过低端了……这也是为何重叠I/O比其余的I/O性能要好的缘由之一。
这个参数就是咱们所谓的重叠结构了,就是这样定义,而后在有Socket链接进来的时候,生成并初始化一下,而后在投递第一个完成请求的时候,做为参数传递进去就能够,
在第一个重叠请求完毕以后,咱们的这个OVERLAPPED 结构体里,就会被分配有效的系统参数了,而且咱们是须要每个Socket上的每个I/O操做类型,都要有一个惟一的Overlapped结构去标识。
这样,投递一个WSARecv就讲完了,至于_DoRecv()须要作些什么呢?其实就是作两件事:
(1) 把WSARecv里这个缓冲区里收到的数据显示出来;
(2) 发出下一个WSARecv();
Over……
至此,咱们终于深深的喘口气了,完成端口的大部分工做咱们也完成了,也很是感谢各位耐心的看我这么枯燥的文字一直看到这里,真是一个不容易的事情!!
【第八步】如何关闭完成端口
休息完毕,咱们继续……
各位看官不要高兴得太早,虽然咱们已经让咱们的完成端口顺利运做起来了,可是在退出的时候如何释放资源我们也是要知道的,不然岂不是功亏一篑…..
从前面的章节中,咱们已经了解到,Worker线程一旦进入了GetQueuedCompletionStatus()的阶段,就会进入睡眠状态,INFINITE的等待完成端口中,若是完成端口上一直都没有已经完成的I/O请求,那么这些线程将没法被唤醒,这也意味着线程无法正常退出。
熟悉或者不熟悉多线程编程的朋友,都应该知道,若是在线程睡眠的时候,简单粗暴的就把线程关闭掉的话,那是会一个很可怕的事情,由于不少线程体内不少资源都来不及释放掉,不管是这些资源最后是否会被操做系统回收,咱们做为一个C++程序员来说,都不该该容许这样的事情出现。
因此咱们必须得有一个很优雅的,让线程本身退出的办法。
这时会用到咱们此次见到的与完成端口有关的最后一个API,叫 PostQueuedCompletionStatus(),从名字上也能看得出来,这个是和 GetQueuedCompletionStatus() 函数相对的,这个函数的用途就是可让咱们手动的添加一个完成端口I/O操做,这样处于睡眠等待的状态的线程就会有一个被唤醒,若是为咱们每个Worker线程都调用一次PostQueuedCompletionStatus()的话,那么全部的线程也就会所以而被唤醒了。
PostQueuedCompletionStatus()函数的原型是这样定义的:
咱们能够看到,这个函数的参数几乎和GetQueuedCompletionStatus()的如出一辙,都是须要把咱们创建的完成端口传进去,而后后面的三个参数是 传输字节数、结构体参数、重叠结构的指针.
注意,这里也有一个很神奇的事情,正常状况下,GetQueuedCompletionStatus()获取回来的参数原本是应该是系统帮咱们填充的,或者是在绑定完成端口时就有的,可是咱们这里却能够直接使用PostQueuedCompletionStatus()直接将后面三个参数传递给GetQueuedCompletionStatus(),这样就很是方便了。
例如,咱们为了可以实现通知线程退出的效果,能够本身定义一些约定,好比把这后面三个参数设置一个特殊的值,而后Worker线程接收到完成通知以后,经过判断这3个参数中是否出现了特殊的值,来决定是不是应该退出线程了。
例如咱们在调用的时候,就能够这样:
for (int i = 0; i < m_nThreads; i++) { PostQueuedCompletionStatus(m_hIOCompletionPort, 0, (DWORD) NULL, NULL); }
为每个线程都发送一个完成端口数据包,有几个线程就发送几遍,把其中的dwCompletionKey参数设置为NULL,这样每个Worker线程在接收到这个完成通知的时候,再本身判断一下这个参数是否被设置成了NULL,由于正常状况下,这个参数老是会有一个非NULL的指针传入进来的,若是Worker发现这个参数被设置成了NULL,那么Worker线程就会知道,这是应用程序再向Worker线程发送的退出指令,这样Worker线程在内部就能够本身很“优雅”的退出了……
学会了吗?
可是这里有一个很明显的问题,聪明的朋友必定想到了,并且只有想到了这个问题的人,才算是真正看明白了这个方法。
咱们只是发送了m_nThreads次,咱们如何能确保每个Worker线程正好就收到一个,而后全部的线程都正好退出呢?是的,咱们没有办法保证,因此颇有可能一个Worker线程处理完一个完成请求以后,发生了某些事情,结果又再次去循环接收下一个完成请求了,这样就会形成有的Worker线程没有办法接收到咱们发出的退出通知。
因此,咱们在退出的时候,必定要确保Worker线程只调用一次GetQueuedCompletionStatus(),这就须要咱们本身想办法了,各位请参考我在Worker线程中实现的代码,我搭配了一个退出的Event,在退出的时候SetEvent一下,来确保Worker线程每次就只会调用一轮 GetQueuedCompletionStatus() ,这样就应该比较安全了。
另外,在Vista/Win7系统中,咱们还有一个更简单的方式,咱们能够直接CloseHandle关掉完成端口的句柄,这样全部在GetQueuedCompletionStatus()的线程都会被唤醒,而且返回FALSE,这时调用GetLastError()获取错误码时,会返回ERROR_INVALID_HANDLE,这样每个Worker线程就能够经过这种方式轻松简单的知道本身该退出了。固然,若是咱们不能保证咱们的应用程序只在Vista/Win7中,那仍是老老实实的PostQueuedCompletionStatus()吧。
最后,在系统释放资源的最后阶段,切记,由于完成端口一样也是一个Handle,因此也得用CloseHandle将这个句柄关闭,固然还要记得用closesocket关闭一系列的socket,还有别的各类指针什么的,这都是做为一个合格的C++程序员的基本功,在这里就很少说了,若是仍是有不太清楚的朋友,请参考个人示例代码中的 StopListen() 和DeInitialize() 函数。
六. 完成端口使用中的注意事项
终于到了文章的结尾了,不知道各位朋友是基本学会了完成端口的使用了呢,仍是被完成端口以及我这么多口水的文章折磨得不行了……
最后再补充一些前面没有提到了,实际应用中的一些注意事项吧。
1. Socket的通讯缓冲区设置成多大合适?
在x86的体系中,内存页面是以4KB为单位来锁定的,也就是说,就算是你投递WSARecv()的时候只用了1KB大小的缓冲区,系统仍是得给你分4KB的内存。为了不这种浪费,最好是把发送和接收数据的缓冲区直接设置成4KB的倍数。
2. 关于完成端口通知的次序问题
这个不用想也能知道,调用GetQueuedCompletionStatus() 获取I/O完成端口请求的时候,确定是用先入先出的方式来进行的。
可是,我们你们可能都想不到的是,唤醒那些调用了GetQueuedCompletionStatus()的线程是之后入先出的方式来进行的。
好比有4个线程在等待,若是出现了一个已经完成的I/O项,那么是最后一个调用GetQueuedCompletionStatus()的线程会被唤醒。日常这个次序却是不重要,可是在对数据包顺序有要求的时候,好比传送大块数据的时候,是须要注意下这个前后次序的。
-- 微软之因此这么作,那固然是有道理的,这样若是反复只有一个I/O操做而不是多个操做完成的话,内核就只须要唤醒同一个线程就能够了,而不须要轮着唤醒多个线程,节约了资源,并且能够把其余长时间睡眠的线程换出内存,提到资源利用率。
3. 若是各位想要传输文件…
若是各位须要使用完成端口来传送文件的话,这里有个很是须要注意的地方。由于发送文件的作法,按照正常人的思路来说,都会是先打开一个文件,而后不断的循环调用ReadFile()读取一块以后,而后再调用WSASend ()去发发送。
可是咱们知道,ReadFile()的时候,是须要操做系统经过磁盘的驱动程序,到实际的物理硬盘上去读取文件的,这就会使得操做系统从用户态转换到内核态去调用驱动程序,而后再把读取的结果返回至用户态;一样的道理,WSARecv()也会涉及到从用户态到内核态切换的问题 --- 这样就使得咱们不得不频繁的在用户态到内核态之间转换,效率低下……
而一个很是好的解决方案是使用微软提供的扩展函数TransmitFile()来传输文件,由于只须要传递给TransmitFile()一个文件的句柄和须要传输的字节数,程序就会整个切换至内核态,不管是读取数据仍是发送文件,都是直接在内核态中执行的,直到文件传输完毕才会返回至用户态给主进程发送通知。这样效率就高多了。
4. 关于重叠结构数据释放的问题
咱们既然使用的是异步通信的方式,就得要习惯一点,就是咱们投递出去的完成请求,不知道何时咱们才能收到操做完成的通知,而在这段等待通知的时间,咱们就得要千万注意得保证咱们投递请求的时候所使用的变量在此期间都得是有效的。
例如咱们发送WSARecv请求时候所使用的Overlapped变量,由于在操做完成的时候,这个结构里面会保存不少很重要的数据,对于设备驱动程序来说,指示保存着咱们这个Overlapped变量的指针,而在操做完成以后,驱动程序会将Buffer的指针、已经传输的字节数、错误码等等信息都写入到咱们传递给它的那个Overlapped指针中去。若是咱们已经不当心把Overlapped释放了,或者是又交给别的操做使用了的话,谁知道驱动程序会把这些东西写到哪里去呢?岂不是很崩溃……
暂时我想到的问题就是这么多吧,若是各位真的是要正儿八经写一个承受很大访问压力的Server的话,你慢慢就会发现,只用我附带的这个示例代码是不够的,还得须要在不少细节之处进行改进,例如用更好的数据结构来管理上下文数据,而且须要很是完善的异常处理机制等等,总之,很是期待你们的批评和指正。
谢谢你们看到这里!!!