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 的网络输入输出。
至此对于完成端口的本质原理应该有了一个比较好的理解,尤其是掌握了 IOCP 是线程池模型的这一
本质,那么对于之后的 IOCP 实际应用就不会有太多的疑问了。接下去就让我们从实际编程的角度来了解
一下 IOCP,也为彻底掌握 IOCP 编程打下坚实的基础。
要应用 IOCP,首先就要我们创建一个叫做 IOCP 的内核对象,这需要通过 CreateIoCompletionPort
这个函数来创建,这个函数的原型如下:
HANDLE WINAPI CreateIoCompletionPort(
HANDLE FileHandle,
HANDLE ExistingCompletionPort,
ULONG_PTR CompletionKey,
DWORD NumberOfConcurrentThreads
__in
__in
__in
__in
);
这个函数是个本身具有多重功能的函数(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 而已,它是不会考虑每种语言环境的特殊细节的,它只考虑系统的环境。
好了让我们继续打造线程的话题,要创建线程,实际核心就是准备一个线程函数,原型如下:
1、使用 CreateThread 时:
DWORD WINAPI ThreadProc(LPVOID lpParameter);
2、使用_beginthread 时:
void __cdecl ThreadProc( void * pParameter );
3、使用_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(m_hThread)
|| 0xFFFFFFFF == reinterpret_cast(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 的基础知识算是介绍完了,作为总结,可以回顾下几个关键步骤:
1、 用 CreateIoCompletionPort 创建完成端口;
2、 定义 IOCP 线程池函数,类似消息循环那样写一个“死循环”调用 GetQueuedCompletionStatus
函数,并编写处理代码;
3、 创建线程;
4、 将重叠 IO 方式的对象句柄绑定到 IOCP 上。
只要记住了上面 4 个关键步骤,那么使用 IOCP 就基本掌握了。最后作为补充,让我再来讨论下这个
核心步骤之外的一些附带的步骤。
现在假设我们已经创建了一个这样的 IOCP 线程池,而且这个线程池也工作的非常好了,那么我们该
如何与这个线程池中的线程进行交互呢?还有就是我们如何让这个线程池停下来?
其实这个问题可以很简单的来思考,既然 IOCP 线程池核心的线程函数中有一个类似消息循环的结
构,那么是不是也有一个类似 PostMessage 之类的函数来向其发送消息,从而实现与 IOCP 线程的交互呢?
答案是肯定的,这个函数就是 PostQueuedCompletionStatus,现在看到它的名字,你应该已经猜到它的
用途了吧?对了,它就是用来向这个类似消息循环的循环中发送自定义的“消息”的,当然,它不是真正的消
息,而是一个模拟的“完成状态”。这个函数的原型如下: