程序员2007精华本(下)
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

并发:软件的多核之痒

□ 策划 / 欧阳璟

美国前总统克林顿曾经在某年的国情咨文当中提到,中国人发明了“危机”这个很好的词汇,它准确地描绘出“危险”与“机会”并存这一事实。

2005年3月,Herb Sutter先生在Dr. Dobb's杂志上发表了著名的《免费午餐已经结束》一文,并指出多核带来的危机即将到来,他提到:“多核将引领软件研发发生基础性变化,特别对接下来几年里那些面向一般应用、运行在PC和低端服务器上的应用软件(在今天已经销售出去的软件里占有很大比例)而言。”

2006年,AMD率先发难,推出双核处理器,紧接着Intel则快速响应,在一年时间内迅速更新两代多核架构。CPU的计算能力在短短不到一年的时间里猛增一倍有余,但是这庞大的计算量仍然悄无声息地隐匿在软件庞大的计算需求之下。这些潜在的危机早已埋伏在静悄悄的软件行业当中,闷头苦干的开发人员似乎还没有意识到危机的存在。

2007年初,Intel公司再次在这潭深深的湖水当中投下了一颗重磅炸弹,80核通用处理器诞生了,它标志着我们正式踏入多核时代。然而,在软件这潭深不见底的湖水当中,仍然是一片寂静。一些人已经开始预见到危机的存在了⋯⋯

这其中,“危险”的一面是,并发已经成为了众多软件提供者们不得不考虑的重要问题。如何突破传统的软件编程方式,突破软件技术的体系结构,将现有的软件计算打散,完全分布在不同的计算内核上处理,将是软件技术人员面临的第一大危机。其次,原有的软件系统是否需要完全移植到新的基础架构上?如果答案是肯定的,那么对于软件提供商来说,这势必会成为一场巨大的灾难。

另外一方面则恰恰是我们中国人所描绘的那样,面对如此巨大的挑战,机遇同样存在。新的计算机体系结构给了那些敢于创新的人们前所未有的机会,谁能最先挠到软件的多核之痒,谁就将引领未来软件技术的发展潮流。

走近多核时代——Intel公司Geoffrey Lowney院士访谈录

□ 记者 / 欧阳璟

《程序员》:假如今天我们回避进入多核时代,沿着现有的单核技术和现有的计算机体系结构发展下去,什么时候将会发展到尽头?怎样的需求推动了多核时代的来临?

Geoffrey:微处理器的发展除了与计算机体系结构与制造工艺的发展紧密相关外,用户的需求也是推动微处理器发展的一个重要因素。多核时代的到来满足了用户对高性能及低功耗的双重需求,可以说是历史必然,而用单核技术是很难构造高能效计算机平台的。

《程序员》:如果多核的问题不可避免,是否会造成原有程序大规模的重写?是否有工具能够帮助我们完成这些工作?

Geoffrey:如果现存的程序已经是多线程的,那么基本无需改动便可以充分利用多核平台带来的性能提升,否则我们可以利用代码优化工具来提高原先串行程序的性能。英特尔软件和解决方案事业部提供一整套强大的线程工具、编译器和其它性能调试工具套件与白皮书,以帮助软件开发人员在其代码中提升线程级并行处理能力。感兴趣的用户可以从 http://www3.intel.com/cd/software/products/apac/zho/index.htm上取得英特尔多核/多线程软件工具的短期试用版,从而评估一下英特尔多核/多线程软件工具带来的并行优化好处。

《程序员》:随着多核技术的到来,您认为计算环境上会有哪些变化,其中变化最大的是什么?

Geoffrey:多核平台使得多个程序/任务的并行执行更加流畅,同时也能更加高效地运行多线程程序。从这个角度上说,整个计算环境正稳步向一个新的并行化的时代迈进。在这一过程中,多任务的并行执行和程序的并行化显得尤其重要。也就是说,一个完全串行的程序是很难充分利用多核平台带来的好处的。这对软件开发人员也就意味着:只有充分发掘程序的并行性并把并行的理念融入到设计和实现上才能使多核平台更加物尽其用。

《程序员》:去年我们曾经采访过包括英特尔、Sun等公司的专家,这些专家一致认为,目前妨碍跨向多核时代的瓶颈是在软件上,您如何看待这个问题?

Geoffrey:对于单个程序来讲,只有把程序多线程化才能充分利用多核处理器所提供的硬件并行特性。从这个角度上说,单个软件是否已经很好地并行化的确会影响到多核处理器是否能充分发挥效能。庆幸的是,很多软件开发厂商的程序都已经是多线程化的(如微软的操作系统及办公室应用程序、BT下载工具、多媒体编解码软件等)。对于其它迄今为止还没有多线程化的软件,软件开发商也正在紧锣密鼓地进行多线程优化。同时,多任务的并行执行也是多核平台的一种非常普遍的应用,即使每个同时运行的程序是单线程的,它们在多核平台上也能够运行得很好。从这个角度上说,用户使用计算机的应用模式是个关键。譬如说,如果一个用户同时进行文档编写、后台测试、电影下载和在线MP3音乐欣赏,那多核平台将会是一个完美的选择。

《程序员》:传统的软件开发技术在多核时代下是否仍然适用?英特尔怎样考虑在软件技术上的过渡?

Geoffrey:传统的软件开发技术跟程序的并行优化并不矛盾,譬如说并行优化可以存在于面向对象编程或结构化编程中。正如前面所讲,英特尔提供了一整套强大的线程工具、编译器和其它性能调试工具套件与白皮书,以帮助软件开发人员在其代码中提升线程级并行处理能力。同时,英特尔的研发人员也正在积极探索新的并行编程模型。

《程序员》:您认为多核时代的到来对软件开发人员是否是一场灾难?英特尔公司为程序员提供了哪些帮助,协助他们跨越这个鸿沟?

Geoffrey:迎接技术挑战是软件开发人员的乐趣之一。一个优秀的软件开发人员应该在关心软件的功能之外,同时关注软件的性能。而多核时代的到来正是让关注性能的软件开发人员有机会更多地关注并行方面的技术挑战,这应该是乐趣,而不是灾难。英特尔除了提供软件工具帮助提升线程级的并行处理能力外,还提供很多技术培训与软件开发人员分享多核的技术细节、多核编程的经验及线程工具的使用技巧等。

《程序员》:一些开发人员怀着侥幸心理,认为高级语言和先进的虚拟机技术(例如Java与.NET)能让他们不必改变传统的单线程编程方式,您怎样看待这个观点?

Geoffrey:大多数的高级语言或者在语言上支持线程,或者提供强大的线程库。多线程程序的开发需要软件开发人员利用到这些高级语言的特性,否则便无法开发出多线程程序。从理论上来讲,一个强大的虚拟机(JVM或.NET CLR)应该可以实现某些串行程序的动态并行优化。由于这一技术本身的实现难度很大,而且对实用程序的并行效果并不理想,完全依赖于虚拟机所达到的并行优化结果应该非常有限。

《程序员》:一些软件已经是用多线程程序开发的,例如用于改善用户体验和程序响应速度的GUI程序,这些程序是否还需要针对多核进行改写?

Geoffrey:如果一个程序本身是很好地多线程化的,那么在多核平台上运行应该可以取得比较好的效果。然而由于不同的多核平台可能在微体系结构上有些差异,针对微体系结构的性能调优应该可以使程序的性能有进一步的提升。

《程序员》:最近AMD公司说要打破现有的市场份额,并突破30%的市场占有率,您认为英特尔将如何应对这种说法,请谈谈英特尔公司的竞争优势?

Geoffrey:根据多项第三方的测试表明:英特尔现在的多核处理器在技术上具有领先地位。英特尔愿意与广大的合作伙伴一起让我们的用户充分享受到多核技术带来的能效提升。

《程序员》:请给中国的软件开发人员提点建议。

Geoffrey:多核时代的到来是一个不可逆转的历史潮流,这对广大的软件开发人员来说是一个很好的机会。只有真正地了解多核并在程序设计与实现中充分发掘出程序的并行性,多核平台带来的效能提升才能得以显著地体现。同时,如何针对多核这一新的平台开发出新的应用模式,也应该是我们不断思考的问题。

多核时代计算环境的改变

□ 文 / 张云泉

实际上,对于信息技术来说,并行机的发展历史已经算比较长的了。20世纪60年代初期美国就出现了全世界最早的并行计算机。70年代之后还出现过向量机、并行向量机(以Cray公司为代表,中国的则是银河系列),以及20世纪90年代初的SIMD并行机,其代表作就是Think Machine公司的CM系列并行机。Think Machine公司通过将大量体系结构简单、功能较弱的处理器用网络连接实现大规模的并行计算系统,但由于应用领域有限等原因,这类系统很快就消亡了。曾经一度流行且目前还占有一席之地的并行计算机是分布式存储的MPP机和基于SMP的共享存储小型机。但目前市场上占据主流位置的应该是Cluster集群系统。

在高端计算里我们需要通过并行去更快更好地解决挑战性的问题。另外一个必须并行的原因是电路设计的物理极限。单个处理器的线宽总有一天要达到物理极限,所以不得不转向多核,把痛苦的事情转嫁到大众身上。这就像我们人体的生长,人青年时期会不断地长高(与处理器主频的增长类似),但长到一定程度时就要向宽的方向发展(多处理器,多核)。

并行处理的精确定义是用多个计算部件共同快速完成挑战性的任务。这里有几个关键词,要多个计算部件(或计算机)、要共同(互相配合)、要快速、还要完成具有挑战性的任务。其好处就是提高性能,缩短解题时间,求解规模更大的问题。如果造一个主频极高成本也极高的单处理器,还不如把几个低主频的处理器一起使用,而且还可以容错。比较常见的并行机就是SMP(对称多处理),但现在新的选项是多核处理器(在一个处理器内实现、不可分割、打包销售、买一送多),且普通大众将来都会逐渐接触和使用到。此外,并行计算还可以分为处理器内(最新的还包括多个处理器核)和处理器之间的两种并行层次。而根据不同的处理器间互联网络,也可以分成不同类型,而且研究如何进行互联也曾经是一个极其热门的研究课题。实际上,现在多处理器核之间也是需要互联网络的。我们甚至可以把多处理器核之间的互连模式看成原来的SMP多处理甚至多个并行计算节点之间互联在单个处理器内的缩小版。从存储模型上,并行处理还也可以分为分布式存储和共享存储两大类。

并行算法基本上就是一个浅而宽的算法结构,实际上就是把长而高的串行算法的时间复杂度通过增加空间复杂度的方式进行压缩,把以前一个周期一个操作去执行的算法结构改造成一个周期可以进行多个操作的并行算法,这就是并行化要做的主要工作。说白了,并行就是在一个时刻或者时间段里有一个以上的事件发生。在并行计算领域里,最有名的两个定律是Amdahl定律和Gustafson定律。Amdahl定律指出,如果一个算法里不能并行的部分所占的比重是10%的话,那么并行化算法所能达到的最大加速比超不过10。这个定理出来以后,对并行计算打击很大。后来Gustafson发现,实际上Amdahl定理存在的问题是只假定并行系统处理一个固定规模的问题,在这种情况下,再增加处理器当然没有意义。但如果把问题规模随着机器规模一起变大,加速比仍然可以变大。Gustafson定理出现以后,并行机的发展前途豁然开阔。在并行计算领域里,人们常常提到并行加速比(求解问题的串行执行时间与并行执行时间的比)。并行计算追求的最理想情况是用P个处理器,就能得到P倍的速度提升。但这通常很难达到,因为并行会引入通讯和调度等额外开销。当然在极个别情况下,也会出现超线性加速比的情况。

图1:RAW处理器的原型芯片外观

图2:RAW处理器的互联网络重构

图3:TRIPS处理器原型系统

图4:TRIPS处理器芯片布局

多核处理器发展趋势

多核处理器的出现实际上是一次计算方式的革命。国外有些专家说,大家的免费午餐没有了(Free Lunch is Over!),我们不得不面对并发和并行操作这些通常是并行计算的专业人员和高端用户才需要面对的问题。对于从事IT的人来说,摩尔定律一直是一个圣经,当然对存储和价格来说目前它仍然成立,但对处理器性能来说,目前我们只能用多核的方式让它继续沿着摩尔定律上升。散热和漏电是两个迫使我们不得不转向多核处理的深层次的工业原因,工业界在2006年突然要面临一个拐点,从单核向多核处理器急速拐弯,这是大部分人甚至处理器制造商都没有预料到的,至少没想到出现的这么快。

传统提高处理器性能的方法,一个是通过缩小线宽,不断提高主频,12年里从60MHz提高到了3.8GHZ。第二条途径是运行时优化,通过采用功能更强大的指令,流水处理、分枝预测、多指令并行和指令重排序等来实现。第三条途径是通过不断增大Cache的容量来实现。实际上这也是一个不断寻求更高性能手段的发展历程,多核的出现也是其中的一个阶段。就如同流水线、多级Cache等刚被引入处理器设计中,受到很大的抵制一样,多核的引入初期,肯定也不会很快被接受。但相信通过一段时间的适应,以及工业界和科研界的努力,多核也会最终成为未来处理器的标准配制,大家逐渐习以为常。多核的引入实际上增加了一个新的处理器设计自由度,在给体系结构设计带来更多灵活性的同时,当然也给用户带来了很多的复杂性。

当前各个主流处理器厂商都推出了自己的多核产品,八核的,甚至更多核的产品也已或即将出现。英特尔公司2006年底抢先推出了自己的四核产品,2007年初更发布了其80核处理器的产品,AMD公司也会在2007年推出自己的四核产品。实际上,不同的多核处理器可以有不同的生产工艺,有的是在一片硅片上同时造出两个紧邻的紧耦合的核来,有的是把两个分离的核封装在一个芯片里。这里面涉及生产工艺复杂性和提高成品率、降低成本、缩短生产周期等方面的问题,所以会有不同的制造选项。多核处理器的高速Cache层次比较复杂,其中有每个核私有的L1 Cache,有多个核共享或私有的L2 Cache,甚至更多的核共享的L3 Cache。这就导致不同的核访问不同位置Cache的速度和延迟不同,出现NUCA的现象(非一致Cache访问)。NUCA也是目前学术界研究的课题之一。

现在才不过几个核,大家还不必太害怕,将来我们可能会面临几百个核,简直是核的海洋,这种情况甚至连搞并行计算的专家都感到害怕和麻烦。一个机器里那么多核,怎么去很好地利用?这肯定是大家首先冒出来的一个问题。本来并行计算就很难了,再放那么多核就更困难了。

