完成端口(Q&A)

完成端口的最大优点在于其管理海量连接时的处理效率,通过操作系统内核的相关机制完成IO处理的高效率。注意:完成端口的优点在于管理连接量的巨大,而不是传输数据量的巨大。在这种场合最适合用完成端口:连接量巨大,且每个连接上收发的数据包容易比较小,通常只有几K甚至不到1K的字节。

完成端口基于监视线程+消息队列,只不过是内核实现的。引入完成端口,则通过在内核开启线程,在内核级别对需要监视的句柄进行监视,可以在驱动投递信息的第一时间发现,并立即使用内核线程进行处理,例如,TCP协议栈接收到了一个数据包,那么这个关联的句柄发生的变化会立即通知给内核对应线程,内核线程将立即处理。

使用完成端口,和二进制协议,例如基于包的传输协议的处理很容易,效率也很高,但是对主流的变长文本协议,例如HTTP服务,FTP服务,EMAIL服务等,完成端口的结合,则比较困难,在网络不好的情况下,效率直线降低,为什么呢?因为TCP有个内部缓冲,在完成端口下,这个缓冲的作用被弱化了,只要有一个包到达,完成端口就执行一次从缓冲里读操作检测执行,而通常应用,一个数据段会由很多包组成,这样,内核线程需要调度很多次,而如果使用非阻塞SOCKET读,通过定时检查机制,可以避免这种内部的频繁调度,但是,由于定时并且运行在用户态而非核心态,因此无法在第一时间知道变化。这么理解呢?就好象你要发快件,在完成端口下,你只要发现有一封快件就绪,你就立即送到快递公司里去处理,而SELECT下,你发现有快件就绪,则通知快递公司,让他们的快递员有空了上门,在定时检测机制下,则是不管有多少快件就绪,反正快递公司每天上门一次。这里的快递公司,就是执行的调用者。

程序设计上,线程不怕多,怕切换的频繁,即使有多于cpu个数的线程,例如使用完成端口的时候,总会有个主线程,但这个主线程在配置完完成端口后就彻底的休息了(可以是join或信号量方式),cpu一直工作在完成端口的工作线程上,也不会频繁切换。

为什么完成端口中工作线程的数目为cpu*2?

设计的目的是为了让cpu满负荷工作。会不会造成额外的线程切换?会,但正常情况下不会,这里异常就是指处理线程进入阻塞状态(Sleep()或者WaitForSingleObject()),这个时候cpu会切换到其他的线程。正常情况下,如果线程没有进入阻塞状态,在满负荷的工作,那么cpu会等待这个线程重回队列然后从队列中取出这个线程再次工作(后进先出,为了减少切换),所以正常情况下,虽然有cpu*2个线程,但是工作的线程也只有cpu数目个,不会有线程频繁切换,也可以把这些长期睡眠的线程换出内存。

ACE中的前摄器使用与完成端口有些类似,WSARecv对应ACE中的ACE_Asynch_Read_Stream,调用ACE_Asynch_Read_Stream的read请求最终在windows下调用的为WSARecv方法,投递了一个读的请求。所以使用起来也差不多,在连接建立后立刻投递一个,然后其他的投递都是在工作线程中处理完之后。

完成端口在win8中有个bug,如果主线程在最后等待退出的部分用的是一个while(){getline()}想处理一些调试命令的话,如果使用AcceptEx实现完成端口,那么线程中GetQueuedCompletionStatus() 是不会读到Accept事件的,除非在cmd上输入个回车,让那个while循环执行一次。

对于完成端口的使用可以参考http://qiusuoge.com/12451.html ,写的非常详细可惜没有找到对应的完整代码。说是最复杂的网络模型,但是看下来也没有什么新的概念与特殊的设计方法。之所以复杂可能与设计的比较灵活有关系。 使用过程中涉及到几个对象线程、socket连接(文件句柄)、完成端口,然后把他们都创建好之后在关联起来就可以了(有的是创建的时候关联),设计耦合度比较松。启动后,投递一个请求(Read或者Accept(使用AcceptEx的时候有这个请求)),socket监听消息,如果收到消息通过完成端口会产生一个事件(Acept或者Read),然后通知调用GetQueuedCompletionStatus()并阻塞上面的线程,然后线程中处理,处理完后继续投递请求。如此循环。工作方式有点类似使用conditionWait的信号量+消息队列(其实这个锁与信号量结合理解也比较麻烦)。

其他的问题,直接引用别的文章中的文字:

1.至于为什么叫Overlapped?Jeffrey Richter的解释是因为“执行I/O请求的时间与线程执行其他任务的时间是重叠(overlapped)的 对于完成端口这个概念,我一直不知道为什么它的名字是叫“完成端口”,我个人的感觉应该叫它“完成队列”似乎更合适一些,总之这个“端口”和我们平常所说的用于网络通信的“端口”完全不是一个东西,我们不要混淆了。

2.而AcceptEx比Accept又强大在哪里呢?是有三点: 1)这个好处是最关键的,是因为AcceptEx是在客户端连入之前,就把客户端的Socket建立好了,也就是说,AcceptEx是先建立的Socket,然后才发出的AcceptEx调用,也就是说,在进行客户端的通信之前,无论是否有客户端连入,Socket都是提前建立好了;而不需要像accept是在客户端连入了之后,再现场去花费时间建立Socket。如果各位不清楚是如何实现的,请看后面的实现部分。

2)相比accept只能阻塞方式建立一个连入的入口,对于大量的并发客户端来讲,入口实在是有点挤;而AcceptEx可以同时在完成端口上投递多个请求,这样有客户端连入的时候,就非常优雅而且从容不迫的边喝茶边处理连入请求了。

3)AcceptEx还有一个非常体贴的优点,就是在投递AcceptEx的时候,我们还可以顺便在AcceptEx的同时,收取客户端发来的第一组数据,这个是同时进行的,也就是说,在我们收到AcceptEx完成的通知的时候,我们就已经把这第一组数据接完毕了;但是这也意味着,如果客户端只是连入但是不发送数据的话,我们就不会收到这个AcceptEx完成的通知……这个我们在后面的实现部分,也可以详细看到。

3.如果各位需要使用完成端口来传送文件的话,这里有个非常需要注意的地方。因为发送文件的做法,按照正常人的思路来讲,都会是先打开一个文件,然后不断的循环调用ReadFile()读取一块之后,然后再调用WSASend ()去发发送。

但是我们知道,ReadFile()的时候,是需要操作系统通过磁盘的驱动程序,到实际的物理硬盘上去读取文件的,这就会使得操作系统从用户态转换到内核态去调用驱动程序,然后再把读取的结果返回至用户态;同样的道理,WSARecv()也会涉及到从用户态到内核态切换的问题 — 这样就使得我们不得不频繁的在用户态到内核态之间转换,效率低下……

而一个非常好的解决方案是使用微软提供的扩展函数TransmitFile()来传输文件,因为只需要传递给TransmitFile()一个文件的句柄和需要传输的字节数,程序就会整个切换至内核态,无论是读取数据还是发送文件,都是直接在内核态中执行的,直到文件传输完毕才会返回至用户态给主进程发送通知。这样效率就高多了。

参考:

http://www.cnblogs.com/pen-ink/articles/1834088.html
http://blog.csdn.net/ithzhang/article/details/8508161
http://qiusuoge.com/12451.html
http://blog.donews.com/sodme/archive/2005/08/30/533535.aspx
http://blog.sina.com.cn/s/blog_6b3ca99d0100krcs.html
http://blog.csdn.net/piggyxp/article/details/6922277

Table of Contents