0%

[翻译] 并发的实践经验

原文:Real-world Concurrency

你可能不需要真的去写多线程的代码。但如果你需要的话,一些关键原则能帮助你掌握这项“魔法”。

软件从业者们可能因为近期微处理器的发展而对软件行业的未来产生恐惧,这种恐惧情有可原。尽管摩尔定律继续存在(晶体管密度依然每18个月翻倍),但因为难以解决的物理限制和实际的工程考虑,新增的晶体管密度不再花在提高时钟频率上了,而是用于在一块CPU晶元上放置多个CPU核心。从软件角度看,这不是一个革命性的变化,而是一个渐进的变化:多核心CPU不是一种新范式,而是将旧范式(多进程)推向更广泛的发展。但根据近期有关这一话题的诸多文章和论文,有人可能会得出这一判断:并发编程的绽放是即将到来的灾难,所谓“免费午餐结束了”。

作为长期处于并发系统第一线的从业者,我们希望为这场总会陷入歇斯底里的争论注入一些现实的冷静(如果不是来之不易的智慧的话)。尤其是,我们希望能回答一个本质的问题:并发性的扩散对你开发的软件意味着什么?也许有点遗憾,这个问题的答案既不简单又不普适——你的软件与并发之间的关系取决于它物理上在哪执行,它在抽象栈中的位置,以及围绕它的经济模型。

考虑到许多软件项目现在都有位于不同抽象层次、跨越不同架构的组件,你可能会发现即使对于你自己写的软件,上面问题的答案也不只一个,而是多个:你可能可以留一些代码永远串行执行,而另一些需要高度并行化且显式多线程执行。再令答案复杂一些,我们会认为你的很多代码不会整整齐齐地属于这两类之一:它可能在本质上是串行的但在某些层次上需要注意并发性。

尽管我们断言需要并行的代码要比某些人恐惧的更少,但必须要承认写并行代码仍然是一种魔法。因此我们也给出了开发高并行度的系统需要的具体实现技术。因此,本文有些二分:我们既认为大多数代码可以(且应该)实现并发,且不需要显式的并行化,同时还要为那些必须写显式并行代码的人解释实现技术。本文半是禁欲的训诫,半是爱经的指导。

一些历史因素

在我们讨论与今天应用有关的并发前,探索并发执行的历史是很有帮助的。即使在60年代——世界仍处于计算机时代的黎明时——人们已经开始清楚单个中央处理单元去处理单个指令流是对系统性能不必要的限制。在计算机设计者们试验不同的方案来绕过这个限制时,1961年Burroughs B5000提出了最后被证明是前进方向的一种方案:互不相干的多个CPU执行多个不同的执行流,但共享内存。就这点而言(以及其它点上)B5000至少领先时代10年。直到80年代对多进程的需要才逐渐被广大研究人员所了解,他们在这十年中探索了一致性缓存协议(例如Xerox Dragon和DEC Firefly),并行操作系统的原型(例如运行在AT&T 3B20A上的多处理器Unix),并开发了并行数据库(例如威斯康星大学的Gamma)。

到了90年代,80年代研究者们种下的种子产生了实用系统的果实,许多计算机公司(例如Sun、SGI、Sequent、Pyramid)在对称多处理器上投入了大量的赌注。这些投在并发硬件上的赌注也使下注在并发软件上变得必要——如果操作系统不能并行执行,系统中的其它程序也不能——这些公司开始意识到他们的操作系统需要围绕着并发执行的概念重写。这些重写发生在90年代早期,新系统在这十年内陆续发布;其中产生的技术今天可以在像OpenSolaris、FreeBSD、Linux这样的开源操作系统中看到。

就在这些计算机公司在多进程上投入大量赌注时,许多数据库厂商也在豪赌高并行度的关系型数据库;包括Oracle、Teradata、Tandem、Sybase、和Informix的后来者需要用并发来获得对当时统治了事务处理的主流厂商的性能优势。与操作系统类似,这项工作在80年代后期和90年代早期开始,并在这十年中逐渐改善。