其实90年代末就已经有人在做多核处理器的研究,其思路是把功能简单的处理器用网络连接起来,互相协作来解决延迟的问题。比较早的是RAW处理器,由美国MIT大学开发,是我们目前称为Tile结构处理器的先驱(http://cag-www.lcs.mit.edu/raw)。现在比较热的一个Tile结构处理器研究项目叫TRIPS,其目标是实现单处理器一个周期达到万亿次操作且可靠、智能自适应的目标,是由美国DARPA的多态计算体系结构项目从2000年开始资助Texas大学的Austin分校2000万美元开展的一个研究项目,整个项目由30名研究人员(含研究生)组成。但Trips所采用的体系结构与传统的冯?诺依曼体系有所不同,不是目前流行的指令流驱动,而是数据流驱动(显示数据图执行EDGE),以数据的到达作为指令执行的触发标志,而不是根据用户或编译器预先规定好的指令顺序来执行。该处理器每周期可以调度一个包含128条指令的指令块,映射到执行单元的网格上执行,且可以通过多态重组合的功能挖掘包括指令级并行、线程并行和数据并行等多层次的并行,从而适应不同的应用需求。该处理器2006年已经推出原型系统,是目前比较被看好的未来处理器的一个发展方向。

并行程序设计环境概述

一般来说,并行程序设计模型主要分两大类,一类是共享存储模型,一类是消息传递模型。共享存储模型大家比较熟悉,主要是采用多线程,其主要程序开发环境是已经成为事实工业标准的OpenMP和早期的Posix Threads,目前主要是商业编译器如Intel等的C++和Fortran编译器提供对该语言的支持,而GCC等开源编译器尚不能支持OpenMP。对于多核来说,马上可以用的标准程序设计环境恐怕就是OpenMP了。虽然可用,但对一般用户来说比较困难的是消息传递开发环境包括MPI和PVM(目前以很少使用)等,此类开发环境是开源的,可以免费下载。其中最常用的两个MPI标准实现是MPICH和LAM/MPI。其中的LAM/MPI也在从MPI1.0版本向MPI 2.0版本,其下一代软件的名称为Open MPI,已经发布了正式版本。此外,由于现有机器体系结构层次非常复杂,还可以把上面几种并行设计环境和向量并行等混合使用,充分挖掘机器的性能潜力,我们通常称之为混合并行。

实际上,并行算法的设计目标是挖掘问题求解过程中的并行性,寻求并行算法与并行机器体系结构的最佳匹配和映射,合理组织并行任务,减少额外消息传递和数据移动开销。总体来说,开发一个并行程序可以有三种途径,一个途径是串行程序自动并行化。这条路目前还没走通,大家认为更为实际的目标应该是人机交互的自动并行化。第二条途径是设计全新的并行程序设计语言。但它有一个致命的缺点就是需要全部改写原来的程序,对用户来说就很痛苦了,成本和风险也很高,且效率没有保证。但是,随着多核的出现,如果面向大众推广并行计算环境的话,就必须有一种新的大众容易接受的程序设计语言,否则很难推广普及。目前国际上正在研究几种新的并行程序设计语言,下面会简单介绍。第三条途径就是串行语言加并行库或伪注释制导语句的扩展,实际上就是增加一个库或一些新的制导语句来帮助进行消息传递和并行。这正是MPI和OpenMP所采取的途径。目前也是比较容易被接受且性能高的途径。但其程序开发效率很低,难度也比较大。

多核处理器对软件设计的挑战与应对思路

随着处理器体系结构变得越来越复杂,从语言到机器硬件的鸿沟越来越大了,需要程序设计语言对底层体系结构进行高度抽象,使用户的程序设计变得简单高效,同时又不损失过多性能。编译器就需要做很多工作来弥补这个鸿沟。很多人也注意到了,现在很多厂家提供的多核处理器,主频是比较低的,主要目的是为了降低散热和功耗。如果买了多核处理器的用户不去用好这几个核,让它们同时合作去完成一个任务,而只用一个核工作的话,摩尔定律就不起作用了。对很多多媒体应用,或大量相互之间不相关的Web访问的应用来说,它们之间是没有依赖关系的,用户不必改写程序,就可以自然的通过操作系统的多核调度获得性能提升。但对于计算强度很大的程序来说,里面有大量数据要串行处理,且存在复杂的数据相关性,如果不进行数据分割的话,只能由一个低频的单核来串行处理,执行时间会很长。既使很多应用可以同时运行,多核还会带来一个运行资源冲突的问题,多核同时运行期间很多资源其实是共享的,比如高速缓存、存储体、BUS等,这就需要改进操作系统和用户调度,来加以缓解。

但多核的一个缺点是存在严重的存储墙(Memory Wall)问题。实际上处理器的管脚是有限,数量增加速度落后于处理器速度的增长,且不可能无限制增加,由此意味着存储访问的带宽增长也有限制。将来数量众多的处理器核,都挤在处理器里面,没办法拿到自己所需要的数据,严重影响性能。此外,多层次的缓存,以及核之间共享缓存的方式,再加上访问本地缓存和远程缓存的时间不一致,都给程序设计和性能优化带来困难。当然,多核并非一无是处,它也有优点,这就是核之间的通讯延迟会有量级的减少,核之间的带宽也有量级的增加。

多核作为一种新的并行层次原来是没有的,现在出来了,应该怎么应对呢?最简单的就是从硬件底层对多核加以隐藏,让用户感觉不到,这当然是最理想的。用户的指令运行时,硬件底层进行相关性分析,并在多核间进行调度。但这种方法的可扩展性很成问题,适用性是不是很广也成问题,且增加了处理器设计的复杂性,很容易成为性能瓶颈和提高生产成本。对小规模问题和少量的核来说也许可以用,但问题规模更大或核数量更多之后,就不可行了。另外一个很自然想到的办法,就是把高端计算里面并行程序设计的语言和环境如MPI和OpenMP等一起来用。再根据多核的特点,充分利用新的体系结构优势,加以性能优化。这是最自然而然且可行的应对措施。

实际上并行不是目标,我们并不愿意去并行,而是一种无奈的妥协,是为了继续使性能增长的摩尔定律有效。有些国外专家说过,为一个1 PHz(千万亿赫兹)的处理器编写程序会比为一百万个1GHz的处理器更容易。并行程序设计不但困难而且容易出错。我们什么时候能不需要并行呢?当然最好是继续增加处理器的主频,我们看到IBM公司的Power6处理器就突破了4GHz的主频限制,在2007年发布时,其主频最高将达到5GHz,并支持十进制运算(实际上,当计算机将十进制转换成二进制进行计算,然后再将计算结果转换成十进制时,就会出现计算精度问题;但目前十进制计算的速度仍然不及二进制),为继续通过提高主频提升性能打开了突破口;还有一个途径就是出现革命性的新的计算技术如量子计算等。

实际上,当前并行计算的现状是部分程序员可以进行并行编程,且大部分程序是MPI程序,OpenMP有一定比例。服务器程序大部分采用多线程。但大部分普通应用都还是串行的。

新一代并行程序设计语言

当前国际上对新一代并行程序设计语言的研究正日渐升温。其中美国HPCS项目(高生产率计算系统,http://www.highproductivity.org/)资助开发的新的高生产率并行编程语言有三种,分别属于三个公司,包括IBM的X10、SUN公司的Fortress和Cray公司的Chapel。这三个语言目前还处在原型开发阶段,大规模推广还需要时间。此外,还有一类称为分割全局地址空间系统(PGAS)的并行程序设计语言,包括UPC(Unified Parallel C,C语言的扩展)、CAF(Co-Array Fortran, Fortran的扩展)和Titanmin(Java的扩展),目前已经开始在部分实际项目中得到应用,且效果不错。对于新并行程序设计语言的研发,国内目前还没有国家项目进行资助,这一点需要引起关注。

HPCS语言是美国为了支持从千万亿次机器到单个多核芯片的应用范围所研发的高效能计算系统的配套语言,属于研究型语言,有很多新的设计思想。其主要设计思路是降低并行程序设计的难度,提高软件生产效率,同时提供高性能、可移植和健壮性的支持。由于存储层次很复杂,还需要在语言里支持进行数据局部性的描述,要明确指出数据所在位置。

举例来说,IBM的X10的意思就是10倍,提高高性能程序设计的生产效率10倍,是2003年启动的,目前已经完成了原型系统的开发,估计还需要较长的时间投入商业使用,现在只是小范围内试用。该语言对多核系统与集群系统提供了统一的支持。为了保持可移植性和安全性,该语言继承了JAVA的虚拟机,是其子集的扩展。它基于JAVA 1.4进行扩展。把JAVA里的并发库用它自己的并行库替换,数组用X10数组替代。X10把存储分成三类,一类是不可变数据、一类是共享堆、一类是活动堆栈。为描述局部性,引入了Place的概念。一般共享多线程的计算将局限于一个Place里,且线程被更轻量级的Activity取代。如果支持分布存储全局并行,就需要多个Place,它们之间全局共享一定的数据,同时也允许每个Place有自己的私有数据。这一点与PGAS语言类似。多个Place之间的并行通过异步操作来进行支持。

PGAS语言是比HPCS语言早一些的语言,但比MPI和OpenMP要晚。PGAS语言的细节虽然不同但内涵相似,特别是在支持SPMD (单程序多数据)计算方面。该类语言仍然需要用户提供数据和任务映射的细节,虽然还是有难度,但对专家来说已经降低了不少。

下表是当前几个比较流行和正在研究的并行程序设计语言的比较。它们都可以部分解决多核带来的软件设计问题,各有不同的特点。当前正在研究的新并行程序设计语言追求的总目标是在保障性能的同时,降低编程的复杂性。

趋势和展望

对于处理器主频来说,摩尔定律已经接近极限了。普通用户也不得不面临并行的问题。多核处理器对操作系统和程序设计等都提出了很多挑战,需要思考怎么解决这些问题。应对多核处理器的软件开发,可以有几种解决思路,包括硬件隐藏、自动并行、OpenMP多线程、MPI优化、新并行语言等。一些新的高生产率和支持全局地址空间的并行程序语言已经出现了,而且正在快速发展,对我们应对多核处理器的挑战提供了可能的最终解决途径。

同步机制漫谈

□ 文 / 张银奎

更快是计算机世界的一个永恒主题。要做到更快有两个方向:一是提高串行执行的速度,二是并行计算(Parallel Computing)。并行计算又可分为同一CPU内部多个流水线间的并行、同一个系统内多个CPU间的并行、和同一个网络中多个计算机系统间的并行。

当并行运行的多个任务彼此无关,互不依赖时,整个系统的性能是最高的。但在现实的并行计算中,这是不可能的。至少同一组内的多个任务之间是存在依赖关系的,它们需要交流信息,报告彼此的计算结果;调整进度,确保各个任务都有条不紊的进行;协调资源,确保共享数据的一致性和安全性和最终结果的正确性。这样便产生了并行计算中的一个基本问题,那就是同步(Synchronization)。并行计算的特征决定了同步是它的一个必然问题。

为了易于理解,我们看一个从银行账户中存款和提款的简单例子,清单1给出的是账户类CAccount的Withdraw和Deposit方法的C++代码。

      1 BOOL CAccount::Withdraw(double dblNumber)
      2{
      3    BOOL bRet=TRUE;
      4    if(GetBalance()>=dblNumber)
      5   {
      6       // Send out money now, we use sleep to
      simulate
      7      Sleep(rand());
      8       m_dblBalance-=dblNumber;
      9    }
      10        else
      11        {
      12           bRet = FALSE;
      13        }
      14
      15        Log(TASK_WITHDRAW,dblNumber,bRet);
      16        return bRet;
      17    }
      18    void CAccount::Deposit(double dblNumber)
      19    {
      20        m_dblBalance+=dblNumber;
      21        Log(TASK_DEPOSIT,dblNumber,TRUE);
      22    }

以上方法很容易理解,参数dblNumber是要支出或存入的金额,第4行检查帐户余额是否足够本次提取,第7行执行支付操作,我们调用Sleep函数延迟一段时间来模拟这个操作,第8行修改账户余额。

图1是使用以上类的TaskSync程序的界面和一次执行记录。点击Deposit和Withdraw按钮会触发创建新的线程来调用CAccount类的Deposit和Withdraw方法。编辑框中的数字既是存入和支出的金额,又是要创建的线程数,因为我们让每个线程都固定的存入或取出1元钱。

在编辑框中各输入10后,随机的反复点击Deposit和Withdraw按钮,持续一段时间后,我们会发现余额变成了负数。

图1:TaskSync程序

观察清单1中的代码,只有在确保余额不小于参数dblNumber时(第4行)才会执行取款动作,然后递减余额(m_dblBalance)。也就是说这个账户是不应该出现负数余额的(不可透支)。那么,是什么原因导致余额变为负数呢?以下是几种猜想:

在某个(些)线程执行取款动作的过程中(第7行),其它线程又修改了余额值。尽管第4行作判断时账户中还有足够的余额,但是在执行递减操作时,其它线程(提款机)可能已经把余额递减为0了,于是再次递减便出现了负数。

在某个(些)线程更新m_dblBalance变量时,也就是执行递减操作(第8行)时,其它线程又修改了它的值。清单2列出了m_dblBalance-=dblNumber语句所对应的汇编代码。可见尽管C++是一条语句,但是编译出的汇编语句还是有很多条的。第1行(清单2)是将this指针存入EAX寄存器,第2行是将m_dblBalance(this+8)从内存加载到FPU(符点处理单元)寄存器栈中,第3行是执行减法运算,ebp+8指向的是参数dblNumber,第4行是将this指针存入ECX寄存器,第5行是将计算结果存回内存中的m_dblBalance成员变量。因为第3行的减法计算是对加载在CPU寄存器中的值做减法,第5行再将这个值存回内存,那么如果在某个线程执行2、3条指令的间隙,其它线程修改了m_dblBalance,那么这个线程使用的仍然是旧的数据,而且第5行会将错误的结果写入到内存中。

在32位x86系统中,m_dblBalance变量在内存中的长度是8个字节(QWORD)。这意味着存取这个变量时需要读写8个字节。如果,两个线程恰好都要读写这8个字节,那么有可能某个线程读到的内容是另一个线程写了一半的数据,或者某个线程写了8个字节的前半部分,另一个线程写了后半部分。

清单2 m_dblBalance-=dblNumber语句所对应的汇编代码。

      1 004013B5   mov        eax,dword ptr [ebp-4]
      2 004013B8   fld        qword ptr [eax+8]
      3 004013BB   fsub       qword ptr [ebp+8]
      4 004013BE   mov        ecx,dword ptr [ebp-4]
      5 004013C1   fstp       qword ptr [ecx+8]

可以说,以上三种猜想都是合理的。猜想1的可能性最大,因为判断条件和递减操作之间的时间较大,在此期间,余额值被其它线程修改的概率很高。猜想2也是可能的,因为在Windows这样的抢先式(preemptive)多任务操作系统中,操作系统可能在某个线程执行完清单2中的第2条指令后将其挂起,然后去执行其它线程。这意味着,因为线程切换,即使系统中只有一个普通的CPU(非Hyper Threading等),那么猜想1和猜想2所描述的情况仍然可能发生。

下面看一下猜想3,对于单CPU系统,因为CPU总是在指令边界(instruction boundary)来确认中断和进行线程切换,而且存取m_dblBalance变量都是使用一个指令来完成的,所以这种情况是不会发生的,也就是说CPU不会在一条指令还没执行完时将某个线程挂起。对于多CPU系统,是可能发生的(见下文),因为多个CPU可能并行执行清单2中的递减操作,也就是所谓的并发(concurrency)情况。

事实上,以上三种情况也是并行计算中经常遇到的三个典型问题。为了解决这些问题,操作系统通常会提供各种同步机制,供自身和应用软件使用。比如Windows操作系统提供了很多种用于线程同步的核心对象,以满足不同的需要,比如关键区(critical section),事件对象(event),互斥对象(semaphore)和spinlock等等。此外,作为计算机系统执行核心的CPU也内建了很多 同步支持。下面以IA32 CPU为例略作介绍。

首先我们介绍一下CPU一级的原子操作(atomic operations)。所谓原子操作,就是CPU会保证整个操作被完整执行,不会被打断成几个部分多次执行。例如,IA32 CPU会保证以下操作(列出的不是全部)都是原子的:

读写一个字节。

读写与16位地址边界对齐的字(WORD)。

读写与32位地址边界对齐的双字(DWORD)。

读写与64位地址边界对齐的四字(QWORD)(从奔腾开始)。

归纳一下,读写一个字节永远是安全的,读写按其长度做内存对齐的数据通常也是安全的。读写内存对齐的数据也有利于提高效率,这也是编译器在编译时会自动做内存对齐的原因。回到我们刚才讨论的猜想3,m_dblBalance是8字节长的,从调试器中可以看到它的地址是0x12fed0,这个地址可以被8整除,符合64位(二进制)对齐标准。所以如果是在奔腾及其之后的CPU上执行,那么读写m_dblBalance是安全的(不必担心不完全的读写)。如果在CAccount类的定义前加上pack(1)编译指令(compiler directive),并在m_dblBalance成员前加上一个一字节的字符变量,那么m_dblBalance的地址变成了0x12fecd,不再64位对齐了。

      #pragma pack(1)
      class CAccount
      {
      protected:
        char n;
        double m_dblBalance;
      …

对于没有对齐的16位,32位和64位数据,CPU是否能以原子方式读写就要视情况而定了,如果它们是位于同一个cache line中的,那么P6系列及其后的IA32 CPU仍会保证原子读写。如果是分散在多个cache line中的,那么奔腾4和至强(Xeon)CPU仍有可能支持其原子读写,但是访问这样的未对齐数据会影响系统的性能。

下面我们再来看一下总线锁定。为了保证某些关键的内存操作不被打断,IA32 CPU设计了所谓的总线锁定机制。当位于前端总线上的某个CPU需要执行关键操作时,它可以设置(assert) 它的#LOCK信号(管脚)。当一个CPU输出了#LOCK信号,其它CPU的总线使用请求便会暂时堵塞,直到发出#LOCK信号的CPU完成操作并撤销#LOCK信号。例如,CPU在执行以下操作时,会使用总线锁定机制:

设置TSS(任务状态段)的Busy标志。设置Busy标志,是任务切换的一个关键步骤,使用锁定机制可以防止多个CPU都切换到某个任务。

更新段描述符。

更新页目录和页表表项。

确认(acknowledge)中断。

除了以上默认的操作外,软件也可以通过在指令前增加LOCK前缀来显式的(explicitly)强制使用总线锁定。例如,可以在以下指令前增加LOCK前缀:

位测试和修改指令BTS、BTR和BTC。

数据交换指令,如XCHG、XADD、CMPEXCHG和CMPEXCHG8B。

以下单操作符算术或逻辑指令:INC、DEC、NOT和NEG。

以下双操作符算术或逻辑指令:ADD、ADC、SUB、SBB、AND、OR和XOR。

Windows操作系统所提供的用于同步访问共享变量的Interlocked Variable Access API就是使用LOCK方法实现的。例如,以下就是InterlockedIncrement API的汇编代码:

      kernel32!InterlockedIncrement:
      7c809766 8b4c2404         mov     ecx,dword ptr
  [esp+4]
      7c80976a b801000000     mov    eax,1
      7c80976f f00fc101          lock xadd dword ptr
  [ecx],eax
      7c809773 40            inc    eax
      7c809774 c20400         ret    4

可以看到,XADD指令前被加上了LOCK前缀。类似的InterlockedExchange API是使用带有LOCK前缀的cmpxchg指令。以下是kernel32.dll和ntdll.dll输出的所有Interlocked API。

      0:001> x kernel32!Interlocked*
      7c80978e kernel32!InterlockedExchange = <no type
  information>
      7c8097b6 kernel32!InterlockedExchangeAdd = <no
  type information>
      7c80977a kernel32!InterlockedDecrement = <no type
    information>
      7c8097a2  kernel32!InterlockedCompareExchange  =
    <no type information>
      7c809766 kernel32!InterlockedIncrement = <no type
    information>
      0:001> x ntdll!Interlocked*
      7c902f55  ntdll!InterlockedPushListSList  =  <no
    type information>
      7c902f06  ntdll!InterlockedPopEntrySList  =  <no
    type information>
      7c902f2f ntdll!InterlockedPushEntrySList = <no
    type information>

除了前面的三种猜想,还有一种可能导致数据不同步,那就是因为处理器的乱序执行(out-of-order execution)和内部缓存(cache)而导致的数据不一致。为了发挥CPU内多条执行流水线(execution pipeline)的效率,CPU可能把一段代码分成几段同时放到几个流水线中执行;另外,为了减少存取内存的次数,少占用前端总线,处理器会对某些写操作进行缓存。比如,一个函数先向地址A写入1,而后又写为2,…….,那么CPU可以延迟中间步骤中的各次写操作,只需要把最终的结果更新到内存,这就是所谓的写合并(Write Combining)。但如果系统中有多个处理器,那么另一个处理器就有可能使用过时的数据。为了解决诸如此类的问题,IA32 CPU配备了SFENSE 、LFENCE和MFENCE三条指令,分别代表Store Fence(写屏障)、Load Fence (读屏障)和Memory Fence。SFENCE用来保证该指令之前(程序顺序)的写操作一定早于它之后的所有写操作而落实完成(complete)、公之于众(通知其它处理器和写入内存)。换句话来说,SFENCE指令之前的写操作是不可能穿越SFENCE而早于其后的写操作而完成的。类似的,LFENCE是用来强制读操作的顺序的,MFENCE可以同时强制读写操作的顺序。因为这三条指令的作用都是为了显式定义内存存取顺序,所以它们又被称为内存定序(memory ordering)指令。

因为SFENCE指令是Pentium IIICPU引入的,而LFENCE和MFENCE是奔腾4和至强引入的,所以这几条指令在Windows XP或之前的系统中还较少使用。

      DDK for Windows Server 2003定义了KeMemoryBarrier
    API,在3790版本DDK的ntddk.h中可以看到其x86实现如下:
      FORCEINLINE VOID KeMemoryBarrier ( VOID )
      {
          LONG Barrier;
          __asm {  xchg Barrier, eax  }
      }

可见使用的是自动锁定的XCHG指令。而在支持Vista的5744及更高版本的DDK中,其定义为:

      FORCEINLINE VOID KeMemoryBarrier ( VOID )
      {
          FastFence();
          LFENCE_ACQUIRE();
          return;
      }

其中FastFence被定义为编译器的intrinsics(内建函数):

      #define FastFence __faststorefence

所谓intrinsics,就是定义在编译器内部的函数片断,很类似于扩展关键字,当编译器看到程序调用这些函数时,会自动产生合适的代码,也有些intrinsics只是以函数调用的形式向编译器传送信息,并不产生代码,如KeMemoryBarrier WithoutFence便是告诉编译器调整内存操作顺序时不能跨越这个位置。类似的定义还有:

      #define LoadFence _mm_lfence
      #define MemoryFence _mm_mfence
      #define StoreFence _mm_sfence

在最新的Vista SDK(SDK 6.0)头文件中也包含以上定义,不过用户态的API名字叫MemoryBarrier()。可见最新的Windows DDK和SDK都已经提供了充分的内存定序支持。

多核计算环境的挑战——本地代码的并发

□ 文 / 王昕

在计算机领域有一个人所共知的“摩尔定律”,它是英特尔公司创始人之一戈登·摩尔(Gordon Moore)于1965年在总结存储器芯片的增长规律时(据说当时在准备一个讲演),发现“微芯片上集成的晶体管数目每12个月翻一番”。当然这种表述没有经过什么论证,只是一种现象的归纳。但是后来的发展却很好地验证了这一说法,使其享有了“定律”的荣誉。后来该定律被表述为“集成电路的集成度每18个月翻一番”,或者说“三年翻两番”。这些表述并不完全一致,但是它表明半导体技术是按一个较高的指数规律发展的。

为什么要有多核?

在“摩尔定律”被提出来后的近40年间,所有的数据都验证了该定律的正确性,直到2004年。在这40年的时间中,我们经历了芯片产业的一个高速发展时期,在Intel等CPU厂商孜孜不倦地市场推动下,我们所使用的PC CPU的主频从最初4004的108Khz一直提升到了当时最先进的P4 3。4Ghz(当然,此处我们忽略了少部分的超频爱好者通过种种技术手段超频所得到到大于该数值的数据)。

然而,当时间到2004年时,事情发生了改变。在这一年间,由于散热受限等原因,Intel不得不向外宣布放弃了P4 4Ghz芯片的研制计划,转而投向多核处理器的怀抱。至此,在芯片业中被奉为“金科玉律”的“摩尔定律”基本上已经算是破灭了。

由于“摩尔定律”是在上个世纪60年代时被提出来的,戈登·摩尔并没有预见到此后若干年间随着芯片业制造工艺发展中而产生的问题。随着元器件越造越小,一些传统工艺无法逾越(或者必须付出巨大的代价来实现)的物理极限横在了我们眼前。按照其出现的时间顺序,我们把这些限制罗列如下(具体的描述,读者可以自行去阅读相关资料):

· 散热

· 电流泄露

· 热噪

· 基本大小的限制

在取消掉P4 4Ghz的研制计划后,Intel转而开始进行多核处理器(Multi-core)的研制。简单地说,多核处理器的基本思想是将多个处理器置入单一芯片中,如此一来,多个处理器能以较缓慢的速度运转,既能减少运转消耗的能量,又能减少运转生成的热量。此外,集多个处理器的能力,可提供比单一处理芯片更大的处理性能。

在摩尔定律还适用的那段日子,软件业也正经历着它的黄金时期,由于计算速度的可预见性增长,使得软件厂商将主要精力都放在软件的功能及其外在展示上,而并没有太注意软件的运算效率。当时存在着的一种比较有趣的现象就是:“安迪送,比尔取。”意即:不论你硬件可以提供多快的速度,我软件都能将它消耗掉。多年来,我们大部分的软件开发人员都是依靠着Intel等厂商所提供的免费午餐,轻松地享受着由于CPU等硬件性能提升所带来的种种好处。但是,世界上没有永远的免费午餐,随着摩尔定律的破灭,单纯地依赖硬件性能的提升来获得软件性能的提升的做法已经变为不可取的了。虽然硬件的性能还在提升(这一点是毋庸质疑的,毕竟硬件厂商的研制能力也在不断提升中),但是我们却无法像过去那样让我们的应用程序简单地从硬件性能提升中获得相应的好处。

为了能够更加有效地利用硬件(主要是CPU)所提供的性能,传统的开发方式在现在已经不再适用了。传统的开发方式所开发的应用程序在大部分情况下所面对的系统中只有一个单独的运算处理单元,为了开发的简化,大部分的应用都是顺序化执行的(通常这就意味着单线程);而现在,我们所面对的系统中的处理单元个数已经不再是单独的,如果我们仍然采用老式的开发方式开发顺序化执行的应用程序,那么我们就将极为可耻地浪费掉硬件厂商为我们提供的部分(有时甚至可能会是大部分)运算能力。而为了能充分地利用这些在传统方式中被浪费掉的资料,我们就不得不对我们开发软件的方式进行一次巨大的变革,这个变革就是:并发!

并发

在计算机领域中,“并发”意味着系统可以在一定的时间段内同时执行多个计算任务的能力,并且在这些计算过程中,不同的计算任务之间还可以共享部分资源。在传统的定义中,“并发”和另外一个术语——“并行” ——之间有着一定的区别(“并行”指的是:在多个处理器上同时执行相同的任务以更快地获得结果)。但为了简化术语(并且在现在,大部分人也是如此认为的),在本文中,我们将用“并发”这一个词来同时指代这两者。

并发并不能算是个新鲜的名词,在操作系统的早期发展过程中(大概是上个世纪60年代),人们就意识到了CPU的运算速度和外设的速度之间有着很大的区别,为了节省在当时还算是宝贵的CPU运算时间,让CPU不会在外设处理数据时等待着,许多多任务处理机制就被设计和开发出来了。下面我们就分别对这些多任务处理机制进行描述,并在接下来的小节中对并发进行更加详细的讨论。

多任务处理

· 批处理式多任务处理(batched multitasking)

在这类系统中,计算机一次性地装载一批不同的应用程序到其内存中并依次执行它们,当其中的一个程序需要等待外设的处理结果时,操作系统就将它的当前上下文场景保留至某处,然后转而执行下一个程序,当下一次执行该程序时,操作系统会从读入并恢复上一次操作过程中保存的上下文,以便于该程序的执行。在所有程序都被执行完毕之前,这个过程将不断地重复。

· 时间片简单轮询多任务处理(time-slicing multitasking)

在此类系统中,操作系统会为所有的应用程序分配一个固定长度的时间片,应用程序在其所占有的时间片中将不会受其他应用程序的影响而独立执行,直至该时间片结束或者应用程序退出。

· 协作式多任务处理(cooperative multitasking)

在此类系统中,应用程序在其运行过程中独占所有的运算资源,只有当该应用程序自愿地将资源的控制权交还给操作系统之后,其他的应用程序才有机会获取计算资源并得以执行。此处“协作式”的意思就是:应用程序必须协助操作系统一同工作。

· 抢占式多任务处理(preemptive multitasking)

在此类系统中,应用程序是否能够获取足够的运算资源从而得以执行,主要取决于操作系统对它们的调度。此处“抢占式”的意思是:在当前应用程序的执行过程中,没有其他的应用程序可以获取到计算资源。

目前大部分具备多任务处理能力的操作系统所采用的机制都是抢占式多任务处理机制加时间片轮询的方式。

为了能充分地利用这些在传统方式中被浪费掉的资料,我们就不得不对我们开发软件的方式进行一次巨大的变革,这个变革就是:并发!

多任务处理→传统并发

根据定义,只要在一定的时间段内多个计算任务得以执行,那么我们就可以认为该系统支持并发操作。因此,当操作系统给单个的应用程序执行单元分配的时间片足够小(事实上也确实如此),导致在人们可分辨的时间段中,确实存在着多个计算任务都被执行的情况,从而就导致了“传统”并发的产生。(注:此处所使用的传统并发,描述的是在老式的、非多处理器环境下的并发,至于SMP(对称多处理器)系统以及Multicore(多核处理器)系统下的并发,我们会在后续的章节处进行讨论)。

传统并发中的几个关键概念

从上面的描述中,我们不难看出。从处理器的层面上来看,传统并发实质上还是一个顺序的过程。但就是因为存在着以下几个关键概念(以及对象),使得一个顺序化的执行过程在外看来就成了并发执行:

· 时间片(time slice)

时间片就是操作系统分配给应用程序执行单元用于执行的时间间隔,当该执行单元所获取的时间片过期时,系统就会向操作系统内核发送一个中断信号,以便于操作系统切换到其他应用程序执行单元上去运行。

· 调度程序(scheduler)

调度程序是现代操作系统中必不可少的一部分,操作系统依靠它来调度不同的应用程序执行单元,为这些执行单元分配合适的时间片(以及优先级等),从而实现多任务处理。

· 上下文切换(context switching)

在一个计算任务的执行过程中,它所使用的资源(如:CPU中的寄存器、内存堆栈等)就构成了它的执行上下文。由于应用程序是一个连续有序的代码段,它不可能知道自己何时会被操作系统内核挂起或者恢复,而当它被挂起时,其他任务可能会修改掉CPU中寄存器或者内存堆栈中的值,当它被恢复时,它又无从得知执行值是否被修改过,如果被修改过,那么如果它在以后的执行过程中继续使用到了这些被修改后的值的话,计算结果的正确性就无法得到保证。为了避免出现上述错误,操作系统就必须提供一个机制来保证,在恢复一个计算任务之后,其上下文环境和其被挂起时是一样的;操作系统内核有责任 通过在任务挂起前保存其上下文 来确保这种状况。当任务恢复时,保存的上下文 就被 操作系统内核恢复到先前的执行情况。保存一个被挂起的任务的上下文 并在 任务恢复时 恢复其上下文的这个处理过程就叫做上下文切换。

· 资源共享(resource sharing)

由于我们不可能无限制地为计算机添加资源,因而在并发的多个计算任务中,一定的资源共享是必须的。可被共享的资源包括:CPU时间、操作系统内核对象、计算机外设、以及文件句柄等……

· 竞争条件(race condition)

竞争条件,有时也翻译成“竞态条件”,是指由和事件时间相关的意料之外的依赖所导致的反常行为。举例来说就是:一个程序员不正确地假设一个特殊的事件总是在另一个事件之前发生。在以前,竞争条件还不是什么问题;那时,计算机系统通常在同一时刻只能运行一个单独的程序,什么都不能打断它或者与它竞争。但是,当今的计算机通常需要同时运行大量的进程和线程,经常还会有多个处理器确实在同时运行不同的程序。这样做更灵活,但是有一个危险:如果这些进程和线程共享了所有的资源,那么它们都可能互相影响。现在,竞争条件缺陷是并发程序设计中的最常见缺陷之一。

· 死锁(deadlock)

死锁是指,在两个或多个并发进程中,如果每个进程持有某种资源而又都等待别的进程释放它们现在保持着的资源,否则就不能向前推进。此时,每个进程都占用了一定的资源但是又不能向前推进,称这一组进程产生了死锁。通俗地讲就是:两个或者多个进程无止境地等待着永远不会成立的条件的一种系统状态。

产生死锁的原因:一是系统提供的资源数量有限,不能满足每个进程的使用;二是多道程序运行时,进程推进顺序不合理。

产生死锁的必要条件包括如下4点:1)互斥条件;2)不可剥夺条件;3)部分分配;4)循环等待。

