IOCP编程原理(转)

在个人博客以前写了不少关于IOCP的“行云流水”似的看了让人发狂的文章,尤为是几篇关于 IOCP加线程池文章,更是让一些功力不够深厚的初学IOCP者,有种吐血的感受。为了让你们可以马上提高内力修为,而且迅速的掌握IOCP这个 Windows平台上的乾坤大挪移心法,此次我决定给你们好好补补这个基础。程序员

要 想完全征服IOCP,并应用好IOCP这个模型,首先就让咱们穿越到遥远的计算机青铜器时代(以出现PC为标志),那时候普通的PC安装的仍是DOS平 台,微软公司主要靠这个操做系统在IT界的原始丛林中打拼,在DOS中编写程序,不得不与不少的硬件直接打交道,而最常操做的硬件无非是键盘、声显卡、硬 盘等等,这些设备都有一个特色就是速度慢,固然是相对于PC平台核心CPU的速度而言,尤为是硬盘这个机械电子设备,其速度对于彻底电子化得CPU来讲简 直是“相对静止”的设备。不少时候CPU能够干完n件(n>1000)事情的时间中,这些硬件可能尚未完成一件事情,显然让CPU和这些硬件同步 工做将是一种严重的浪费,而且也不太可能,此时,聪明的硬件设计师们发明了一种叫作中断的操做方式,用以匹配这种速度上的严重差别。中断工做的基本原理就 是,CPU首先设置一个相似回调函数的入口地址,其次CPU对某个硬件发出一个指令,此时CPU就去干别的活计了,最后那个慢的象蜗牛同样的硬件执行完那 个指令后,就通知CPU,让CPU暂时“中断”手头的工做,去调用那个“回调函数”。至此一个完整的中断调用就结束了。这个模型曾经解决了显卡与CPU不 同步的问题,最重要的是解决了硬盘速度与CPU速度严重不匹配的问题,并所以还派生出了更有名的DMA(直接内存访问技术,主要是指慢速硬件能够读写本来 只能由CPU直接读写的内存)硬盘IO方式。(注意这里说的中断工做方式只是中断工做方式的一种,并非所有,详细的中断原理请参阅其它专业文献。)算法

其 实“中断”方式更像是一种管理模型,好比在一个公司中,若是要老板时时刻刻盯着员工做事情,那么除非是超人,不然无人可以胜任,同时对于老板这个稀缺资源 来讲也是一种极起严重的浪费。更多时候老板只是发指令给员工,而后员工去执行,而老板就能够作别的事情,或者干脆去打高尔夫休息,当员工完成了任务就会通 过电话、短信、甚至e-mail等通知老板,此时老板就去完成一个响应过程,好比总结、奖罚、发出新指令等等。由此也看出若是一个公司的“老板占用率” (相似CPU占用率)过高,那么就说明两种状况:要么是它的员工很高效,单位时间内完成的指令很是多;要么是公司尚未创建有效的“中断”响应模型。若是 你的公司是后者,那么你就能够试着用这个模型改造公司的管理了,由此你能够晋升到管理层,而不用再去管你的服务端程序有没有使用IOCP了,呵呵呵。数据库

若是真的搞明白了这个传说中的“中断”操做方式,那么理解IOCP的基本原理就不费劲了。编程

结 束了计算机的青铜时代后,让咱们穿越到如今这个“计算机蒸汽”时代,(注意不是“计算机IT”时代,由于计算机还无法本身编写程序让本身去解决问题)。在 现代,Windows几乎成了PC平台上的标准系统,而PC平台上的几大件仍是没有太大的变化,除了速度愈来愈快。而由于操做系统的美妙封装,咱们也不用 再去直接同硬件打交道了,固然编写驱动程序的除外。安全

在Windows平台上,我 们不断的调用着WriteFile和ReadFile这些抽象的函数,操做着“文件”这种抽象的信息集合,不少时候调用这些函数时,是以一种“准同步”的 方式操做硬件的,好比要向一个文件中写入1M的信息,只有等到WriteFile函数返回,操做才算结束,这个过程当中,咱们的程序则相似死机同样,等待硬 盘写入操做的结束(实际是被系统切换出了当前的CPU时间片)。于此同时,调用了WriteFile的线程则没法干别的任何事情。由于整个线程是在以一种 称为过程化的模型中运行,全部的处理流程所有是线性的。对于程序的流畅编写来讲,线性化的东西是一个很是好的东西,甚至几乎早期不少标准的算法都是基于程 序是过程化得这一假设而设计的。而对于一些多任务、多线程环境来讲,这种线性的工做方式会使系统严重低效,甚至形成严重的浪费,尤为在现代多核CPU已成 为主流的时候,显然让一个CPU内核去等待另外一个CPU内核完成某过后再去工做,是很是愚蠢的一种作法。网络

