链接:http://waterwood.blog.163.com/blog/static/43596554200793033955/
声明:以下内容转载自新帆:nntp://news.newsfan.net 新闻组:计算机.软件.编程.VisualStudio 作者:爱果斯坦,评注为水木所加,转载请注明出处。
最近,对在一段代码中是用CriticalSection还是Interlocked***拿不定主意,正好我也想对这个问题有个定量的了解,于是做了些测试,总结成下面这篇文章。希望有所助益。
首先要明确一点:同步器的相对效率,是在虽然进行多线程同步,但并没有发生“冲突”的前提下而言的。就是说,一个线程独占了一项资源,随后很快就释放了,而在此期间并没有其它线程也试图访问该资源而进入等待状态——真正需要进行等待的情况,发生概率必须很小。良好的多线程设计必须保证这一点,否则如果你的几个线程总是我等你、你等我,说明设计上有很严重的问题(这样严重的问题几乎可以视为Bug)。如果它们常常在同一时刻只能有一个在运行,那要多线程有什么意思?还不如用单线程,起码节省同步的时间。
现在来看CriticalSection。虽然,它内部使用了Event,但并不是每次都用。第一个成功进入CriticalSection的线程就根本不需要使用内核对象。虽然不能看到源代码,但我考虑过,如果我来设计,我会怎样做,而且我相信,这也正是Windows中实际所做的(真要实现起来,还会有更多细节问题需要考虑,我这里只提一些要点,如有兴趣,可以试试自己实现一个CriticalSection,也是个不错的练习):
1.使用InterlockedExchange(或者某个相关函数),在CRITICAL_SECTION结构里保存本线程的ID号。
2.如果原来的ID号是NULL,或者就是本线程ID(别忘了CriticalSection是允许同一线程重复进入的),那么调用就成功了。
3.如果进入CriticalSection失败,则本线程的ID号已经存入,直接用WaitForSingleObject等待内含的Event即可(因此,只有进入等待的时候,才需要使用内核级的等待函数。实际上,需要令保存的线程ID构成一个链表,以便应付多个线程同时进入等待的情况)。
4.成功进入CriticalSection的线程在离开的时候,再次使用InterlockedExchange恢复所保存的线程ID。如果发现保存的线程ID不是NULL也不是自己,就说明另外一个线程在等待中,调用一次SetEvent即可(但要注意,这也是一个慢速得多的函数,好在它只有在存在等待线程的时候才需要)。
在MSDN中,对Windows2000新增的函数InitializeCriticalSectionAndSpinCount的说明里,所透露的一些细节也可以成为我的以上推测的佐证。
最后,要完全解决这个问题,需要了解一下386以上汇编语言,再综合MSDN和《Windows核心编程》中得来的星点知识。我的估计如下:
◎最快的当然是C语言的加减运算,它们直接对应简单的机器指令;
◎Interlocked****等函数其次,它们对应的指令其实也很简单(起码在x86架构上是这样,通常只需1~3个指令)。但是,它们需要CPU放弃通常的指令优化,还可能需要通过系统总线通知其它CPU(CPU内部的一级、二级缓存,直到内存都可能牵连到)。我估计,这会减慢速度5~10倍;
◎CrititalSection应该与Interlocked****相当,只是稍微慢一点。因为它实际上调用前者。
◎其它如互斥器、信号器之类最慢,我的估计是也许比CrititalSection慢几十甚至100倍。因为它们需要切换到内核模式,再切换回来,这需要执行大量指令(在X86上看上去指令不多,但这些指令一个就对应RISC类型的CPU,如Alpha上的一个子程序,一个指令就是几十个时钟周期)。也因此,互斥器等内核对象本身的速度差别是微不足道的,没有必要考虑,因为内核模式切换才是效率瓶颈。
以下是我的实测结果:
The system performance counter's frequency is:3579545 Repeat times for each test is:1000000
General ++/-- operators: t1(++ only)=0.00371197, t2(-- only)=0.00372959
Both time=0.00754256, t1+t2-both:-0.000101004, NET TIME COST:0.00754256
Interlocked(In/De)crement:0.115052
Critical section:0.140891
Mutex:1.47912
General ++/-- operators: t1(++ only)=0.00376848, t2(-- only)=0.0037205
Both time=0.00756789, t1+t2-both:-7.89067e-005, NET TIME COST:0.00756789
Interlocked(In/De)crement:0.114687
Critical section:0.141372
Mutex:1.49058
General ++/-- operators: t1(++ only)=0.00371739, t2(--
only)=0.00380439
Both time=0.00749592, t1+t2-both:2.58552e-005, NET TIME
COST:0.00747007
Interlocked(In/De)crement:0.114999
Critical section:0.14113
Mutex:1.47861
General ++/-- operators: t1(++ only)=0.00379342, t2(--
only)=0.00371602
Both time=0.00754006, t1+t2-both:-3.06324e-005, NET TIME
COST:0.00754006
Interlocked(In/De)crement:0.114092
Critical section:0.141929
Mutex:1.48663
General ++/-- operators: t1(++ only)=0.00380472, t2(--
only)=0.00377649
Both time=0.00752712, t1+t2-both:5.40851e-005, NET TIME
COST:0.00747303
Interlocked(In/De)crement:0.113746
Critical section:0.140481
Mutex:1.48115
测试程序是一个单线程命令行程序,使用VC2005编译,Release版,无调试信息、优先为速度优化。运行时,不考虑其它线程,只管用一个线程反复锁定与释放资源,看其执行速度。实测用的机器是P4
2.4G(有3、4年的较旧机器了),每种调用重复一百万次,轮回测试了5趟。第一行打出的performance
frequency没什么大用,这是硬件相关的值,说明我用的这个平台上的计时精度而已。后面每趟测试中给出的就是跑完循环所用的时间,单位为秒。其中的++和--运算就是基本的C运算符,但作用目标是volatile
LONG型(如果不加上volatile,编译器优化会把整个循环都省略掉,而且,这也就不成为对多线程环境的测试了)。
开始先只做++和--,然后在每趟循环中++和--各执行一次,前两次的时间之和,再减后一个的差值应该就是空循环的时间,所以在随后所有测试的结果中,一般都会扣除空循环所需的时间。但不幸这个时间太微小,甚至有时会成为负数(这时我就忽略该项)。其它循环都是每次两项调用——一增一减,或者一锁一放。
结论:我的估计大体没什么意外,但在具体的速度比值上,就有些出入。实际看来:InterlocdedIncrement和InterlockedDecrement的速度是++和--的大约16分之一(可能因为我忘了考虑函数调用时,入栈出栈和指令跳转的开销)。CriticalSection只比前者慢大约25~30%,这点差异通常几乎可以忽略。这是最接近我的估计的,也说明我对其实现方式的推测正确。Criticalsection的技术本质,就是基于Interlocked****,从而以几乎相同的时间代价完成更复杂的同步需要。但也不要忘了,在现实中,如果在可以用Interlocked****处理的简单数值上动用Critical
section,往往用一个Interlocked****单独调用能解决的事就需要进入和离开Criticalsection的两次调用,所以实际差异恐怕还得加倍。另一方面,任何需要同步处理比单个数值更复杂情况的时候,Interlocked****就肯定不值得考虑(而且几乎肯定根本就无法适用)。最后,互斥器比我的估计快很多,只比前者慢10倍(充分证明我对汇编编程的了解都是纸上谈兵,呵呵)。当然,一个数量级的差距在编程时仍然是不可忽略的差异。所以,内核同步器应该尽量只在跨进程等迫不得已的情况下加以利用。
评注:
多线程、多进程编程尤其是多线程编程在实际应用中有着重要的意义,尤其是在网络通信,图形图像处理,工控自动化以及B/S开发等领域。对于线程及线程同步的恰当使用可以作为衡量程序员基本素质的标准之一。关于多线程我将会专门撰文介绍,这里只对作者的观点做几点说明:
1.在多线程、多进程的应用程序中,程序的执行效率具有非常重要的意义,尤其是数据吞吐量大,或者服务终端数多的服务程序。
2.在线程、进程同步的代码中,一个基本的原则是:在保证逻辑正确的前提下,尽量缩小同步代码的范围,不然的话,就会失去多线程的优势。根据传统的观点,线程式服务的代码应当尽量保证其“局限性”,也就是说,线程中的代码应当紧凑、循环、简洁,充分利用CPU的“指令预测”能力,这样最大的好处是,如果CPU数目增加,能够使服务性能实现“线性”增长。
3.对于单个数据的同步操作来说,最简单的同步修改方法是InterlockedIncreament,InterlockedDecreament,InterlockedExchange等同步化的数据修改函数,效率最高。
4.临界区同步适合同步稍大范围的代码,效率也很高,但仅能实现类似于WaitForSingle和WaitAll的逻辑。
5.如果要实现更为复杂的同步,则应采用Event,Mutex等系统级同步对象结合WaitForSingleObject(Ex)(),WaitForMultipleObjects(Ex)()等API实现 WaitForSingle,WaitAll,WaitAny等逻辑。此时的程序执行效率要比前二者低1个数量级,所以合理分配代码同步区域至关重要。
6.除了C++能够调用这些系统API,相应的同步机制在java,.net中都有类似的封装实现(.net中的实现实际上就是对系统API的封装调用)。