· 同步(synchronization)

在计算机领域中,同步是被用于保证多个操作的执行结果与我们预期相符的一种行为。

· 其他……

其他和并发相关的概念还包括:饥饿(starvation)、活锁(livelock)等……

构建并发程序的几种机制

现代操作系统中为构建并发程序提供了如下几种机制:

· 进程(Process)

定义:进程是程序在内存中的一次运行。进程是系统对资源分配的最小单位。一个进程可以包含多个线程。

在基于进程的并发过程中,我们将一个计算任务分解成多个独立的进程来执行,它们之间的调度由操作系统内核来进行。由于进程都拥有独立的进程空间,所以如果一旦它们想和外界通讯,就必须使用某种显示的进程间通讯(IPC:inter-process communication)机制,这是基于进程来设计并发程序的一个缺陷:进程间的通讯会增加程序构建过程中的复杂性;基于进程来构建并发程序的另外一个缺陷就是:进程间的通讯通常都比较低效。

· 线程(Thread)

定义:线程有时也被称为轻量进程(lightweight processes),指运行中的程序的调度单位。线程必须存在于某个进程中。

在基于线程的并发过程中,计算任务被分解成同一个进程间的多个线程来执行,线程间的调度由操作系统内核来进行。由于多个线程都存在于同一进程空间中,它们共享该进程的进程空间,所以它们自己的通讯可以通过进程中的共享资源来进行。基于线程的并发过程中的一个缺陷就是:当一个线程不慎破坏了某个数据时,会同时影响到进程间的其他线程使用该数据。