面 对这种状况,不少程序员的选择是多线程,也就是专门让一个线程去进行读写操做,而别的线程继续工做,以绕开这些看起来像死机同样的函数,可是这个读写线程 自己仍是以一种与硬盘同步的方式工做的。然而这并非解决问题的最终方法。咱们能够想象一个繁忙的数据库系统,要不断的读写硬盘上的文件,可能在短短的一 秒钟时间就要调用n屡次WriteFile或ReadFile,假设这是一个网站的后台数据库,那么这样的读写操做有时还可能都是较大的数据块,好比网站 的图片就是比较典型的大块型数据,这时显然一个读写线程也是忙不过来的,由于颇有可能一个写操做尚未结束,就会又有读写操做请求进入,这时读写线程几乎 变成了无响应的一个线程,能够想象这种状况下,程序可能几乎总在瘫痪状态,全部其它的线程都要等待读写操做线程完活。也许你会想多建n个线程来进行读写操 做,其实这种状况会更糟糕,由于无论你有多少线程,先不说浪费了多少系统资源,而你读写的多是相同的一块硬盘,只有一条通道,结果依然是同样的,想象硬 盘是独木桥,而有不少人(线程)等着过桥的情形,你就知道这更是一个糟糕的情形。因此说在慢速的IO面前,多线程每每不是“万灵丹”。数据结构

面 对这种情形,微软公司为Windows系统专门创建了一种相似“青铜时代”的中断方式的模型来解决这个问题。固然,不能再像那个年代那样直接操做硬件了, 须要的是旧瓶装新酒了。微软是如何作到的呢,实际仍是经过“回调函数”来解决这个问题的,大体也就是要咱们去实现一个相似回调函数的过程,主要用于处理来 自系统的一些输入输出操做“完成”的通知,至关于一个“中断”,而后就能够在过程当中作输入输出完成的一些操做了。好比在IO操做完成后删除缓冲,继续发出 下一个命令,或者关闭文件,设备等。实际上从逻辑的角度来说,咱们依然能够按照线性的方法来分析整个过程,只不过这是须要考虑的是两个不一样的函数过程之间 的线性关系,第一个函数是发出IO操做的调用者,而第二个函数则是在完成IO操做以后的被调用者,。而被调用的这个函数在输入输出过程当中是不活动的,也不 占用线程资源,它只是个过程(其实就是个函数,内存中的一段代码而已)。调用这些函数则须要一个线程的上下文,实际也就是一个函数调用栈,不少时候,系统 会借用你进程空间中线程来调用这个过程,固然前提条件是事先将能够被利用的线程设置成“可警告”状态,这也是线程可警告状态的所有意义,也就是大多数内核 同步等待函数bAlertable(有些书翻译作可警告的,我认为应该理解为对IO操做是一种“时刻警戒”的状态)参数被传递TRUE值以后的效果。比 如:WaitForSingleObjectEx、SleepEx等等。多线程

固然上 面说的这种方式实际上是一种“借用线程”的方式,当进程中没有线程可借,或者可借的线程自己也比较忙碌的时候,会形成严重的线程争用状况,从而形成总体性能 低下,这个方式的局限性也就显现出来了。注意“可警告”状态的线程,并不老是在能够被借用的状态,它们自己每每也须要完成一些工做,而它调用一些可以让它 进入等待状态的函数时,才能够被系统借用,不然仍是不能被借用的。固然借用线程时由于系统有效的保护了栈环境和寄存器环境,因此被借用的线程再被还回时线 程环境是不会被破坏的。并发

