我们都能依稀记得,在2005年Sun发布了代号为Tiger的Java 5。在其众多的特性之中, JVM的改进和java.util.concurrent包的出现无疑非常引人注目。改进后的JVM可以使用低层机器指令取代锁,精化了互斥访问的粒度,提高了系统的可伸缩性和活性。而concurrent包提供了大量线程和锁之上的并发抽象,比如线程池、闭锁、信号量、关卡等。这些可以帮助开发者快速地构建出高效、可伸缩的系统来。就在同一年,还发生了另一件影响深远的事件:AMD首次发布了其双核CPU,从而打响了AMD与Intel两大芯片厂商的多核之争。
当Java遇到多核
早在十多年前,IBM、Sun与HP就已经设计出了双核处理器,比如IBM于2001年推出的基于双核的POWER4处理器和Sun的UltraSPARC芯片,但这些双核处理器都是用于高端的RISC领域,价格高昂,被大众使用的X86并没有享受到它带来的性能优势。直到Intel和AMD相继推出自己的双核处理器后,X86领域才算是有了自己的多核架构。
所谓双核处理器,简单地说就是在一块CPU基板上集成两个处理器核心,并通过并行总线将各处理器核心连接起来。多核并不是一个新概念,而只是CMP(Chip Multi Processors,单芯片多处理器)中最基本、最简单、最容易实现的一种类型。其实在RISC处理器领域,多核都早已经实现。
多核与单核的区别在于,前者可以让程序真正地“同时”执行,而不是多个进程轮流使用CPU,从而给用户造成“多个程序正在同时执行”的假象。简单地说,“并发”就是为了让程序运行得更快。在以前,达到这个目的的手段通常是依赖CPU时钟频率的提升。然而普通单核心处理器的频率难于提升,性能没有质的飞跃。由于频率难于提升,Intel在发布3.8GHz的产品以后只得宣布停止4GHz的产品计划;而AMD在实际频率超过2GHz以后也无法大幅度提升,3GHz成为了AMD无法逾越的一道坎。因此,CPU内部开始出现了两个、四个甚至更多的内核。
为了充分发挥每一个核的效用,应用程序需要多个线程同时运行来保持CPU核的忙碌。Java可以帮助你在多核系统上构建良好的应用程序。它可以方便地、相对便宜地创建线程,这非常重要。如果创建线程的开销比线程完成工作的开销还要大,那么并发将变得毫无意义。concurrent包中提供的并发构建块非常丰富,几乎覆盖了所有并发编程中用到的工具。不仅如此,Java还在快速地演变着,以适应更高并发性系统的构建,例如,Java 7中即将加入ForkJoin框架,按照Doug Lea的描述,它专门适用于“>32个CPU(内核)的系统”。
除了这些API层面的支持外,Java在并发编程上的底层平台上显得更加野心勃勃,因为Java有自己的存储模型,这个存储模型早在Java 1.1时代就存在了,尽管当时还不完善,但綷-过多次修补和改进,已綷-变得非常成熟了。Java存储模型可以抹平不同硬件平台提供的存储模型的差异,严格地定义了线程间通讯的规范。例如,对于C语言来说,相同的代码在X86和PowerPC上会有不同的语义。
细心的读者可能注意到,Java不过是这场变革的一个突出的代表而已。很多语言都在不同程度上调整自己,以适应2.0计算时代对并发的需要。在不久,当有人问C#之父Anders Hejlsberg,“未来几年内语言的发展方向在何处”时,Anders表示“要处理好多核的问题,并提供一个更好的并发模型”。Erlang语言最近也受到越来越多的关注,这说明人们迫切需要一个强大而又充分简单的工具来解决并发编程的挑战。
以Java语言为代表的编程语言在面对并发时代所做出的努力是令人激动的,但是相比于硬件,软件的发展总是滞后的,人们总是等意识到软件出现问题了才开始着手改进。2.0时代的并发计算正在向桌面和客户端转移,但是有多少人做好准备了?于是有人开始惊呼:狼来了?
关于“狼来了”的讨论,最好的结论就是无论狼是否真的会来,羊圈的篱笆仍然都要修理。这里的“篱笆”就是指并发编程技术。由于是底层的计算平台正在发生变化,因此不仅仅是开发者,包括需求分析人员、设计者、程序员和测试者都应该在工作时考虑到并发带来的影响。比如:
我们经常要思考,如何才能获得一个最佳的程序粒度,同时保证它们最大限度地彼此隔离,从而可以简单地分配到不同的处理器单元上?线程间通讯的内容有哪些?等等。要想很好地回答这些问题,仅靠程序员或者设计者是不够的。它需要从获取需求开始,就着力对任务进行划分。系统对于并发性的要求,很难在开发的中后期通过“重构”来引入,因此必须在设计之初就给予关注。评价软件有很多标准,比如可扩展性、模块化、松耦合等等,今天还要考虑软件是否有足够的并发性,以充分利用底层的计算资源。它必将成为衡量软件质量的重要标准之一。
编写并发程序需要面对很多挑战。尤其是多核的流行,它使程序中可以有多个线程真正地 “同时”运行。因此开发者要面对的一个最大挑战是划分任务。也许你需要对数据进行划分,清晰地识别出任务边界,还要尽可能地让每个任务在执行时只使用自己的数据。如果不同的线程要共享数据,问题将迅速变得复杂。你无法再像以前那样,只要等上几个月,就可以换上更强劲的处理器,从而让你的程序运行的更快。今天,“免费的午餐”已经结束了。能不能把并行化的工作完全交给操作系统和编译器呢?这是一个充满诱惑力的愿景-但是,并行化的工作现在无法自动实现,未来也只能在一定程度上有所缓解,而不可能全部交由机器完成。并行化过程的重点在于分解程序的任务流和执行流,这最终被归结为数学问题——一个无解的数学问题!面对多核,我们固然不能停止让机器变得更聪明的努力,但是对于大多数人来说,更重要的还是充实自己的知识储备,适应新的思维方式,就像当年从过程化编程过渡到面向对象编程时所做的那样。
并发程序难以开发是事实,同时测试与调试更加困难。因为并发错误通常更加隐蔽,它们只有在高负荷和一些特定时序下才会出现,而且难以再现。因此,并发程序的测试需要更多的投入、更好的工具、更精巧的测试策略。通常,并行程序要对需要测试两个要点:性能与安全性。安全性包括程序是否“做了该做的事”、以及是否“没做不该做的事”两个方面。而性能是指是否在规定的时间里做了正确的事。完整的测试计划应该包括:测试计划、单元测试、代码审查和静态分析等。测试计划应该确保后期的测试行为得以贯彻,同时还要衆-调测试过程中需要的各种资源。并发程序测试的难度远远高于串行程序测试。以单元测试为例,我们几乎无法只使用唯一的线程完成并发测试,至少需要两个线程。然而在JUnit框架,只能识别其自身的线程,对于其他线程抛出的断言失败或异常,毫无察觉。因此,测试者在编写时就要编写大量衆-调线程的代码,这本身就会带来新的bug,更糟糕的是,不良的测试还会掩盖被测代码中的错误。
并发,释放2.0的力量
2.0时代是软件空前繁荣的时代,软件将帮助越来越多的人完成越来越多工作。我们也一直在探讨解决2.0时代的计算方法。那么,无限度地提升CPU核的时钟频率或者在一个CPU内加入更多的内核,就是2.0时代的计算方法了么?不是。2.0时代的计算方法不仅要有强劲的计算硬件做基础,也要有更优秀、更复杂的软件做后盾。单靠硬件的发展是无法满足计算量的激增的,软件也必须做出相应的调整。
在多核系统上进行并发编程仍然很困难,它不符合人类的思维方式。纵观计算机软件开发的历史,总是伴随着新技术推出、开发者学习消化的旋律。从最早的几十个机器指令,到今天的结构化语言、面向对象、AOP--
2.0时代的另一个效应是,它把并行计算和并发编程摆在了更多的程序员面前。以前,并发编程还是计算机科学家的专利,很多开发者都将它视为“禁地”。如今,除了复杂的商业应用和庞大的科学计算外,桌面端、各种终端都在逐渐地走向高并发的运行环境。我们可以学习新的技术、新的思路或者新的语言,比如,在上世纪80年代诞生于爱立信实验室的Erlang语言,近期就表现出极大的活力。在Erlang中,如果要访问共享数据,就需要向数据的拥有者发送一条消息并等待回应,这种方法在构造高可用的并发系统时已綷-取得了极大的成功。尤其是我们已经习惯了基于对象的抽象。要让并发变得容易,就要放弃一些抽象。在此我们不去讨论什么是正确的取舍,但是我们正在苦于这样的幻想:“能不能不做任何取舍呢?”
相对于Web2.0和企业2.0的波涛汹涌来说,计算2.0显得有些波澜不惊。但是,这种处于底层的深刻变化,将会彻底改变上层应用的面貌。多核的普及,将使并行/并发的大行其道,企业或者个人只有快速抓住这个趋势,才能顺利地畅游于软件的2.0时代。(文/韩锴 方妙)
韩 Works咨询师。从事Eclipse插件与客户端软件开发,对并发程序设计、敏捷过程具有浓厚的兴趣。