· 纤程(Fiber)

纤程是专属于Windows平台下的一个术语,它是一个类似于协程(coroutine)的轻量级线程(lightweight thread,green thread)。纤程间的调度必须得由用户自己编程来进行,从这一点来看,有人把它称为cooperative thread而把传统的线程称为preemptive thread。微软设计纤程的本意是为了方便用户把Unix程序移植到Windows上来。但有意思的是,据笔者所知,Unix所使用的POSIX标准中并没有纤程这个概念,网上流传的几个轻量级线程库(lightweight thread library)也没有一个成为事实上的标准。

纤程的好处在于,纤程间的切换都是在用户态进行的,速度比传统的上下文切换要快很多;缺陷在于:必须得自己来调度纤程间的切换,这就导致了程序编写的复杂度大为增加。

并发程序中所采用的几种通讯模型

如同上面所说的,有时我们需要在多个计算任务执行组件间进行数据通讯,以保证数据的同步。总的来说,在并发程序中经常被使用的通讯模型包括:

· 共享内存通讯(share memory communication)

在该模型下,不同的并发执行组件通过修改共享内存中的值来向其他组件通知数据的改变。通常,为了避免竞争条件,在使用该类通讯模型时,我们还同时需要使用某种形式的锁(locking)机制(如:互斥量、信号量等)。这也是我们所最熟悉的一种并发通讯模型,例如:在C/C++、Java、C#等编程语言中所支持的并发开发方式中,使用的就是该类模型。

· 消息传递通讯(message passing communication)

在该类模型中,并发执行组件通过向其他组件发送消息来传递对方所需要的数据,消息的传递可以是异步的,也可以是同步的。此类并发通讯模型从本质上来说,更接近人们平时的思考习惯,而且与共享内存相比,它的健壮性更好。然而在程序员的行业中,由于先入为主的印象以及效率影响,它的应用并不如上面所提到的共享内存模型那么广。在现阶段,使用消息传递机制的并发语言主要有:Erlang、Occam等。(注:纯粹从并发吞吐量的效率来说,Erlang要比其他使用共享内存通讯机制的语言更强,但这主要是由于Erlang所依赖的虚拟机(VM)中采用的是green threads的缘故)。

· 软件事务内存(software transactionmemory,STM)

软件事务内存实质上应该是属于并发控制的一种机制,它所用来进行通讯的实际上还是被多个并发组件共享的所谓事务变量(Transaction variable),它的概念来自于数据库领域中的“事务”,我们知道,数据库中的事务具有ACID(原子性:Atomicity、一致性:Consistency、独立性:Isolation、及持久性:Durability)这四个特性。通过对具有原子性以及独立性的事务变量进行读写,我们就可以消除掉传统的锁机制带来的的复杂性、性能瓶颈以及其他难点。目前该技术还处于实验阶段,在现阶段对于并发编程的许多论文中都对该技术进行了详细的描述和探讨,使用该技术的语言主要有:Haskell。另外,还有一个与之极为密切相关的术语:无锁并发(lockless concur rency),详细的描述读者可以自行去参考相关资料。

并发编程可以给我们带来的好处

简单地说来,并发编程可以给我们带来如下好处:

· 提升应用程序的吞吐量

在单位时间内可执行的任务数目变多了;

· 更高效的输入/输出响应

I/O密集型的应用在以往都是将大量的时间花在等待I/O操作的结束上面。而采用了并发编程,我们可以用一个单独的任务来进行此类等待,让其他的任务可以继续执行;

· 更恰当的程序结构

有些领域的应用天然就应该被表述为并发的(如电信中的交换机控制程序)。

真正的并发涉及到多个处理器,由于系统中同时存在着多个处理器,那么我们就可以设法让多个线程在同一时刻同时运行在不同的处理器上。

从传统的顺序化编程转向并发编程带来的基本挑战

由于长时间的占据着统治地位,顺序化编程的方式早已成为了大多数程序员的编程习惯。然而在即将到来的并发应用的世界中,游戏规则已经发生了改变。为了充分利用硬件提供的计算资源,大部分的应用都被分解成了多个小的相对独立的并发执行单元;由于并发执行的多个单元在执行顺序及位置的不可确定性,我们又不得不对所有的这些并发执行单元进行协调,以保证计算结果的正确性;另外,为了保证计算过程中的数据同步,我们还不得不谨慎地挑选合适的通讯机制。简单地说,从顺序化编程转向并发编程的过程中的几个基本的挑战就是:

· 确定该应用是可以被分解成多个并发执行单元来进行的

· 将应用分解成适当的多个执行单元,使之能够并发执行

· 协调上述并发执行单元,使应用得以准确高效地执行

用一句话说就是:发现并确认、分解、通讯和同步

注意,不是所有的应用都可以从并发中获益的,有句著名的谚语就说明了这个问题:一位母亲可以在九个月间产下一名婴儿,但这不意味着九个母亲就可以在一个月间产下一名婴儿。与并发相关的软件问题包括:embarrassingly parallel以及Grand Challenge problems。

编写并发程序过程中所出现的其他挑战

在以往的并发程序中,占据着统治地位的通讯模型就是我们上面所提到的共享内存模型。使用该类模型经常碰到的问题就是:竞争和死锁,另外一个相对不那么明显但同样很重要的问题则是:性能!竞争会带来潜在的数据不同步的风险,死锁则会导致程序无法顺利执行完毕,而性能则会导致程序无法得到良好的可伸缩性(scalability)。

还有两个和并发编程相关的概念就是:线程安全以及可重入。线程安全指的是:一段代码可以被多个线程多次调用,并且这些调用不会导致该段代码产生错误的结果。可重入指的是:一段代码可以重复被多个并发执行单元同时装载进进程空间内执行。一般来说,可重入代码中不会涉及到任何的全局变量的引用。还有就是:可重入代码一定是线程安全的。

多核计算环境

前面我们所描述的都是传统的并发,也即单处理器条件下的并发。但是我们都知道,多核的时代已经来临了,多核所带来的计算环境下肯定还有着种种与传统并发不一样的挑战,下面我们就来对此进行简单的讨论。

多处理器系统(Multi-processors)

在多核处理器出现之前,那时也存在着一部分的并发应用。在某些场合中,由于单处理器不能向该类应用提供足够的系统健壮性、性能及可伸缩性等方面的保证,因此人们就设计出了多处理器系统。多处理器系统架构主要包括以下三大种类:

· SMP(Symmetric multiprocessing:对称式多处理器)

· NUMA(Non-Uniform Memory Access:非一致内存访问)

· 大规模并行计算机(massively parallel computer)

其中,SMP系统是目前我们最常见的一类系统,NUMA好像处于渐渐退出历史舞台的一种局面中,而最后的大规模并行计算机,由于笔者经验有限,没有接触过,无法得知更多与之相关的信息。

真正的并发及“伪”并发

根据在同一时刻所运行的计算任务不同,并发可以分为两种:真正的并发以及伪并发。我们在前面所提到的“传统并发”实质上就是一种伪并发,它是通过操作系统快速地在各个计算任务之间切换从而向用户展示了一种“这些计算任务正在被并发执行”的假象,实质上,在某个确定的时间点上,只有一个线程被运行着。而真正的并发则涉及到多个处理器,由于系统中同时存在着多个处理器,那么我们就可以设法让多个线程在同一时刻同时运行在不同的处理器上。

超线程(Hyper-thread)

超线程技术是Intel在多核处理器之前提出的一个概念。所谓的超线程计算,计算在一颗CPU中同时执行多个程序而共同分享一颗CPU内的资源,理论上要像两颗CPU一样在同一时间执行两个线程,P4处理器需要多加入一个Logical CPU Pointer(逻辑处理单元)。而其余部分如ALU(整数运算单元)、FPU(浮点运算单元)、L2 Cache(二级缓存)则保持不变,这些部分是被分享的。虽然采用超线程技术能够利用Intel P4系列CPU所提供的超长流水线来模拟同时执行两个线程,但它并不象两个真正的CPU那样,每个CPU都具有独立的资源。当两个线程都同时需要某一个资源时,其中一个要暂时停止,并让出资源,直到这些资源闲置后才能继续。从这方面来讲,超线程技术实质上还是一种“伪”并发。

多核

在多核处理器中,每块芯片中都集成了多个(一般是2的N次方个,如2、4、8……)物理意义上的运算核心。虽说多核的定义很简单,但在这多个核心在处理器中的设计应用方式上面却大有文章可供挖掘。由于现在的大部分多核处理器都是双核的,所以我们目前可以获得到的资料也都是用来描述双核处理器的。据现有的资料显示,在AMD的设计中,所有组件都直接连接到CPU,消除系统架构方面的挑战和瓶颈。两个处理器核心直接连接到同一个内核上,核心之间以芯片速度通信,进一步降低了处理器之间的 延迟。而Intel采用多个核心共享前端总线的方式。因此,有专家认为,AMD的架构对于更容易实现双核以至多核,Intel的架构会遇到多个内核争用总线资源的瓶颈问题。

多处理单元下的并发编程与单处理单元下的并发编程之间的区别

在前面我们提到了“真正的并发”以及“伪并发”,它们两者之间的区别也自然导致了针对多核(包括多处理器,在下文中统一用多核代替)的并发编程与针对单核(单处理器)的并发编程之间存在着一些细微的差别,除了这些“细微”的区别之外,针对单核和多核的并发编程并没有太大的区别。此处,我们所说的“细微”指的是站在应用程序程序员层面上的视角,实际上在底层的操作系统内核及编译器优化这个层面上来看,多核程序和单核程序的编写复杂度要远远大于我们开发应用程序的程序员所想象的。

在多核系统中,有时我们可能会存在着这样一种需求:将某个线程绑定在某个处理器上执行,这样就可以减少一些无谓的线程上下文切换;而这种需求是在单核系统中无法想象的,因为在单核系统中,所有的线程都运行在同一个处理器之上。为了达到我们的需求,我们就必须在程序编写过程中手动地为该线程设置其处理器亲和性。在Windows系统中,我们可以通过调用SetThreadAffinity Mask来达到此类效果。

另外,在多核系统中,有时我们需要在两个处理器之间进行数据同步,由于涉及到处理器内部的高速缓存同步等因素,我们有时可能并不需要让应用程序进入到内核态中等待(或者是我们不希望使用传统的作为内核对象的锁机制),此时我们就可以使用自旋锁(spin lock)来保证数据的同步过程不受竞争的影响。和自旋锁相关的API包括:POSIX中的pthread_spin_init等以及WindowsAPI中的Initialize CriticalSection AndSpinCount等。

多处理单元下的并发编程与单处理单元下的并发编程之间的区别(续)

前面我们说过了,在应用程序层面上来看,单核并发和多核并发之间并无大的区别。实际上,在AMD给出的建议中也是让程序员尽量去调用操作系统为我们所提供的API来进行并发程序的编写。