鉴于借用的线程的不方便和不专业,咱们更但愿经过明确的 “建立”一批专门的线程来调用这些回调函数(为了可以更深刻的理解,能够将借用的线程想象成出租车,而将专门的线程想象成私家车),所以微软就发明了 IOCP“完成端口”这种线程池模型,注意IOCP本质是一种线程池的模型,固然这个线程池的核心工做就是去调用IO操做完成时的回调函数,这就叫专业! 这也是IOCP名字的来由,这就比借用线程的方式要更加高效和专业,由于这些线程是专门建立来作此工做的,因此不用担忧它们还会去作别的工做,而形成忙碌 或不响应回调函数的状况,另外由于IO操做毕竟是慢速的操做,因此几个线程就已经足能够应付成千上万的输入输出完成操做的请求了(还有一个前提就是你的回 调函数作的工做要足够少),因此这个模型的性能是很是高的。也是如今Windows平台上性能最好的输入输出模型。它首先就被用来处理硬盘操做的输入输 出,同时它也支持邮槽、管道、甚至WinSock的网络输入输出。app

至此对于完成端口的本质原理应该有了一个比较好的理解,尤为是掌握了IOCP是线程池模型的这一本质,那么对于以后的IOCP实际应用就不会有太多的疑问了。接下去就让咱们从实际编程的角度来了解一下IOCP,也为完全掌握IOCP编程打下坚实的基础。

要应用IOCP,首先就要咱们建立一个叫作IOCP的内核对象,这须要经过CreateIoCompletionPort这个函数来建立,这个函数的原型以下:

HANDLE WINAPI CreateIoCompletionPort(

  __in          HANDLE FileHandle,

  __in          HANDLE ExistingCompletionPort,

  __in          ULONG_PTR CompletionKey,

  __in          DWORD NumberOfConcurrentThreads

);

这 个函数是个自己具备多重功能的函数(Windows平台上这样的函数并很少),须要用不一样的方式来调用,以实现不一样的功能,它的第一个功能正如其名字所描 述的“Create”,就是建立一个完成端口的内核对象,要让他完成这个功能,只须要指定NumberOfConcurrentThreads参数便可, 前三个参数在这种状况下是没有意义的,只须要所有传递NULL便可,象下面这样咱们就建立了一个完成端口的内核对象:

HANDLE hICP = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,0,1);

这 里首先解释下为何第一个参数不是NULL而是INVALID_HANDLE_VALUE,由于第一个参数按照定义是一个文件的句柄,也就是须要IOCP 操做的文件句柄,而表明“NULL”文件句柄的实际值是INVALID_HANDLE_VALUE,这是由于NULL实际等于0,而0这个文件句柄被用于 特殊用途,因此要用INVALID_HANDLE_VALUE来表明“NULL”意义的文件,INVALID_HANDLE_VALUE的值是-1或者 0xFFFFFFFF。

最后一个参数 NumberOfConcurrentThreads就有必要好好细细的说说了,由于不少文章中对于这个参数老是说的含糊其辞,不知所云,有些文章中甚至 人云亦云的说赋值为CPU个数的2倍便可,所谓知其然,不知其因此然。其实这个参数的真实含义就是“真正并发同时执行的最大线程数”,这个并发是真并发, 怎么去理解呢,若是你有两颗CPU,而你赋值为2那么就是说,在每颗CPU上执行一个线程,而且真正的并发同时执行,固然若是你设置了比CPU数量更大的 数值,它的含义就变成了一个理论并发值,而实际系统的最大可能的严格意义上的并发线程数就是CPU个数,也就是你在任务管理器中看到的CPU个数(多是 物理个数,也多是内核个数,还有多是超线程个数,或者它们的积)。讲到这里你们也许就有疑问了,为何有些文章资料中说要设置成CPU个数的2倍呢? 这一般是一个半经验值,由于大多数IOCP完成回调的过程当中,须要一些逻辑处理,有些是业务性的,有些要访问数据库,有些还可能访问硬盘,有些可能须要进 行数据显示等等,不管哪一种处理,这老是要花费时间的,而系统发现你设置了超过CPU个数的并发值时,那么它就尽量的来回切换这些线程,使他们在一个时间 段内看起来像是并发的,好比在1ms的时间周期内,同时有4个IOCP线程被调用,那么从1ms这段时间来看的话,能够认为是有4个线程被并发执行了,当 然时间能够无限被细分,真并发和模拟并发实际就是针对时间细分的粒度来讲的。这样一来如何设置并发数就是个设计决策问题,决策的依据就是你的回调函数究竟 要干些什么活,若是是时间较长的活计,就要考虑切换其它线程池来完成,若是是等待性质的活计,好比访问硬盘,等待某个事件等,就能够设置高一点的并发值, 强制系统切换线程形成“伪并发”,若是是很是快速的活计,那么就直接设置CPU个数的并发数就好了,这时候防止线程频繁切换是首要任务。固然并发数最好是 跟踪调试一下后再作决定,默认的推荐值就是CPU个数的2倍了。(绕了一大圈我仍是“人云亦云”了一下,哎呦!谁扔的砖头?!)