这些趋势的结果是,在90年代后期,并发系统已经在高性能计算机上取代了它们的单处理器前辈们:当1993年第一次公布前500台超级计算机的名单时,性能最高的单处理器机器只排到34位,超过80%的TOP500机器都是这样那样的处理器机器。到了1997年,单处理器机器从这份名单中彻底消失了。在超级计算机世界以外,许多面向事务的应用都可以随着CPU数量而扩展,允许用户不动架构就能实现扩展系统的梦想。

90年代并发系统的崛起正逢另一趋势:CPU时钟频率持续增加的同时,主存速度没有跟上。为了应对相对较慢的内存,微处理器架构引入了更深(也更复杂)的流水线、缓存、和分支预测单元。即使这样,时钟频率本身也很快成为一种谎言:虽然CPU能够以广告中的速度运行,只有一小部分代码能真正实现(更别提超过)每条指令一个周期的速度——大多数代码要在一条指令上花费3、4、5(甚至更多)个周期。

许多人看到这两个趋势——并发的崛起和增加时钟频率的无效化——得出合理的结论:与其将晶体管的预算花在“更快”但不会真的提高多少性能(且在功耗、发热、面积上有可怕的开销)的CPU上,为什么不利用并发软件崛起的优势,将晶体管用在一块晶元上多个(简单的)CPU核上呢?

正是并发软件的成功促成了多处理器芯片的诞生,这是一个非常重要的历史点,值得再强调一次。有一种看法认为,是微处理器架构——出于恶意、懦弱、或绝望——将并发强加给软件的。实际上,正相反:是并发软件的成熟指引着设计师考虑在一块晶元上实现并发硬件。(读者可以参考最早的多处理器芯片——DEC的Piranha——来了解这种动机的详细讨论。)软件没准备好的话,今天这些多核处理器就不会具备商业可行性。如果有什么区别的话,被一些人谴责将要结束的“免费午餐”,事实上正在供应。一个人只需要饿了知道怎么吃就行!

并发是为了性能

从这次的历史回顾中得出的最重要结论是,并发一直被用于一个目的:提高系统性能。这点太显然了,似乎不值得说出来——如果并发不提高性能,为什么我们还要并发呢?——然而,尽管显而易见,并发性的存在理由却越来越被人们遗忘,就好像并发硬件的普及唤醒了大家对所有软件都必须用到所有可用的物理资源的焦虑。就像没有程序员会对超标量微处理器的流水线停顿抱有道德上的责任一样,也没有软件工程师只因为硬件支持并发就觉得自己有责任使用并发。相反,当且仅当我们需要提高系统性能时我们才应该想到并使用并发。

并发执行能通过三种基本途径提高性能:它能降低延时(即令一项工作执行得更快);它能隐藏延时(即允许系统在一项长延时操作的同时继续工作);或能提高吞吐(即令系统能做更多工作)。

使用并发来降低延时是高度问题相关的,因为它需要为要处理的任务准备一个并行算法。对于某些种类的问题——尤其是科学计算中遇到的——这很直接:工作可以被切分为先验的多个计算元素的任务集合。但很多这类的问题都是可以并行化的,它们不需要共享内存的紧密耦合——而且它们通常能够在小型机器的网格上更经济地执行,而不是使用更少的高度并发的机器。进一步,使用并发来降低延时需要单位工作执行的时间足够长,来平摊多个计算单元之间巨大的通信成本:你可以想象用并发来并行化4000万个元素——但仅仅处理40个元素的计算时间就不太能支付并行性的开销了。简而言之,并发能降低延时的程度更多地取决于问题而不是决心——而许多重要问题根本无法用并发来解决。

对于无法并行化的长时间运行的操作,在它等待时可以通过并发执行来处理其它工作;在这个模型中,操作的延时没有减少,但它隐藏在整个系统的推进中了。当操作本身很可能被程序之外的东西——例如磁盘IO操作、DNS查询——阻塞时,用并发来隐藏延时就尤其吸引人了。但你也要非常小心:只是为了提升响应能力,并行程序可以变成一种巨大的复杂度负担。进一步,并发执行不是隐藏系统引发的延时的唯一方法:你也可以用非阻塞操作(例如异步IO)和事件循环(例如Unix的poll/select)在串行程序中达到同样的效果。因此,想要隐藏延时的程序员应该把并发当作一种选项,而不是必须。