但是,如果我们切换一下视角,把目光放到底层开发人员上时,我们就将得到另外的答案。由于单核和多核之间在硬件架构上的不同,所以也自然导致直接和硬件打交道的方法也不同。对于操作系统内核来说,它得修改原有的调度程序(scheduler),以保证在多个处理器之间尽量做到负载平衡(load balan cing);对于编译器的优化端来说,至少我已经听说了,Intel准备在其所出品的ICC编译器中,将代码直接编译成可被并发执行的代码,这种做法的激进程度甚至超过了另外一个著名的用于并发编程的API:OpenMP。

免费大餐不久就将结束。对此,你准备好了吗?

多核多线程编程Java篇

□ 文 / 俞黎敏 审 / 韩锴

2006年是双核的普及年,多核化趋势正在改变IT计算的面貌。跟传统的单核CPU相比,多核CPU带来了更强的并行处理能力、更高的计算密度和更低的时钟频率,并大大减少了散热和功耗。目前,在Intel、AMD、IBM、SUN等几大主要芯片厂商的产品线中,双核、四核甚至八核CPU已经占据了主要地位。在将应用从单核环境向多核系统迁移的过程中,通过选择合适的操作系统,应用开发人员可以大大地减少麻烦。

有效地利用多核技术,不仅会大大地改善下一代网络环境的性能和可扩展性,还会对系统设计和软件开发产生深远的影响。

图1:典型的多核CPU结构

操作系统多核处理模式

操作系统支持多核芯片的多处理模式主要有以下三种:

一、非对称多处理(Asymmetric Multiprocessing,AMP)——每个CPU内核运行一个独立的操作系统或同一操作系统的独立实例(Instantiation)。这种处理模式属于系统层次,一个核跑一个OS,每一个程序永远只能用一个核。这样是一个与传统单核CPU系统相类似的运行环境。但是双核的时候可以跑两个OS,难道4核的时候需要跑4个OS?8核、16核、32核或更多核的时候……,跑些什么OS呢?所以沿着这个处理模式的方向不可能走很远。

二、对称多处理(Symmetric Multiprocessing,SMP)——一个操作系统的实例可以同时管理所有CPU内核,而且应用程序并不绑定于某一个内核上。一个设计良好的SMP操作系统允许多个应用线程协同地运行在任何一个内核上。这种协同性使得应用程序任何时候都可以利用芯片的整体计算能力。再加上如果操作系统能提供适当的优先权和线程优先排序能力,就能帮助应用开发人员确保CPU为最需要的应用服务。这种处理模式属于是线程层次,就是说每一个新的线程自动利用空闲的核,这样的话应用程序的开发人员不必插手,由底层的操作系统就可以完成此事。开发人员只要照常写多线程(Multi-Threaded)的应用就可以了。多线程编程模型不仅是目前提高应用性能的手段,更是下一代编程模型的核心思想。它的目的就是“最大限度地利用CPU资源”,当某一线程的处理不需要占用CPU而只需要I/O等其它资源时,就可以让需要占用CPU资源的其它线程有机会获得CPU资源。因此,就目前来说,多线程编程模型仍是计算机系统架构的最有效的编程模型。这种模式可以坚持很长时间,使用这种方法就可以提高性能。问题仅仅在于,多线程程序并不好写,特别是不好调试。所以开发人员要花费更大的精力在学习多线程开发上面。

三、混合多处理(Bound Multipro cessing,BMP)——一个操作系统的实例可以同时管理所有CPU内核,但每个应用被锁定于某个指定的核心。这种处理模式属于算法层次了,就是说执行每一个具体的算法任务,都尽可能利用多核并行。比如说100个整数相加,在4核CPU上,可以分别让每个核处理25个数的加法,最后将4个结果加起来。但是这种方法要求我们重写现有的大部分程序,无论是客户端还是服务端的应用。好在似乎只有少数应用需要这么干,而且还有OpenMP等开发工具帮助我们。于是通过把应用(或线程)绑定在指定的内核上,设计人员可以把潜在的并行问题控制在应用和线程层面。解决这些问题将允许应用完全并行地运行,因而最大化地发挥多核CPU带来的强大性能。

上述三种模式都有其各自的优点和缺点。每一个模式适合于解决某方面的特定问题,而且对操作系统的要求也各不相同。总的来说,无论是第二层次还是第三层次,多核带来的冲击势必是不可忽略的。

对于开发人员,当然会强烈要求软件开发的平台无关性,让平台相关的部分由编译器或者虚拟机去完成,算法的多核实现必须需要数据之间无相关性,但只有少部分算法内容能用到多核中,所以对于一般开发人员来说,只用到第二种处理模式就可以了,如果是开发操作系统、高级语言开发工具或编译工具就应该会用到第三处理模式了。

Java中的多线程技术

多线程和并发性并不是什么新内容,但是Java语言设计中的创新之一就是,它是第一个直接把跨平台线程模型和正规的内存模型集成到语言中的主流语言。核心类库包含一个Thread类和Runnable接口,可以用它们来构建、启动和操纵线程,Java语言包括了跨线程传达并发性约束的构造 —— synchronized和volatile。在简化与平台无关的并发类的开发的同时,它绝没有使并发类的编写工作变得更繁琐,反而使它变得更容易了。因此,在Java中使用多线程相对于在C/C++当中使用多线程来说更加简单与快捷。基本上所有的Java程序,包括J2SE、J2EE、J2ME程序都使用了多线程技术。在传统单核、单进程CPU上,Java多线程程序在性能上无法与C++ 单进程程序相比。但是,随着多核、超线程CPU时代的到来,未来CPU上将能够同时运行更多的线程。首先受益的是JavaEE企业级软件,JavaEE软件部署在高性能服务器上。每一次用户请求,都需要服务器端至少一个线程来响应。尽管有线程池的约束,Java EE软件的线程数目仍然非常可观。特别是对一些大型的网站来说,如果服务器使用多核、超线程CPU,那么就将极大地提高线程处理能力,提高系统的并发访问量。

构建线程化的应用程序往往会对程序带来重要的性能影响。例如,请考虑这样一个程序,它从磁盘读取大量数据并且在把它们写到屏幕之前处理这些数据(例如一个DVD播放器)。在一个传统的单线程程序(今天大多数客户端所使用的程序)上,一次只有一个任务执行,每一个这些活动分别作为一个序列在不同阶段的发生。只有在一块已定义大小的数据读取完成时才能进行数据处理。因此,能处理数据的程序逻辑直到磁盘读操作完成后才得到执行。这将导致性能非常的差。

在一个多线程程序中,可以分配一个线程来读取数据,让另一个线程来处理数据,而让第三个线程把数据输送到图形卡上去。这三个线程可以并行运行,这样一来,程序在读取磁盘数据的同时仍然可以处理数据,从而提高了整体的性能。许多示例程序都可以被设计来同时做两件事情以进一步提高性能。Java虚拟机(JVM)本身就是基于此原因才广泛使用了多线程技术。

线程化Java代码

所有的程序都至少使用一个线程。在C/C++和Java中,这是指用为调用main()而启动的那个线程。另外线程的创建需要若干步骤:创建一个新线程,然后指定给它某种工作。一旦工作做完,该线程将自动被JVM杀死。

Java提供两个方法来创建线程并且给它们指定工作。第一种方法是继承java.lang.Thread类,然后用该线程的工作函数重载run()方法。

创建一个线程的第二种方法是使用一个实现java.lang.Runnable接口的类。这个Runnable接口指定一个run()方法,然后该方法成为线程的主函数。现在,Java程序的一般风格是鼓励使用接口,因为通过使用接口,这个类在后面仍然能够继承其它的超类。

下面是采用实现Runnable接口方法的一个线程示例:

      public class MainThread{
        public static void main(String[] args)
        {
        /*1、创建了一个线程对象,但是并没有启动线程。*/
            Thread threadNotStart =
            new Thread(new SubThread());
        /* 2、创建了一个线程对象,而且启动了这一个线程。*/
            Thread threadStart =
            new Thread(new SubThread());
            threadStart.start();
        /* 3、创建并启动一个线程后,主线程自己也继续执行接下来
  的其它执行语句。*/
            System.out.println
            ("MainThread:JVM Exit.");
        }
      }
      class SubThread implements Runnable{
        public void run()
        {
            System.out.println
            ("SubThread:Java Thread …");
        }
      }

线程的含义

在采用多线程技术来增强性能的同时,也增加了程序内部运行的复杂性,这种复杂性主要是由线程之间的交互引起的。随着越来越多的核心芯片加入到处理器中,要使用的线程数目也将相应地增长,如果在创建多线程程序时不能很好地理解这些问题,那么是调试时将很难发现错误。因此,让我们先看一下这些问题及其解决办法。

等待另一个线程完成:假定我们有一个整型数组要进行处理。我们可以遍历这个数组,每次一个整数并执行相应的操作,或者更高效地我们可以建立多个线程,这样来让每个线程处理数组的一部分。假定我们在开始下一步之前必须等待所有的线程结束。为了暂时同步线程之间的活动,这些线程使用了join()方法——它使得一个线程等待另一个线程的完成。加入的线程(线程B)等待被加入的线程(线程A)的完成。在join()中的一个可选的超时值使得线程B可以继续处理其它工作——如果线程A在给定的时间内还没有终止的话。这个问题将触及到线程的核心复杂性——等待线程的问题。

在锁定对象上等待:假定我们编写一个航空公司座位分配系统。在开发这种大型的程序时,为每个连接到该软件的用户分配一个线程是很经常的,如一个线程对应一个机票销售员(在很大的系统中,情况并非总是如此)。如果有两个用户同时想分配同一个座位,就会出现问题。除非采取特殊的措施,否则一个线程将分配该座位,而另一个线程也会做相同的事情。两个用户都会认为他们在这趟航班上拥有一个分配的位子。

为了避免两个线程同时修改一样的数据项,我们让一个线程在修改数据前锁定数据项。用这种方法,当第二个线程开始作修改时,它将等待到第一个线程释放锁为止。当这种发生时,线程将会看到座位已被分配,因而座位分配的请求就会失败。两个线程竞争分配座位的问题也就是著名的竞争条件问题。当竞争发生时,有可能导致系统的泄漏。为此,解决的办法之一就是锁定存在竞争条件的代码——该代码读取并访问至少一个跨线程共享的变量。

Java提供了若干种锁的选择。其中最为常用的是使用同步机制。当一个方法的签名包含synchronized同步关键字时,在任何给定时间里只有一个线程能够执行这个方法。然后,当线程完成执行后,对该方法的锁定即被解除。例如:

      protected   synchronized   int   reserveSeat(Seat
  seat){
        if(seat.getReserved() == false){
            seat.setReserved();
            return 0;
        }
        else{
            return -1;
        }
      }

就是一个方法——在这种方法中每次只运行一个线程。这种锁机制就避免了上面所描述的竞争条件。

为了保证线程间正确地互交,需要对线程进行恰当的同步。前文介绍的synchronized机制是基于Java对象内置锁实现的。但是内置锁在使用时存在一些限制。因此,在Java 5中添加了显式的锁,Lock、ReadWriteLock接口和Reentrant Lock、ReentrantReadWriteLock实现类等。这些新类型和它们的方法可以在java.util.concurrent.locks包中找到。

在锁机制解决了竞争条件的同时,它们也带来了新的复杂性。在这种情况下,最困难的问题就是死锁。假定线程A持有对象X的锁,并且试图获取对象Y的锁;同时线程B持有Y的锁,并且试图获取X的锁,那么这两个线程将永远被锁定——这正是术语死锁的意义。死锁问题可能很难判定,因此必须相当小心以确保在线程之间没有这种依赖性。

使用线程池

如前文所提及,在线程完成执行时,它们将被JVM杀死,而分配给它们的内存将被垃圾回收机制所回收。不断地创建和毁灭线程所带来的麻烦是它浪费了时钟周期,因为创建线程确实耗费额外的时间。一个通用的且最好的实现是在程序运行的早期就分配一组线程,我们称之为一个工作者线程(Worker Thread),然后在这些线程可用时再使用它们,这种方案就称为“线程池”。通过使用线程池,在创建时分配给一个线程指定的功能就是呆在线程池中并且等待分配一项工作。然后,当分配的工作完成时,该线程被返回给线程池。

Java 5.0引入了java.util.concurrent包——它包括了一个预先构建的线程池框架——这大大便利了上述方法的实现。有关Java线程池的更多信息及一部教程,请参见 http://java.sun.com/developer/JDCTechTips/2004/tt1116.html#2

在设计线程程序和线程池时,自然出现关于应该创建多少线程的问题。答案看你怎样计划使用这些线程。如果你基于彼此独立(即使有互交也不严重)的任务来用线程划分工作,那么线程的数目等于任务的数目。例如,一个字处理器可能使用一个线程用于显示(在几乎所有系统中的主程序线程负责更新用户接口),一个用于标记文档,第三个用于拼写检查,而第四个用于其它后台操作。在这种情况中,创建四个线程是理想的并且它们提供了编写该类软件的一个很自然的方法。

然而,如果程序——像早些时候所讨论的那个一样——使用多个线程来做类似的工作,那么线程的最佳数目将是系统资源的反映,特别是处理器上可执行管道的数目和处理器的数目的反映。在采用英特尔超线程技术处理器的系统上,当前在每个处理器核心上有两个执行管道。最新的多核处理器在每个芯片上有两个处理器核心。英特尔指出将来的芯片有可能具有多个核心,主要原因在于,额外的核心会带来更高的性能而不会从根本上增加热量或电量的消耗。因此,管道数将会越来越多。

照上面这些体系结构所作的算术建议,在一个双核心Pentium 4处理器系统上,可以使用四条执行管道,因此使用四个线程将会提供理想的性能。(但实践中会使用五个线程,以防止某个线程崩溃后导致后有处理器空闲)。

Java 5.0增强特性

Java 5.0增加了新的类库并发集java.util.concurrent,该类库为并发程序提供了丰富的API,使得多线程编程在Java 5.0中更加容易、灵活。

Java 5.0是用Java语言创建高可伸缩的并发应用程序的主要基石。JVM已经进行了改进,允许类利用硬件级别支持并发,并且提供了一组丰富的新并发构造块,使得开发并发应用程序更加容易。

java.util.concurrent包提供的高级实用程序类——线程安全集合、线程池和同步实用程序。其中包含的大量有用的构建快,可以用它们来改进并发类的性能、可伸缩性、线程安全性和可维护性。通过这些构建快,您不必在您的代码中大量使用同步、wait/notify和Thread.start(),而是用更高级别、标准化的、高性能的并发实用程序来替换它们。

创建java.util.concurrent的目的就是要实现Collection框架对数据结构所执行的并发操作。通过提供一组可靠的、高性能并发构建块,开发人员可以提高并发类的线程安全性、可伸缩性、性能、可读性和可靠性。

Java 5.0中的并发改进可以分为三组:

· JVM级别更改。大多数现代处理器对并发对某一硬件级别提供支持,通常以compare-and-swap(CAS)指令形式。CAS是一种低级别的、细粒度的技术,它允许多个线程更新一个内存位置,同时能够检测其他线程的冲突并进行恢复。它是许多高性能并发算法的基础。在Java 5.0之前,Java语言中用于协调线程之间的访问的惟一原语是同步,同步是更重量级和粗粒度的。公开CAS可以开发高度可伸缩的并发Java类。这些更改主要由JDK库类使用,而不是由开发人员使用。