上 面的所有就是建立一个完成端口对象,接下来就是打造线程了,打造的方法地球人都知道了,就是CreateThread,固然按照人云亦云的说法应该替之以 _beginthread或_beginthreadex,缘由嘛?你想知道?真的想知道?好了看你这么诚恳的看到了这里,那就告诉你吧,缘由其实就是因 为咱们使用的语言从本质上说是C/C++,不少时候咱们须要在线程函数中调用不少的C/C++味很重的库函 数,而有些函数是在Windows诞生之前甚至是多线程多任务诞生之前就诞生了,这些老爷级的函数不少都没有考虑过多线程安全性,还有就是C++的全局对 象静态对象等都须要调用它们的构造函数来初始化,而调用的主体就是线程,基于这些缘由就要使用C/C++封装过的建立线程函数来建立线程,而 CreateThread始终是Windows系统的API而已,它是不会考虑每种语言环境的特殊细节的,它只考虑系统的环境。

好了让咱们继续打造线程的话题,要建立线程,实际核心就是准备一个线程函数,原型以下:

一、使用CreateThread时:

DWORD WINAPI ThreadProc(LPVOID lpParameter);

二、使用_beginthread时:

void __cdecl ThreadProc( void * pParameter );

三、使用_beginthreadex时:

unsigned int __stdcall ThreadProc(void* pParam);

其实上面三个函数原型都是很简单的,定义一个线程函数并非什么难事,而真正困难的是对线程的理解和定义一个好的线程函数。这里我就不在多去谈论关于线程原理和如何写好一个线程函数的内容了,你们能够去参阅相关的文献。

现 在咱们接着讨论IOCP的专用线程如何编写,IOCP专用线程编写的核心工做就是调用一个同步函数GetQueuedCompletionStatus, 为了理解的方便性,你能够想象这个函数的工做原理与那个有名的GetMessage是相似的,虽然这种比喻可能不太确切,可是他们工做方式是有些相似的地 方,它们都会使调用它们的线程进入一种等待状态,只是这个函数不是等待消息队列中的消息,它是用来等待“被排队的完成状态”(就是它名字的含义)的,排队 的完成状态,其实就是IO操做完成的通知(别告诉我你还不知道什么是IO操做),若是当前没有IO完成的通知,那么这个函数就会让线程进入“等待状态”, 实际也就是一种“可警告”的状态,这样系统线程调度模块就会登记这个线程,一旦有IO完成通知,系统就会“激活”这个线程,当即分配时间片,让该线程开始 继续执行,已完成IO完成通知的相关操做。

首先让我看看GetQueuedCompletionStatus的函数原型:

BOOL WINAPI GetQueuedCompletionStatus(

  __in          HANDLE CompletionPort,

  __out         LPDWORD lpNumberOfBytes,

  __out         PULONG_PTR lpCompletionKey,

  __out         LPOVERLAPPED* lpOverlapped,

  __in          DWORD dwMilliseconds

);

第一个参数就是咱们以前建立的那个完成端口内核对象的句柄,这个参数实际也就是告诉系统,咱们当前的线程是归哪一个完成端口对象来调度。

第二个参数是一个比较有用的参数,在函数返回后它将告诉咱们这一次的IO操做实际传输或者接收了多少个字节的信息,这对于咱们校验数据收发完整性很是有用。