在问题不适合并行化,也没有能隐藏的延时时,并发执行的第三种提高性能的方法是提高整个系统的吞吐。你可以用多个并发执行的顺序逻辑来同时容纳更多的任务,而不需要用并行逻辑让单个操作更快。重要的是,一个使用并发来提高吞吐的系统不需要只包含(甚至大部分是)多线程代码。相反,系统中不共享状态的组件仍然可以完全是串行的,只是系统会并发执行这些组件的多个实例。这样系统中就只需要共享那些针对并行执行中的共享状态做了显式设计的组件,理想情况下只剩下那些已知的可以在并发环境中工作得很好的元素才需要共享:数据库和/或操作系统。

为了做到这一点,在一个典型的MVC应用中,view(通常实现在JavaScript、PHP、或Flash环境中)和controller(通常实现在J2EE或Ruby on Rails环境中)可以只包含串行逻辑,且仍然可以达到很高程度的并发,只要model(通常实现为数据库)支持并发。已知大多数人不会自己写数据库(也几乎不会有人自己写操作系统),不显式创建线程或获取锁而构建一个高度并发、高度可伸缩性的MVC系统是很可能的(事实上已经有很多了);这种并发性来自架构而不是实现。

展示魔法

假如你就是那个正在开发操作系统或数据库或其它必须显式并行化的代码的人呢?如果你把自己归类为少数要写这种代码的人,可能不需要警告你写多线程代码很难。事实上,这个领域在困难度上的名声已经导致有些人(错误地)总结不可能写出多线程代码:“没人知道该如何组织和维护依赖锁的大型系统”,一个近期(也是典型)的断言。写可伸缩且正确的多线程代码的部分困难度是缺乏有经验的从业者将其智慧付诸书面:口头传统代替了正式的写作导致这个领域笼罩在神秘之中。因此本着令这一领域对其它从业人员不再神秘的精神,我们提供了一组编写多线程代码的技巧。

分辨冷热路径

如果要给那些必须开发并行系统的人一条建议,那就是要知道你需要哪些代码路径并行执行(所谓的热路径),哪些可以串行执行而不影响性能(所谓的冷路径)。以我们的经验,我们写的很多软件在并发执行方面都是冷的:它只在初始化、管控路径上、卸载等场景中被执行。将这些冷路径改造为高度并行化不光是浪费时间,还很危险:这些路径经常是并行化中最困难也最容易出错的部分。

对于冷路径,锁的粒度尽量粗。不要怕你的子系统中有涵盖大量冷门操作的锁。相反,在热路径上——必须并发执行以得到最高吞吐的路径——你必须非常小心:锁的策略必须非常简单且粒度合适,你必须小心避免可能成为瓶颈的动作。如果你不知道给定的代码是否会是系统的热路径呢?在缺乏数据的情况下,宁可假设你的代码在冷路径上,并采用相应的粗粒度锁策略——但准备好被数据打脸。

直觉常常是错误的——靠数据说话

以我们的经验,许多扩展性的问题是由于程序员一开始以为的(或期望的)旧路径实际是热路径。在新软件起步时,你会需要一些直觉来分辨冷热路径——但一旦你的软件可用了,甚至是原型状态,直觉的使命就结束了:你的直觉必须让位于数据。并发系统中收集数据本身就是一个难题。它需要你首先有一个充分并发执行的机器,从而突出扩展性的问题。等你有了这种物理资源,你还需要能给系统加类似于生产环境系统的压力。等机器有负载了,你必须有能动态地控制系统的基础设施,以找到任何扩展性问题的根源。

第一个问题在过去尖锐:有段时间多处理器机器罕见到许多软件开发商都没办法接触到一台。幸运的是,随着多核CPU的崛起,这不再是个问题:没有任何理由找不到一台至少是双核的机器,而且只要一点点努力,大多数人都可以(2008年)把他们的代码跑在一台八核(双路四核)的机器上。

但即使物理条件改善了,第二个问题——知道如何给系统加压力——却变糟了:生产部署越来越复杂,在开发中模拟其负载也变得困难且代价昂贵。你必须将负载的产生和模拟尽可能地当作第一优先级的问题;你越早在开发中解决这个问题,越早可以得到关键数据,这对你的软件有着巨大的意义。尽管测试压力应该尽量模拟生产压力,但时效性要比完全的精确更重要:达不到完美的负载模拟不应该阻碍你模拟负载,因为将多线程系统置于错误的负载之上要比完全没有负载好得多。

