2022年手把手教你玩转网络编程模型之完成端口篇收集 .pdf
手把手教你玩转网络编程模型系列之三完成端口(CompletionPort)篇-By PiggyXP(小猪)前言完成端口的代码在两年前就已经写好了,但是由于许久没有写东西了,不知该如何提笔,所以这篇文档总是在酝酿之中酝酿了两年之后,终于决定开始动笔了,但愿还不算晚.这篇文档我非常详细并且图文并茂的介绍了关于网络编程模型中完成端口的编程模型方方面面的信息,从 API 的用法到使用的步骤,从完成端口的实现机理到实际使用的注意事项,都有所涉及,并且为了让朋友们更直观的体会完成端口的用法,本文附带了有详尽注释的使用MFC 编写的图形界面的示例代码。我的初衷是希望写一份互联网上能找到的最详尽的关于完成端口的教学文档,而且让对Socket编程略有了解的人都能够看得懂,都能学会如何来使用完成端口这么优异的网络编程模型,但是由于本人水平所限,不知道我的初衷是否实现了,但还是希望各位需要的朋友能够喜欢。由于篇幅原因,本文假设你已经熟悉了利用Socket进行 TCP/IP编程的基本原理,并且也熟练的掌握了多线程编程技术,太基本的概念我这里就略过不提了,网上的资料应该遍地都是。本文档凝聚着笔者心血,如要转载,请指明原作者及出处,谢谢!不过代码没有版权,可以随便散播使用,欢迎改进,特别是非常欢迎能够帮助我发现Bug 的朋友,以更好的造福大家。_ 本文配套的示例源码下载地址(在我的下载空间里)http:/ go!Have fun!名师资料总结-精品资料欢迎下载-名师精心整理-第 1 页,共 26 页 -目录: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 配置如下:名师资料总结-精品资料欢迎下载-名师精心整理-第 2 页,共 26 页 -图 1 笔者计算机配置信息大体就是 i7 2600+16GB内存,我们进行如下的测试,通过Client生成 3 万个并发线程同时连接至 Server,然后每个线程每隔3 秒中发送一次数据,一共发送3 次,然后观察服务器端的CPU 和内存的占用情况。如图 2 所示,是客户端3 万个并发线程发送共发送9 万条数据的log 截图图 2 客户端发 3 万并发线程log 信息名师资料总结-精品资料欢迎下载-名师精心整理-第 3 页,共 26 页 -图 3 是服务器端接收完毕3 万个并发线程和每个线程的3 份数据后的log 截图图 3 服务器端接收3 万并发线程log 信息最关键是图4,图 4 是服务器端在接收到28000个并发线程的时候,CPU 占用率的截图,使用的软件是大名鼎鼎的Process Explorer,因为相对来讲这个比自带的任务管理器要准确和精确一些。图 4 服务器端和客户端的CPU 占用情况名师资料总结-精品资料欢迎下载-名师精心整理-第 4 页,共 26 页 -我们可以发现一个令人惊讶的结果,采用了完成端口的Server程序(蓝色横线所示)所占用的 CPU 才为 3.82%,整个运行过程中的峰值也没有超过4%,是相当气定神闲的,哦,对了,这还是在Debug模式下运行的情况,如果采用Release方式执行,性能肯定还会更高一些,除此以外,在UI 上显示信息也很大成都上影响了性能。相反采用了多个并发线程的Client程序(紫色横线所示)居然占用的CPU 高达 11.53%,甚至超过了Server程序的数倍,其实无论是哪种网络操模型,对于内存占用都是差不多的,真正的差别就在于CPU 的占用,其他的网络模型都需要更多的CPU 动力来支撑同样的连接数据。虽然这远远算不上服务器极限压力测试,但是从中也可以看出来完成端口的实力,而且这种方式比纯粹靠多线程的方式实现并发资源占用率要低得多。三完成端口的相关概念在开始编码之前,我们先来讨论一下和完成端口相关的一些概念,如果你没有耐心看完这段大段的文字的话,也可以跳过这一节直接去看下下一节的具体实现部分,但是这一节中涉及到的基本概念你还是有必要了解一下的,而且你也更能知道为什么有那么多的网络编程模式不用,非得要用这么又复杂又难以理解的完成端口呢?也会坚定你继续学习下去的信心_ 1.异步通信机制及其几种实现方式的比较我们从前面的文字中了解到,高性能服务器程序使用异步通信机制是必须的。而对于异步的概念,为了方便后面文字的理解,这里还是再次简单的描述一下:异步通信 就是在咱们与外部的I/O 设备进行打交道的时候,我们都知道外部设备的I/O 和 CPU 比起来简直是龟速,比如硬盘读写、网络通信等等,我们没有必要在咱们自己的线程里面等待着I/O操作完成再执行后续的代码,而是将这个请求交给设备的驱动程序自己去处理,我们的线程可以继续做其他更重要的事情,大体的流程如图1 所示:图 1 异步通信流程我可以从图中看到一个很明显的并行操作的过程,而“同步”的通信方式是在进行网络操作的时候,应用程序发出一个网络操作调用异步:这个网络操作会由操作系统来帮我们完成异步:应用程序执行其他的事情,而不用挂起等待网络操作会和我们的应用程序同步进行通知应用程序处理网络数据网络操作完成名师资料总结-精品资料欢迎下载-名师精心整理-第 5 页,共 26 页 -主线程就挂起了,主线程要等待网络操作完成之后,才能继续执行后续的代码,是没法这样并行的;这样无疑比“阻塞模式+多线程”的方式效率要高的多,这也是前者为什么叫“异步”,后者为什么叫“同步”的原因了,因为不需要等待网络操作完成再执行别的操作。而在 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之流只是会接收到数据到达的通知,而只名师资料总结-精品资料欢迎下载-名师精心整理-第 6 页,共 26 页 -能由应用程序自己再另外去recv数据,性能上的差距就更明显了。最后,我的建议是,想要使用基于事件通知的重叠I/O和基于完成例程的重叠I/O的朋友,如果不是特别必要,就不要去使用了,因为这两种方式不仅使用和理解起来也不算简单,而且还有性能上的明显瓶颈,何不就再努力一下使用完成端口呢?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.完成端口(CompletionPort)对于完成端口这个概念,我一直不知道为什么它的名字是叫“完成端口”,我个人的感觉应该叫它“完成队列”似乎更合适一些,总之这个“端口”和我们平常所说的用于网络通信的“端口”完全不是一个东西,我们不要混淆了。首先,它之所以叫“完成”端口,就是说系统会在网络I/O操作“完成”之后才会通知我们,也就是说,我们在接到系统的通知的时候,其实网络操作已经完成了,就是比如说在系统通知我们的时候,并非是有数据从网络上到来,而是来自于网络上的数据已经接收完毕了;或者是客户端的连入请求已经被系统接入完毕了等等,我们只需要处理后面的事情就好了。各位朋友可能会很开心,什么?已经处理完毕了才通知我们,那岂不是很爽?其实也没什么爽的,那是因为我们在之前给系统分派工作的时候,都嘱咐好了,我们会通过代码告诉系统“你给我做这个做那个,等待做完了再通知我”,只是这些工作是做在之前还是之后的区别而已。其次,我们需要知道,所谓的完成端口,其实和 HANDLE一样,也是一个内核对象,虽然 Jeff Richter吓唬我们说:“完成端口可能是最为复杂的内核对象了”,但是我们也不用去管他,因为它具体的内部如何实现的和我们无关,只要我们能够学会用它相关的API 把这个完成端口的框架搭建起来就可以了。我们暂时只用把它大体理解为一个容纳网络通信操作的队列就好了,它会把网络操作完成的通知,都放在这个队名师资料总结-精品资料欢迎下载-名师精心整理-第 7 页,共 26 页 -列里面,咱们只用从这个队列里面取就行了,取走一个就少一个,。关于完成端口内核对象的具体更多内部细节我会在后面的“完成端口的基本原理”一节更详细的和朋友们一起来研究,当然,要是你们在文章中没有看到这一节的话,就是说明我又犯懒了没写,在后续的文章里我会补上。这里就暂时说这么多了,到时候我们也可以看到它的机制也并非有那么的复杂,可能只是因为操作系统其他的内核对象相比较而言实现起来太容易了吧_ 四完成端口的基本流程说了这么多的废话,大家都等不及了吧,我们终于到了具体编码的时候了。使用完成端口,说难也难,但是说简单,其实也简单-又说了一句废话=。=大体上来讲,使用完成端口只用遵循如下几个步骤:(1)调用 CreateIoCompletionPort()函数创建一个完成端口,而且在一般情况下,我们需要且只需要建立这一个完成端口,把它的句柄保存好,我们今后会经常用到它,(2)根据系统中有多少个处理器,就建立多少个工作者(为了醒目起见,下面直接说Worker)线程,这几个线程是专门用来和客户端进行通信的,目前暂时没什么工作;(3)下面就是接收连入的Socket连接了,这里有两种实现方式:一是和别的编程模型一样,还需要启动一个独立的线程,专门用来 accept客户端的连接请求;二是用性能更高更好的异步AcceptEx()请求,因为各位对accept用法应该非常熟悉了,而且网上资料也会很多,所以为了更全面起见,本文采用的是性能更好的AcceptEx,至于两者代码编写上的区别,我接下来会详细的讲。(4)每当有客户端连入的时候,我们就还是得调用CreateIoCompletionPort()函数,这里却不是新建立完成端口了,而是把新连入的Socket(也就是前面所谓的设备句柄),与目前的完成端口绑定在一起。至此,我们其实就已经完成了完成端口的相关部署工作了,嗯,是的,完事了,后面的代码里我们就可以充分享受完成端口带给我们的巨大优势,坐享其成了,是不是很简单呢?(5)例如,客户端连入之后,我们可以在这个Socket上提交一个网络请求,例如WSARecv(),然后系统就会帮咱们乖乖的去执行接收数据的操作,我们大可以放心的去干别的事情了;(6)而此时,我们预先准备的那几个Worker线程就不能闲着了,我们在前面建立的几个Worker就要忙活起来了,都需要分别调用GetQueuedCompletionStatus()函数在扫描完成端口的队列里是否有网络通信的请求存在(例如读取数据,发送数据等),一旦有的话,就将这个请求从完成端口的队列中取回来,继续执行本线程中后面的处理代码,处理完毕之后,我们再继续投递下一个网络通信的请求就OK 了,如此循环。关于完成端口的使用步骤,用文字来表述就是这么多了,很简单吧?如果你还是不理解,我再配合一个流程图来表示一下:当然,我这里假设你已经对网络编程的基本套路有了解了,所以略去了很多基本的细节,并且为了配合朋友们更好的理解我的代码,在流程图我标出了一些函数的名字,另外需要注意的是由于对于客户端的连入有两种方式,一种是普通阻塞的accept,另外一种是性能更好的AcceptEx,为了能够方面朋友们从别的网络编程的方式中过渡,我这里画了两种方式的流程图,方便朋友们对比学习,图1 是使用accept的方式,当然配套的源代码我默认就不提供了,如果需要的话,我倒是也可以发上来;图 2 是使用 AcceptEx的,并配有配套的源码:名师资料总结-精品资料欢迎下载-名师精心整理-第 8 页,共 26 页 -图 1 使用 accept进行完成端口通信的流程图初始化 Socket库LoadSocketLib()服务器端初始化StartListen()初始化 Socket的一系列参数initListenSocket()建立完成端口及配置参数initIOCP()线程一:启动监听线程,用于接收客户端连入的 Socket_AcceptThread()是否有客户端连入监听端口否将连入的 Socket和现有的完成端口绑定AcceptConnection()在连入的这个 Socket上投递一个 WSARecv用户是否停止?结束StopListen()否是是线程二:启动数个Worker线程,用来接收网络操作完成的通知_WorkerThread()从系统缓冲区中读取数据检查完成端口的状态,是否有网络操作到达GetQueuedCompletionStatus用户是否停止?否是主线程无所事事,Worker 线程很忙主线程:main()通信结束,通知线程们退出PostQueuedCompletionStatus在这个Socket上投递下一个 WSARecv名师资料总结-精品资料欢迎下载-名师精心整理-第 9 页,共 26 页 -图 2 使用 AcceptEx进行完成端口通信的流程图两个图中最大的相同点是什么?是的,最大的相同点就是主线程无所事事,闲得蛋疼,为什么呢?因为我们使用了异步的通信机制,这些琐碎重复的事情完全没有必要交给主线程自己来做了,只用在初始化的时候和Worker线程交待好就可以了,用一句话来形容就是,主线程永远也体会不到Worker线程有多忙,而Worker线程也永远体会不到主线程在初始化建立起这个通信框架的时候操了多少的心,图 1 中是由 _AcceptThread()负责接入连接,并把连入的Socket和完成端口绑定,另外的多个_WorkerThread()就负责监控完成端口上的情况,一旦有情况了,就取出来处理,如果 CPU 有多核的话,初始化 Socket库_LoadSocketLib()服务器端初始化_Start()初始化 Socket的一系列参数_initListenSocket()建立完成端口及配置参数_initIOCP()结束Stop()唯一的线程:启动数个Worker 线程,用来接收网络操作完成的通知_WorkerThread()检查完成端口的状态,是否有网络操作到达GetQueuedCompletionStatus否主线程无所事事,_Worker 线程很忙主线程:main()通信结束,通知 Worker 线程们退出PostQueuedCompletionStatus()判断网络操作的类型Switch(OpType)分支二:如果是 Recv操作_DoRecv()投递多个 AcceptEx异步请求_PostAccept()将Listen Socket和完成端口绑定分支一:如果是 Accept操作_DoAccpet()在ListenSocket上投递下一个AcceptEx_PostAccept()用户是否停止?将新连入的 Socket和完成端口绑定在新连入的 Socket上投递第一个 WSARecv 请求_PostRecv()处理接收到的数据在新这个 Socket上投递下一个WSARecv 请求_PostRecv()是名师资料总结-精品资料欢迎下载-名师精心整理-第 10 页,共 26 页 -就可以多个线程轮着来处理完成端口上的信息,很明显效率就提高了。图 2 中最明显的区别,也就是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完成的通知,这个我们在后面的实现部分,也可以详细看到。总而言之,对于普通的PC 机来讲,我们只有使用AcceptEx,才可以使我们的并发处理的连接请求达到单主机 65000左右的上限。什么?超过这个上限?No,no,无论你换成多么强大的Server,也不可能超过65535的上限的,因为各位可以去看下表示地址信息的SOCKADDR_IN的定义,对于端口的部分,使用的是unsigned short,这也就意味着端口编号的上限就是65535了,另外系统自己的各种服务还会占用一些端口,所以65000基本就是极限值了。当然,我说的是IPV4,IPV6 就另当别论了。最后,各位要有一个心里准备,相比accept,异步的 AcceptEx使用起来要麻烦得多,名师资料总结-精品资料欢迎下载-名师精心整理-第 11 页,共 26 页 -五完成端口的实现详解又说了一节的废话,终于到了该动手实现的时候了,这里我把完成端口的详细实现步骤以及会涉及到的函数,按照出现的先后步骤,都和大家详细的说明解释一下,当然,文档中为了让大家便于阅读,这里去掉了其中的错误处理的内容,当然,这些内容在示例代码中是会有的。【第一步】创建一个完成端口首先,我们先把完成端口建好再说。我们正常情况下,我们需要且只需要建立这一个完成端口,代码很简单: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线程,如图3 所示名师资料总结-精品资料欢迎下载-名师精心整理-第 12 页,共 26 页 -图 3 在 i7 2600上初始化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 HANDLEm_nThreads;for(int i=0;i 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 );乍一看起来参数很多,但是实际用起来也很简单:参数 1-sListenSocket,这个就是那个唯一的用来监听的Socket 了,没什么说的;参数 2-sAcceptSocket,用于接受连接的 socket,这个就是那个需要我们事先建好的,等有客户端连接进来直接把这个Socket拿给它用的那个,是AcceptEx 高性能的关键所在。参数 3-lpOutputBuffer,接收缓冲区,这也是 AcceptEx比较有特色的地方,既然 AcceptEx不是普通的 accpet 函数,那么这个缓冲区也不是普通的缓冲区,这个缓冲区包含了三个信息:一是客户端发来的第一组数据,二是server的地址,三是 client 地址,都是精华啊,但是读取起来就会很麻烦,不过后面有一个更好的解决方案。参数 4-dwReceiveDataLength,前面那个参数 lpOutputBuffer中用于存放数据的空间大小。如果此参数=0,则 Accept 时将不会待数据到来,而直接返回,如果此参数不为0,那么一定得等接收到数据了才会返回,所以通常当需要Accept 接收数据时,就需要将该参数设成为:sizeof(lpOutputBuffer)-2*(sizeof sockaddr_in+16),也就是说总长度减去两个地址空间名师资料总结-精品资料欢迎下载-名师精心整理-第 16 页,共 26 页 -的长度就是了,看起来复杂,其实想明白了也没啥,参数 5-dwLocalAddressLength,存放本地址地址信息的空间大小;参数 6-dwRemoteAddressLength,存放本远端地址信息的空间大小;参数 7-lpdwBytesReceived,out 参数,对我们来说没用,不用管;参数 8-lpOverlapped,本次重叠 I/O 所要用到的重叠结构。这里面的参数倒是没什么,看起来复杂,但是咱们依旧可以一个一个传进去,然后在对应的IO 操作完成之后,这些参数Windows内核自然就会帮咱们填满了。但是非常悲催的是,我们这个是异步操作,我们是在线程启动的地方投递的这个操作,等我们再次见到这些个变量的时候,就已经是在Worker线程内部了,因为Windows会直接把操作完成的结果传递到Worker线程里,这样咱们在启动的时候投递了那么多的IO 请求,这从 Worker线程传回来的这些结果,到底是对应着哪个IO 请求的呢?。聪明的你肯定想到了,是的,Windows内核也帮我们想到了:用一个标志来绑定每一个IO 操作,这样到了 Worker线程内部的时候,收到网络操作完成的通知之后,再通过这个标志来找出这组返回的数据到底对应的是哪个Io 操作的。这里的标志就是如下这样的结构体:typedefstruct _PER_IO_CONTEXT OVERLAPPED m_Overlapped;/每一个重叠 I/O网络操作都要有一个SOCKET m_sockAccept;/这个I/O 操作所使用的 Socket,每个连接的都是一样的WSABUF m_wsaBuf;/存储数据的缓冲区,用来给重叠操作传递参数的,关于WSABUF/后面还会讲charm_szBufferMAX_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