第 三个参数是与完成端口句柄绑定的一个一对一的数据指针,固然这个数据是咱们绑到这个完成端口句柄上的,其实这个参数也是相似本人博客文章中所提到的那个 “火车头”的做用的,它的做用和意义就是在咱们获得完成通知时,能够拿到咱们在最开初建立完成端口对象时绑定到句柄上的一个自定义的数据。这里给一个提示 就是,在用C++的类封装中,一般这个参数咱们会在绑定时传递类的this指针,而在GetQueuedCompletionStatus返回时又能够拿 到这个类的this指针,从而能够在这个完成线程中调用类的方法。

第四个参数就是在本人其它IOCP相关博文中详细介绍过的重叠操做的数据结构,它也是一个火车头,这里就不在赘述它的用法了,请你们查阅本人其它博文拙做。

第 五个参数是一个等待的毫秒数,也就是GetQueuedCompletionStatus函数等待IO完成通知的一个最大时间长度,若是超过这个时间 值,GetQueuedCompletionStatus就会返回,而且返回值一个0值,此时调用GetLastError函数会获得一个明确的 WAIT_TIMEOUT,也就是说它等待超时了,也没有等到一个IO完成通知。这时咱们能够作一些相应的处理,而最多见的就是再次调用 GetQueuedCompletionStatus函数让线程进入IO完成通知的等待状态。固然咱们能够传递一个INFINITE值,表示让此函数一直 等待,直到有一个完成通知进入完成状态队列。固然也能够为这个参数传递0值,表示该函数没必要等待,直接返回,此时他的工做方式有些相似 PeekMessage函数。

函数的参数和原型都搞清楚了,下面就让咱们来看看调用的例子:

UINT CALLBACK IOCPThread(void* pParam)

{

CoInitialize(NULL);

       DWORD dwBytesTrans = 0;

       DWORD dwPerData = 0;

LPOVERLAPPED lpOverlapped = NULL;

       while(1)

       {

              BOOL bRet = GetQueuedCompletionStatus( hICP,&dwBytesTrans

,&dwPerData,&lpOverlapped,INFINITE);

              if( NULL == lpOverlapped )

              {

                     DWORD dwError = GetLastError();

                     ......//错误处理

}

              PMYOVERLAPPED pMyOL

= CONTAINING_RECORD(lpOverlapped, MYOVERLAPPED, m_ol);

              if( !HasOverlappedIoCompleted(lpOverlapped) )

              {//检测到不是一个真正完成的状态

                     DWORD dwError = GetLastError();

                     ......//错误处理

              }

                     ...... //继续处理

}

       return 0;

}

在这个线程函数中,咱们写了一个死循环,这个是必要的,由于这个线程要反复处理IO完成通知的操做。跟咱们常见的消息循环是殊途同归。

有 了线程函数,接着就是建立线程了,对于IOCP来讲,建立多少线程实际上是一个决策问题,通常的原则就是建立的实际线程数量,不该小于调用 CreateIoCompletionPort建立完成端口对象时指定的那个最大并发线程数。通常的指导原则是:若是完成线程的任务比较繁重大多数状况下 执行的是其它的慢速等待性质的操做(好比磁盘磁带读写操做,数据库查询操做,屏幕显示等)时,因为这些操做的特色,咱们能够适当的提升初始建立的线程数 量。可是若是是执行计算密集型的操做时(好比网游服务端的场景变换运算,科学计算,工程运算等等),就不易再靠增长线程数来提升性能,由于这类运算会比较 耗费CPU,无法切换出当前CPU时间片,多余的线程反倒会形成由于频繁的线程切换而形成整个程序响应性能的降低,此时为了保证IOCP的响应性,能够考 虑再创建线程池来接力数据专门进行计算,这也是个人博文《IOCP编程之“双节棍”》篇中介绍的用线程池接力进行计算并提升性能的思想的核心。

下面的例子展现了如何建立IOCP线程池中的线程:

SYSTEM_INFO si = {};

GetSystemInfo(&si);

//建立CPU个数个IOCP线程

for( int i = 0; i < si.dwNumberOfProcessors; i ++ )

{

UINT nThreadID = 0;

//以暂停的状态建立线程状态

HANDLE hThread = (HANDLE)_beginthreadex(NULL,0,IOCPThread

,(void*)pThreadData,CREATE_SUSPENDED,(UINT*)&nThreadID);

//而后判断建立是否成功

if( NULL == reinterpret_cast<UINT>(m_hThread)

                     || 0xFFFFFFFF == reinterpret_cast<UINT>(m_hThread) )

{//建立线程失败

              ......//错误处理

}

::ResumeThread(hThread);//启动线程

}