· 低级实用程序类——锁定和原子类。使用CAS作为并发原语, Reentrant Lock类提供与synchronized原语相同的锁定和内存语义,然而这样可以更好地控制锁定(如计时的锁定等待、自旋锁和可中断的锁定等待)和提供更好的可伸缩性(竞争时的高性能)。大多数开发人员将不会直接使用ReentrantLock类,而是使用在ReentrantLock类上构建的高级类。

· 高级实用程序类。这些类实现并发构建块,每份关于并发的计算机科学文献中都会讲述这些概念——信号、互斥、闩锁(Latch)、屏障(Barriar)、交换程序、线程池和线程安全集合等。Java 5.0中几乎包括了与所有这些概念对应的类实现。大部分开发人员都可以在应用程序中用这些类,来替换许多(如果不是全部)同步、wait()和notify()的使用,从而提高性能、可读性和正确性。

Java 5.0为开发人员开发高性能的并发应用程序提供了一些很有效的新选择。例如:

· 线程安全集合

JDK1.2中引入的Collection框架是一种表示对象集合的高度灵活的框架,它使用基本接口List、Set和Map,并通过JDK为每个接口提供了多种实现(HashMap、Hashtable、TreeMap、WeakHashMap、HashSet、TreeSet、Vector、ArrayList、LinkedList等等)。其中一些集合已经是线程安全的(Hashtable和Vector);通过同步的封装工厂(Collections.synchronizedMap()、synchronizedList()和synchronizedSet()),其余的集合均可表现为线程安全的。

java.util.concurrent包 添 加 了 多 个 新 的 线 程 安 全 集 合 类(ConcurrentHashMap、CopyOnWriteArrayList和CopyOn WriteArraySet)。这些类的目的是提供高性能、高度可伸缩性、线程安全的基本集合类型版本。

java.util包中的线程集合仍有一些缺点。例如,在迭代锁定时,通常需要将该锁定保留在集合中,否则,会有抛出ConcurrentModification Exception的危险。此外,如果从多个线程频繁地访问集合,则常常不能很好地执行这些类。java.util.concurrent中的新集合类允许通过在语义中的少量更改来获得更高的并发。

Java 5.0还提供了两个新集合接口——Queue和BlockingQueue。Queue接口与List类似,但它只允许从后面插入,从前面删除。通过消除List的随机访问要求,可以创建比现有ArrayList和LinkedList实现性能更好的Queue实现。因为List的许多应用程序实际上不需要随机访问,所以Queue通常可以替代List,来获得更好的性能。

java.util.concurrent包中其他类别的有用的类也是同步工具。这组类相互协作,控制一个或多个线程的执行流。

一个基于典型的场景就是生产者——消费者了。BlockingQueue可以安全地与多个生产者和多个使用者一起使用。

· Synchronizer

Semaphore、CyclicBarrier、Countdown Latch和Exchanger类都是同步工具的例子。每个类都有线程可以调用的方法,方法是否被阻塞取决于正在使用的特定同步工具的状态和规则。

· 低级别工具——锁定Lock和原子Atomic

Java语言内置了锁定工具——synchronized关键字。当线程获得监视器时(内置锁定),其他线程如果试图获得相同锁定,那么它们将被阻塞,直到第一个线程释放该锁定。同步还确保随后获得相同锁定的线程可以看到之前的线程在具有该锁定时所修改的变量的值,从而确保如果类正确地同步了共享状态的访问权,那么线程将不会看到变量的“失效”值,这是缓存或编译器优化的结果。

虽然同步没有什么问题,但它有一些限制,在一些高级应用程序中会造成不便。Lock接口将内置监视器的锁定行为普遍化,允许多个锁定实现,同时提供一些内置锁定缺少的功能,如计时的等待、可中断的等待、自旋锁、每个锁定有多个条件等待集合以及非块结构结构的锁定。

java.util.concurrent.lock包中的类ReentrantLock被作为Java语言中synchronized功能的替代,它具有相同的内存语义、相同的锁定,但在争用条件下却有更好的性能,此外,它还有synchronized没有提供的其他特性。

把代码块声明为synchronized,有两个重要效果,通常是指该代码具有 原子性(atomicity)和 可见性(visibility)。原子性意味着一个线程一次只能执行由一个指定监控对象(lock)保护的代码,从而防止多个线程在更新共享状态时相互冲突。可见性则更为微妙;它要对付内存缓存和编译器优化的各种反常行为。

ReentrantLock是具有与隐式监视器锁定(使用synchronized方法和语句访问)相同的基本行为和语义的Lock的实现,但它具有扩展的能力。

作为额外收获,在竞争条件下,ReentrantLock的实现要比现在的synchronized实现更具有可伸缩性。(有可能在JVM的将来版本中改进synchronized的竞争性能。)这意味着当许多线程都竞争相同锁定时,使用ReentrantLock的吞吐量通常要比synchronized好。换句话说,当许多线程试图访问ReentrantLock保护的共享资源时,JVM将花费较少的时间来调度线程,而用更多的时间执行线程。

虽然ReentrantLock类有许多优点,但是与同步相比,它有一个主要缺点——它可能忘记释放锁定。建议当获得和释放ReentrantLock时使用下列结构:

      Lock lock = new ReentrantLock();
      lock.lock();
      try{
        /* 1、执行相关的操作,这里是受到Lock保护,是线程安全
  的。*/
      }finally{
        lock.unlock();
        /* 2、一定要在finally这里进行Lock的释放,以免发生死
  锁。*/
      }

在Java 5.0之前,如果不使用本机代码,就不能用Java语言编写无等待、无锁定的算法。在java.util.concurrent中添加原子变量类之后,这种情况发生了变化。所有原子变量类都公开比较并设置原语(与比较并交换类似),这些原语都是使用平台上可用的最快本机结构(比较并交换、加载链接/条件存储,最坏的情况下是旋转锁)来实现的。java.util.concur rent.atomic包中提供了原子变量的多种风格(AtomicBoolean、AtomicInteger、AtomicLong、 AtomicReference与原子整型、原子长型、原子引用以及原子标记引用和戳记引用类的数组形式、基于反射的实用工具等,其原子地更新一对值)。

原子变量类可以认为是volatile变量的泛化,它扩展了volatile变量的概念,来支持原子的条件比较并设置更新。读取和写入原子变量与读取和写入对volatile变量的访问具有相同的存取语义。如下代码就可以方便地创建一个线程安全的计数器:

      import java.util.concurrent.atomic.AtomicLong;
      public class CasCounter{
        private AtomicLong counter =
            new AtomicLong();
        public long getCounter(){
            return counter.get();
            // 获得当前的值
        }
        public long increment(){
            return counter
            .incrementAndGet();  // 递增
        }
        public long decrement(){
            return counter
            .decrementAndGet();  // 递减
        }
        // 其它方法
      }

优缺点与合理使用

多线程的最大优点是提供了更好的性能,线程中的处理程序依然是顺序执行,符合普通人的思维习惯。

但是多线程的缺点也同样明显,线程的滥用会给系统带来上下文切换的额外负担。并且在线程间共享变量可能造成死锁。

合理使用线程和异步,当需要执行I/O操作时,使用异步操作比使用线程+同步I/O操作更合适。I/O操作不仅包括了直接的文件、网络的读写,还包括数据库操作、Web Service、HttpRequest以及.Net Remoting等跨进程的调用。

多线程则适用于那种需要长时间CPU运算的场合,例如比较耗时的图形处理和算法执行。但是往往由于使用线程编程的简单和符合习惯,所以很多时间往往会使用线程来执行耗时较长的I/O操作。这样在只有少数几个并发操作的时候还无伤大雅,如果需要处理大量的并发操作时就不合适了。

性能问题与调优

Java自九十年代中期出现以后,在赢得赞叹的同时,也引来了一些批评。赢得的赞叹主要是Java的跨平台的操作性,即所谓的“Write Once,Run Anywhere”。但由于Java的性能和运行效率同C相比,仍然有很大的差距,从而引来了很多的批评。

对于服务器端的应用程序,由于不大涉及到界面设计和程序的频繁重启,Java的性能问题看似不大明显,从而一些J2EE的组件,如JSP、Servlet、EJB等在服务器端编程方面得到了很大的应用,但实际上,Java的性能问题在服务器端依然存在。

当你在平台上运行线程化的Java程序时,你将可能想要监控在处理器上的加载过程与线程的执行。最好的获得这些数据与管理JVM怎样处理并行处理的JVM之一是BEA的JRockit,JRockit还有其它一些由来自于BEA和Intel公司的工程师专门为Intel平台设计和优化的优点,目前是Intel平台上面最快的JVM。

不管你使用哪一种JVM,Intel的VTune Performance Analyzer将会给你一个关于JVM怎样执行你的代码的很深入的视图——这包括每个线程的性能瓶颈等。另外,Intel还提供了关于如何在Java环境下使用VTune Performance Analyzer的白皮书,可以方便地进行性能调优。

总之,由于Intel等CPU厂商还将继续生产超线程技术的处理器并且发行更多的多核芯片,所以在分析了多线程在Java平台工作机理后,想从这些多管道中得到性能效益的压力也会增加。并且,由于核心芯片数目的增加,管道的数目也将相应地增加。唯一的利用它们的优点的办法就是使用多线程技术,如在本文中所讨论的。并且Java多线程程序的优势也越来越明显。

总结

多线程与多核是Java未来发展方向,在1995年还是一种概念的Java,到今天已拥有超过300万的开发者,而且随着网络和互联网的普及,正潜移默化地渗透到我们生活中的每个环节。Java近年来的影响有目共睹,一种编程语言建设并带动了一条产业链的成长与发展,各种针对它报表和统计数字已经清晰地勾勒出一个近乎完美的高大形象,有人甚至将其评为十年来对整个IT产业最有颠覆性影响力的技术。

Java倡导的多线程、跨平台性使其迅速打开了市场局面,而且也成为竞争者的追逐目标和模仿对象。同时,Java引以自豪的跨平台是以牺牲运算速度为代价的,相对于.Net平台,Java的速度始终不是其长项。在速度决定未来的今天,如果想在Java上获得更快的速度,在硬件上投入也许是一个简单的方法,但这又将问题抛给了成本。

传统上提升CPU性能的主要手段是提高CPU的主频。但是,经过30多年的发展,CPU的主频速度已经接近物理极限,很难再提高CPU的主频。现在,CPU已经进入了超线程、多核CPU的时代。为了提高CPU的运算性能,现在只有使用具有超线程技术的多核CPU。

传统的CPU,只有一个内核,这个内核也只能够同时运行一个线程。采用超线程技术的CPU,可以在一颗内核上同时运行多个线程。而多核CPU更是在一个CPU上嵌入多颗采用超线程技术的内核。这样,多核CPU就可以同时运行更多的线程。

多核、超线程CPU已经成为大势所趋。这意味着,传统的单线程的程序,将无法利用未来多核CPU多线程并发执行的能力。单线程程序将会极大地浪费多核CPU的运算能力!

所有主要的CPU厂商在每个硅核上都采用多个内核,典型的服务器每个机箱内都有多个插座。覆盖全球的服务器集群和系统网格正在迅速普及。并行已成为主流,所以,如果软件开发人员想与时俱进,最好学会如何运用它。

仔细雕琢多核背景下的.NET应用

□ 文 / 王翔

在广大用户不断从多核处理器体系获益的情况下,开发人员却要面对一个极富挑战性的新跨越:从积极的意义看,在单个处理器速度提升空间已经日趋狭窄的情况下,通过多核可能获得显著增长的计算能力;不过从消极的方面看,除非您对应用做很多适应性的特殊加工,否则您恐怕不会享受到这个技术进步带来的性能提升。

笔者认为要充分利用多核特性,开发人员要同时做到三个方面:

· 将长篇幅的执行过程细化为可以独立并行和一定需要串行的相间步骤。

· 更多地把异步处理加入到并行调度部分,毕竟在多核真并发环境下您不应该臆测并发过程的完成次序,变“家长式”的轮循关切为每个并发过程的主动报告不失为一个明智的选择。

· 务必要“并而有序”,并发处理要限制其逻辑范围,每个范围的边界一定要增加必要的同步处理,同时对于并发程度也要进行限制,否则即便是多核也会因为并发过多而负担过重,适得其反。

.Net的并发线程支持

多线程和并发是常常紧密联系在一起的话题,和其他技术一样微软给CLR管理下的多线程打上了“托管”(Managed)的标签。.Net Framework将应用进程细分为应用程序域(AppDomain),在CLR中包装的托管线程(Managed Thread)可以在一个或者多个应用程序域中运行,也就是说一个应用程序域中可能有多个托管线程在执行,同样的也可能存在某个托管线程被不同应用程序域按需游移使用。在托管线程的调度上,.NET继续沿用了基于优先级的抢占算法,不过调度的间隙是相对很短的时间片,因此即便在以前单核单处理器环境或者采用了超线程准多核处理器环境,总体上CLR依然以看上去在并行地执行.NET应用。为了达到“并而有序”的目的,就需要进行线程同步,不过这方面托管线程并没有比Win32前辈有多少改变,CLR同样并不准备为应用的托管线程同步负责。好在.NET增加了一系列的用于同步的原语和现成的同步类型,借助他们可以用准“八股”的方式书写托管线程应用。

并发同步

这个话题从多线程出现开始就一直是无法回避但又充满争议的,.NET同样把这个选择权交给开发人员,实现上主要可以归纳为5种同步模式:

· 最为普遍的方式莫过于由客户程序通过手工方式进行同步,该模式要求开发人员尽量让每个并行单元避免操作共享资源,即便用到共享资源也尽可能将其限制在一个调用区域里,例如:通过Monitor对共享资源使用的Enter和Exit进行控制,或者通过MethodImplOptions.Synchronized来 修 饰 某 个 具 体 方 法 的MethodImplAttribute属性。不过使用时需要切记不要对静态对象和this指针进行用户代码锁定。

· 对于类似System.String之类的不可变类型共享资源可以忽略其共享特性,每个并发单元可以视为完全独立的读取、操作其内容,因为即便其他并行单元对其内容进行了修改,那么它实际上已经在一个新的引用上执行操作,对于其他执行单元而言他们引用的还是原有内容。

· 通过语言编译器所支持的lock语句进行精细颗粒度的执行过程同步处理,尤其对于同步要求相对复杂且易于出异常的情况下,使用lock语言可以由编译器保证异常过程、正常过程均可以有效的释放锁资源。

· 对于.Net Framework部分常用的集合类型,可以通过IsSynchronized方法显式的声明其集合操作的线程安全性。

· 对于涉及往复调用或者跨进程的包括上下文操作的同步,可以使用任何ContextBoundObject的Synchronizati onAttribute方法来同步所有实例方法和字段,例如:Enterprise Service、Remoting、Web Service等。

