IOCP编程之“双节棍”

在我博客之前的一些文章中,我讨论了关于使用BindIoCompletionCallback函数编写IOCP服务器的话题,在之后的一段时间中我也用此函数展开了伟大的服务器编程实践活动,在实际的应用中,我发现这个函数的很多怪脾气,今天我觉得有必要为大家澄清一下关于此函数的种种诽谤和传闻。

其实这是一个非常非常好用的函数,直接利用了Windows系统所有的优秀特性于一身——多线程、线程池、IOCP等等,我们要做的就是实现一个原型如下的CallBack函数:

VOID CALLBACK FileIOCompletionRoutine(

  [in]                 DWORD dwErrorCode,

  [in]                 DWORD dwNumberOfBytesTransfered,

  [in]                 LPOVERLAPPED lpOverlapped

);

他的参数中最有用的就是那个dwErrorCode,这里要特别注意的就是此参数并不像MSDN中说的那么简单的只是一些SOCKET错误码,在用SOCKET句柄代替文件句柄调用BindIoCompletionCallback函数的时候,dwErrorCode参数通常会返回一些0xC开头的系统内部状态码,比如常见的0xC000023F,表示目标地址不可到达,通常是目标UDP地址不可达或者目标UDP端口没有开放,这些错误码都可以在Windows的头文件中找到,通常在Ntstatus.h文件中。

通常我们可以通过调用RtlNtStatusToDosError函数来将这些内部的NTStatus码,翻译为所谓的“DOS”错误码,其实这个函数的名字很诡异,它实际完成就是从系统Ring0层错误码转换为Ring3层错误码,跟DOS其实没有关系。当然这个函数的调用也要通过LoadLibrary调入NtDll.Dll之后,再调用 GetProcAddress函数得到它的地址,然后调用之。

同时,还要特别注意的就是传说中BindIoCompletionCallback失败时,调用GetLastError函数得到错误码时,往往也需要用RtlNtStatusToDosError函数翻译一下,当然我没有遇到过BindIoCompletionCallback函数调用失败的情况,但是如果遇到了也不要慌张,这里已经告诉你了如何看懂这个诡异的错误码。

错误码搞清楚了,那么就可以从容不迫的处理关于回调FileIOCompletionRoutine发生的各种错误,从而让我们的服务器更健康。

当然今天我们讨论的不只是这个函数的错误码这么简单,我们要说的是“双节棍”,大家都知道“双节棍”,是有两根棍子用铁链链接起来才能发挥威力,这里说的BindIoCompletionCallback只是一根棍子,另一根棍子就是QueueUserWorkItem这个最简单的线程池函数。

两根棍子有了,链接到一起的方法就是在FileIOCompletionRoutine回调函数中根据操作类型,来调用QueueUserWorkItem将对应操作的WorkItem放入线程池,通常操作无非就是接收和发送数据,对应的需要编写“接收完成的WorkItem”和“发送完成的WorkItem”。这其实就是用普通线程池接力IOCP本身的线程池。为什么要这么啰嗦的做呢?

这是因为,在实践过程中,我发现,通常我们在接收到数据后,会做一些“慢速”的操作,最常见的就是访问数据库,虽然我用上了OLEDB,但是对于网络速度来说,似乎这还是慢的,尤其是有大量用户朝服务器发送数据请求时,我发现服务器响应很不尽人意,甚至有时出现了频繁掉线的情况。如果直接使用BindIoCompletionCallback通过在FileIOCompletionRoutine中直接调用这些慢速操作时,服务器的响应很低效,此时实际只有IOCP的线程池在工作,而我隐约感觉到这个IOCP的线程池是个很保守的线程池,它假设回调函数FileIOCompletionRoutine很快就会完成,否则就不再去响应其它的完成请求,基于这样的理解,我就在接收完成或发送完成操作之后直接调用QueueUserWorkItem再启动线程池,专门来处理数据,结果性能一下子有了质的飞跃,而且掉线的情况也成了凤毛麟角。在我实际用一个双核的破PC做测试时,服务器的响应一下子由500-1000连接飙升到了6000以上,大家不要笑哈,我说的这个成绩可是普通的破PC装的可是“菜羊”CPU,硬盘只是一块破SATA2盘,内存只有可怜的1G,这个成绩还是比较满意的。最后这个服务程序在上了8G内存双至强带Raid阵列的专业服务器之后,性能简直让我自己都瞠目结舌。

这里要特别提示的就是,在使用这个双节棍的结构中,内存管理就是核心的问题,尤其是要注意申请和释放,而要极力避免的就是共享内存,即尽量不要在线程池中使用并发控制来访问共享内存,对于一些应用来说这似乎又是无法避免的,我的策略是使用数据库来控制共享的状态,因为数据库的并发控制可以用“完美”这个词来形容,当然代价就是性能,但是只要你用了RAID,数据库还是不会让你失望的,至少这样我们省去了自己去做该死的并发控制的麻烦,而且不用担心服务器宕机,因为所有关键的状态都在数据库中,重启服务器自动就可以还原到原状态。

最后我需要澄清的一个事实就是,很多人说BindIoCompletionCallback函数不会启动超过一个线程以上的更多线程,我觉得这是个严重的诽谤,因为在单核单CPU机器上确实是这样的,但是在多核多CPU平台上,只要你没有BT的并发控制,线程数量一般是和CPU数目成正比的,我的服务器在双核机器上一般都会跑到10多个线程,因为有两个线程池,抛开调试时VS环境附加的几个线程,线程池的净线程数还是比较高的。另外要注意的一个问题就是似乎我们这里讨论的两类线程池函数都没有利用Intel的超线程技术,在单CPU超线程的机器上,你不会看到多余一个线程的壮观场面,而在多核CPU上,多个线程是可以被看到的。这也是我们选用IOCP线程池函数的初衷,因为它自动就可以适应多CPU的环境,带有先天的自动适应性。

当然目前Windows的线程池还很弱,除了2008平台以上,2003的服务器中线程池函数还是很弱,我们没法控制线程的亲缘性,没法控制线程的数量,没法处理线程的异常终止等等,我在BindIoCompletionCallback时就遇到过这类问题,因为回调函数的错误导致线程池罢工,服务器死锁等等情况,但是只要有了健壮的异常处理这类事故还是可以避免的,但是这都不是我们拒绝使用这根“双节棍”的理由,所谓“艺高人胆大,胆大艺更高”,只有不断的实践总结经验才是正确的道路。任何的道听途说,以讹传讹都不足为信。

在不久的将来,我将步入研究Windows2008平台“双节棍”的征程当中,因为2008的线程池比之2003的线程池那不是一般的强大。

谨以此文献给那些过去、或者现在、或者将来奋战在服务器开发一线的程序员兄弟们,提前祝大家2010年春节快乐!

............

展开阅读全文

没有更多推荐了,返回首页