创 建好了IOCP的线程池,就能够往IOCP线程池中添加用来等待完成的那些重叠IO操做的句柄了,好比:重叠IO方式的文件句柄,重叠IO操做方式的 SOCKET句柄,重叠IO操做的命名(匿名)管道等等。上面的这个操做能够被称做将句柄绑定到IOCP,绑定的方法就是再次调用 CreateIoCompletionPort函数,此次调用时,就须要明确的指定前两个参数了,例子以下:

//建立一个重叠IO方式的SOCKET

SOCKET skSocket = ::WSASocket(AF_INET,SOCK_STREAM,IPPROTO_IP,

                            NULL,0,WSA_FLAG_OVERLAPPED);

......//其它操做

//绑定到IOCP

CreateIoCompletionPort((HANDLE)skSocket,hICP,NULL,0);

由代码就能够看出这步操做就很是的简单了,直接再次调用CreateIoCompletionPort函数便可,只是此次调用的意义就不是建立一个完成端口对象了,而是将一个重叠IO方式的对象句柄绑定到已建立好的完成端口对象上。

至此整个IOCP的基础知识算是介绍完了,做为总结,能够回顾下几个关键步骤:

一、  用CreateIoCompletionPort建立完成端口;

二、  定义IOCP线程池函数,相似消息循环那样写一个“死循环”调用GetQueuedCompletionStatus函数,并编写处理代码;

三、  建立线程;

四、  将重叠IO方式的对象句柄绑定到IOCP上。

只要记住了上面4个关键步骤,那么使用IOCP就基本掌握了。最后做为补充,让我再来讨论下这个核心步骤以外的一些附带的步骤。

如今假设咱们已经建立了一个这样的IOCP线程池,并且这个线程池也工做的很是好了,那么咱们该如何与这个线程池中的线程进行交互呢?还有就是咱们如何让这个线程池停下来?

其 实这个问题能够很简单的来思考,既然IOCP线程池核心的线程函数中有一个相似消息循环的结构,那么是否是也有一个相似PostMessage之类的函数 来向其发送消息,从而实现与IOCP线程的交互呢?答案是确定的,这个函数就是PostQueuedCompletionStatus,如今看到它的名 字,你应该已经猜到它的用途了吧?对了,它就是用来向这个相似消息循环的循环中发送自定义的“消息”的,固然,它不是真正的消息,而是一个模拟的“完成状 态”。这个函数的原型以下:

BOOL WINAPI PostQueuedCompletionStatus(

  __in          HANDLE CompletionPort,

  __in          DWORD dwNumberOfBytesTransferred,

  __in          ULONG_PTR dwCompletionKey,

  __in          LPOVERLAPPED lpOverlapped

);

它 的参数与GetQueuedCompletionStatus相似,其实为了理解上的简单,咱们能够认为 PostQueuedCompletionStatus的参数就是原样的被copy到了GetQueuedCompletionStatus,怎么调用这 个函数就应该能够理解了。一般在须要中止整个IOCP线程池工做时,就能够调用这个函数发送一个特殊的标志,好比设定dwCompletionKey为 NULL,而且在自定义lpOverlapped指针结构以后带上一个表示关闭的标志等。这样在线程函数中就能够经过断定这些条件而明确的知道当前线程池 须要关闭。固然也能够定义其它的操做扩展码来指定IOCP线程池执行指定的操做。下面的例子代码演示了如何发送一个IO完成状态:

MYOVERLAPPED *pOL = new MYOVERLAPPED ;

.......//其它初始化代码

pOL->m_iOpCode = OP_CLOSE;//指定关闭操做码

.......

PostQueuedCompletionStatus(hICP,0,NULL,(LPOVERLAPPED)pOL);

至 此IOCP的基础性的支持算是介绍完了,本篇文章的主要目的是为了让你们理解IOCP的本质和工做原理,为轻松驾驭IOCP这个编程模型打下坚实的基础。 最终须要掌握的就是认识到IOCP其实就是一个管理IO操做的自定义线程池这一本质。实际编码时决策性的问题就是理解最大并发数和预建立线程数的意义,并 根据实际状况设定一个合理的值。

相关文章
相关标签/搜索