一旦一个系统有了负载——开发环境或生产环境都行——如果没办法知道它的扩展性的障碍在哪,再开发软件就没意义了。在生产系统中找到扩展性的障碍需要你有能力安全地动态控制它的同步原语。在开发Solaris时,我们在这方面的需求非常强烈,因此我们中的一员(Bonwick)在1997年开发了一种技术(lockstat)来做到这点。这个工具马上就变得很重要——我们很快就开始想,没有它我们是怎么解决扩展性问题的——它也导致我们中的另一个人(Cantrill)进一步将动态插装推广为DTrace,一个几乎可用于任何生产系统的动态插装的工具,在2004年首次出现在Solaris中,已经被移植到了FreeBSD和Mac OS等许多其它系统中。(lockstat中的插装方法已经被重新实现为一个DTrace的provider,而这个工具本身也被重新实现为了一个DTrace consumer。)

今天,动态插装不仅在继续为我们提供能找到阻碍系统扩展性因素的数据,也用于收集足够的数据以理解哪种技术最适合减少这种争用。设计新的锁策略原型代价昂贵,而人的直觉也常常出错;在分解一个锁或重构一个子系统以使其更加并行化之前,我们总是要努力把能证明这个子系统并行化的缺乏就在阻碍系统扩展性的数据拿到手。

知道什么时候应该分解锁,什么时候不应该