虽然“条条大路通罗马”,但是上面5个模式对于上下文同步效率要差很多,其次是使用封装类集合类IsSynchronized的方法显式同步。不过,从开发的角度看如果您的并发调用对于效率要求不是特别严格,使用封装好的IsSynchronized方法倒不失为一个省心的选择。对于会被反复读取但修改机会相对较小的共享资源,可以通过ReaderWriterLock类来精化您的操作逻辑,这可以类比一般商用数据库的ReadCommitted隔离级别,它可以确保在读锁存在的情况下,相关的读操作都可以通过一个一致的视图访问到共享数据,即便有其他并发单元要修改共享内容也需要等读锁退出并成功地申请到写锁后再进行。(这个类在.NET之前是很多开发人员所梦想的,以前都是高级开发人员谨慎调试后提供给项目组其他成员使用。)不过如果您的工作是开发普适公共类库,那么您可能无法确定共享资源的“读/写”频率比,那么下面一个简单的2 in 1示例也许可以增加您公共库的灵活性:

      C#
      #define RWLOCK
      using System.Threading;
      using System.Collections.Generic;
      public sealed class CommonThreadSafeLibrary
      {
      #if RWLOCK
          private   ReaderWriterLock   rwLock   =   new
    ReaderWriterLock();
          private Queue<int> queue = new Queue<int>();
    // 共享资源
      #endif
          public void ModifySharedData()
          {
      #if MONITOR
          Monitor.Enter(queue);
      #endif
      #if RWLOCK
              rwLock.AcquireWriterLock(-1);
      #endif
              try
              {
                  //修改共享数据queue的内容
              }
              finally
              {
      #if MONITOR
            Monitor.Exit(queue);
      #endif
      #if RWLOCK
                  rwLock.ReleaseWriterLock();
      #endif
            }
        }
      }

池化并发操作

并发执行单元即便仅仅实例化,则它起码也是个内存对象,更何况一个并发操作从通知CLR让其准备执行到真正在寄存器上运行这个过程本身也是相对比较昂贵的调度过程,因此笔者建议在您的项目或者产品的其他并发控制已经调试相对稳定的时候,尽量考虑将他们池化,您至少会从两方面获益——执行效率和资源占用。采用线程池后每个线程被统一化了,他们使用相同大小的堆栈空间、持有相同的默认优先级、统一后台运行。使用线程池的一个最基本考虑就是加载和卸载线程会引致很多额外的负载,而大多数情形下使用并发的后台服务需要不断地进行线程生命期过程来响应不断提交的请求。.NET平台提供了一个托管的线程池类型,他同时提供了线程调度和任务排队两个非常有用的功能,而且在委托的帮助下异常简洁而精彩地实现了异步通知机制,借助这个委托,开发人员可以很容易地实现超时控制、处理结果持久化等操作。

一般而言笔者建议您要为应用中托管线程池提供一个配置机制或者是处理单元发现机制,目的是确保您的线程池不至于在过多的并发下因为线程的调度把自己忙得“晕头转向”。

不过考虑到线程池对于并发线程处理上采用“一刀切”的办法,对于某些定制并发操作有其局限性,下面的情形不适合其使用:

· 需要使线程具有特定的优先级。

· 您的任务会导致线程长时间被阻塞。由于线程池具有最大线程数限制,因此大量阻塞的线程池线程可能会阻止任务启动。

· 需要将线程放入单线程单元。所有ThreadPool线程均处于多线程单元中。

· 一组线程中每个执行单元有明确的分工,而并非执行相同的处理逻辑。

虽然一般而言池化的线程数最好不要超过处理核心数量的25倍,但有些情况考虑到线程调度的性能损失,这个上限仍然不足以充分发挥您的硬件优势,这时可以考虑使用非托管的IcorThread Pool接口(需要引用mscoree类型库)。例如:对于一个用户量非常大的即时消息服务登陆系统而言,您需要2000个池化线程。

图1

COM声明和C#示例

      interface ICorThreadpool : IUnknown {
        HRESULT CorSetMaxThreads(
            [in] DWORD MaxWorkerThreads,
            [in] DWORD MaxIOCompletionThreads
        );
      }
      public void StartUpUnmangedPool()
      {
          ICorThreadPool threadControl = (ICorThreadPool)new
  CorRuntimeHostClass();
          threadControl.CorSetMaxThreads(2000, 2000);}

并发资源的回收

CLR对于资源的回收过程有截然不同的两套方案:一个是面向工作站的版本,特点是在处理过程中通过应用的短暂停顿进行,更多的时候是通过监控应用运行信息,在应用闲暇的时候执行,方式上有些类似“零存零取”;另一个是面向服务器的版本,特点是为了提供更好的吞吐量,在一个相对更长的时间里完成更深入的垃圾回收,方式上类似“零存整取”。至于到底是运行于哪个方案,则只能在CLR宿主进程启动的时候选择,相比较而言服务器模式提供了很多并发代码的回收优化处理,例如:为每个线程提供一个相互隔离的堆,借此限制并发单元执行过程中的资源冲突。

工作站版本提供了一个并发回收的配置选项,该版本下CLR会启动一个额外的线程进行简单的预先检查、登记工作,目的就是罗列可以被彻底回收的内容,在这个登记工作结束后CLR会一次性地把这些垃圾清除掉,所有的清除指令是以并发的方式同时发出的,在这个设计下虽然总和的垃圾收集时间被延长了(因为并发的垃圾回收执行本身也存在一定的争用),但从CLR角度看绝对延迟时间被缩短了(因为所有的回收是几乎同时进行的)。对于ASP.Net应用、ASP.NetWeb Service、Windows Form、Windows Service、Console程序都可以在其web.config或者app.config中配置这个工作站版本的并发垃圾回收:

      web.config / app.config
      <?xml version="1.0" encoding="UTF-8"?>
      <configuration>
        <runtime>
          <gcConcurrent enabled="true"/>
        </runtime>
      </configuration>

不过并发线程的执行常常会破坏垃圾回收过程,对于整体线程调度过于频繁的应用而言,开发人员常常被运行人员告知CPU使用过高,但内存使用却几乎持续的情况,其中一个可能的原因就是GC始终没有机会参与到回收过程中或者是回收过程始终没有机会执行。这种情况下您可以慎重地考虑借助非托管的IGCThreadControl接口来进行更为底层的回收操作,因为它要求必须在一个定制的非托管CLR宿主进程中执行,而且一定要涉及很多非托管对象的操作,实现难度不是一般的大。不过在并发吞吐量非常大的情况下,笔者倒是建议您彻底放弃GC,采用托管与非托管组合的方式变通地完成这个方案,这样一方面可以确保您的多处理器、多核硬件资源可以被充分的利用,同时降低托管部分.NET应用开发和性能优化的门槛。下面是一个示意结构,其中托管环境与非托管环境的并发基数可以有比较大的差别,例如把业务级的并发放在托管部分,而把业务逻辑实现中更高强度的并发放在非托管部分,对于包括诸多托管资源调用的并发也可以放在非托管部分完成。实施前需要考虑到跨进程调用的额外负载,如果不可接受的话,只能考虑增加处理器、更换更多核处理器、甚至Scale out集群来分担过于密集以至于GC难于调度的并发处理任务。

其他建议

· 对于包括静态构造函数的类,最好不要在其静态构造函数构造过程进行非常大的并发操作,否则会因为静态构造优先执行的特性导致实例构造过程大量阻塞。

· 一定不要把类型(Type)信息作为同步对象

· 对于执行过程非常简单且非常短暂的共享对象可以考虑采用Interlocked.Increment方法完成。

展望

多核为更高吞吐率业务处理、SOA环境、分布式计算提供了进一步发展的空间,同时也为虚拟化、并行信息检索、并行多媒体技术等提供了必要支持,而.NET开发者借助微软对于相关厂商的全面支持也会从操作系统、CLR、开发环境等多个方面获得相应的支持,您需要做的就是“拿来”并且“应用”他们,让并发处理能力成为您项目或者产品By Design的特质。

图2

· The art of Computer Programming, Pearson Education

· Pattern-Oriented Software Architect, Vol. II, Patterns for Concurrent and Network Object, wiley

· Fundamentals of Concurrent Programming for DotNet

积极准备、谨慎行动——应对多核编程革命

□ 文 / 赖勇浩

多核革命

2001年,IBM推出了基于双核的Power4处理器;随后Sun和HP都先后推出了基于双核架构的UltraSPARC IV以及PA-RISC8800处理器。但这些面向高端应用的RISC处理器曲高和寡,并没有能够引起广大群众的关注。直到2005年第二季度,Intel发布了基于X86的桌面双核处理器,从此多核才走进平常百姓家。

在今天多核处理器已占据了越来越多的市场份额,作为一线的编程人员,我们必须直面多核革命带来的冲击。因为从单核到多核并不像处理器时钟频率的提升那样对程序员而言是透明的,如果我们的编写的程序没有针对多核的特点来设计,那就不能完全获得多核带来的性能提升。在这个新旧交替的战国时代,我们有什么选择、能否借鉴以前的开发经验?多核编程,既是机遇也是挑战,如何在这个行业大变革中把握方向、与时俱进,成为摆在我们面前的迫切课题。

是的,人类最为伟大的技能就是能够借鉴以往的经验。我们应该借鉴前人的经验,积极学习并行编程技能同时在实际工作中小心求证、谨慎行动。多核,特别是双核,与双路SMP(对称多处理器)架构非常相似,见图1。

图1:Intel和AMD的早期双核CPU内部结构示意图

从图1可以看到尽管Intel与AMD的双核技术有所不同,但仍然可以发现所谓双核处理器就是将两个运算核心集成在一个处理器上。这跟在一块主板上集成两颗处理器的双路SMP系统相当相似,不同之处仅在于双核系统两个计算核心之间相互交换数据并不需要通过前端系统总线(FSB),而双路系统的两个处理器是通过FSB来交换数据的,这也是我们编写程序时需要注意的一个小细节。

就像针对SMP编程一样,针对多核处理器编程也必须使用多线程或者多进程的形式来编写应用程序才能够得到多核带来的性能提升。可见我们在SMP并行编程上积累的经验大多都可以应用到多核编程上来。

编程的变革

多核时代的到来,给我们的编程思维带来了巨大的冲击。为了能够充分地利用多核性能,我们必须学会以分块的思维设计程序、以多进程或多线程的形式来编写程序。到底应该使用多进程还是多线程的形式来编写程序是最让程序员感到困惑的问题之一,我觉得需要根据具体的应用来决定,但通常情况下使用多线程进行多核编程比使用多进程有更大的优势:

A)线程的创建和切换开销比进程更小。

B)线程间通信的方式多,而且简单也更有效率。

C)多线程有汗牛充栋的基础库作为支持。

D)多线程的程序比多进程的程序更容易理解和修改。

除了编程形式,我们使用多线程编程的动机也发生了改变。以往,对于Windows程序员来说,使用多线程的主要原因是为了提高用户体验:如在长时间的计算中提高UI、I/O或者网络的响应速度。而在多核时代我们编写应用程序为了充分利用多个计算核心,缩短计算时间或者在相同的时间段内计算更多任务。如在游戏编程时通过多线程的方式把碰撞检测的计算分散到多个CPU内核可以大大缩减计算时间;也可以利用多核做更细致的检测计算,从而能够模拟更加真实的碰撞。

在多核时代,我们对编程语言的选择也要更加谨慎。无论开发何种项目,相对于C/C++/Fortran等编译型语言,C#/Java/Python等脚本语言也许是更好的选择。原因在于脚本语言比较高级,一般都提供了对多线程的原生支持;如C#的System.Threading.Thread、Java的java.lang.Thread及Python的Threading.Thread。相形之下,编译型语言往往都是通过平台相关的库来提供多线程支持,如Win32 SDK、POSIX threads等。没有统一的标准,造成使用C/C++编写多线程程序需要考虑更多的细节,提高了项目成本。从当前来看,C/C++的用户虽然不少,但在多核时代脚本语言会更受欢迎,因为船小好调头。脚本语言一般都没有ISO标准,说改就可以改,很快就会出现针对多核的解释器和编译器了。不过PHP/Ruby/Lua等脚本语言就会比较难得到多核程序员们的宠爱了——因为它们并没有提供内核级线程支持,它们的多线程是用户级的,甚至不支持线程,用它们编写的多线程程序仍然无法完全利用多核优势,见表1。

虽然C/C++在多线程编程方面因为没有从语言级提供支持而失去了部分优势;但因为当前的主流操作系统都以C语言接口的方式提供创建线程的API,而C/C++又有相当丰富的程序库,也就一定程度上弥补了语言上的不足。使用C/C++编写多线程程序不仅可以使用Win32 SDK,还可以使用POSIX threads、MFC和boost.thread等。虽然这些库都提供了一定程度的封装,减轻了程序员进行多线程的负担,但对于目标定位于提升计算密集型程序性能的多核程序员来说,这些方式仍然太为复杂。因为使用这些库几乎要增加一倍的关键代码,相应地调试和测试的成本也大大增加。更好的选择应该是使用OpenMP这种通过编译器加强来支持多线程的基础库。OpenMP通过使用#pragma编译器指令来指定并行代码段,对程序的改动相当少;而且可以指定编译为串行版本以方便调试,更可以和不支持OpenMP的编译器共存。

可见即便脚本语言在语言层次上提供了对多线程编程的原生支持,但却并没有比C/C++领先多远。根本原因在于脚本语言的基础——数据结构和算法的基础库与CRT/STL等C/C++基础库然一样是以串行形式来设计开发的。针对多核编程去修改基础库这一几乎所有编程语言都需要面对的燃眉之急是拉开两大阵营领先优势的生死之战,而所有权集中于某一公司或者组织的C#/Java/Python这类脚本语言船小好调头,估计将赢得这场关键之役。

多核程序设计

随着时间推进,我们终将需要面对多核系统来设计程序。多核编程我个人认为基本上等同于共享内存的并行编程,多核程序设计可以借鉴以往并行编程的经验——如分块的设计思维、并行设计方法论和多样的并行支持方式。

因为线程是操作系统分配CPU资源的最小单位,所以如果想要设计多核并行的程序,那么我们就要形成将程序分块的设计思维。还记得初中课本上华罗庚先生的《统筹方法》吗?现在我们可以借助华老的文章来谈谈怎么样去分块:

比如,想泡壶茶喝。当时的情况是:开水没有;水壶要洗,茶壶茶杯要洗;火生了,茶叶也有了。怎么办?

办法甲:洗好水壶,灌上凉水,放在火上;在等待水开的时间里,洗茶壶、洗茶杯、拿茶叶;等水开了,泡茶喝。

办法乙:先做好一些准备工作,洗水壶,洗茶壶茶杯,拿茶叶;一切就绪,灌水烧水;坐待水开了泡茶喝。

办法丙:洗净水壶,灌上凉水,放在火上,坐待水开;水开了之后,急急忙忙找茶叶,洗茶壶茶杯,泡茶喝。

哪一种办法省时间?我们能一眼看出第一种办法好,后两种办法都窝了工。

假定华老有两个机器人给他泡茶喝,那最好的方法显然是按照“办法甲”分工:机器人A去烧水,机器人B洗茶具;等水开了,泡茶喝。看,不经意间,我们就应用了分块的思维——把不相关的事务分开给不同的处理器执行。再举个我们工作中经常遇到的例子:有数据类型为T的序列A,求序列中值与K相等的元素个数。实现这个功能的C++函数如下:

