文/王越
在美国宾夕法尼亚州的东部,有一个风景秀美的城市叫费城。在这个城市诞生了一系列改变世界的奇迹:第一个三权分立的国家——美立坚合众国,就在第五街的路口诞生;举世闻名的费城交响乐团,1900年在市中心的Academy of Music奏响了他们的第一个音符。而写这篇文章时,我正坐在三十四街的宾夕法尼亚大学计算机系的一楼实验室,面前摆放着世界上第一台电子计算机——ENIAC。
1946年2月14日,ENIAC问世,每秒可运行5000次加法运算或500次乘法运算,面积达170平方米,重约30吨,拉开了计算机处理器革命的序幕。这场革命是各处理器厂商长达数十年的竞赛,而摩尔定律从一开始就准确地预测了这场比赛的走势。根据摩尔定律,同样价格的集成电路上可容纳的晶体管数目,每隔约18个月便会增加一倍,性能也将提升一倍。但事实上,并无法用老路子来保持这个增长速度,因为会遇到包括能耗、散热等各种技术瓶颈。所以每隔几年就会有用来绕过这些瓶颈的新一代产品推出。如采用超纯量(superscala)、指令管线化、快取等。这些技术通过一定程度的高效并行来挖掘计算机处理器的速度所能达到的高度,以促使用户更新换代。
![未命名](http://www.programmer.com.cn/wp-content/uploads/2012/02/%E6%9C%AA%E5%91%BD%E5%90%8D12.jpg)
世界上第一台计算机ENIAC,1946年2月14日诞生于宾夕法尼亚大学
和66年前的ENIAC相比,今天的处理器已有了质的飞越。而21世纪的前十年,我们更是见证了个人计算机处理器的三次重大革命——64位处理器、多核心和高效图形处理器在个人电脑出现。在这样的背景下,乔布斯在2008年WWDC(苹果全球开发者大会)上,宣布下一代Mac操作系统Mac OS X 10.6将被命名为Snow Leopard(雪豹)来适应硬件架构的革新。就在那天下午,Bertrand Serlet在一场开发者内部讲座上透露,和先前两个发行版包含大量的新功能(10.4 Tiger包含150个新功能,10.5 Leopard包含300个新功能)不同,Snow Leopard不含任何新功能,仅是对Leopard中诸多技术的重大更新,以使其在现代架构上更稳定、高效。 在这十年的最后一年,2009年8月28日,苹果发布了Mac OS X 10.6来有效地支持这三项技术,而本文将为读者介绍其对应的三项软件技术——64位架构、Grand Central Dispatch,以及OpenCL。 其他Mac OS X 10.6技术更新,如全新的QuickTime X和跳票的ZFS,有着更复杂的历史背景(以后再为读者介绍)。
64位架构出现的缘由
前文提到,根据摩尔定律,同样价格的集成电路上可容纳的晶体管数目,约每隔18个月便会增加一倍,性能也将提升一倍。事实上,存储器的容量增长可能更快,每过15个月就会翻一番。有了更快更强的电脑,可能会让数值计算的科学家们喜出望外,但对普通大众来说,摩尔定律给普通消费者一个假象——如果你觉得1000美元的苹果电脑太贵,那等上18个月就可以用500美元买到同样的电脑。十年前你在用电脑写Word文档,十年后你还在用电脑写Word文档,反正计算机不是耗材,一台电脑只要不坏,就不用去买新的。计算机产业的巨头们自然知道摩尔定律对他们造成的致命打击,因此,一个阴谋被以Intel和Microsoft为首的巨头们构想出来—Intel负责把硬件越做越快,而Microsoft则负责把自己的软件越做越臃肿、越做越慢—至于你信不信,反正我是信的。因此,使用软件、服务等,直接促进计算机产业的消费,使得计算机产业走上可持续发展的道路。这在计算机产业被称为Andy-Bill定律,分别以Intel和Microsoft总裁的名字命名。
当然,软件公司未必真心欺骗消费者,故意把软件做大做慢——为了实现一个新功能,软件势必会比原先庞大。但现代软件的速度、大小和其增加的功能并不成比例。比如对最终用户来讲,Windows Vista到底比Windows XP多了多少功能呢?可能只有20%~30%。Word 2007对比Word 2003多了多少功能呢?可能也只有20%~30%。但Windows Vista、Word 2007占用的CPU、内存、磁盘空间,却比Windows XP和Word 2003翻了几番。究其原因,为了能赶快把新功能带给用户,我们不惜使用更方便但低效的编程语言(.NET、Java等依赖虚拟机的语言就要比C慢许多,Python等动态语言比C慢的不是一星半点)、快速开发(我们原先处理一个大文本,先分块,一点一点读到内存中,然后把处理完的部分写回磁盘,清空内存;而现在直接把它全读进来处理,开发方便,执行也快)。而用户必须为这些新功能买不成比例的单。64位就是在这个背景下迅速走入寻常百姓家的——程序占用越来越多的内存,而32位的寻址空间已不能满足软件运行的需要了。
64位 CPU是指CPU内部的通用寄存器的宽度为64bit,支持整数的64bit宽度的算术与逻辑运算。早在1960年代,64位架构便已存在于当时的超级电脑,且早在1990年代,就有以RISC为基础的工作站和伺服器。2003年才以x86-64和64位元PowerPC处理器架构(在此之前是32位元)的形式引入到个人电脑领域。从32位元到64位元架构的改变是一个根本的改变,因为大多数作业系统必须进行全面性修改以取得新架构的优点。
成功的迁移
苹果向64位处理器的迁移花了整整6年时间,远长于该公司其他技术的迁移——向Intel的迁移仅用了一年时间,从经典Mac OS到Mac OS X也仅用了三年时间。总而言之,这场迁移是非常成功的:一方面,用户基本无痛苦,老的32位程序在目前最新版的Mac OS X Lion中依然可以完全兼容地执行;另一方面,对开发者而言,基本只需做微小的调整,重新编译程序,而且若干技术如Universal Binary,使他们发布程序非常方便。当然,对于某些大量使用过时技术的公司,如Adobe和Microsoft,这场迁移则要折腾得多。
这场迁移整整用了四个发行版的时间(10.3至10.6),不同于Windows或Linux,Mac OS X对64位的迁移自下而上,再自上而下。先是内核扩展,逐渐上升至Unix空间,然后上升至用户界面,再上升至整个应用程序生态,最后完成内核的迁移。要提醒读者的是,Mac OS X的32位和64位内核空间与用户空间的分配和实现,和Windows存在本质的区别,但在本期介绍中,我们尽可能少地把Mac OS X 的64位迁移和Windows进行比较,不拘泥于技术细节,对此区别有兴趣的读者,请移步AppleInsider的系列专题。
2003年,苹果发布了其第一款64位计算机工作站Power Mac G5。同期发布的Mac OS X 10.3也因此增加了非常简单的64位支援,于是XNU内核开始支持64位的寄存器和整数计算。但对于用户空间而言,程序可见的地址依然是32位的。程序当然可以使用大于4GB的内存(Power Mac G5最高可达8GB寻址空间),但这要求程序手动地在两个32位内存空间中来回转换。
两年后,苹果发布了当时最成功的Mac OS X发行版Mac OS X 10.4 Tiger。10.4的内核是革命性的,除了增加对内核并行多线程的支持,它把用户空间可见的地址空间扩展到了64位,因此理论上用户程序可以以64位方式执行。当然,在这个时期,几乎系统内的所有程序,哪怕是内核,依然是32位的。系统中唯一带的64位二进制文件是名为libSystem.dylib的系统库。它是Mac OS X上对C标准和POSIX标准的支持库,由libc、libinfo、libkvm、libm和libpthread五部分组成。但这仅有的libSystem.dylib理论上就能让所有仅使用C标准库和POSIX标准库的程序以64位模式运行。当时,用户对64位的需求较少,主要限于科学计算或图形处理等需要大数组的领域。因此,10.4能较好地满足这部分用户的需求。但如果程序需要调用除BSD Unix以外的系统调用,比如想用Cocoa来画图形界面,那么该程序仅能以32位方式运行了。对于一些需要64位寻址空间的科学计算程序,比如Mathematica,就需要采用一些比较麻烦的做法:用一个进程调用32位的Cocoa画图形界面,用另一个进程调用64位的libSystem来进行运算和Unix系统调用,并用Unix管道或进程间通信的方式管理两个进程间的输入/输出。
苹果在Mac OS X 10.4发布同期的另一项重要决策是向Intel平台x86及x86_64架构的迁移。为了帮助开发者和用户顺利迁移,苹果正式公布了Universal Binary。Universal Binary 技术是Mach-O二进制文件早就具有的特性,只是在这个场合作为一个商业词汇进行宣传。NeXT时代NeXTSTEP操作系统就支持许多种不同的硬件架构,自然可以要求开发者对每个平台发布一个独立的版本,但这样的分发模式很麻烦,消费者也需要搞清到底购买哪种平台的软件。因此NeXT的Mach内核所支持的Mach-O二进制文件格式引入了一种叫fat binary的特性,说白了就是在一个平台架构上分别交叉编译所有平台的二进制格式文件,然后把每个文件都打包成一个文件。Universal Binary就是指同时打包Intel平台和PowerPC平台的二进制文件。Mac OS X 10.4最终支持四个平台的BSD系统调用——32位Power PC、64位PowerPC、32位 x86和64位x86_64。作为最终用户,无须搞清这些区别,因为使用Universal Binary技术,买回来的软件直接会解出相应平台程序的二进制文件并执行。这是苹果很成功的一步——不像Windows系统中要用不同的路径(\Windows\System、\Windows\System32、\Windows\System64)分别存放不同架构的二进制库,并且用户还需在32位版和64位版之间犹豫不决。
Mac OS X 10.5 Leopard经过一系列跳票终于在2007年末发布,跳票主要原因是当时苹果投入了大量人力和物力去做iPhone,以至于10.5跳票了整整一年。10.5包含了约300项新功能,而最重要的一项是苹果把对64位的支持带入了Cocoa层面。因此,几乎系统中所有的库都有四个平台的版本。在WWDC上乔布斯亲自向与会者介绍迁移到64位的好处,而能使用更大的内存自然是一项重要优势,程序可以申请更大的内存,把所有数据一并读入内存中操作,而无须分块后来来回回地在内存和磁盘搬运数据。另外,对Intel平台来说,x86架构只有8个寄存器,而x86_64平台有16个寄存器,这也就意味着,对该平台来说,只要重新编译程序,程序就能自由调度比原先翻倍的寄存器数量而无须快取或在内存中来回查找和读写。根据粗略估算,一般涉及大量数值计算的程序会加快一倍。所以他很开心地劝说所有的开发者都迁移到64位架构。
历时整整6年时间,苹果完成了向64位处理器的迁移,同时这也给苹果提供了良好的清理门户的机会——清理过时的技术和API。
彻底的清理
同时,苹果做出了一个大胆的举动——Carbon框架并未出现在这次迁移中。Carbon是Mac OS X诞生之初为了帮助Mac OS开发者把老程序迁移到新的Mac OS X操作系统上所提出的一个兼容API,这套API长得很像经典Mac OS的API,但能够得到Mac OS X平台提供的一切新特性,Adobe、Microsoft等都是通过Carbon把它们经典的Mac OS程序移植到Mac OS X上的。苹果的本意是希望开发者用Carbon迁移老程序,用Cocoa开发新程序,但在Carbon诞生之初,其受关注度远大于Cocoa,据TeXShop开发者Dick Koch回忆,在Mac OS X 刚诞生的开发者大会上,Carbon讲座的教室挤满了人,而Cocoa相关的讲座上听者无几。维护两套雷同的API的代价自然很高,所以砍掉一个是大势所趋。Carbon和Java的热度甚至一度让苹果产生索性把Cocoa或Objective-C砍掉的想法。大量苹果自家的程序如Finder、iTunes、Final Cut、QuickTime等也都是用Carbon写成的。不过在此后由于大量涌现在Mac OS X平台上的新程序都是Cocoa写的,导致Cocoa技术不断走高。2007年的iPhone也完全依赖于Objective-C和Cocoa的一个裁剪版Cocoa Touch。因此在WWDC2006上,苹果在Mas OS X Leopard 10.5的开发预览版中包含了测试版本的64位Carbon库,甚至还有讲座教如何开发64位的Carbon程序。但苹果却在2007年告诉Carbon开发者,他们的程序将不可能再被编译成64位,要做到这点,必需先把程序用Cocoa重写。
这个突然的决定激怒了很多开发者,尤其是以Microsoft和Adobe这些巨头为代表的公司。Adobe全套的Creative Suite和Microsoft全套的Microsoft Office是很多苹果用户必备的软件,数百万行代码全是用Carbon写的。所以直到今天,除了Adobe Photoshop等少数程序终于在2010年全面移植到Cocoa后做出了64位版,其他大部分程序依然停留在Carbon的32位模式。
苹果也花了很长时间来重写Finder、FinalCut、iTunes、QuickTime等程序或技术,耗费了大量精力。当Adobe发布64位的Lightroom 2.0时,苹果还在手忙脚乱地重写Aperture。不过公正地讲,长痛不如短痛,砍掉对Carbon的支持能够使苹果把更多精力放在该做的事上,也使得Mac OS X的结构更简洁,并且事实上,64位的迁移为苹果提供一个砍去老API的机遇,哪怕对Cocoa也是。一方面,Cocoa框架中很多类不是使用类似Carbon的API,就是依赖于用Carbon实现(注意,和传统观念不同,Carbon和Cocoa在早期Mac OS X上是相互依赖的,比如菜单NSMenu就使用了Carbon的菜单管理器),这些API在64位得到了彻底清理,QuickTime相关的C接口全被砍去。Cocoa经过很长时间的发展,自然也保留了很多过时的API以保证和原先的产品兼容,而这次机会给苹果足够的理由彻底推翻原先的设计。在Mac OS X 10.5中, Objective-C的运行库libobjc更新到2.0,提供了全新的并发、异常处理、自动内存回收、属性(property)等新机制,其中很多新特性只供64位享用。同时,所有int都被改为NSInteger,Core Graphics中的float都改为CGFloat,以保持API统一,这些都是64位架构上的改动。因此64位迁移给苹果一个很好的清理门户的机会。
作为相反的例子,这次清理也有不彻底的地方。比如从老版Mac OS中混进来的Keychain库,甚至具有Pascal风格的API,由于没有替代品,它也得到了64位的更新。所以类似keychain这样的库成了现在Mac OS X程序员的噩梦。我每次用到Keychain都有痛不欲生的感觉。
而2009年发布的Mac OS X 10.6 Snow Leopard则是对64位真正完整的支持。Unix层虽然10.4就提供了64位的libSystem,但所有的Unix用户空间工具包括ls、Python等,以及Xcode中的gcc,也都是以32位二进制的模式发布的。图形界面层,在10.5 Leopard中,虽然整个系统的库都迁移到64位,以32位和64位的混合模式发布,但用户应用程序依然是32位的。只有Chess、Java、Xcode套件等少数程序以64位编译。但在10.6中,基本所有的应用程序都被迁移到64位,不管是Safari、Mail、Dock,还是TextEdit。当然,各种Unix工具包括LLVM、GCC等也都以64位的模式发布。10.6只有四个Carbon程序(Front Row、iTunes、DVD Player以及Grapher)未得到64位升级【2009年查阅,现页面已更新至10.7】。其中, Front Row在Mac OS X 10.7 Lion中被砍掉, iTunes在10.7发布时依然以32位模式发布,在2011年末的更新中才迁至64位。
为了使应用支持64位,苹果不遗余力地改写了大量代码,Snow Leopard中最重要的重写当属Finder,这个程序自Mac OS X发布以来就一直是一个Carbon程序,并且苹果一直不停地改进它以展示Carbon无所不能。但自从10.5时代苹果下决心砍掉Carbon后,该程序被完整地重写。新的Finder和Carbon版的Finder看上去并没有太大差别,但Finder使用Cocoa重写后,不仅速度更快,而且增加了许多Cocoa新特性,比如加入了更多的Core Animation特效来平滑过渡动画。总之,虽然苹果在10.6期间没有提供太多新功能,但这样大规模的重写,为今后代码的可维护性奠定了良好的基础。
Mac OS X 10.6发行版也完成了64位化的最后一步——内核的64位化。我们将在下期杂志中和读者仔细讨论。
经过6年时间,4个发行版,苹果终于完成了向64位的迁移,并随着Snow Leopard的发布推出了解决并行编程问题的Grand Central Dispatch(简称GCD)技术,释放了多核系统的潜力。
和10.5一样,在10.6 Snow Leopard中,苹果继续利用64位的迁移砍掉了诸多老技术,很多新技术仅以64位的模式被支持。例如重写的QuickTime X框架,虽然QuickTime X应用程序以32位和64位的模式发布,但其API仅暴露给64位。另一个例子是Objective-C 2.1的运行库,快速Vtable调度,新的和C++统一的异常处理模型,以及彻底解决对象的FBI问题等,都仅限64位程序使用。
内核的64位化
读者应该发现,经过这4个发行版,Mac OS X自下而上地对整个系统向64位迁移。10.3内核空间提供了64位整数运算的支持。10.4允许程序以64位模式运行在用户空间,并且提供了64位的libSystem使得开发者可以开发64位的Unix程序,而10.5中系统所有未废弃的函数库、框架都提供64位版本,到了10.6,所有用户空间的程序,包括Unix层和图型界面层,基本都更新到64位。细心的读者不禁会问—那内核是64位的吗?是的,自下而上支持64位后,10.6又从上往下,迁移了整个系统中最后一个也是最重要的部分—内核。
内核64位化的意义
对于Windows、Linux,以及FreeBSD等操作系统,64位实现的第一步是实现64位的内核。然而Mac OS X却反其道而行。主要原因是,反正32位的内核也能以非模拟、非兼容的方式原生地运行64位用户空间程序,而内核和与内核动态链接的驱动,很少需要用到64位的寻址空间(你什么时候见过内核本身使用4GB内存?),所以该问题可以暂缓。
但要记住,用户空间的内存是由内核管理的,虚拟内存、内存分页等机制,都是由内核一一实现的。一旦在不久的将来,随着用户空间的内存占用越来越多,虚拟内存的分页比也会不断膨胀。比方说,一个用户程序使用4GB的空间,每个分页包含4KB的页面,那么总共有1M个页面。因此,假设一个页面需要64B的PTE来记录该页的位置,那总共也就需要64MB的内核空间来记录这个用户空间程序的虚拟内存,不算太多。而在不久的将来,如果一个64位用户程序使用128GB的空间,则需要32M个页面,每个页面64B的PTE会导致2GB的内核地址空间来寻址(暂不考虑大分页)。32位的内核就显得非常紧张。
另外,上一期我们也提到64位的Intel架构提供了比32位多一倍的寄存器,因此,用户空间程序对64位内核的系统调用也会更快。根据苹果的数据,系统调用的响应速度比原先快了250%,而用户空间和内核空间的数据交换也快了70%,因此,64位内核要比32位内核更快。
内核完成64位迁移
虽然在Mac OS X 10.6中,苹果提供了64位模式运行的内核,但在大部分苹果计算机上,这个特性并不默认启用。其原因是,虽然64位程序和32位程序可以在计算机上同时运行,但64位的程序只可以加载64位的库或插件,32位程序只能加载32位的库或插件。因此,如果默认使用64位模式启动,则诸多第三方的32位驱动或内核模块将无法使用。当然,用户可以通过修改com.apple.Boot.plist、nvram,或开机按住6和4强制加载64位内核,不过苹果并不推荐这样的方式。直到Mac OS X 10.7时,第三方内核扩展已趋完善,大部分的Mac才默认使用64位内核模式启动。
苹果用了整整6年的时间完成64位的迁移,在2009年WWDC的一个讲座上,Bertrand Serlet告诉开发者,我们这个64位技术的讲座,只针对Mac OS X,而iPhone、iPad等iOS设备,由于使用ARM平台,在可预见的未来可能并不会支持64位技术。
不过两年之后的2011年10月27日,ARMv8发布,ARM正式宣布支持64位。未来会不会出现基于ARM的Mac,或是64位的iPad,除了苹果,谁知道呢?
GCD来临
很长一段时间以来,处理器靠更快的运行时钟来获得更高的效率。软件开发者无需改动或重新编译他们的代码,就能得到摩尔定律许诺他们的好处,因为处理器顺序地执行计算机指令,新一代的处理器就自动会跑得比原先更快。后来每每达到一个技术极限时,总有一些聪明的方法绕过这些极限,比如超纯量、指令管线化、快取等,不是悄无声息地把多条互相独立的指令同时运行,就是隐藏掉数据读写的延时。
GCD出现的缘由
到了21世纪,能想的办法基本都想尽了—现代处理器已经足够并行了,也采取了各项优化来不断提升各种预测器的准确率,而时钟频率却是不能无限提高的—提高时钟频率会极大地增加处理器的产热,使得服务器机房或笔记本的散热成为一个头痛的问题。同时对于便携设备而言,高频也意味着短得多的电池时间,因此摩尔定律正在经受重大的考验。
因此大约在21世纪头十年过掉一半时,“多核”处理器,终于开始跃入普通消费者的视线。“多核”顾名思义,就是把原先单核的半导体线路复制多份排于同一裸片上,每个核相互独立,又能彼此通信。多核处理器的出现,有效缓解了计算机处理器生产商的设计和制造压力,从而达到忽悠消费者买更新款产品这一不可告人的目的。
但这一次技术革新,并不如之前那么顺利,因为程序并不会自动在多核系统上跑得更快,甚至有很多程序每一步都有前后依赖,不能高效地并行运行。即使能够高效并行的程序,也需要大规模改写才能充分利用多核所带来的优势。
传统的并发编程模式,就是学习使用线程和锁。这听起来很简单,几句话能说明白:
把每个任务独立成一个线程;
不允许两个线程同时改动某个变量,因此得把变量“锁”起来;
手动管理线程的先后并发顺序和并发数量,让它们均匀地占满系统资源;
最好系统中只有这个程序在运行,否则你精心设计好的线程管理算法往往不能达到原来该有的效果;
最后祈祷程序在用户那儿不出问题。
但是实际操作起来,多线程程序的编写要比单线程难上不止一个数量级。一方面,调用大量内存和数据反复的加解锁本身效率就非常低下;另一个重要原因在于,由于多线程程序可能以任意的次序交错执行,程序再也无法像顺序执行时那样产生确定的结果。多线程程序看似容易编写,但难分析、难调试,更容易出错。即使是最熟练的开发者,在茫茫线程和锁之间,也会迷失方向。且程序的错误在很多时候甚至是不可重现的。所以,程序员使用线程和锁机制编写并行程序的代价是很高的。
GCD就是在这种背景下被苹果提出来的。2008年最初提出但未公布细节时,很多人怀疑它是FreeBSD的ULE调度器在Mac OS X上的实现。ULE是FreeBSD当时最新的内核调度器,用来替换掉老一代的4BSD调度器,当时使FreeBSD上跑多线程程序的效率获得了重大的性能提高,远高于同期Linux和Solaris的算法效率。但当时我就认为GCD依赖FreeBSD这项技术的可能性不大,因为Mac OS X中管理进程和线程主要用的是Mach而不是BSD。不过后来证实我只猜对了一半,GCD的实现,实际上是依赖于FreeBSD的另一项技术kqueue。kqueue是一个由FreeBSD 4时代引入的新功能,内核级别地支持消息通信管理。GCD的队列,其实就是用kqueue实现的。
GCD出现的意义
在GCD中,开发者不再管理和创建线程,而是将要实现的运算抽象成一个个任务,一起扔给操作系统,转而让操作系统管理,这在计算机科学中,被称为线程池管理模式。
在GCD中,开发者使用很简单的方式就能描述清应用程序所需执行的任务,以及任务之间的相互关联。每一个任务在代码中被描述成块(block),然后开发者把一个一个块显式地按顺序扔到队列(queue)中。使用块和队列两个抽象的表述,开发者无须创建线程,也无须管理线程,更无须考虑数据的加解锁。换之而来的,是更简短可读的代码。剩下的事,全都扔给操作系统去完成。
在操作系统那边,GCD在程序运行时,管理着一定数量的线程,线程的数量是自动分配的,取决于用户计算机的配置和用户程序运行时的负载。多核工作站每个程序配到的线程,自然就会比单核手机或双核笔记本来得多。而且这个线程的数量是会动态变化的。当程序非常忙时,线程数会相应增多,而当程序闲置时,系统会自动减少其线程数量。然后,GCD会一一从队列中读入需要执行的块,然后扔到线程上并发执行。
相信读者已经看出GCD和传统线程—锁机制的区别来了。传统的方式按劳分配,强调程序自由独立地管理,妄想通过“无形的手”把系统资源平均分配,走的是资本主义市场经济的道路。而GCD按需分配,真正实现了社会主义计划经济管理模式。因此在政治上GCD就是一个代表先进生产力的计算机技术(我被自己雷了,但事实就是这样)。
GCD是一个自底向上的技术,它实际上由以下6个部分组成。
编译器层面,LLVM为C、Objective-C和C++提供了块语法,这个内容等下会介绍。
运行库方面,有一个高效分配管理线程的运行库libdispatch。
内核方面,主要基于XNU内核Mach部分提供的Mach semaphores和BSD部分提供的kqueue()机制。关于XNU内核的更多细节,请参考即将发行的四月刊《半导体的丰收(下)》。
dispatch/dispatch.h提供了丰富的底层编程接口。
在Cocoa层面,NSOperation被重写,因为使用libdispatch,所以先前使用NSOperation的程序不需改动,就自动享受Grand Central Dispatch的最新特性。
Instruments和GDB提供了非常完整的分析和调试工具。
GCD还有一些工程上的优势。首先,程序的响应速度会更快。GCD让程序员更方便地写多线程程序,因此写一个多线程程序来实现前后台简单多了,极大改善了Mac OS X上应用程序的生态环境。而且GCD的代码块队列开销很小,比传统线程轻量得多。统计表明,传统的Mac OS X上使用的POSIX线程需要数百个计算机汇编指令,占用512KB的内存,而一个代码块队列才用256字节的长度,把块加入队列,只需要15个计算机汇编指令,因此开成百上千个也不费什么事。
其次,线程模式是一种静态的模式,一旦程序被执行,其运行模式就被固定下来了。但用户的计算机配置各不相同,运行时别的程序有可能耗用大量的计算资源。这些都会影响该程序的运行效率。而动态分配系统资源则能很好地解决这个问题。苹果自然也是不遗余力地忽悠开发者使用GCD,因为各个软件共享多核运算的资源,如果GCD被更多的开发者采用,整个苹果平台的生态也就更健康。
而最重要的,还是GCD采用的线程池模式极大简化了多线程编程,也降低了出错的可能性。著名FreeBSD开发者Robert Watson还发布了一个他修改过的Apache,并释出了补丁,声称只需原先1/3至1/2的代码量,就实现了原先的多线程模块,并比原先的效率更好。
如何应用GCD
当然,老王卖瓜,自卖自夸,没有实际的例子,是不能让读者信服的。下面我们就来简单讲解GCD的技术。
首先是块状语法,是一个对C、C++和Objective-C语言的扩展。用来描述一个任务,用^引导的大括号括起来。比如最简单的:
x = ^{ printf(“hello world\n”);}
则 x 就变成了一个块。如果执行:
x();
那么程序会打印hello world出来。当然,blcok像函数一样,可以跟参数,比如:
int spec = 4;
int (^MyBlock)(int) = ^(int aNum){
return aNum * spec;
};
spec = 0;
printf(“Block value is%d”,
MyBlock(4));
这里MyBlock是一个带参数的代码块。
读者看到这里不禁要问,块到底有什么好处?它和C的函数指针有什么不同?我们依然用上面的例子来说明问题,虽然后面我们把spec变量改为0,但事实上在MyBlock创立时,已经生成了一个闭包,因此它最后输出的结果,仍是16,不受spec值改动的影响。这对于搞函数式编程的人来说再熟悉不过了,因此很多开发者亲切地称呼块语法的C扩展为“带lambda的C”。
有了闭包功能的C顿时牛起来—你可以把函数和数据包装在一起—这就是块的真正功能。因为只要一个闭包包含了代码和数据,它的数据就不会被别的闭包轻易改动,所以在它执行时,你根本不用为数据上锁解锁。
有了一系列的代码块后,接下来的事是把代码块扔到队列里。比如最简单的:
dispatch_queue_t queue = dispatch_get_global_queue(0,0);
来创建一个轻量级的队列,然后
dispatch_async(queue,
^{printf(“hello world\n”);});
那这个代码块就被扔进queue这个队列中了。你可以手动依次添加任意多个项目,比如“带着老婆”、“出了城”、“吃着火锅”、“唱着歌”、“突然就被麻匪劫了”等。当然在更多的场合,你会更倾向于使用自动事件源,每当一个事件触发时(比如定时器到点、网络传来包裹,或者用户点击了按钮),相应的代码块被自动添加到队列中。
一旦队列不是空的,GCD就开始分配任务到线程中。拿上面的例子来说,“老婆”、“城”等变量可是封在闭包里的,所以在运行时,不用考虑它们被某个别的闭包改掉(当然也有方法来实现这个功能)。总体而言,这个模式比线程—锁模型简单太多—它的执行是并行的,但思维却是传统的异步思维,对没有学习过系统多线程编程的开发者来说,依然能很容易地掌握。
读者可能要问,如果闭包之间有复杂的依赖关系,需要申明某两个操作必须同步或异步怎么办?比如“出了城”必须在“吃着火锅”之前。在GCD中,可以使用dispatch_async和dispatch_sync来描述这样的依赖关系,而在Cocoa层面,NSOperation中的队列依赖关系甚至可以被描述成有向图。
GCD得到广泛应用
GCD一经推出就得到了广泛的应用。苹果自家的软件Final Cut Pro X、Mail等软件,都采用GCD来实现任务并发和调度,因此Mac OS X 10.6成为了有史以来最快的发行版。从iOS 4开始,iPhone和iPad也加入了GCD的支持。更别提原来使用Cocoa的NSOperation相关接口的程序,无需改动即享受GCD的优惠。
GCD在Mac OS X 10.6发布后,又以libdispatch为名,作为一个独立的开源项目发布。 所需的外围代码,如编译器的块支持、运行库的块支持、内核的支持,也都能在LLVM和XNU等开源项目代码中找到,所以很快被别的操作系统采用。作为Mac OS X的近亲, FreeBSD在一个月后即完整移植了整套GCD技术,并最终在FreeBSD 9.0和8.1中出现。诸多Linux发行版也提供libdispatch的包,使用Linux内核的epoll来模拟FreeBSD的kqueue。2011年5月5日, Windows的移植工作也宣告完成。
另外,GCD也成为拯救动态语言的重要法宝。由于受GIL(全局解释锁)的限制,动态语言虽然有操作系统原生线程,但不能在多核处理器上并行执行。而GCD成功绕开了这个限制,如加入GCD支持的Ruby 实现MacRuby就能在多核处理器上高效执行。 因此,在苹果生态圈以外,GCD也会得到越来越多的应用。读者马上还会看到,苹果同时推出的另一项主推技术中也使用了GCD,
随着CPU与GPU合并成技术发展的趋势,苹果开发出了OpenCL框架,能够进行高速并行处理的能力使OpenCL成为了业界标准,被广泛应用。
最近几年,GPU的发展吸引了很多来自科学计算界人士的目光。GPU有稳定的市场推动力—公众喜闻乐见的电子游戏产生了源源不断的升级GPU的需求—因此比CPU的更新步伐更快。从技术上讲,GPU本身就是多核架构,高端显卡往往有五百多个核心,即使低端的集成GPU也有二三十个核心,所以能够通过并行来高效处理成千上万的线程。同时,对于科学技算中的浮点计算,GPU往往通过硬件加速使其效率比传统CPU更高,因为图形渲染等工作基本都是浮点计算。
GPGPU浮出水面
早期的GPU只能执行固定的程序,而不开放给程序员编程。随着时代的发展,图像处理有时需要对着色器进行编程以实现一些特效,因此需要程序员可以使用GPU的汇编语言写简单的着色程序。这自然对程序员要求过高,所以一些高阶的着色语言又被GPU厂商开发出来。比如微软和NVIDIA共同开发的Cg语言,就能为顶点和像素编写专门的着色程序。这类技术虽然面向图形渲染工作者,却吸引了一小簇科学计算研究者的兴趣。以计算流体力学为例,它是用纳维斯托克斯方程【注:把牛顿第二定律和质量守恒应用到流体后,所得到的偏微分方程】来求解流体力学问题的一种算法,广泛用于天气预报、F1方程式赛车设计等工程领域。同时,对于电影制片特效,计算流体力学也是最基本的用来模拟流体流动特放的算法,皮克斯动画工作室的《寻找尼莫》中的海洋流动和水花等,都是使用纳维斯托克斯方程来模拟的。
首先,对于一个几何空间进行网格化,每个网格中的流体,都可以列出纳维斯托克斯方程,把这些方程联立起来进行求解,即可得到各点的温度、压力、湿度、速度等流体信息。整个求解过程可以高度并行,因为每个网格的控制方程是完全一样的;同时也牵涉大量的浮点运算。但Cg这类语言并非面向普通的计算,其变量都是颜色、顶点、像素等图形学专用变量。来自北卡罗莱那大学教堂山分校的Mark Harris突发奇想:可以把流体力学中每个网格的速度、压力等变量,存成RGBA颜色后让Cg去处理,所以他在《GPU Gems》中著名的一章,公布了使用Cg来高速实现计算流体力学运算的成果,吸引了大量计算界的目光。然而,这种编程模式对科技工作者来说很不友好,因为这要求一个学力学的、学生物的、学化学的学生,先要明白复杂的GPU渲染原理,了解图形学中材质、顶点、合成、像素、光栅化、光线跟踪等深奥的理论,才能编写他们专业相关的GPU程序。
GPU生产厂商洞察到了GPU高速并行浮点数运算的潜力,所以GPGPU(General Purposed Graphics Processing Unit)概念终于浮出水面。一方面GPU设计一代比一代可编程化,另一方面各公司也在加紧研制新一代GPU编程语言。新一代的语言对比Cg,去掉了对于渲染相关的知识要求,独立于图形学之外,是纯粹的普通语言,比如变量不再是像素、顶点、面等类型,而是C/C++语言开发者喜闻乐见的浮点数组、整形数组等。这一时期为代表的语言,主要是CUDA(Compute Unified Device Architecture)。CUDA是NVIDIA在2007年公布的一项面对科学计算工作者的编程框架。通过该技术,使用者可利用NVIDIA的GeForce 8以后的GPU和较新的Quadro GPU进行高性能编程。用户先编写一个特殊的C++代码文件,扩展名为cu,文件中需要申明创建的变量、GPU计算核心(kernel)以及使用给定的编程接口来实现变量在CPU和GPU中的传送。然后通过NVIDIA自家的编译器编译这个代码,链接到NVIDIA自家的库上,即可把该运算核心编译为GPU汇编语句扔到特定型号的GPU上高度执行。其他厂家也紧随其后,比如AMD为ATI生产的GPU卡提供了一个类似的框架叫Stream SDK(先前被命名为 CTM, Close to Metal, ATI Stream Computing – Technical Overview, 03/20/2009 http://en.wikipedia.org/wiki/Close_to_Metal)。而微软更是趁Vista和Win7推出了DirectCompute,作为旗下DirectX技术的一部分。
CUDA并不完美
对科学工作者来说,CUDA比Cg友好太多。使用CUDA加速流体力学运算相关的论文更是雨后春笋般涌现。然而不久后,我发现它存在许多问题。
首先,对初学者来说,CUDA编程模式很容易学混。因为一个GPU数组和一个CPU数组在CUDA中的表述都是同样的C指针,但对于GPU数组和CPU数组,CUDA的处理模式完全不同,CPU数组使用常规的malloc来初始化,而GPU数组得使用CUDA提供的malloc。所以程序写着写着,就忘了一个变量到底是给CPU用的还是给GPU用的,这无疑增加了学习难度。同时,CUDA对C/C++语言进行了一系列扩展,这不但意味着写的程序不再具有C/C++那样良好的可移植性,而且这种计算核心和传统C程序混写的编程语言很不美观。
其次,CUDA这类语言的实现各自为政。如果你写了一个CUDA程序,就意味着这个代码只能运行在NVIDIA的显卡上。如果想使用ATI的显卡呢?没门,请用ATI Stream SDK重写。
再次,CUDA是在编译时就静态产生GPU代码的,所以只能产生特定的GPU代码。如果你发布了一个CUDA程序,它仅对某几种NVIDIA显卡进行特定的代码优化。如果NVIDIA自家出了一种新显卡,很抱歉,哪怕新显卡可能兼容老显卡的汇编指令而你的程序恰巧可以在新显卡上跑起来,你也无法发挥新显卡的所有特性。必须用针对新显卡的编译器重新编译源代码,才能够保证程序在新显卡上高效执行。
最后,CUDA这类语言仅能产生高效的GPU代码,而无法产生CPU代码,即:写完的代码只能跑在GPU上,在CPU上只能“模拟执行”,仅供调试用。所以在一台不具备给定GPU的机器上,无法高效运行CUDA程序。同样,如果你有一个性能很强的工作站,那么你的CPU亳无用处—CUDA不可能分配一部分任务给CPU完成。
另外还有未来计算机架构的不确定性。当时,GPU越来越一般化,可以跑多种数值计算程序,而CPU随着多核成为主流也越来越像GPU。所以很多厂家在考虑CPU和GPU合并的可能性。
当时轰动一时的热门事件,是CPU厂商AMD买下了GPU厂商ATI,来开发下一代处理器AMD Fusion,把GPU和CPU合并到一起。Intel自然不甘示弱,做出了Nehalem平台,在该平台上,CPU和集成GPU处于同一个包装中,外界一度猜测这样可使合并后的CPU具有图形处理工能,从而用户购置计算机就不用再考虑配一块GPU了。
更强大的是,当时Intel还公布了Larrabee计划,让GPU支援x86指令,使得一个常规的x86平台的程序不需要修改和重新编译便可在GPU上运行。
虽然事实和这些预期有稍许出入,但当时的技术趋势是:在将来可能出现一种新的合并GPU/CPU的技术,能够并行高速地运行一般的计算机程序,而面对这样新的可能的平台,我们如何准备?
OpenCL诞生
OpenCL则是苹果为这个新局面画下的蓝图。这项技术初期全称为Open Computing Library(如果留意苹果早期宣传广告的话),后改名为Open Computing Language。这项技术从本质上来说,和CUDA并没有太多的两样,但由于苹果在借鉴他人技术并把他人技术改得更棒这一点上是出了名的,所以OpenCL很好地解决了以上所有问题。
下面简单介绍一下这个框架。OpenCL技术的结构十分清晰,对程序员来说,它是一个Mac OS X的Framework,定义了两套标准,一套是一个C语言的编程界面(API),使得开发者创建、拷贝、回收GPU使用的对象,同时也包含检测处理器、为该处理器编译并调用核心程序(kernel)相关的接口;另一套是OpenCL核心程序语言的定义,是一套基于C99发展而来的语言。
例如我们有两个大数组,1024维的a和1024维的b(当然,1024不算大,OpenCL往往用来处理十万、百万数量级的任务),我们把两个数组对应的元素加和,结果是一个1024维的数组c。C程序员很容易能写出下面的程序:
for (int i = 0; i < 1024; i++)
c[i]=a[i]+b[i];
OpenCL的核心程序,则是取每个独立的可并行的循环分支,即上面程序中的 c[i]=a[i]+b[i]。所以核心程序大概是下面这样:
__kernel add(float *a, float *b, float *c){
int i = get_global_id(0);
c[i]=a[i]+b[i];}
其中,get_global_id()函数可以返回当前函数是全局中的第几个元素。把该程序保存为add.cl,就是一个OpenCL的核心程序,为C99语言的一个子集。
使用OpenCL的API就能调用这个核心程序。每个OpenCL程序基本上是模式化地照搬下面流程:
1. 探测硬件(用clGetDeviceIDs函数护取计算设备(可以指定使用GPU或是CPU),用clCreateContext函数来新建一个上下文(context),用clCreateCommandQueue函数针对设备和上下文新建一个命令队列);
2. 编译核心(读入add.cl,用clCreateProgram-WithSource和clBuildProgram以及clCreateKernel来编译读进来的字符串,产生一个核心程序);
3. 写入数组(用clCreateBuffer创建a、b、c三个内存对象,用clEnqueueWriteBuffer把C数组写到内存对象中);
4. 运行核心(把内存对象作为核心程序函数的输入参数执行这个核心,程序会并发为1024个线程,每个线程执行一次相应的加法运算);
5. 读出结果(用clEnqueueReadBuffer读取c内存对向,写为C的数组);
6. 回收内存。
OpenCL之美
让我们逐条来看前面那些问题是如何被解决的。
首先,OpenCL Framework由C API和OpenCL语言组成,泾渭分明,所有的GPU变量在C API中,都是内存对象的形式出现,有别于C自建的数组。因此,你永远不会搞混两者。同理,OpenCL核心程序是独立在C源程序之外的,不仅美观,也能保证你的C程序能被所有C编译器编译,因为调用OpenCL库和调用其他C的函数库没有任何不同。
其次,苹果开发出OpenCL后,觉得该技术甚好,索性联合AMD、ARM、ATI、TI、Intel、IBM、Nokia等公司,把它做成一个由Khronos组织主持的开放标准。不管电脑上用的显卡是ATI的还是NVIDIA的,OpenCL都能像OpenGL那样在你的设备上无缝运行。事实上,OpenCL已同OpenAL和OpenGL一样,成为Khronos Group旗下的三大业界标准。
再次,CUDA是在编译时就静态产生GPU代码的,所以只能产生特定的GPU代码。而OpenCL的核心程序(kernel)是在运行时被编译成GPU指令的。由于kernel所用的OpenCL语言,仅是C99的一个子集,所以负责编译这个程序的是OpenCL运行库自带的LLVM-Clang。这样做的好处是明显的,举例来说,如果用户有一堆OpenCL的程序,比如苹果最新的Final Cut Pro X就在许多地方采用了OpenCL,如果某一天硬件厂商发布了一个全新的GPU架构,那么用户安装显卡后,只要下载或更新相关的驱动程序和运行库即可,而不需要再求软件厂商发布一个新版本的Final Cut Pro X。因为OpenCL在运行时,会根据显卡厂商提供的驱动和新运行库自动优化程序到特定架构上。所以,程序兼容性问题也被圆满解决。
最后,由于OpenCL是个开放标准,也支持CPU和其他任何计算设备,比如数字信号处理芯片(DSPs)和各种专门的处理器架构。所以只要有相关的驱动和运行库,OpenCL程序可以高效地并行运行在任何架构的运算设备上。由于OpenCL和GCD的编程模式是一样的,因此当OpenCL程序在CPU上执行时,是跑在GCD队列上的。
由于OpenCL能高速地进行并行处理(如http://macresearch.org/opencl_episode1 的演示,OpenCL编写的GPU程序比单核CPU能快上数十至数百倍,笔者的论文Yue Wang, Ali Malkawi, Yun Yi, Implementing CFD (Computational Fluid Dynamics) in OpenCL for Building Simulation, 12th Conference of International Building Performance Simulation Association, 2011也得出了类似的结论),OpenCL被广泛地使用在很多产品中,苹果也是OpenCL的主要用户之一。如上面提到的Final Cut Pro X就是个典范,使用GCD和OpenCL进行大量并行的流媒体处理。在老版本Final Cut中,每当用户执行一次流媒体操作,都会弹出一个进度条来告诉用户剩余的处理时间,而Final Cut Pro X优化后的速度是如此实时,以至于这个进度条被去除了。Mac OS X许多的底层库也使用OpenCL重写,如Core Image,本身也是一个GPU加速库,使用OpenCL后相比原来,依然获得了可观的性能提升。
Snow Leopard的发布标志着第一个OpenCL框架的完整实现,OpenCL成为业界标准后,AMD抛弃了原先的策略,投入开放标准的怀抱,一连放出了几个测试版本的集成OpenCL的ATI Stream SDK,并在2009年年底发布了稳定版,2011年8月8日宣布废除原先的Close to Metal相关技术。NVIDIA也是早早地在CUDA SDK中加入了OpenCL相关的库。CUDA越来越不被看好,所以NVIDIA索性把CUDA发布为一个开源项目,并把CUDA架构在LLVM之上。这和OpenCL近几年的走强有很大关系。
开发者的瓶颈
目前看来,OpenCL虽然解决了上面的所有问题且且速度飞快,但对普通程序员来说,依然是非常底层的技术。而且由于硬件的限制(显卡不支持指针运算),很多C的标准并未在OpenCL中出现,写链表还需要用整数去模拟地址。程序员需要手动管理内存,处理底层的核心调用以及数据读写。而显卡厂商也大多不愿公开GPU的技术细节,因此不像CPU程序很容易通过汇编指令分析计算机底层干了什么,显卡对于开发者纯粹是个黑盒,把整个问题分成多少个线程并发也没有一个规律可循,有可能不起眼的改动会使程序运行瞬间变快或变慢数十倍,开发者也不知道其中的原因,只能凭经验操作。而且由于不存在良好的调试工具,所以很难改正程序的错误。
显卡作为系统最为重要的共享资源之一,不像现代操作系统那样提供内存保护机制,因此一个用户OpenCL程序的错误很容易导致整个计算机崩溃,所以经常是程序跑一遍后发现操作系统挂了,重启后发现了一个可能的错误,改完后编译运行,操作系统又挂了。我用OpenCL编写科学计算程序时,大量时间是在重启电脑而不是写程序。这些问题仍然阻碍着OpenCL被广泛采纳,不过,在科学计算界,已经涌现出了越来越多相关的论文和技术,相信在不久的将来,情况会有所改观。
结语
当写完这篇技术长文时,天色已晚,走出教室,和ENIAC擦肩而过。ENIAC的出现激励了之后一次次的处理器革命。2009年发布的Snow Leopard可能在整个Mac OS X发行版历史中不算最出彩,却是对于半导体集成电路革命的一次重大收获。
作者王越,美国宾夕法尼亚大学计算机系研究生,中国著名TeX开发者,非著名OpenFOAM开发者。