全局锁很容易成为阻碍扩展性的因素,当我们收集到能证明某个锁很热的数据时,想把它分解为每个CPU一把锁、一个锁的哈希表、每个数据结构一把锁等等,这些想法就很合理了。这可能是最终的正确解法,但在盲目朝着这条(复杂的)路径走之前,仔细检查一下这把锁保护下的任务:分解锁不是减少争抢的唯一方法,通过减少持有锁的时间可以(通常是)更简单地减少争抢。这可以通过算法的提高(许多扩展性的提高是通过把临界区的执行时间由O(N2降为O(N)来实现的!)或找出不需要在临界区中的动作来完成。后者有一个经典案例:如果数据显示你把时间花在了析构共享数据结构的元素上了,你可以把那些需要在临界区中析构的元素移出共享结构并保存起来,然后把它们的析构动作推迟到锁被释放后。因为这些数据已经在锁的保护下移出共享结构,就不会出现竞态(其它线程看到的数据的移除是原子的),而且锁的持有时间也降低了,却只增加了一点点实现复杂度。

警惕读写锁

在分解一个锁时,有一个新手会犯的错误:看到一个数据结构被大量读少量写,有人可能会想把互斥锁换成读写锁,以允许多个读者并发读。初看很合理,但除非锁的持有时间很长,否则这种方法的扩展性不会比互斥锁更好(事实上可能更差)。为什么?因为读写锁自己的状态必须原子更新,因为缺乏更复杂(空间效率更低)的同步原语,读写锁会使用单个word来保存读者的数量。因为读者数量必须原子更新,获取一把读锁和获取一把互斥锁需要完成相同的总线事务,总线上争抢也相同。

仍然有许多场景中锁的持有时间远长于任何内存争抢的开销(例如在读锁下做IO操作),但你需要确保收集数据以确保它对扩展性的影响符合预期。即使在这些适用于读写锁的情况下,仍然需要注意阻塞语义。例如,如果读写锁实现为有写锁阻塞时获取读锁也会阻塞(常见的避免写者饿死的范式),你就不能递归地获取读锁:如果在上层获取读锁和递归下层获取读锁之间有写者阻塞了,就产生死锁了。这些不是说不能用读写锁——只是它们不应该被美化。

考虑每个CPU一把锁

按CPU加锁(也就是按当前的CPU ID去获取锁)可以用于分散争抢,因为每个CPU的锁不太会有争抢(每个CPU同时只能运行一个线程)。如果锁的持有时间短,操作模式的一致性要求不同,就可以让线程在常见的情况(不需要一致性)下去抢CPU对应的锁,而在不常见的情况下去抢所有CPU的锁以达到一致性状态。考虑一个具体(且平凡)的例子:如果要实现一个全局的counter,经常更新但很少读,就可以将其实现为每个CPU一个counter,被CPU对应的锁保护。这样更新counter只需要更新CPU对应的counter,而在需要读的时候获取所有锁,将它们对应的counter值加起来。

关于这项技术的两点说明:首先,只应在数据显示需要这么做时才用这项技术,因为它显然引入了很大的实现复杂度;其次,确保在冷路径上获取所有锁时使用相同的顺序:如果一种情况下按从低到高的顺序获取锁,另一种情况下按从高到低的顺序获取锁,就会(非常容易)发生死锁。

知道何时用broadcast,何时用signal

几乎所有条件变量的实现都允许线程等在一个条件变量上,或是被singal唤醒(此时只有一个睡眠中的线程会被唤醒)或是被broadcast唤醒(此时所有睡眠的线程都会被唤醒)。这两个概念有着微妙的语义差别:因为broadcast会唤醒所有等待中的线程,它通常应该被用于表明条件变化,而不是资源可用。如果在更适合用signal的地方用了broadcast,结果就是惊群效应:所有等待中的线程都会醒来,争抢保护条件变量的锁,然后(假设第一个抢到锁的线程也会消耗可用的资源)在它们发现资源已经被消耗后再次睡去。这种无谓的调度和锁活动会对性能产生严重影响,尤其是对基于Java的系统,notifyAll()(broadcast)似乎已经被确立为推荐模式;众所周知,将这些不必要的notifyAll()替换为notify()(signal)能获得可观的性能收益。

学会验尸

在关于并发的诸多预言中,死锁似乎尤为可怕,已经成为了基于锁的多线程编程中所有困难的化身。这种担心有点奇怪,因为死锁实际上是软件中最简单的异常状态:因为(根据定义)那些陷入死锁的线程停止工作,死锁实现了高效冻结整个系统运转的任务。为了调试死锁,你只需要一个线程列表,线程对应的调用栈,以及一些对系统本身的认识。这些信息包含在了一个对软件开发至关重要的状态快照中,其名字就反映了它在计算机早期的起源:核心转储(core dump)。

调试core dump——验尸——对于实现并行系统的人来说是项基本技能:在高度并行的系统中问题不一定能重现,单个core dump经常是你仅有的调试手段。大多数调试器都支持调试core dump,其中许多还允许用户自定义扩展。我们鼓励从业者们理解他们的调试器对于调试core dump的支持(尤其是对并行程序)并开发针对他们系统的扩展。

设计可组合的系统

针对基于锁的系统,批评者们最令人难堪的说法是,他们认为这些系统不可组合:“锁和条件变量不支持组件化编程。”,一个典型的厚颜无耻的说法,“锁导致没办法通过粘合小程序来构建大型程序”。当然,这种说法是错误的。只需要指出诸如数据库和操作系统这样的基于锁的系统的组合为了更大型的系统,却仍然完全不需要关心底层的锁。

有两种方法可以使基于锁的系统完全可组合,每种都有各自的用武之地。第一种(也最明显),你可以将锁完全封装在子系统中。例如,在并发操作系统中,控制权永远不会在持有内核锁时返回到用户空间;用于实现系统本身的锁完全隐藏在了组成系统接口的系统调用接口后面。更普遍地,只要系统组件间有着清爽简洁的接口,这个模型就能工作:只要控制权不会带着锁返回给调用方,这个子系统就仍然可组合。

第二种(可能反直觉),可以通过不要任何锁来获得并发性和组合性。这种情况下,一定没有全局的子系统状态——子系统状态必须封装进每个实例的状态中,且必须由子系统的使用者来保证这些子系统不会并行访问它们的实例。通过把加锁的责任交给子系统的用户,子系统本身就可以由不同的子系统和不同的上下文并发使用。一个具体的例子是在Solaris内核中广泛使用的AVL树的实现。与任何平衡二叉树一样,这个实现足够复杂,值得组件化,但通过不持有任何全局状态,这个实现可以被不同的子系统并发使用——唯一的限制就是对一个AVL树的操作必须串行化。

在mutex够用时就不要用信号量

信号量是一种通用的同步原语,出自Dijkstra,可被用于实现广泛的行为。用信号量来代替mutex保护临界区听起来可能挺诱惑的,但这两者之间有一个重要的区别:与信号量不同,mutex有所有权语义——锁要么被持有,要么没被持有,且如果被持有了,也知道是谁持有的。而相反,信号量(和它的亲戚条件变量)没有所有权语义:当你等在一个信号量上时,没办法知道哪个线程正在阻塞它。

在用于保护临界区时,这种所有权的缺失会导致一些问题。首先,没办法把被阻塞线程的调度优先级传给处于临界区中的线程。这种传递调度优先级的能力——优先级继承——在实时系统中非常关键,在缺少其它协议时,基于信号量的系统非常容易发生优先级反转。第二个因所有权缺失而导致的问题是,它剥夺了系统断言其自身的能力。例如,追踪所有权时,实现了线程阻塞的机器就可以检测到诸如死锁和递归获取锁等问题,就可以在检测到时触发严重的错误(以及重要的core dump)。最后,所有权的缺失导致调试更加麻烦。在多线程系统中一个常见问题是锁在某些错误的返回路径上没被放掉。追踪所有权时,你至少能有上个(犯错的)所有者的确凿证据——这就是没有正确放锁的路径的线索。没有所有权的话,你就没有线索,只能盯着代码、天花板、或是前方发呆。

以上不是说不应该用信号量(事实上,有些问题特别适合于信号量的语义),只是在mutex就够用时就不应该用信号量了。

考虑用内存退休法来实现哈希表的按桶加锁

哈希表是性能关键的系统软件中常见的数据结构,有时必须要被并行访问。这种情况下,为每个哈希桶增加一把锁,在读者或写者遍历这个桶时加锁,看起来很直接。但问题是在改变表大小时:动态调整哈希表大小是其高效操作的关键,而调整大小就意味着改变包含哈希表的内存。即,在调整大小时指向哈希表的指针必须也改变——但我们不希望查找哈希表前先拿一把全局锁来决定当前的哈希表是哪个!

这个问题有几种解法,但一个(相对)直接的解法是不要释放旧的哈希表关联的内存,而是令其退休。在调整大小时,要拿到所有桶的锁(用定义良好的顺序来避免死锁),然后分配新的哈希表,旧哈希表的内容重新哈希后插入到新表中。这之后,旧表不会被释放,而是放到一个旧哈希表的队列中。之后查找操作需要做轻微改动以保证操作正确:首先拿到对应桶的锁,然后检查当前哈希表的指针,并与刚刚用于确定桶的哈希表指针做对比。如果哈希表被改过了(即发生了resize),它就必须放掉锁重来一次查找(会获取新表中正确的桶的锁)。

实现中有一些微妙的问题——哈希表的指针必须被声明为volatile,以及哈希表的大小必须保存在表中——但考虑到替代方案,其实现复杂度并不高,且(假设哈希表在resize时数量翻倍)内存开销也只是原来的两倍。举一个生产代码的例子,读者可以看Solaris中文件描述符锁的实现,可以在网上搜索“flist_grow”找到其源代码。

警惕伪共享

有多种不同的协议来在有缓存的多处理器系统中保持内存一致性。通常,这些协议规定,对于一个指定的内存线,只有一块缓存可以处于脏状态。如果另一块缓存想要写这个脏内存线,它必须先从之前的拥有缓存中读到并持有这个脏内存线。用于一致性(一致性粒度)的线的大小对并行软件有着非常重要的影响:因为同时只有一块缓存可以拥有一条内存线,你会希望避免这种场景,一个内存线中有两个(或多个)小的、不相交的数据结构,且会被不同的缓存并行访问。这种场景——称为伪共享——会导致其它方面可扩展的软件在扩展性上达不到最优。实践中最容易出这种问题的场景是当有人试图用一个锁的数组来减少争抢时:一个锁结构的大小通常不会超过一两个指针的大小,这通常远小于一致性粒度(通常在64字节上下)。不同的CPU获取不同的锁因此可能争抢相同的缓存线。

很难动态发现伪共享:不只需要有总线分析器,还要有方法将总线的物理地址翻译为对软件有意义的虚拟地址,再基于此找到真正导致伪共享的结构。(整个过程非常费力且容易犯错,我们曾经试验过——有一些成功——用静态方法检查伪共享。)幸运的是,伪共享很少是系统扩展性的最大障碍,在多核系统(缓存更可能被不同CPU共享)中这甚至不是个问题。但这仍然是从业者需要警惕的问题,尤其是在创建希望被并行访问的数组时。(此时数组元素应该被填充为缓存线大小的整数倍。)

考虑用非阻塞的加锁来监控争抢

许多同步原语有着不同的入口点,以指定这种同步原语不可用时的不同行为:默认入口点通常会阻塞,而另一个入口点则会返回一个错误码。第二种变体有很多使用方式,但一个尤为有趣的方式是来监控它自身的争抢:当试图获取同步原语失败了,子系统就可以知道这里有争抢了。如果子系统有办法动态减少争抢,这种方法就特别有用了。例如,Solaris内核的内存分配器有每个CPU缓存的内存缓冲区。当某个CPU耗尽了它对应的缓存时,它必须从一个全局的池子里获取一组新的缓冲区。这种情况下,代码不是简单的加锁,而是先尝试加锁,在失败时增加一个counter(再通过阻塞的入口点拿锁)。如果counter达到了一个预设的阈值,每个CPU的缓存大小会增加,因此动态减少争抢。

当重新加锁时,考虑用版本号来检测状态变化

当锁的顺序变得复杂时,有时你会需要先放一把锁,加另一个,再重新加第一个。这可以变得很复杂,因为第一个锁保护的状态可以在锁被放掉期间发生变化——而重新核实状态可能会消耗巨大、低效、甚至不可能。在这些情况下,考虑在数据结构中加个版本号;当数据结构发生变化时,就产生一个版本号。在放锁和重新拿锁的地方,必须在放锁前缓存版本号,在重新拿锁后检查版本号:如果相同,数据结构在放锁期间就没有变化,就可以继续处理;如果版本号变了,就意味着状态变了,需要相应处理(例如重新尝试更重的操作)。

只在必须的时候使用wait-free或lock-free结构

在我们的职业生涯中,我们都曾在生产代码中实现过wait-free或lock-free结构,但我们只在因为正确性的原因而没办法加锁时才这么做。例子包括锁系统本身,跨中断级别的子系统,以及动态插装设施。这些受限的场景是例外,不是规则;在正常场景中,应避免使用wait-free和lock-free数据结构,因为它们的错误方式很野蛮(活锁要比死锁难调试得多),它们导致的复杂度上升和维护负担也很明显,且它们在性能上的收益通常为零。

准备接受成功的喜悦和失败的痛苦

令一个系统可扩展可以令人沮丧:直到所有扩展性的障碍都被移除掉之前系统都不会体现出可扩展,但通常没办法知道当前的障碍是不是最后一个。移除最后一个障碍令人难以置信的满足:随着这种变化,吞吐量最终会像通过一个打开的水闸一样在系统中涌出。而相反,令人心碎的是,你工作在分解复杂的锁上,最后发现虽然它是扩展性的障碍,但它仅仅是隐藏了另一个障碍,移除它只提高了一点点性能——可能一点都没有。最令人沮丧的是,你必须回到系统中收集数据:系统不能扩展是因为误解了障碍,还是因为遇到了新障碍?如果是后者,你会安慰于知道你的工作对获得扩展性是必要的——尽管不足够——以及有一天系统的吞吐会喷薄而出的荣耀还在等着你。

并发的自助餐

大家公认,写多线程代码很难:尽管我们试图阐明多年来的一些经验教训,但无论如何,它仍然很难。有些人变得对这种困难念念不忘,将即将到来的多核计算视为软件业的灾难。这种担心毫无理由,因为它忽视了事实上只有少数软件工程师真的需要写多线程代码:对于大多数人,可以站在已经实现高度并行的子系统的肩膀上来获得并发能力。实现数据库或操作系统或虚拟机的从业者仍将需要关注写多线程代码的细节,但对于其它每个人,面临的挑战不是如何实现这些组件,而是如何最好地使用它们来交付可扩展的系统。虽然午餐可能不是完全免费的,但实际上你可以随便吃,吃到饱——自助餐开着呢!