代码段1:统计序列中值为K的元素个数

      template <class T>
      size_t Count(const T& K, const T* pA, int num) {
        size_t cnt = 0;
        for(int i = 0; i < num; ++i)
              if(pA[i] == K) ++cnt;
        return cnt;
      }

从代码段1中显而易见Count(k, p, n) = Count(k, p, n/2) + Count(k, p+n/2, n-n/2),即序列中值等于K的元素个数为前半段中值为K的元素个数加上后半段中值等于K的元素个数。如果我们开启两条线程,一条统计前半段(执行Count(k, p, n/2)),另一条统计后半段(执行Count(k, p+n/2, n-n/2)),那么在双核系统上我们将可以节省一半的运行时间(忽略生成线程的开销等)。

以上分块的思维都是简单直接的,如果是复杂的任务,就不可能容易地找出分块的方案了,所以需要并行设计的方法论来指导我们。经过几十年的并行程序研究,前人已经总结出若干行之有效的并行设计方法,在这里介绍一个经典的方法:数据相关图。仍然以《统筹方法》中经典的泡茶为例,我们可以画出以下数据相关图:

图2:《统筹方法》中办法甲的数据图

从图2中可以看出数据相关图是一个有向图,其中每个顶点代表一个要完成的任务;箭头表示箭头指向的任务依赖于引出箭头的任务,如果数据相关图中没有从一个任务到另一个任务的路径,那么这两个任务不相关,可以并行处理。如果华老自己动手泡茶喝,那图2中红色虚框的部分是可以并行的;而如果华老有两个机器人帮他泡茶,而且有不少于2个水龙头供机器人使用,那绿色虚框的部分都可以并行而且能取得更高的效率。可见能够合理利用的资源越多,并行的加速比率就越高。

在数据相关图中,如果有不相关的任务对数据集的不同元素进行相同的操作,我们称这种数据相关表现了数据并行性。如在科学计算中经常会对某一N维向量乘以一个实数值:

      for( int i = 0; i < N; ++i)
        v[i] *= r;

如果有N个处理器,那么这N次带有数据并行性的迭代可以同时执行。除了数据并行性,如果有不相关的任务对数据集的不同元素进行不同的操作,则表现了功能并行性。还有形状为简单路径或链的数据相关图意味着在处理单个问题上不存在并行性,但如果需要处理多个问题,且每个问题可以分为几个阶段,那么就能支持与阶段数相同的并行性,这种情况称之为流水线。关于功能并行性和流水线,由于篇幅关系,这里不能详述,有兴趣的读者可以查阅并行编程相关书籍。

既有了分块的思维,又有并行程序设计的方法论作为指引,现在我们就缺怎么去开发并行程序了。当前比较流行的思想有以下三种:

扩展编译器。开发并行化编译器,使其能够发现和表达现有串行语言程序中的并行性,例如Intel C++ Compiler就有自动并行循环和向量化数据操作的功能。这种把并行化的工作留给编译器的方法虽然降低了编写并行程序的成本,但因为循环和分枝等控制语句的复杂组合,编译器不能识别足够多的可并行代码而错误地编译成了串行版本。

扩展串行编程语言。这是当前最为流行的方式,通过增加函数调用或者编译指令来表示低层语言以获取并行程序。用户能够创建和结束并行进程或线程,并提供同步与通信的功能函数等。这方面较为杰出的库有MPI和OpenMP等;在解释型脚本阵营,ParallelPython也吸引了不少粉丝。

创造一个并行语言。虽然这是一个疯狂的想法,但事实上近几十年来一直有人在做这样的事情,如HPF(High Performance Fortran)是Fortran90的扩展,用多种方式支持数据并行程序。

新瓶旧酒

虽然多核CPU正在成为主流,但毕竟时间不长,现在大部分应用程序都是在单核时代开发的,那么这些旧程序如何才能在新的环境焕发自己新的光彩?

1. 精确评估旧程序是否需要作出修改。如Foxmail、Windows优化大师之类的桌面软件原本就只占用极少的CPU资源,那么就不需要针对多核改写。而作为网站服务器端运行的CGI程序基本上都是以多进程或多线程的方式来响应请求的,可以充分利用多核系统的性能优势,一般而言不需要针对多核改写。

2. 就重避轻。一个应用程序,性能瓶颈通常只有几个或者一两个甚至这些瓶颈相关的功能用户很少使用。那么为了这些少量需求而对已有程序进行伤筯动骨的改造是不合适的,更不宜以多线程的架构重写整个应用。如果应用程序是使用C/C++/Fortran编写的,那使用OpenMP使性能瓶颈部分的代码并行化是相当好的选择。如果代码是使用C#/Java/Python等脚本编写的,可能需要多花一些功夫来完成同样的工作。

3. 不追逐潮流。一句话,如果旧的应用程序没有性能瓶颈,那就不要作任何改动,否则只会引火烧身。像暴风影音、千千静听这一类多媒体播放软件,针对多核优化是可做可不做的事情;但如果做了,用户可能反而会觉得太占用资源,因为换了双核系统在播放视频/音频的时候做其它事情仍然有点“卡”,那就不如不做。

综上所述,如果我们手上维护着旧的程序,那我们最应该做的事情是评估软件是否有性能瓶颈,切忌为双核而双核,要以不变应万变。

写在最后

多核时代的到来,必定会给编程带来巨大的变化,对此我有几个建议:

并行计算方面已经有很多研究人员做了几十年基础工作,有很多可以学习和利用的知识。我们应该学复杂的、用简单的,复杂的例如MPI也要去了解,但应用的时候就越简单越好,如上文代码1统计序列中值为K的元素个数的函数比较好的并行方案是使用OpenMP:

代码段2:使用OpenMP并行

      // 上略
      size_t cnt = 0;
      #pragma omp parallel for reduciotn(+:cnt)
      for(int i = 0; i < num; ++i)
      // 下略

简单地增加了一行源码就实现了并行,不仅比使用Win32 SDK/PThreads创建线程的代码少得多而且更容易维护。

如非必要,不要并行。一直以来,我们都是接受串行编程的教育,而且大多数程序员都习惯于编写串行程序。即使我们对并行编程进行了学习,实践的时候仍难免会引起一堆让人手忙脚乱的麻烦。所以现阶段在实际项目中如非必要,不要并行;比较适宜的方式是先在非核心业务中熟悉并行编程,然后再在有必要性的部分工作中实操。

并行可以作为最后的优化手段。知道在什么时候使用并行跟知道如何编写并行代码一样重要。如果你竭尽全力优化之后程序仍然不能让你的老板、客户满意,那你可以试试将性能瓶颈部分并行化,作为优化的最后选择。

克服多核软件开发之痛

□ 文 / 李宗达

不论你是否已经准备好,两家微处理器开发公司不仅推出双核,更已经朝向四核、八核的方向迈进,我们也陆续看到了支持多核的主板与系统不断推出,同时一些专注于企业服务器的国际大厂,也重新调整他们对多核系统的产品策略。眼看着多核处理的时代已经就在眼前,但就如多年来的软硬件发展不对称现象一样,我们可以看到微处理器已经摇摇领先在前,可是我们的软件开发能力与工具仍有一段落差。我想对于程序员最关心的话题,莫过于在多核时代的浪潮下,其软件开发会很困难吗?程序员要如何开始?

多核开发对企业的冲击

多核软件发展很困难吗?专家都知道较高的微处理器执行频率,是创造更快速软件处理的基础,但是一味拉高执行频率,终究会遇到技术与物理因素瓶颈。1GMHz是Pentium III的瓶颈,虽然后来经由IBM协助找到了技术改良,但是又遇到了4G的门坎,这也是为什么我们买得到的微处理器通常在3.2G以下的处理速度。即使达到了此速度,相信大家都有痛苦经验,需要忍受过热、耗电,甚至不稳定,尤其是企业服务器机房,更是有如烤炉;几年前Intel发展HT(Hyper Threading)技术的处理器,目的就是想要在同样的时间内,加倍线程的执行数量,这种做法可以视为是一种并行计算的落实方法,放弃只是一味追求高频。其结果就是在单位时间内,线程被处理数量增加,对于商业应用软件而言,无疑是工作执行量(throughput)的增加。但是此法毕竟仍局限于单一处理单元(PE, Process Unit);如果想办法在同一颗处理器内将PE加倍变成两个,那是不是可以得到在不增加电源消耗的原则下,处理的数量倍增于HT架构。同理,如果将双核延伸到更多,那效率是不是也成线性成长,这也是为什么多核处理器的影响力,远甚于HT技术。

了解了多核开发的本质之后,我们不禁要关心对企业的影响。对于一些建构在IBM、Oracle与BEA等中间件(如Websphere、WebLogic等)下的商用软件而言,企业在没有享受双核优势之前,首先面临的冲击就是计价方式的改变。过去以CPU数量来计价软件平台的采购算法,因为多核的出现,商业与采购行为也跟着改变:大部份企业的信息管理或是采购单位会质疑,这些国际大厂请你说服我接受多核就要加倍计价,请拿出量测数据来说明,你的平台软件有针对多核达到提高执行效率的绩效。

除了计价方式的冲击之外,另外就是既然引入了多核系统,那未来的商业应用软件开发的技术,是不是也要跟着改变,所以多核系统的引入,不是只是单纯影响到MIS管理人员在评估与采购时的决策,对于程序员的开发方法、开发工具、技巧也提出了需要重新思考的要求。

多核开发的解决之道

要怎么开始多核软件的开发?我想最大转换在于处理对象的改变,也就是以往软件开发的对象是程序(process),现在要改变为以线程(thread)为对象,同时考虑多个线程之间在共享内存区域内的互动行为,简单的说,就是并行处理(parallel processing)。OpenMP (http://www.OpenMP.org)是一套常被引用来解决此问题的开发模型(model)与应用程序编程接口(API),它主要解决在共享的内存区块下,将其区分成private与shared区域,所有的线程可以存取shared区内的信息,或是只能使用自己拥有的private区内数据,透过执行环境(例如Runtime Library)来支持这种处理方式,以达到将多个线程并行导入微处理器内进行译码与执行。OpenMP目前由业界多家知名计算机软硬件公司所支持,并可跨越Unix/Linux与Windows平台。除了OpenMP之外,其他解决方法还包括MPI、HPF、PThread等等,会挑选使用OpenMP有几种理由,除了并行处理、可信赖度与系统延展性考虑之外,容易使用也是因素之一。目前,OpenMP可配合Fortran或是C/C++等语言使用,市场上也有许多Compiler/Debugger/Runtime Analysis/Cluster Tools可供选择;其他如学习特定语言与Concurrent Programming技巧,也是解决方式之一。

平心而论,采用并行处理方法的观念来提升多核的效能,这想法并非创新,相关的程序发展技巧也并不匮乏。不过我们所了解的一般企业用户本身的信息开发部门,以及诸多的企业应用软件开发商,对于接触OpenMP这类的Concurrent Programming技术门坎都感觉过高,而且也认为开发成本将会提高,所以我们发现目前企业对于双核的应用,绝大部分比例是先从建置后端企业服务器开始,例如Mail Server、ERP,尤其是根植于DB Server的一些企业级应用软件,因为目前大部分的DB Server除了支持64 bit之外,也有支持多核的版本。在不容易找寻足够且具备能力的多核开发程序员之前,我们认为企业在多核的应用上,呈现只有半套解决方案的偏安状态,也就是只有针对后端的服务器系统达到发挥双核的效能,大部份客户端的接口系统,还是停留在传统的单核开发层面上。

程序员观点的工具与技术修练

如果开发多核软件的门坎比较高一些,那有没有其他更快的途径?就如同我们学习其他软件开发技巧一般,除了基本计算器观念(这里指的是并行设计的算法基础)之外,最直接的帮助当然来自于高效率的开发工具,好的开发工具除了可以降低入门门坎之外,对于一些细节但常规的工作,通常可以由工具来协助代劳,所以程序员可以将专注力放在主要解决的问题本身。例如在窗口平台上使用广泛的VisualC++工具,提供了开发OpenMP的方便性、Java语言也提供了开发的可行性,有些中间件则是从根本上做改写。

在新一代的微软开发工具(Visual Studio 2005)中,强化了几点特色,可以大幅度提高开发多核软件的时效。Synch、Debugging、其他如early out、scheduling unpredictable work等,VS2005提供了这方面的强化检查措施。而微软比较冷门的产品MicrosoftWindowsCompute Cluster,搭配Visual Studio的开发工具,其实也提供了一个很好的平台组合,同时解决开发的问题,以及未来管理与部署的工作。

Java/C++等语言本身的线程就是特色之一,Sun公司甚至针对其开发工具,除了提供开发并行系统的功能外,在加上诸如优化的优势。而IBM夹其软硬件整体解决方案的优势,所以对于多核的做法,相对就比较完整。撇开硬件方面不谈,其XL Fortran或是C++编译程序,提供了合适的开发途径。有些公司采用不同的处理方式,例如就BEA的解决方案来看,其一贯的做法是不只关心平台软件的发展,更从最底层的JVM着手,所以提供了代号JRockit的自家JVM。它不提供程序员相关的发展工具,而是直接提供平台,也不失为一种解决方案。

当然,微处理器设计公司如Intel或是AMD也释出其专门的开发工具。通常是可以整合其他更便利的集成开发环境,来弥补微处理器公司对于开发工具套件不在行的缺失。例如,透过整合Intel C++到微软VS2003环境中,可以同时兼顾工具方便性与开发目的达成。总体来讲,各有优势,要看程序员的背景以及过去所习惯的开发工具。

对于想要接触并行程序的程序员而言,不要忘了自身的专业基础训练,要发展好的多核软件,不仅需要了解并行程序撰写观念及作法,在这之前,更需要具备良好的传统序列程序撰写基础,因为好的序列程序将有助于将程序并行化。然后再扩展到如何面对有效使用资源的问题,尤其是开始会对如何有效解决多个线程,而这些执行都是相互独立的,但却可以透过一个抽象数据存储来共享状态的想法改变。当然,彼此间的互相操作与通信同步讯号配合等问题,以及如何从更高层次的角度,来抽象出如何面对要解决的问题本身,并将其并行化处理,这也都是必须的基本技术素养。

结论

工具与技术都备齐了,还要注意哪些因素?就如我们在前面所提到的最大差异点,传统的软件与多核软件处理的对象不同,因此请随时记住,你所开发的软件是否有充分发挥微处理器在效能上的优势,如果没有做到这点优化,那就是还有努力空间,至于如何改进,算法、编程语言、辅助工具都必须相互配合。如果你的任务是在一些中间软件上进行开发,那也不要忽略研究此中间件如何达到效能优化的做法。当然,程序员如果可以了解即将开发的软件类型特征,根据这样的特征来思考如何优化多核的开发,举例来说,如果是开发科学计算或是大量图形处理等软件,相信你已经拥有OpenMP或是MPI等Concurrent Programming相关知识(包括计算平台的结构内有关存储器、Hardware implemen-tations、Interconnected communication、Cost of instructions等),则重点可以放在并行语言本身;如果你是要开发服务器端软件,则加强多线程方面的程序规划是首要之务;如果是企业信息系统管理,则考虑到成本与维护等因素,或许慎选好的配套软硬件,就可以达到事半功倍的效果。

(2007年第4期)