预测是非常困难的,尤其是关于未来的。
尼尔斯·玻尔
本章呈现了一些关于并行编程未来的相互冲突的愿景。这些愿景中哪一个会成为现实尚不清楚,事实上,甚至不清楚它们是否会实现。然而,这些愿景都很重要,因为每个愿景都有其忠实的支持者,如果足够多的人对某件事深信不疑,你将不得不面对它对其支持者的思想、言语和行为产生的影响。此外,一个或多个这样的愿景最终将会实现。但大多数都是虚假的。分辨出哪些是哪些,你就会变得富有[Spi77]!
因此,以下章节将概述事务内存、硬件事务内存、回归测试中的形式验证以及并行函数编程。但首先,我们将讲述一个来自21世纪初的关于预测的警示故事。
他身后有一个伟大的未来。
大卫·马兰尼斯
多年过去,透过多年的经验之眼,一切似乎都显得如此简单和纯真。而21世纪初,在很大程度上,人们对于摩尔定律即将失效、无法继续提供当时传统的CPU时钟频率提升这一前景还抱有天真。当然,偶尔也会有关于技术极限的警告,但这些警告已经持续了几十年。鉴于此,请考虑以下情景:
1.单处理器Uber Alles(图17.1),2.多线程狂热(图17.2),
3.更多相同(图17.3),以及
4.碰撞假人撞击记忆墙(图17.4)。
5.惊人的加速器(图17.5)。
以下各节将介绍这些情形。
正如在2004年所述[McK04]:
在这种情况下,CPU时钟速率的摩尔定律增长和横向扩展计算的持续进步使得SMP系统变得无关紧要。因此,这种情况被称为“单处理器至上”,字面意思是单处理器至高无上。
这些单处理器系统仅受指令开销的影响,因为内存屏障、缓存抖动和争用不会影响单CPU系统。在这种情况下,RCU仅适用于特定应用,如与NMI的交互。尚不清楚缺乏RCU的操作系统是否会认为采用它有必要,尽管已经实现RCU的操作系统可能
会继续这样做。
然而,最近多线程cpu的发展似乎表明这种情况不太可能。
确实不太可能!但更大的软件社区不愿意接受他们需要拥抱并行计算的事实,因此过了好一阵子,这个社区才意识到摩尔定律带来的CPU核心频率提升的“免费午餐”已经彻底结束了。永远不要忘记:信念是一种情感,不一定是理性技术思考的结果!
同样来自2004年[McK04]:
一种较为温和的单处理器Uber Alles变体采用了硬件多线程技术,事实上,多线程CPU现在已成为许多台式机和笔记本电脑系统的标准配置。最激进的多线程CPU共享所有级别的缓存层次结构,从而消除了CPU到CPU的内存延迟,进而大大减少了传统同步机制带来的性能损失。然而,多线程CPU仍然会因竞争和由内存屏障引起的流水线停滞而产生开销。此外,由于所有硬件线程共享所有级别的缓存,给定硬件线程可用的缓存量仅为等效单线程CPU的一小部分,这可能会降低具有大缓存需求的应用程序的性能。还有一种可能性是,有限的缓存可用性会导致基于RCU的算法因宽限期引起的额外内存消耗而产生性能损失。研究这一可能性将是未来的工作。
然而,为了避免性能下降,许多多线程CPU和多CPU芯片会根据硬件线程至少部分地划分缓存层级。这增加了每个硬件线程可用的缓存量,但同时也重新引入了从一个硬件线程传递到另一个硬件线程时的内存延迟。
我们都知道这个故事的结局,即在一个芯片上插入多个多线程核心,每个核心连接到一个插槽,针对每核活动线程数较少的情况进行了不同程度的优化。问题在于未来的共享内存系统是否总是能适应单个插槽。
再次引用2004年文献[McK04]:
More-of-the-Same方案假设内存延迟比率将保持在今天的大致水平。
这一情景实际上代表了一种变化,因为要实现更多的相同效果,互连性能必须跟上摩尔定律核心CPU性能的增长。在这种情况下,由于流水线停顿、内存延迟和争用导致的开销仍然显著,而RCU仍保持其目前的高度适用性。
而变化是摩尔定律仍在提供的不断增加的集成水平。但从长远来看,会是更多的CPU/芯片?还是更多的I/O、缓存和内存?
服务器似乎选择了前者,而芯片上的嵌入式系统(SoC)继续选择后者。
还有2004年的一段话[McK04]:
如果图17.6中显示的内存延迟趋势继续存在,那么相对于指令执行开销,内存延迟将继续增长。
像Linux这样的系统,如果大量使用RCU,将会发现额外使用RCU是有利可图的,如图17.7所示。从该图可以看出,如果RCU被大量使用,增加内存延迟比会使RCU相对于其他同步机制的优势逐渐增大。相反,对于少量使用RCU的系统,需要越来越高的读取强度才能使RCU发挥作用,如图17.8所示。从该图可以看出,如果RCU使用较少,增加内存延迟比会使RCU相对于其他同步机制的优势逐渐减弱。由于在高负载下观察到Linux每个宽限期有超过1,600次回调[SM04b],可以认为Linux属于前者。
一方面,这段文字未能预见RCU在更新强度显著的工作负载中可能遇到的缓存热问题,部分原因是当时认为RCU不太可能用于此类工作负载。然而,在实际应用中,SLAB_TYPESAFE_BY_RCU在多个情况下被用于解决这些缓存热问题,序列锁定也是如此。另一方面,这段文字也未能预见RCU会被用于减少调度延迟或提高安全性。
本书生成的大部分数据是在一个八插槽系统上收集的,每个插槽有28个核心,每个核心有两个硬件线程,总计448个硬件线程。空闲系统的内存延迟小于一微秒,这并不比2004年类似规模系统的延迟差。有人声称这些延迟接近一微秒只是因为x86 CPU家族相对较强的内存排序机制,但这一特定论点可能还需要一段时间才能得到解决。
硬件加速器的潜力在2004年并不像2021年那样清晰,因此本节没有引用。然而,2020年11月的Top 500榜单[MDSS20]中包含了许多加速器,所以可以说这一部分反映的是现状而非未来。同样的情况也适用于前面的大多数部分。
硬件加速器被用于许多其他用途,包括加密、压缩和机器学习。
简而言之,要警惕预测,包括本章其余部分的预测。
一切都应该尽可能简单,但不能更简单。
路易斯·祖科夫斯基笔下的阿尔伯特·爱因斯坦
使用数据库外事务的想法可以追溯到几十年前[Lom77,Kni86,HM93],数据库与非数据库事务之间的关键区别在于,非数据库事务省略了定义数据库事务的“ACID”1属性中的“D”。支持基于内存的事务,或称为“事务性内存”(TM)的概念则更为近期[HM93],但遗憾的是,尽管提出了其他一些类似建议[SSHT93],商品硬件中对此类事务的支持并未立即实现。不久之后,沙维特和图伊图提出了一种仅软件实现的事务性内存(STM),能够在商品硬件上运行,尽管存在内存排序问题[ST95]。这一提议沉寂多年,可能是因为研究界的注意力被非阻塞同步所吸引(见第14.2节)。
但到世纪之交,TM开始受到更多关注[MT01,RG01],到本世纪中叶,人们的兴趣水平只能被称为“炽热的”[Her05,Gro07],只有少数人持谨慎态度[BLM05,MMW07]。
TM的基本思想是原子地执行一段代码,使得其他线程看不到中间状态。因此,TM的语义可以通过简单地用递归获取和释放全局锁来实现每个事务,尽管性能和可扩展性极差。无论是硬件还是软件实现,TM实现中固有的大部分复杂性在于高效检测并发事务何时可以安全并行运行。由于这种检测是动态进行的,冲突的事务可以被中止或“回滚”,在某些实现中,这种失败模式对程序员是可见的。
由于事务回滚随着事务规模的减小而变得越来越不可能,TM对于基于内存的小型操作可能非常有吸引力,例如用于栈、队列、哈希表和搜索树的链表操作。然而,目前要为大型事务,特别是包含非内存操作如输入输出和进程创建的事务,提供理由则困难得多。接下来的部分将探讨“事务内存无处不在”这一宏伟愿景当前面临的挑战[McK09b]。第17.2.1节考察了与外部世界交互所面临的挑战,第17.2.2looks节讨论了与进程修改的交互。
原始语句,第17.2.3节探讨了与其他同步原始语句的交互,最后是第17.2.4closes节的一些讨论。
用唐纳德·克努斯的智慧之言:
许多计算机用户觉得输入和输出实际上不是“真正的编程”的一部分,它们仅仅是(不幸的是)为了将信息输入和输出到机器中而必须做的事情。
无论我们是否相信输入和输出是“真正的编程”,事实是软件绝对必须处理外部世界。因此,本节对事务内存的外部世界能力进行批评,重点是I/O操作、时间延迟和持久存储。
17.2.1.1输入/输出操作
可以在基于锁的关键区中执行I/O操作,同时持有危险指针,在序列锁定的读侧关键区中执行,也可以在用户空间RCU读侧关键区中执行,甚至必要时可以同时进行。当你尝试在一个事务内执行I/O操作时会发生什么?
底层问题在于交易可能会因冲突而回滚。大致来说,这意味着任何给定交易中的所有操作都必须可撤销,即执行两次的操作与只执行一次的效果相同。不幸的是,I/O通常是最典型的不可撤销操作,这使得将一般的I/O操作包含在交易中变得困难。事实上,一般I/O是不可撤销的:一旦按下启动核弹头的按钮,就无法回头了。
以下是处理事务内I/O的一些选项:
1.限制事务中的I/O操作,使用内存缓冲区进行缓冲I/O。这些缓冲区可以像其他任何内存位置一样包含在事务中。这似乎是首选机制,在许多常见情况下,如流I/O和大容量存储I/O,它确实表现良好。然而,在多个进程将多条记录导向输出流合并到单个文件时,需要特殊处理,例如使用fopen()的“a+”选项或open()的O_APPEND标志。此外,正如将在下一节中看到的,常见的网络操作无法通过缓冲来处理。
2.禁止在事务中执行I/O,这样任何尝试执行I/O操作的行为都会使包含该操作的事务(以及可能的多个嵌套事务)中止。这种方法似乎是针对非缓冲I/O的常规TM方法,但要求TM与其他能够容忍I/O的同步原语进行互操作。
3.禁止在事务中进行输入/输出,但请让编译器协助执行此禁令。
4.仅允许在任何给定时间进行一个特殊的不可撤销事务[SMS08],从而允许不可撤销事务包含I/O操作。2这种方法可以实现
总体而言,这种方法严重限制了I/O操作的可扩展性和性能。鉴于可扩展性和性能是并行处理的一等目标,这种做法的通用性似乎有些自我局限。更糟糕的是,使用不可撤销性来容忍I/O操作似乎极大地限制了手动事务回滚操作的使用。最后,如果存在一个不可撤销的事务正在操作某个数据项,那么任何其他操作该相同数据项的事务都不能具有非阻塞语义。
5.创建新的硬件和协议,以便将I/O操作拉入事务性基底。在输入操作的情况下,硬件需要正确预测操作的结果,并且如果预测失败,则中止事务。
输入/输出操作是TM的一个众所周知的弱点,而且尚不清楚支持事务中的输入/输出问题是否有一个合理的通用解决方案,至少如果“合理”包括可用的性能和可伸缩性的话。然而,继续投入时间和精力解决这个问题可能会产生更多的进展。
17.2.1.2 RPC操作
可以在基于锁的关键区中执行RPC,同时持有危险指针,在序列锁定的读侧关键区中执行,也可以在用户空间RCU读侧关键区中执行,甚至必要时可以同时进行。当你尝试在一个事务中执行RPC时会发生什么?
如果RPC请求及其响应都包含在事务中,并且事务的某些部分依赖于响应返回的结果,那么就无法使用缓冲I/O时可以使用的内存缓冲技巧。任何尝试采用这种缓冲方法都会导致事务死锁,因为请求无法传输,直到事务被保证成功为止,但事务的成功可能要等到收到响应后才能确定,如下例所示:
begin_trans(); rpc_request() ; i = rpc_response(); a[i]++; end_trans(); |
事务的内存占用量在接收到RPC响应后才能确定,在无法确定事务的内存占用量之前,无法判断该事务是否可以提交。唯一符合事务语义的操作是无条件中止事务,这至少可以说是毫无帮助的。
以下是TM可选择的一些选项:
1.禁止在事务中执行RPC,以防止任何尝试执行RPC操作的行为
中止包围的事务(也许还有多个嵌套事务)。Alter-
首先,让编译器强制执行无RPC事务。这种方法可以
2.仅允许一个特殊的不可撤销事务[SMS08]在任何给定时间进行,从而允许不可撤销事务包含RPC操作。这通常可行,但严重限制了RPC操作的可扩展性和性能。鉴于可扩展性和性能是并行处理的一等目标,这种方法的通用性似乎有些自我限制。此外,使用不可撤销事务来允许RPC操作会限制一旦RPC操作开始后手动事务回滚的操作。最后,如果有不可撤销事务正在操作某个数据项,那么其他任何操作该相同数据项的事务都必须具有阻塞语义。
3.识别那些在收到RPC响应之前就能确定交易成功的特殊情况,并在发送RPC请求前自动将其转换为不可撤销的交易。当然,如果多个并发交易以这种方式尝试RPC调用,可能需要回滚除一个之外的所有交易,这会导致性能和可扩展性的下降。然而,对于以RPC结束的长时间运行交易而言,这种方法仍然有价值。此方法仍需限制手动交易中止操作。
4.确定RPC响应可能移出事务的特殊情况,然后使用类似于缓冲I/O所用的技术继续执行。
5.扩展事务底层架构,包括RPC服务器及其客户端。理论上这是可行的,分布式数据库已经证明了这一点。然而,考虑到基于内存的事务处理没有慢速磁盘驱动器可以隐藏延迟,分布式数据库技术是否能满足所需的性能和可扩展性要求尚不清楚。当然,随着固态硬盘的出现,数据库可能需要重新设计其延迟隐藏方法。
如前一节所述,输入/输出是TM的已知弱点,而RPC只是I/O的一个特别有问题的情况。
17.2.1.3时间延迟
一个重要的特例是事务访问与额外事务访问之间的交互涉及事务内的显式时间延迟。当然,事务内的时间延迟与TM的原子性属性相悖,但这种特性可以说是弱原子性的核心所在。此外,正确地与内存映射I/O交互有时需要精心控制的时间,而应用程序通常会出于各种目的使用时间延迟。最后,在基于锁的关键区段中,可以在持有危险指针的情况下执行时间延迟;在序列锁定读侧关键区段中执行时间延迟;在用户空间RCU读侧关键区段中执行时间延迟,甚至可以在必要时同时进行。这样做可能从竞争或可扩展性的角度来看并不明智,但这样做并不会引发任何根本性的概念问题。
那么,TM对于事务中的时间延迟能做些什么呢?
1.忽略事务中的时间延迟。这看起来很优雅,但像许多其他“优雅”的解决方案一样,在与旧代码接触时无法生存。这种代码在关键部分很可能有重要的时间延迟,在被事务化时会失败。
2.遇到延迟操作时中止事务。这很有吸引力,但不幸的是,自动检测延迟操作并不总是可能的。这是在执行关键计算的紧致循环,还是仅仅在等待时间流逝?
3.要求编译器禁止事务中的时间延迟。
4.让时间延迟正常执行。不幸的是,一些TM实现只在提交时发布修改,这可能会破坏时间延迟的目的。
不清楚是否存在唯一正确的答案。具有弱原子性的TM实现,如果在事务中立即发布更改(并在中止时回滚这些更改),可能更适合最后一种选择。即使在这种情况下,事务另一端的代码(或可能是硬件)也可能需要大幅重新设计,以容忍中止的事务。这种重新设计的需求会使将事务内存应用于遗留代码变得更加困难。
17.2.1.4持久性
有许多不同类型的锁定原语。一个有趣的区别是持久性,换句话说,锁是否可以独立于使用锁的进程的地址空间存在。
非持久锁包括pthread_mutex_lock()、pthread_rwlock_ rdlock()和大多数内核级别的锁定原语。如果实现非持久锁的数据结构中的内存位置消失,那么该锁也会随之消失。对于pthread_mutex_lock()的典型使用情况,这意味着当进程退出时,其所有锁都会消失。这一特性可以被利用来简化程序关闭时的锁清理过程,但同时也使得不相关的应用程序更难共享锁,因为这种共享需要应用程序共享内存。
持久锁有助于避免在不相关的应用程序之间共享内存。持久锁定API包括flock系列、lockf()、System V信号量或O_CREAT标志的open()。这些持久API可用于保护跨越多个应用程序运行的大规模操作,在O_CREAT的情况下甚至能经受住操作系统重启。如有需要,通过分布式锁管理器和分布式文件系统,锁甚至可以跨越多台计算机系统,并且能够在任何或所有这些计算机系统的重启中持续存在。
任何应用程序都可以使用持久锁,包括用多种语言和软件环境编写的程序。事实上,一个用C语言编写的程序可以获取一个持久锁,而另一个用Python语言编写的程序可以释放这个持久锁。
如何为TM提供类似的持久性功能?
1.将持久性事务限制在专门设计来支持它们的特殊用途环境中,例如SQL。鉴于数据库系统已有几十年的历史,这显然可行,但不能提供与持久锁相同的灵活性。
2.使用某些存储设备和/或文件系统提供的快照功能。
不幸的是,它不处理网络通信,也不处理对不提供快照功能的设备的I/O,例如内存棒。
3.建造一台时间机器。
4.通过使用现有的持久性设施来完全避免问题,大概会避免在事务中使用这些设施。
当然,之所以称之为事务内存,应该让我们有所顾虑,因为名称本身与持久化事务的概念相冲突。然而,考虑这种可能性是值得的,因为它是一个重要的测试案例,可以探测事务内存固有的局限性。
进程不是永恒的:它们被创建和销毁,其内存映射被修改,它们与动态库链接,并且它们被调试。这些部分将探讨事务性内存如何处理不断变化的执行环境。
17.2.2.1多线程事务
在持有锁的情况下创建进程和线程是完全合法的,同样地,在序列锁定读侧临界区和用户空间RCU读侧临界区内持有危险指针时也是如此,必要时甚至可以同时进行。这不仅合法,而且非常简单,从以下代码片段可以看出:
pthread_mutex_lock ( ...); 对于(i = 0;i < ncpus;i++) pthread_create(&tid[i],.. .);for(i = 0;i < ncpus;i++) pthread_join(tid[i],.. .);pthread_mutex_unlock(.. .); |
这段伪代码片段使用`pthread_create()`每个CPU周围生成一个线程,然后使用`pthread_join()`等待每个线程完成,整个过程都在`pthread_mutex_lock()`的保护下进行。其效果是并行执行基于锁的临界区,也可以通过`fork()`和`wait()`实现类似的效果。当然,为了抵消线程生成开销,临界区需要相当大,但在生产软件中有很多大型临界区的例子。
TM对事务中的线程生成会做些什么?
1.声明pthread_create()在事务中是非法的,最好通过中止事务来实现。或者,让编译器强制执行pthread_创建无()事务。
2.允许pthread_create()在一个事务中执行,但只有父线程被视为该事务的一部分。这种方法似乎与现有的和假设的TM实现相当兼容,但对于不谨慎的人来说似乎是一个陷阱。这种方法还引发了进一步的问题,例如如何处理冲突的子线程访问。
3.将pthread_create()转换为函数调用。这种方法虽然有吸引力,但也有其不便之处,因为它无法处理子线程之间相互通信的常见情况。此外,它也不允许事务主体的并发执行。
4.扩展事务以覆盖父线程和所有子线程。这种方法引发了关于冲突访问本质的有趣问题,因为假设父线程和子线程可以相互冲突,但不能与其他线程冲突。同样引人关注的是,如果父线程在提交事务前没有等待其子线程会发生什么。更有趣的是,如果父线程根据参与事务的变量值有条件地执行pthread_join()会怎样?对于锁定情况,这些问题的答案相对简单明了。而对于事务处理,答案留给读者自行思考。
鉴于事务处理在数据库领域中并行执行已司空见惯,当前的事务管理提案未能提供相应的支持或许令人惊讶。另一方面,上述示例展示了较为复杂的锁定机制,这在简单的教科书案例中并不常见,因此其缺失也情有可原。尽管如此,一些研究人员正在利用事务自动并行化代码[RKM+ 10],并且有传言称其他事务管理研究者也在探索事务内的分叉/合并并行性,因此这一主题可能很快会得到更深入的研究。
17.2.2.2exec()系统调用
可以在基于锁的关键区中执行exec()系统调用,同时持有危险指针,在序列锁定的读侧关键区中,以及在用户空间RCU读侧关键区中,甚至必要时可以同时进行。具体语义取决于原语类型。
对于非持久性原语(包括pthread_mutex_lock()、pthread_rwlock_rdlock()和用户空间RCU),如果exec()成功,整个地址空间将消失,同时持有的任何锁也会随之失效。当然,如果exec()失败,地址空间仍然存在,因此相关的锁也会继续有效。这可能有点奇怪,但定义明确。
另一方面,持久性原语(包括flock家族、lockf()、System V信号量和用于打开的O_CREAT flag to open())无论exec()成功还是失败都会存活,因此被exec()的程序可能会释放它们。
当您尝试在事务中执行exec()系统调用时,会发生什么情况?
1.在事务中不允许执行exec(),这样当遇到exec()时,包含的事务就会中止。这一点是明确的,但显然需要使用非TM同步原语与exec()配合使用。
2.在事务中不允许执行exec(),由编译器强制执行此禁止。
C++中有一个TM的草案规范,它采用了这种方法,允许函数用transaction_safe和transaction_unsafe进行装饰
属性。4这种方法比在运行时中止事务有一些优点,但是同样需要使用非TM同步原语与exec()结合使用。一个缺点是需要对许多库函数进行transaction_safe和transaction_unsafe属性的装饰。
3.以类似于非持久锁定原语的方式处理事务,使得如果exec()失败,事务仍然存活;如果exec()成功,则自动提交。仅部分受事务影响的变量驻留在mmap()的内存中(因此可以在成功的exec()系统调用后存活)的情况留给读者自行思考。
4.如果exec()系统调用将会成功,那么就中止事务(以及exec()系统调用),但如果exec()系统调用会失败,则允许事务继续。这在某种意义上是“正确”的方法,但需要大量的工作才能得到一个相当不令人满意的结果。
exec()系统调用或许是通用图灵机应用障碍中最奇特的例子,因为尚不清楚哪种方法是合理的,有些人可能认为这只是现实生活中与exec()交互风险的反映。话虽如此,禁止在事务中使用exec()的两种选项可能是最合乎逻辑的选择。
类似的错误也出现在exit()和kill()系统调用中,以及会导致事务退出的longjmp()或异常。(long jmp()或异常是从哪里来的?)
17.2.2.3动态链接和加载
基于锁的关键区、持有危险指针的代码、顺序锁定的读侧关键区以及用户空间RCU的读侧关键区(单独或组合)可以合法地包含调用动态链接和加载函数的代码,包括C/C++共享库和Java类库。当然,这些库中的代码在编译时是无法预知的。那么,如果在一个事务中调用了动态加载的函数会发生什么?
这个问题分为两部分:(a)如何在事务中动态链接并加载一个函数,以及(b)对于该函数内的代码不可知性该如何处理?公平地说,(b)项对锁定和用户空间-RCU也提出了一些挑战,至少理论上是这样。例如,动态链接的函数可能会导致锁定时出现死锁,或者(错误地)将用户空间-RCU读侧临界区引入静止状态。不同之处在于,虽然锁定和用户空间-RCU临界区允许的操作类别已经非常明确,但在TM的情况下似乎仍存在相当大的不确定性。事实上,不同的TM实现似乎有不同的限制。
那么,TM对于动态链接和加载的库函数可以做些什么呢?关于实际加载代码的选项(a)部分包括以下内容:
1.以类似于处理页面错误的方式处理动态链接和加载,以便加载并链接函数,可能在过程中中止事务。如果事务被中止,则重试将发现该函数已经存在,因此可以预期事务正常进行。
2.禁止在事务处理中动态链接和加载函数。
对于选项(b),无法检测尚未加载函数中的TM不友好操作的可能性包括以下内容:
1.只需执行代码:如果函数中存在任何与TM不兼容的操作,直接中止事务。不幸的是,这种方法使得编译器无法判断一组事务是否可以安全组合。一种允许组合性的方法是不可撤销事务,然而,当前实现仅允许在任意给定时间进行单个不可撤销事务,这会严重限制性能和可扩展性。不可撤销事务还限制了手动事务中止操作的使用。最后,如果有不可撤销事务正在操作某个数据项,其他任何操作该相同数据项的事务都不能具有非阻塞语义。
2.装饰函数声明,指示哪些函数是TM友好的。
这些装饰可以通过编译器的类型系统来强制执行。当然,对于许多语言而言,这需要提出、标准化并实现语言扩展,伴随着相应的时间延迟,以及对大量原本无关的库函数进行相应的装饰。尽管如此,标准化工作已经在进行中[ATS09]。
3.如上所述,禁止在事务中动态链接和加载函数。
输入输出操作当然是TM的一个已知弱点,动态链接和加载可以被视为输入输出的另一种特殊情况。然而,TM的支持者要么解决这个问题,要么接受一个世界,在这个世界里,TM只是并行程序员工具箱中的众多工具之一。(公平地说,许多TM的支持者早已接受了包含更多工具的世界。)
17.2.2.4内存映射操作
在基于锁的关键区中执行内存映射操作(包括mmap()、shmat()和munmap()[Gro01]),同时持有危险指针,在序列锁定读侧关键区中,以及在用户空间RCU读侧关键区中,甚至同时执行这些操作都是完全合法的。当你尝试在一个事务内执行这样的操作时会发生什么?更具体地说,如果被重映射的内存区域包含当前线程事务中的某些变量,会发生什么?如果这个内存区域包含其他线程事务中的变量又会怎样?
由于大多数锁定原语不定义重映射其锁定变量的结果,因此没有必要考虑TM系统元数据被重新映射的情况。
以下是一些TM内存映射选项:
1.事务中的内存重映射是非法的,这将导致所有包含的事务被中止。这在一定程度上简化了事情,但也要求TM与同步原语互操作,这些同步原语能够容忍在它们的关键部分内进行重映射。
2.事务中内存重映射是非法的,编译器负责强制执行此禁令。
3.内存映射在事务中是合法的,但是会终止该区域映射的所有其他包含变量的事务。
4.内存映射在事务中是合法的,但如果被映射的区域与当前事务的足迹重叠,则映射操作将失败。
5.所有内存映射操作,无论是在事务内部还是外部,都检查被映射的区域是否与系统中所有事务的内存占用空间相重叠。如果存在重叠,则内存映射操作失败。
6.系统中任何事务的内存占用范围重叠的内存映射操作的效果由TM冲突管理器决定,它可能会动态地确定是否要使内存映射操作失败或中止任何冲突的事务。
值得注意的是,munmap()将相关内存区域未映射,这可能具有其他有趣的含义。
17.2.2.5调试
通常的调试操作,如断点,在基于锁的关键区和用户空间-RCU读侧关键区中都能正常工作。然而,在最初的事务内存硬件实现[DLMN09]中,事务内的异常会终止该事务,这意味着断点会终止所有包含的事务。
那么如何调试事务呢?
1.在包含断点的事务中使用软件仿真技术。当然,当在任何事务范围内设置断点时,可能需要对所有事务进行仿真。如果运行时系统无法确定某个断点是否位于事务范围内,则为了安全起见,可能需要对所有事务进行仿真。然而,这种方法可能会带来显著的开销,从而掩盖正在追踪的错误。
2.仅使用能够处理断点异常的硬件TM实现。不幸的是,截至本文撰写时(2021年3月),所有此类实现都是研究原型。
3.仅使用软件TM实现,(粗略地说)比简单的硬件TM实现更容许异常。当然,软件TM的开销往往高于硬件TM,因此在所有情况下,这种方法可能不可接受。
有理由相信,事务内存相比其他同步机制能够提高生产效率,但如果传统调试技术无法应用于事务中,这些改进很容易丧失。这在新手处理大型事务时尤为明显。相比之下,那些自诩为“顶尖高手”的程序员可能无需使用此类调试工具,尤其是在处理小型事务时。
因此,如果事务内存要向新手程序员兑现其生产率承诺,那么调试问题确实需要解决。
如果事务内存有一天证明它可以满足所有人的需求,它将不再需要与其他任何同步机制交互。在此之前,它需要与那些无法实现其功能或在特定情况下更自然地工作的同步机制合作。以下部分概述了该领域的当前挑战。
17.2.3.1锁定
获取锁时同时持有其他锁是常见的做法,这通常效果很好,至少只要采用众所周知的软件工程技巧来避免死锁。从RCU读侧临界区获取锁也不罕见,因为RCU读侧原语不会参与基于锁的死锁循环,从而缓解了死锁问题。此外,在持有危险指针和序列锁读侧临界区时获取锁也是可行的。但当你试图在一个事务中获取锁时会发生什么?
理论上,答案很简单:只需在事务中操作表示锁的数据结构,一切都能完美解决。实际上,根据TM系统的实现细节,可能会出现一些不明显的问题[VGS08]。这些问题可以解决,但代价是,在事务外部获取锁的开销增加了45 %,而在事务内部获取锁的开销则增加了300 %。虽然对于包含少量锁定的事务程序来说,这些开销可能是可以接受的,但对于希望偶尔使用事务的生产质量基于锁的程序而言,这些开销通常是完全不可接受的。
1.仅使用对锁定友好的TM实现。不幸的是,对锁定不友好的实现具有一些吸引人的特性,包括成功事务的低开销和能够容纳非常大的事务。
2.在向基于锁定的程序引入TM时,仅“在小范围内”使用TM,从而适应对锁定友好的TM实现的限制。
3.完全放弃基于锁定的旧系统,重新实现所有功能以事务形式。这种方法不乏支持者,但需要解决本系列中描述的所有问题。在解决问题期间,竞争同步机制当然也有机会改进。
4.严格地将TM用作基于锁的系统的优化,正如TxLinux [RHP+07]组和许多事务锁省略所做的那样
项目[PD11、Kle14、FIMR16、PMDY20]。这种方法似乎很合理,但仍然保留了锁定设计约束(例如避免死锁的需要)。
5.努力减少锁定基本元素所造成的开销。
可能在TM接口和锁定方面存在问题这一事实让许多人感到意外,这突显了在实际生产软件中尝试新机制和基本组件的必要性。幸运的是,开源的出现意味着现在有大量此类软件可以免费提供给所有人,包括研究人员。
17.2.3.2读写锁定
读取-获取读者-写入锁并同时持有其他锁是常见的做法,只要采用众所周知的软件工程技术来避免死锁,这种方法就有效。在RCU读侧临界区中读取-获取读者-写入锁也是可行的,这样做可以缓解死锁问题,因为RCU读侧原语无法参与基于锁的死锁循环。同时,也可以在持有危险指针和序列锁读侧临界区中获取锁。但当你试图在一个事务内读取-获取读者-写入锁时会发生什么?
不幸的是,直接尝试在一个事务中读取并获取传统的基于计数器的读写锁的做法违背了读写锁的目的。要理解这一点,可以考虑两个事务同时尝试读取并获取同一个读写锁的情况。由于读取操作涉及修改读写锁的数据结构,这将导致冲突,其中一个事务会被回滚。这种行为完全不符合读写锁允许并发读取的目标。
以下是TM可选择的一些选项:
1.使用每CPU或每线程的读写锁定[HW92],这允许给定的CPU(或线程)在获取锁时仅操作本地数据。这样可以避免两个事务同时获取锁时产生的冲突,使它们能够按预期进行。不幸的是,(1)每CPU/线程锁定的写入获取开销可能非常高,(2)每CPU/线程锁定的内存开销可能难以承受,
(3)只有在您能够访问相关源代码时,此转换才可用。其他更新的可扩展读写锁[LLO09]可能会避免一些或所有这些问题。
2.在向基于锁的程序引入TM时,仅“在小范围内”使用TM,从而避免在事务中获取读取-获取读者-写入锁。
3.完全放弃基于锁定的旧系统,重新实现所有功能以事务形式进行。这种方法不乏支持者,但需要解决本系列中描述的所有问题。在这些问题得到解决期间,竞争的同步机制当然也有机会改进。
4.严格地将TM用作基于锁的系统的优化,就像TxLinux [RHP+07]组所做的那样,并且像最近使用TM来省略读写锁的工作[FIMR16]所做的那样。这种方法似乎是合理的,至少在
POWER8 CPU[LGW+15],但保留了锁定设计约束(例如避免死锁的需要)。
当然,将TM与读写锁定结合在一起可能会有其他不明显的问题,就像独占锁定一样。
本节主要讨论RCU。当TM与其他延迟回收机制如引用计数器和危险指针结合时,会出现类似的问题和可能的解决方案。在下面的文本中,特别指出已知的差异。
如第9.5.5节和第9.6.3节所述,引用计数、危险指针和RCU都被大量使用。这意味着任何选择不克服本节中提到的每一个挑战的TM实现都需要与所有这些同步机制干净且高效地互操作。
来自德克萨斯大学奥斯汀分校的TxLinux小组似乎成为了应对RCU/TM互操作性挑战的团队[RHP+07]。由于他们将TM应用于使用RCU的Linux 2.6内核,因此不得不将TM与RCU集成,用TM代替锁定来处理RCU更新。遗憾的是,尽管论文指出RCU实现中的锁(例如rcu_ctrlblk.lock)被转换为事务,但对基于RCU的更新所使用的锁(如dcache_lock)的具体处理方式却只字未提。
最近,迪米特里奥斯·西亚卡瓦拉斯等人将HTM和RCU应用于搜索树[SNGK17,SBN+ 20],克里斯蒂娜·吉安努拉等人使用HTM和RCU对图进行着色[GGK18],而朴成宰等人则利用HTM和RCU优化了NUMA系统上的高竞争锁定[PMDY20]。
需要注意的是,RCU允许读取器和更新器并行运行,进一步使得RCU读取器能够访问正在被更新的数据。当然,无论RCU的性能、可扩展性和实时响应优势如何,这一特性都与TM的原子性属性相悖,尽管POWER8 CPU系列的暂停事务功能[LGW+ 15]使其成为这一规则的例外。
那么,基于TM的更新应该如何与并发的RCU读取器交互呢?以下是一些可能性:
1. RCU的读者会中止并发冲突的TM更新。这实际上是TxLinux项目采取的方法。这种方法确实保留了RCU的语义,同时也保持了RCU的读取端性能、可扩展性和实时响应特性,但不幸的是,它会导致不必要的冲突更新中止。最坏的情况下,一长串RCU读者可能会使所有更新者陷入饥饿状态,理论上可能导致系统挂起。此外,并非所有TM实现都提供了实现此方法所需的强原子性,这是有充分理由的。
2. RCU读取器在运行时会从任何冲突的RCU加载中获取旧(事务前)值。这不仅保留了RCU的语义和性能,还防止了RCU更新饥饿。然而,并非所有TM实现都能及时访问由正在进行的事务暂时更新的变量的旧值。特别是那些基于日志的TM实现,在日志中维护旧值(从而提供出色的TM提交性能),可能不会对此方法感到满意。也许
rcu_dereference()原始方法可以用来允许RCU访问TM实现范围内的旧值,尽管性能可能仍然是一个问题。然而,有一些流行的TM实现已经以这种方式与RCU集成[PW07,HW11,HW14]。
3.如果RCU读取器执行的访问与正在进行的事务冲突,则该RCU访问将被延迟,直到冲突的事务提交或中止。这种方法保留了RCU的语义,但牺牲了RCU的性能和实时响应,尤其是在存在长时间运行的事务时。此外,并非所有TM实现都能延迟冲突访问。然而,对于仅支持小事务的硬件TM实现而言,这种方法似乎非常合理。
4. RCU的读者被转换为事务。这种方法几乎可以保证RCU与任何TM实现兼容,但同时也将TM的回滚强加于RCU的读侧临界区,破坏了RCU的实时响应保证,并且还降低了RCU的读侧性能。此外,在RCU的读侧临界区内包含TM实现无法处理的操作时,这种方法是不可行的。对于危险指针和引用计数器而言,由于没有明确界定的读取代码段概念,这种方法更难以应用。
5.许多使用RCU的更新操作会修改一个指针以发布新的数据结构。在某些情况下,只要事务遵守内存顺序,并且回滚过程使用call_rcu()释放相应的结构,RCU可以安全地看到随后被撤销的事务指针更新。不幸的是,并非所有TM实现都尊重事务内的内存屏障。显然,人们认为由于事务应该是原子性的,因此事务内部访问的顺序不应受到影响。
6.禁止在RCU更新中使用TM。这可以保证工作,但会限制TM的使用。
似乎很可能会发现更多的方法,特别是考虑到用户级RCU和危险指针实现的出现。有趣的是,许多性能更好且扩展性强的STM实现内部都使用了类似RCU的技术[Fra04,FH07,GYW+ 19,KMK+ 19]。
在基于锁的关键区中,完全合法地操作那些在同一锁的关键区内被同时访问甚至修改的变量,一个常见的例子就是统计计数器。同样的情况也适用于RCU读侧关键区,事实上这是常见的情况。
考虑到在生产数据库系统中普遍存在的所谓“脏读”机制,事务管理的支持者对事务外访问给予了极大的关注也就不足为奇了,弱原子性[BLM06]的概念就是一个很好的例子。
以下是一些非交易选项:
1.由于事务外访问而产生的冲突总是会中止事务。这是强原子性。
2.忽略事务外访问引起的冲突,因此只有事务之间的冲突才能中止事务。这是弱原子性。
3.允许事务在特殊情况下执行非事务操作,例如分配内存或与基于锁的关键区交互。
4.生产允许执行某些操作(例如,添加)的硬件扩展
由多个事务同时对单个变量执行。
5.引入弱语义到事务内存中。一种方法是与第17.2.3.3节中描述的RCU结合使用,而格拉莫利和古埃拉乌伊则调查了其他多种弱事务方法[GG14],例如,将大型“弹性”事务限制分割为较小的事务,从而降低冲突概率(尽管性能和可扩展性较差)。或许进一步的经验会表明,某些额外事务访问的使用可以被弱事务所替代。
看起来事务是在真空中构思出来的,无需与其他任何同步机制进行交互。如果真是这样,那么当将事务与非事务访问结合时,产生大量混乱和复杂性也就不足为奇了。但除非事务仅限于对孤立数据结构的小更新,或者被限制在不与庞大的现有并行代码体互动的新程序中,否则如果要在短期内实现大规模的实际影响,事务必须如此结合。
普遍采用TM的障碍导致了以下结论:
1.TM的一个有趣特性是事务可以回滚和重试。这一特性导致了TM在处理不可逆操作时遇到困难,包括未缓冲的I/O、RPC、内存映射操作、时间延迟以及exec()系统调用。此外,这一特性还带来了所有可能失败带来的复杂性,这些复杂性往往以开发人员可见的方式显现出来。
2.另一个有趣的特性是,TM与它保护的数据交织在一起,这一点由Shpeisman等人指出[SATG+09]。这一特性导致了TM在I/O、内存映射操作、跨事务访问和调试断点方面的问题。相比之下,传统的同步原语,如锁和RCU,保持了同步原语与其保护的数据之间的明确分离。
3.TM领域的许多工作人员的既定目标之一是简化大型顺序程序的并行化。因此,通常期望单个事务串行执行,这可能在很大程度上解释了TM在多线程事务方面的问题。
TM研究人员和开发人员应该对这一切做些什么呢?
一种方法是专注于小事务,即关注那些硬件辅助可能比其他同步原语提供显著优势的小型事务,以及有证据表明结合使用TM锁定方法可以提高生产力的小程序[PAT11]。Sun在其Rock研究CPU中采用了小事务的方法[DLMN09]。一些TM研究人员似乎同意这两种“小即是美”的方法[SSHT93],另一些人则对TM寄予更高的期望,还有一些人暗示高期望可能是TM最大的敌人[Att10,第6节]。尽管如此,TM仍有可能应对更大的问题,本节列出了若要实现这一宏伟目标必须解决的一些问题。
当然,所有参与的人都应该把这当作一次学习经验。看来,TM研究者们可以从那些成功地使用传统同步原语构建大型软件系统的实践者那里学到很多东西。
但就目前而言,STM的现状可以用一系列漫画来概括。首先,图17.9显示了STM的愿景。和往常一样,现实有点不同。
如图17.10,17.11和17.12.7所示,更加细致入微。不太夸张的STM回顾也可获得[Duf10a,Duf10b]。
一些商用硬件支持HTM的受限变体,将在下文进行说明。
在自动化之前,确保您的报告系统是合理干净且高效的。否则,您的新计算机只会加速混乱。
罗伯特·汤森
截至2021年,硬件事务内存(HTM)已在多种类型的商用商品计算机系统上使用多年[YHLR13,Mer11,JSG12,Hay20]。本节试图确定HTM在并行程序员工具箱中的位置。
从概念的角度来看,HTM使用处理器缓存和推测执行,使得一组指定的语句(称为“事务”)能够从其他处理器上运行的任何其他事务的角度来看,以原子方式生效。该事务由一个开始事务机器指令启动,并通过一个提交事务机器指令完成。通常还存在一个中止事务机器指令,它会取消推测(就像开始事务指令及其后续所有指令都没有执行一样),并在失败处理程序处开始执行。失败处理程序的位置通常由开始事务指定。
指令,既可以作为显式的失败处理程序地址,也可以通过指令本身设置的条件代码。每个事务都与其他事务原子性地执行。
HTM有许多重要的优点,包括自动动态分区数据结构、减少同步原语缓存缺失以及支持相当多的实用应用程序。
然而,仔细阅读细则总是值得的,HTM也不例外。本节的一个主要观点是,在什么条件下,HTM的好处能够超过其细则中隐藏的复杂性。为此,第17.3.1describes节讨论了HTM的好处,而第17.3.2describes节则探讨了它的弱点。这种方法与之前论文[MMW07,MMTW10]以及前一节所采用的方法相同。
第17.3.3then节描述了HTM在Linux内核(以及许多用户空间应用程序)中使用的同步原语组合方面的弱点。Section17.3.4looks部分讨论了HTM如何最好地融入并行程序员的工具箱,而第17.3.5节列出了可能大幅扩展HTM范围和吸引力的一些事件。最后,第17.3.6presents节是结论性评述。
HTM的主要优势在于(1)避免了其他同步原语常遇到的缓存未命中问题,(2)能够动态地划分数据结构,以及(3)它拥有相当数量的实际应用。我打破传统,没有单独列出易用性,原因有二。首先,易用性应源于HTM的主要优势,本节将重点讨论这些优势。其次,围绕测试编程天赋[Bo06,DBA09,PBCE20]甚至在面试中使用小型编程练习[Bra07]的做法存在相当大的争议。这表明我们实际上并不清楚什么使编程变得容易或困难。因此,本节余下部分将集中讨论上述三项优势。
17.3.1.1避免同步缓存未命中
大多数同步机制都基于由原子指令操作的数据结构。由于这些原子指令通常首先使相关的缓存行被其运行的CPU拥有,因此在同一实例的后续执行中,在其他CPU上将导致缓存未命中。这种通信缓存未命中的问题严重降低了传统同步机制的性能和可扩展性[ABD+ 97,第4.2.3节]。
相比之下,HTM通过使用CPU的缓存来同步,避免了需要单独的同步数据结构和由此产生的缓存未命中。当锁数据结构放置在单独的缓存行中时,HTM的优势最为明显,在这种情况下,将给定的关键区转换为HTM事务可以完全减少该关键区的开销,实现一次缓存未命中。对于常见的短关键区情况,至少在省略锁不与经常写入的变量共享缓存行的情况下,这些节省是非常显著的。
使用某些传统同步机制的主要障碍在于需要静态划分数据结构。有许多数据结构可以轻松划分,最典型的例子是哈希表,每个哈希链构成一个分区。为每个哈希链分配锁,从而可以轻松地将哈希表的操作并行化到特定的链上。同样,数组、基数树、跳跃表等其他几种数据结构的划分也非常简单。
然而,对于许多类型的树和图进行分区相当困难,结果通常非常复杂[Ell80]。尽管可以使用两阶段锁定和哈希锁数组来分区一般数据结构,但其他技术已被证明更为优越[Mil06],相关内容将在第17.3.3节中讨论。鉴于其避免了同步缓存未命中问题,HTM因此成为大型不可分区数据结构的一个非常现实的选择,至少假设更新相对较小的情况下是这样。
17.3.1.3实用价值
HTM的实际价值已在多个硬件平台上得到证明,包括Sun Rock [DLMN09]、Azul Vega [Cli09]、IBM Blue Gene/Q [Mer11]、Intel Haswell TSX [RD12]和IBM System z [JSG12]。
预期的实际效益包括:
1.锁定省略以实现内存数据访问和更新[MT01,RG02]。
2.对大型不可分区数据结构的并发访问和小的随机更新。
然而,HTM也有一些非常真实的缺点,将在下一节中讨论。
HTM的概念非常简单:一组访问和更新内存的操作是原子性的。但是,就像许多简单的想法一样,当您将其应用于现实世界中的实际系统时,会出现一些复杂的情况。这些复杂情况如下:
1.事务大小限制。
2.冲突处理。
3.中止和回退。
6.语义差异。
以下章节将对每种并发症进行描述,随后为总结。
当前HTM实现的事务大小限制源于处理器缓存用于存储受事务影响的数据。尽管这使得特定CPU可以通过在其缓存范围内执行事务,使其对其他CPU显得原子化,但也意味着任何无法适应的事务都无法提交。此外,改变执行上下文的事件,如中断、系统调用、异常、陷阱和上下文切换,要么必须中止该CPU上的任何正在进行的事务,要么由于其他执行上下文的缓存占用而进一步限制事务大小。
当然,现代CPU倾向于拥有大容量缓存,许多事务所需的数据可以轻松地存储在一个兆字节的缓存中。不幸的是,对于缓存而言,仅仅大小并不是决定因素。问题在于,大多数缓存可以被视为硬件实现的哈希表。然而,硬件缓存不会链接它们的桶(通常称为集),而是为每个集提供固定数量的缓存行。给定缓存中每个集提供的元素数量被称为该缓存的关联性。
尽管缓存关联度各不相同,但我正在使用的笔记本电脑中,一级缓存的八路关联度并不罕见。这意味着,如果某个事务需要访问九个缓存行,并且这九个缓存行都映射到同一组,则该事务根本无法完成,更不用说该缓存中可能有多余的兆字节空间了。是的,给定数据结构中的随机选择的数据元素,该事务能够提交的概率相当高,但无法保证[McK11c]。
已经有一些研究工作旨在缓解这一限制。完全关联的受害者缓存可以减轻关联度的约束,但目前对受害者缓存大小存在严格的性能和能效要求。尽管如此,未修改的缓存行的HTM受害者缓存可以非常小,因为它们只需要保留地址:数据本身可以写入内存或由其他缓存影射,而地址本身足以检测到冲突写入[RD12]。
无界事务内存(UTM)方案[AAKL06,MBM+06]使用DRAM作为极其庞大的受害者缓存,但将此类方案集成到生产质量的缓存一致性机制中仍然是一个未解决的问题。此外,使用DRAM作为受害者缓存可能会带来不幸的性能和能效后果,特别是当受害者缓存需要完全关联时。最后,“无界”特性假设所有DRAM都可以用于受害者缓存,而实际上分配给特定CPU的大量但仍固定的DRAM会限制该CPU的事务大小。其他方案结合了硬件和软件事务内存[KCH+06],可以设想使用STM作为HTM的备用机制。
然而,据我所知,除了简化TM读集的表示之外,目前可用的系统没有实现这些研究想法中的任何一个,也许是有充分的理由。
17.3.2.2冲突处理
第一个复杂性是冲突的可能性。例如,假设事务A和B定义如下:
交易 x = 1; y = 3; | A | 交易 y = 2; x = 4; | B |
假设每个事务在其自身的处理器上并发执行。如果事务A同时向x存储数据,而事务B向y存储数据,那么两个事务都无法继续进行。为了理解这一点,假设事务A执行了向y的存储操作。那么事务A将在事务B中交错执行,这违反了事务之间必须原子性执行的要求。同样地,允许事务B执行向x的存储操作也违反了原子性执行的要求。这种情况被称为冲突,当两个并发事务访问同一个变量时,至少有一个访问是存储操作,就会发生冲突。因此,系统有义务中止一个或两个事务以允许执行继续进行。具体选择哪个事务中止是一个有趣的话题,很可能在未来一段时间内仍能成为博士论文的研究课题,例如参见[ATC+11]。对于本节的目的,我们可以假设系统随机做出选择。
另一个复杂的问题是冲突检测,至少在最简单的情况下相对直接。当处理器执行事务时,它会标记该事务触及的每一个缓存行。如果处理器的缓存接收到涉及已被当前事务标记为已触及的缓存行的请求,则可能发生潜在冲突。更复杂的系统可能会尝试将当前处理器的事务安排在发送请求的处理器之前,优化这一过程很可能会在未来很长一段时间内继续产生博士学位论文。然而,本节假设了一个非常简单的冲突检测策略。
然而,为了使HTM有效工作,冲突的概率必须非常低,这反过来要求数据结构的组织方式能够维持足够低的冲突概率。例如,具有简单插入、删除和搜索操作的红黑树符合这一描述,但维护树中元素数量准确计数的红黑树则不符合。另一个例子是,在单次事务中枚举树中所有元素的红黑树将有较高的冲突概率,从而降低性能和可扩展性。因此,许多串行程序在HTM能够有效工作之前需要进行一些重构。在某些情况下,实践者可能会选择采取额外的步骤(在红黑树的情况下,可能切换到可分区的数据结构,如基数树或哈希表),并仅使用锁定机制,尤其是在HTM能够在所有相关架构上轻松实现之前[Cli09]。
此外,并发事务之间的冲突访问可能会导致失败。下节将讨论如何处理此类失败。
因为任何事务都可能在任何时候被中止,所以事务中不应包含无法回滚的语句。这意味着事务不能执行输入/输出操作、系统调用或调试断点(对于HTM事务!!!,不允许单步执行)。相反,事务必须限制在访问常规缓存内存。此外,在某些系统上,中断、异常、陷阱、TLB未命中等事件也会导致事务中止。鉴于错误条件处理不当所引发的大量错误,我们有理由质疑中止和回滚对易用性的影响。
当然,中止和回滚引发了关于HTM是否适用于硬实时系统的问题。HTM的性能优势是否超过了中止和回滚的成本?如果是的话,在什么条件下可以实现这一点?事务能否使用优先级提升?或者高优先级线程的事务应该优先中止低优先级线程的事务?如果是这样,硬件如何高效地得知优先级?关于HTM在实时环境中的应用文献非常稀少,可能是因为在非实时环境中让HTM正常工作已经存在足够多的问题。
由于当前的HTM实现可能会确定性地中止某个事务,软件必须提供回退代码。这种回退代码必须使用某种其他形式的同步机制,例如锁定。如果使用基于锁的回退方法,那么锁定的所有限制,包括死锁的可能性,都会重新出现。当然,可以希望回退方法不常被使用,这可能允许采用更简单且不易发生死锁的锁定设计。但这引发了系统如何从使用基于锁的回退方法过渡到事务的问题。一种方法是采用“测试-再测试-设置”原则[MT02],即所有人在锁释放之前都暂停操作,这样系统可以在那时以干净的状态进入事务模式。然而,这可能导致大量的自旋,如果锁持有者已经阻塞或被抢占,这样做可能是不明智的。另一种方法是允许事务并行处理,同时由一个线程持有锁[MT02],但这在维护原子性方面带来了困难,特别是当线程持有锁的原因是因为相应的事务无法放入缓存时。
最后,处理中止和回滚的可能性似乎给开发人员带来了额外的负担,他们必须正确地处理所有可能的错误条件组合。
很明显,HTM的用户必须投入大量的验证工作来测试备用代码路径以及从备用代码返回事务性代码的过程。也没有理由相信HTM硬件的验证要求会少一些。
尽管事务大小、冲突以及中止或回滚都可能导致事务中止,但人们可能希望足够小且持续时间短暂的事务最终能够成功。这将允许事务无条件重试,就像使用这些指令实现原子操作的代码中,比较交换(CAS)和加载链接/存储条件(LL/SC)操作会被无条件重试一样。
不幸的是,除了低时钟速率的学术研究原型[SBV10]外,目前可用的HTM实现拒绝提供任何形式的前向进展保证。如前所述,因此HTM无法用于避免这些系统中的死锁。希望未来的HTM实现能够提供某种形式的前向进展保证。在此之前,HTM在实时应用中必须极其谨慎地使用。
截至2021年,这一黯淡景象中唯一的例外是IBM大型机,它提供受限交易[JSG12]。这些限制相当严格,详见第17.3.5.1节。HTM向前推进保证能否从大型机迁移到普通CPU家族将十分有趣。
17.3.2.5不可撤销的业务
另一个后果是,HTM事务无法支持不可撤销的操作。当前的HTM实现通常通过要求事务中的所有访问都必须是可缓存内存(从而禁止MMIO访问)以及在中断、陷阱和异常时中止事务(从而禁止系统调用)来强制执行这一限制。
请注意,只要缓冲区填充/刷新操作发生在事务之外,HTM事务就可以支持缓冲I/O。之所以可行,是因为向缓冲区添加数据和从中移除数据是可撤销的:只有实际的缓冲区填充/刷新操作是不可撤销的。当然,这种缓冲I/O方法会导致I/O被计入事务的开销中,增加事务的大小,从而提高失败的可能性。
尽管在许多情况下,HTM可以作为锁定的直接替代品(因此得名事务锁省略(TLE)[DHL+08]),但
在语义上存在细微差别。布伦德尔[BLM06]给出了一个特别恶劣的例子,涉及协调的基于锁的关键区段,在事务执行时会导致死锁或活锁;但一个更简单的例子是空关键区段。
在基于锁的程序中,一个空的关键区段会保证所有先前持有该锁的进程现在都已释放了它。这种惯用法被2.4版本的Linux内核网络堆栈用于协调配置变更。但如果将这个空的关键区段转换为事务,则结果是一个空操作。所有先前关键区段终止的保证就丧失了。换句话说,事务锁省略保留了锁定的数据保护语义,但失去了锁定的时间消息传递语义。
锁定与事务之间的一个重要语义差异在于优先级提升,这是为了避免基于锁的实时程序中出现优先级反转。优先级反转可能发生的情况是,一个持有锁的低优先级线程被一个中等优先级的CPU绑定线程抢占。如果每个CPU至少有一个这样的中等优先级线程,低优先级线程将永远没有机会运行。如果一个高优先级线程现在尝试获取锁,它会阻塞。它无法获取锁,直到低优先级线程释放锁,而低优先级线程又无法释放锁,直到它有机会运行,而它也无法有机会运行,直到其中一个中等优先级线程放弃其CPU。因此,中等优先级线程实际上阻塞了高优先级进程,这就是“优先级反转”这一名称的由来。
避免优先级反转的一种方法是优先级继承,即高优先级线程在锁定时暂时将其优先级让给锁的持有者,这也可以称为优先级提升。然而,优先级提升不仅可用于防止优先级反转,如清单17.1所示。该清单的第1-12行展示了一个低优先级进程,尽管如此,它仍需每隔一毫秒左右运行一次;而同一清单的第14-24行则展示了一个高优先级进程,通过优先级提升确保受提升()按需定期运行。
boost_()函数通过始终保持两个boost_ lock[]锁中的一个来安排这一点,这样,boost_()的第20-21行就可以根据需要提升优先级。
这种安排要求boosted()在系统变得繁忙之前在线5上获得第一个锁定,但即使是现代硬件也很容易安排。
不幸的是,这种安排在事务锁定省略的情况下可能会崩溃。`boostee()`函数的重叠关键区段会变成一个无限事务,迟早会被中止,例如,在运行`boostee()`函数的线程首次被抢占时。此时,`boostee()`将回退到锁定模式,但由于其优先级较低且安静初始化期已经结束(这正是`boostee()`被抢占的原因),该线程可能再也没有机会运行。
如果增强线程()没有持有锁,那么增强线程()在Listing17.1will的第20和21行上的空临界区就会变成一个无效的事务,从而导致增强线程()永远不会运行。这个例子说明了事务内存的一些微妙后果及其回滚重试的语义。
鉴于这种经验可能会揭示出更多的细微语义差异,因此在大型程序中应用基于HTM的锁省略时应谨慎行事。话虽如此,在适用的情况下,基于HTM的锁省略可以消除与锁变量相关的缓存未命中问题,这已在2015年初的大规模实际软件系统中带来了数十个百分点的性能提升。因此,我们可以预期这种技术将在提供可靠支持的硬件上得到广泛应用。
17.3.2.7摘要
尽管HTM似乎有令人信服的应用场景,但当前的实现存在严重的事务大小限制、冲突处理复杂性、回滚问题以及需要谨慎处理的语义差异。表17.1总结了HTM在锁定方面的现状。可以看出,虽然HTM目前的状态缓解了一些锁定的严重缺陷,但它也引入了许多自身的问题。这些问题已被TM社区的领导者所承认[MS12]。
此外,这并不是全部。锁定通常不会单独使用,而是通常与其他同步机制结合使用,包括引用计数、原子操作、非阻塞数据结构、危险指针[Mic04a,HLM02]和RCU [MS98a,MAK+01,HMBW07,McK12b]。下一节将探讨这种增强如何改变这一方程。
实践者长期以来一直使用引用计数、原子操作、非阻塞数据结构、危险指针和RCU来避免锁定的一些缺点。
例如,在许多情况下,通过使用引用计数、危险指针或RCU来保护数据结构,特别是对于只读关键部分[Mic04a,HLM02,DMS+ 12,GMTW08,HMBW07],可以避免死锁。这些方法还减少了对数据结构进行分区的需求,如第10章所述。RCU进一步提供了无竞争且有界等待的读侧原语[MS98a,DMS+ 12],而危险指针则提供了无锁读侧原语[Mic02,HLM02,Mic04a]。将这些考虑因素加入表17.1中,得到了增强锁定与HTM之间的更新比较,如表17.2所示。两表之间的差异总结如下:
1.使用非阻塞读侧机制可以缓解死锁问题。
2.诸如危险指针和RCU等读取侧机制可以高效地处理不可分区的数据。
3.危险指针和RCU不会相互竞争,也不会与更新器竞争,这使得读取为主的负载具有出色的性能和可伸缩性。
4.危险指针和RCU提供了前向进展保证(分别锁自由和有界等待自由)。
5.危险指针和RCU的私有化操作很简单。
当然,也可以增强HTM,如下一节中所讨论的。
尽管HTM的应用范围可能还需要一段时间才能像第276页图9.33中所示的RCU那样清晰地界定,但这并不是不朝这个方向前进的理由。
HTM最适合用于更新密集型工作负载,涉及对运行在大型多处理器上的相对较大的内存数据结构的不同部分进行相对较小的更改。这既满足了当前HTM实现的大小限制,又最大限度地减少了冲突及随之而来的中止和回滚的可能性。鉴于当前同步原语,这种情况也相对难以处理。
使用锁定与HTM结合似乎可以克服HTM在不可撤销操作上的困难,而使用RCU或危险指针可能缓解HTM在只读操作中遇到的事务大小限制,这些操作会遍历数据结构的大部分[PMDY20]。当前的HTM实现会在冲突时无条件地中止更新事务,但未来的HTM实现可能会更顺畅地与这些同步机制互操作。在此期间,更新与大型RCU或危险指针读侧临界区冲突的概率应该远小于
与等效的只读事务冲突。然而,由于相应的冲突流,持续不断的RCU或危险指针读取者可能会饿死更新者。这种漏洞可以通过给非事务读取提供加载内存位置的预事务副本来消除(这需要显著的硬件成本和复杂性)。
HTM事务必须有回退机制这一事实,在某些情况下可能会迫使数据结构的静态分区性回归到HTM。如果未来的HTM实现能够提供前向进展保证,这种限制或许可以缓解,这可能在某些情况下消除对回退代码的需求,从而允许HTM在冲突概率较高的场景中高效使用。
简而言之,尽管HTM可能具有重要的用途和应用,但它只是并行程序员工具箱中的另一个工具,而不是整个工具箱的替代品。
能够大大增加对HTM需求的游戏改变者包括以下内容:
1.前向进展保证。
2.事务大小增加。
3.改进的调试支持。
4.原子性弱。
在以下章节中对此进行了扩展。
正如第17.3.2.4节所讨论的,当前的HTM实现缺乏前向进展保证,这要求有备用软件来处理HTM故障。当然,提出保证很容易,但提供这些保证并不总是那么容易。对于HTM而言,阻碍保证的因素可能包括缓存大小和关联性、TLB大小和关联性、事务持续时间和中断频率以及调度器实现。
缓存大小和关联性在第17.3.2.1节中进行了讨论,同时提出了一些旨在克服当前限制的研究。然而,HTM前向推进保证会受到大小限制,尽管这些限制将来可能会变得更大。那么,为什么目前的HTM实现不为小事务提供前向推进保证呢?例如,仅限于缓存的关联性?一个可能的原因是需要处理硬件故障。例如,一个失效的缓存SRAM单元可以通过停用该单元来处理,从而降低缓存的关联性,进而减少可以保证前向推进的最大事务大小。鉴于这只会减少保证的事务大小,似乎还有其他原因在起作用。也许,在生产质量的硬件上提供前向推进保证比想象中要困难得多。
考虑到在软件中很难做出前进保证,这是一个完全可信的解释。将问题从软件转移到硬件并不一定会使它更容易解决[JSG12]。
对于一个物理标记和索引的缓存,事务能够适应缓存是不够的。它的地址转换也必须适应TLB。因此,任何前向进展保证都必须考虑TLB大小和关联性。
鉴于当前HTM实现中中断、陷阱和异常会中止事务,必须确保给定事务的执行时间短于预期的中断间隔。无论给定事务涉及的数据多么少,如果运行时间过长,也会被中止。因此,任何向前推进的保证不仅取决于事务大小,还取决于事务持续时间。
前向进展保证的关键在于能够确定多个冲突事务中哪一个应该被中止。很容易想象出一个无尽的事务序列,每个事务都会中止前面的一个事务,而这个前面的事务又会被后续的事务中止,结果是没有任何事务真正提交。冲突处理的复杂性体现在已提出的大量HTM冲突解决策略[ATC+ 11,LS11]上。布伦德尔指出,额外的事务外访问也会引入更多复杂性[BLM06]。虽然很容易将所有这些问题归咎于这些额外的事务外访问,但这种思维方式的愚蠢之处可以通过将每个额外的事务外访问放入单独的单次访问事务中来轻易证明。问题在于访问模式,而不是它们是否恰好包含在一个事务中。
最后,任何事务的前向进展保证也取决于调度程序,它必须让执行事务的线程运行足够长的时间以成功提交。
因此,HTM供应商提供前向进展保证存在重大障碍。然而,如果其中任何一家能够做到这一点,其影响将是巨大的。这意味着HTM交易将不再需要软件回退,从而最终实现TM承诺的死锁消除。
然而,2012年底,IBM大型机宣布了一种HTM实现方案,该方案除了常规的最佳努力HTM实现外,还包含了受限事务[JSG12]。受限事务从tbeginc指令开始,而不是用于最佳努力事务的tbegin指令。受限事务保证总是能够完成(最终),因此如果事务中止,硬件不会跳转到回退路径(这是最佳努力事务的做法),而是从tbeginc指令重新启动事务。
大型机架构师需要采取极端措施来实现这一前瞻性的保证。如果某个受限事务反复失败,CPU可能会禁用分支预测、强制执行顺序,甚至禁用流水线。如果重复的失败是由于高竞争,CPU可能会禁用推测性 获取、引入随机延迟,甚至序列化冲突CPU的执行。“有趣”的前向进展场景涉及少至两个CPU或多至一百个CPU。也许这些极端措施提供了一些见解,解释了为什么其他CPU到目前为止没有提供受限事务。
顾名思义,受限事务实际上受到严重限制:
1.最大数据占用空间为四个内存块,每个内存块不能大于32字节。
2.最大代码占用空间为256字节。
3.如果给定的4K页面包含受限事务代码,则该页面可能不包含该事务的数据。
4.可执行的汇编指令的最大数量为32。
5.禁止反向分支。
尽管如此,这些约束支持许多重要的数据结构,包括链表、栈、队列和数组。因此,受限HTM似乎有可能成为并行程序员工具箱中的一个重要工具。
请注意,这些前向推进保证不必是绝对的。例如,假设使用HTM时使用全局锁作为回退机制。如果回退机制已经精心设计以避免第17.3.2.3节中讨论的“羊群效应”,那么如果HTM回滚足够不频繁,全局锁就不会成为瓶颈。话虽如此,系统越大、临界区越长、从“羊群效应”中恢复所需的时间越长,“足够不频繁”就越需要罕见。
17.3.5.2事务大小增加
前向进展保证很重要,但正如我们所见,它们将是基于交易规模和持续时间的条件性保证。已经取得了一些进展,例如,一些商用HTM实现使用近似技术来支持极其庞大的HTM读集[RD12]。另一个例子是,POWER8 HTM支持挂起事务,这可以避免向挂起事务的读写集添加无关访问[LGW+ 15]。这一功能已被用于生成高性能的读写锁[FIMR16]。
需要注意的是,即使是小规模的保证也非常有用。例如,对于栈、队列或出队操作,两个缓存行的保证就足够了。然而,更大的数据结构需要更大的保证,比如遍历一棵树时,所需的保证等于树中的节点数。因此,即使保证的大小有适度增加,也会提高HTM的实用性,从而增加了CPU提供这种保证或提供足够好的替代方案的需求。
17.3.5.3改进的调试支持
另一个制约交易规模的因素是调试交易的需求。当前机制的问题在于,单个步骤的异常会终止整个包围的交易。针对这一问题有许多变通方法,包括模拟处理器(速度慢!)、用STM替代HTM(速度慢且语义略有不同!)、使用重试技术回放以模拟向前进展(奇怪的故障模式!),以及全面支持调试HTM交易(复杂!)。
如果某家HTM供应商能够开发出一种HTM系统,允许在事务中直接使用传统的调试技术,包括断点、单步执行和打印语句,这将使HTM更具吸引力。一些事务内存研究者早在2013年就开始意识到这个问题,至少有一项提案涉及硬件辅助调试设施[GKP13]。当然,这一提议依赖于现有硬件具备此类功能[Hay20,Int20b]。更糟糕的是,一些尖端调试工具与HTM不兼容[OHOC20]。
17.3.5.4弱原子性
鉴于HTM在未来一段时间内可能会面临某种规模限制,HTM需要与其他机制顺畅互操作。如果额外的事务外读取不会无条件中止带有冲突写入的事务,而只是提供预事务值,那么HTM与主要读取机制如危险指针和RCU的互操作性将会得到改善。这样,危险指针和RCU可以用来让HTM处理更大的数据结构,并减少冲突概率。
这并非易事。最直接的实现方式是在每个缓存行和总线上增加一个状态,这是一项不小的额外开销。然而,这种开销带来的好处是允许大容量读取器运行,而不会因持续冲突导致更新者饥饿的风险。另一种方法是由Siakavaras等人[SNGK17]在二叉搜索树中广泛应用的方法,即只在实际更新时使用HTM,而在只读遍历时使用RCU。这种方法的性能比其他事务内存技术高出多达220 %,这一加速效果与Howard和Walpole [HW11]将RCU与STM结合时观察到的结果相似。在这两种情况下,弱原子性都是通过软件而非硬件实现的。尽管如此,如果能在硬件和软件中同时实现弱原子性,可能会获得更多的加速效果,这仍然值得研究。
尽管当前的HTM实现确实在某些情况下带来了实际的性能提升,但也存在显著的不足。最突出的问题包括事务大小受限、需要处理冲突、需要回滚和中止、缺乏向前推进的保证、无法处理不可撤销操作以及与锁定相比细微的语义差异。此外,关于HTM实现可靠性仍有许多令人担忧的原因[JSG12,Was14,Int20a,Int21,Lar21,Int20c]。
这些不足之处在未来的实现中可能会有所缓解,但似乎仍需继续努力,使HTM能够与多种其他同步机制良好配合,正如前面所提到的[MMW07,MMTW10]。尽管已经有一些研究使用HTM与RCU结合[SNKG17,SBN+20,GGK18,PMDY20],但在HTM如何更好地与RCU及其他延迟回收机制协同工作的方面,进展甚微。
简而言之,目前的HTM实现似乎是并行程序员工具箱中受欢迎且有用的补充,但要充分利用它们还需要做大量有趣和具有挑战性的工作。然而,它们不能被视为挥动就能解决所有并行编程问题的魔杖。
没有实验的理论:我们走得太远了吗?
迈克尔·米岑马赫
形式验证在多个生产环境中早已证明其价值[LBD+04,BBC+ 10,Coo18,SAE+ 18,DFLO19]。然而,核心形式验证是否会被纳入用于复杂并发代码库如Linux内核的持续集成自动化回归测试套件中,仍是一个开放的问题。尽管已经有一个针对Linux内核SRCU的概念验证[Roy17],但该测试仅针对最简单的RCU实现的一小部分,并且难以跟上不断变化的Linux内核的步伐。因此,值得探讨的是,要将形式验证作为Linux内核回归测试的第一级成员需要具备哪些条件。
以下列表是一个很好的开始[McK15a,幻灯片34]:
1.任何需要的翻译都必须是自动化的。
2.必须正确处理环境(包括内存排序)。
3.内存和CPU开销必须适中。
4.必须提供导致漏洞位置的具体信息。
5.除了源代码和输入之外,信息的范围必须适度。
6.必须找到与代码的用户相关的漏洞。
这个列表建立在Richard Bornat的格言之上,但比它更谦虚:“形式验证研究人员应该验证开发人员编写的代码 他们用语言编写它,在运行环境中运行,就像他们编写它一样。”以下各节讨论上述每项要求,然后介绍一个记分卡,说明几个工具在多大程度上符合这些要求。
尽管Promela和spin是无价的设计辅助工具,但如果你需要正式回归测试你的C语言程序,每次重新验证代码时都必须手动翻译成Promela。如果代码恰好位于每60到90天发布一次的Linux内核中,那么每年你需要手动翻译四到六次。随着时间推移,人为错误会逐渐累积,这意味着验证结果将无法与源代码匹配,从而使验证变得毫无意义。显然,重复验证要么要求形式验证工具直接输入你的代码,要么需要有无bug的自动翻译功能,将你的代码转换为验证所需的形式。
PPCMEM和herd理论上可以直接输入汇编语言和C++代码,但这些工具只适用于非常小的测试,这通常意味着你必须手动提取你的机制的核心。就像Promela和spin一样,两者都
PPCMEM和herd非常有用,但是它们不适合回归套件。
相比之下,cbmc
和Nidhugg可以输入规模合理(尽管仍然相当有限)的C程序,如果它们的能力继续增长,很可能会成为回归测试套件中的优秀补充。Coverity的静态分析工具也可以输入C程序,而且是大规模的,包括Linux内核。当然,与cbmc和Nidhugg相比,Coverity的静态分析要简单得多。另一方面,Coverity对“C程序”的定义非常全面,这带来了特殊的挑战[BBC+ 10]。亚马逊网络服务使用了多种形式验证工具,包括cbmc,并将其中一些工具应用于回归测试[Coo18]。谷歌直接在其大型Java代码库上使用了一些相对简单的静态分析工具,这些代码库可能不如C代码库多样化[SAE+ 18]。脸书对其代码库进行了更为激进的形式验证,包括并发分析[DFLO19,O‘H19],但尚未应用于Linux内核。最后,微软长期以来一直在其代码库上使用静态分析[LBD+04]。
有了这个列表,显然可以创建复杂的正式验证工具,直接使用生产质量的源代码。
然而,使用C代码作为输入的一个缺点是它假设编译器是正确的。另一种方法是使用C编译器生成的二进制文件作为输入,从而考虑到任何相关的编译器错误。这种方法已在多个验证工作中被采用,最著名的是SEL4项目[SM13]。
但是,直接从源代码或二进制文件进行验证都有消除人为翻译错误的优点,这对于可靠的回归测试至关重要。
这并不是说具有专用语言的工具毫无用处。相反,它们对于设计时验证非常有用,如第12章中所讨论的那样。然而,这些工具对于自动回归测试并不特别有用,而这正是本节的主题。
正确建模环境对于形式验证工具至关重要。一个常见的遗漏是内存模型,许多形式验证工具,包括Promela/spin,都局限于顺序一致性。第12.1.4.6is节中提到的QRCU经验是一个重要的警示故事。
普拉梅拉和自旋假设顺序一致性,这与现代计算机系统不匹配,正如第15章所见。相比之下,PPCMEM和herd的一大优势在于它们对各种CPU系列内存模型的详细建模,包括x86、Arm、Power,以及在herd中的Linux内核内存模型[AMM+ 18],该模型已被接受到Linux内核版本v4.17中。
cbmc和Nidhugg工具提供了一些选择内存模型的能力,但没有PPCMEM和herd提供的多样性。然而,随着时间的推移,更大规模的工具可能会采用更多样化的内存模型。
从长远来看,将I/O包含在形式验证工具中[MDR16]是有帮助的,但可能还需要一段时间才能实现。
然而,无法匹配环境的工具仍然有用。例如,在一个假设的顺序一致系统中,许多并发错误仍然是错误,这些错误可以通过过度近似系统内存模型并采用顺序一致性的工具来定位。不过,这些工具将无法发现涉及缺少内存排序指令的错误,正如第12.1.4.6节所述的警示故事所指出的那样。
几乎所有硬核形式验证工具本质上都是指数型的,这可能看起来令人沮丧,直到你考虑到许多最有趣的软件问题实际上是不可判定的。然而,即使在指数型之间也有程度上的差异。
PPCMEM旨在未优化,以确保感兴趣的内存模型能够准确表示。而herd工具则更积极地进行优化,如第12.3节所述,因此比PPCMEM快几个数量级。然而,无论是PPCMEM还是herd,都针对非常小的测试用例,而不是较大的代码库。
相比之下,Promela/spin、cbmc和Nidhugg是为(相对)较大的代码体设计的。Promela/spin被用于验证好奇号漫游车的文件系统[GHH+ 14],正如前面提到的,cbmc和Nidhugg都被应用于Linux内核RCU。
如果启发式方法继续以过去三十年的速度进步,我们可以期待形式验证的开销大幅减少。话虽如此,组合爆炸仍然是组合爆炸,这将显著限制可验证程序的规模,无论是否继续改进启发式方法。
然而,组合爆炸的另一面是马其顿的菲利普二世那句永恒的忠告:“分而治之。”如果一个大型程序可以被分解并验证各个部分,那么结果可能会出现组合内爆[McK11e]。一个自然的划分点是在API边界上,例如锁定原语的边界。一次验证过程可以确认锁定实现的正确性,而后续的验证过程则可以确保锁定API的正确使用。
这种方法的性能优势可以通过使用Linux内核内存模型[AMM+ 18]来展示。该模型提供了spin_lock()和spin_unlock()原语,但这些原语也可以通过cmpxchg_acquire()和smp_store_release()模拟实现,如清单17.2(C-SB+l-o-o-u+l-o-o- *u .litmus和C-SB+l-o-o-u+l-o-o-u*-C.litmus)所示。表17.4比较了使用模型中的spin_lock()和spin_unlock()与模拟这些原语的性能和可扩展性。差异在于
清单17.2:使用cmpxchg_acquire()模拟锁定 |
1 C C-SB+l-o-o-u+l-o-o-u-C 2 3 {} 4 5 P0(int *sl, int *x0, int *x1) 6 { 7 int r2; 8 int r1; 9 10 r2 = cmpxchg_acquire(sl,0,1); 11 WRITE_ONCE(*x0,1); 12 r1 = READ_ONCE(*x1); 13 smp_store_release(sl,0); 14 } 15 16 P1(int *sl, int *x0, int *x1) 17 { 18 int r2; 19 int r1; 20 21 r2 = cmpxchg_acquire(sl,0,1); 22 WRITE_ONCE(*x1,1); 23 r1 = READ_ONCE(*x0); 24 smp_store_release(sl,0); 25 } 26 存在28(0:r1=0/1:r1=0) |
当然,工具能够自动划分大型程序、验证各个部分,然后验证这些部分的组合是非常有用的。与此同时,大型程序的验证仍需要大量的手动干预。这种干预最好通过脚本来实现,以便在每次发布时可靠地进行重复验证,并最终以适合持续集成的方式进行。Facebook的Infer工具已经在这方面迈出了重要一步,通过组合性和抽象化实现了这一目标[BGOS18,DFLO19]。
无论如何,我们可以预期形式验证能力会随着时间的推移而继续增加,任何这样的增加反过来都会增加形式验证对回归测试的适用性。
任何大小的软件制品都包含错误,因此只报告错误存在与否的形式验证工具并无特别用途,需要的是至少提供一些关于错误所在位置和错误性质的信息的工具。
cbmc的输出包括回溯映射到源代码,类似于Promela/spin的方法,Nidhugg也是如此。当然,这些回溯可能相当长,分析起来也可能非常繁琐。然而,这样做通常比传统方式查找错误要快得多,也更令人愉快。
此外,形式验证工具最简单的测试之一是漏洞注入。
毕竟,我们中的任何人都可以编写`printf(“verified\n”)`,但事实是,形式验证工具的开发者和其他人一样容易出错。因此,那些仅仅声明存在错误的形式验证工具本质上更不可靠,因为它们在真实代码上的验证难度更大。
除此之外,编写形式验证工具的人可以利用现有的工具。例如,一个旨在仅确定是否存在严重但罕见错误的工具可能会使用二分法。如果测试程序的老版本中没有该错误,而新版本中有,则可以使用二分法快速定位插入错误的提交,这可能足以找到并修复错误。当然,这种策略对于常见错误并不适用,因为在这种情况下,由于所有提交至少包含一次常见错误,二分法会失败。
因此,许多形式验证工具提供的执行轨迹将继续具有价值,特别是对于复杂且难以理解的错误。此外,最近的研究应用了类似于传统Hoare逻辑的不正确性逻辑形式化方法,用于完整的正确性证明,但其唯一目的是发现错误[O‘H19]。
在过去,形式验证研究人员要求有一个完整的规范来验证软件。不幸的是,一个数学上严格的规范可能比实际代码还要大,而且每行规范中包含错误的可能性与每行代码一样高。证明代码忠实实现了规范的形式验证努力,实际上只是证明了两者之间逐个错误的兼容性,这可能并没有多大帮助。
更糟糕的是,包括Linux内核RCU在内的许多软件组件的要求是基于经验的[McK15h,McK15e,McK15f].16对于这种常见的软件类型,完整的规范是一种礼貌的虚构。硬件的完整规范同样不减少这种虚构性,这一点在2017年末的Meltdown和Spectre侧信道攻击中得到了明确体现[Hor18]。
这种情况可能会让人放弃对真实世界软件和硬件工件进行正式验证的所有希望,但事实证明,还有很多可以做的事情。例如,设计和编码规则可以作为部分规范,代码中包含的断言也可以。实际上,像cbmc和Nidhugg这样的形式验证工具都会检查可以触发的断言,隐式地将这些断言视为规范的一部分。然而,这些断言也是代码的一部分,这使得它们不太可能过时,特别是当代码还接受压力测试时。17 cbmc工具还会检查数组越界引用,从而隐式地将其添加到规范中。上述不正确逻辑也可以被视为使用了一个隐式的“不存在错误”的规范[O‘H19]。
这种隐式指定的方法相当合理,特别是当你不把形式验证视为完全正确的证明,而是将其视为一种具有不同优势和劣势的验证方式时,即测试。从这个角度来看,软件总是会有缺陷,因此任何有助于发现这些缺陷的工具都是非常有益的。
发现错误并修复它们当然是任何类型的验证工作的全部要点。显然,要避免出现假阳性。但是即使没有假阳性,也有错误和错误。
例如,假设一个软件工件恰好有100个剩余的bug,每个bug平均每百万运行时间出现一次。再假设一个全知的正式验证工具找到了这100个bug,开发人员随后修复了它们。这个软件工件的可靠性会发生什么变化?
答案是可靠性降低了。
要理解这一点,请记住历史经验表明,大约有7%的修复会引入新的错误[BJ12]。因此,修复这100个平均故障时间(MTBF)约为10,000年的错误,将会引入另外七个错误。历史统计数据显示,每个新错误的MTBF将远低于70,000年。这反过来意味着,这七个新错误的总MTBF很可能远低于10,000年,从而导致原本100个错误的修复实际上降低了整个软件的可靠性。
更糟糕的是,想象另一个软件制品,它平均每天有一个错误,每百万年有99个错误。假设一个形式验证工具找到了这99个百万年的错误,但未能发现那个每天出现一次的错误。修复这99个已定位的错误需要时间和精力,会降低可靠性,而对每天频繁发生的错误却无能为力,这种错误可能会带来尴尬,甚至更加严重的问题。
因此,最好有一个验证工具能够优先定位最棘手的bug。然而,正如第17.4.4节所述,可以利用其他工具。一个强大的工具就是传统的测试。鉴于对bug的了解,应该可以构建特定的测试来发现它,可能还需要使用第11.6.4节中描述的一些技术来增加bug显现的概率。这些技术应能计算出bug原始失败率的大致估计值,进而用于优先处理bug修复工作。
最近有一些形式验证工作优先考虑执行次数较少的执行,基于一个合理的假设,即较少的预占更有可能发生。
识别相关的漏洞听起来可能要求过高,但如果我们真的要提高软件的可靠性,这就是真正需要的。
表17.5显示了所涵盖的正式验证工具的简易记分卡
在本章中,较短的波长比较长的波长更好。
Promela需要手动翻译,并且仅支持顺序一致性,因此它的前两个单元格是红色的。它有合理的开销(至少对于形式验证而言),并且提供了回溯功能,所以接下来的两个单元格是黄色的。尽管需要手动翻译,但Promela以自然的方式处理断言,因此第五个单元格是绿色的。
PPCMEM通常需要手动翻译,因为它支持的测试用例规模较小,所以它的第一个单元格是橙色。它处理多种内存模型,因此第二个单元格是绿色。其开销相当高,所以第三个单元格是红色。它提供了操作之间关系的图形显示,虽然不如回溯法有帮助,但仍然非常有用,因此第四个单元格是黄色。它需要构建一个存在子句,并且不能接受进程内部断言,所以第五个单元格也是黄色。
群集工具的大小限制与PPCMEM类似,因此其第一个单元格也是橙色。它支持多种内存模型,所以第二个单元格是蓝色。它的开销合理,因此第三个单元格是黄色。它的错误定位和断言功能与PPCMEM非常相似,因此接下来的两个单元格也是黄色。
cbmc工具直接输入C代码,因此它的第一个单元格是蓝色的。它支持几种内存模型,所以第二个单元格是黄色的。它的开销合理,因此第三个单元格也是黄色的,不过也许求解器性能会继续提升。它提供回溯功能,因此第四个单元格是绿色的。它直接从C代码中获取断言,所以第五个单元格是蓝色的。
Nidhugg还直接输入C代码,因此它的第一个单元格也是蓝色。它仅支持几种内存模型,所以第二个单元格是橙色的。它的开销相当低(对于形式验证而言),因此第三个单元格是绿色的。它提供回溯功能,所以第四个单元格也是绿色的。它直接从C代码中获取断言,所以第五个单元格是蓝色的。
那么第六行和最后一行呢?现在还为时过早,无法判断这些工具在查找正确漏洞方面表现如何,所以它们都标有问号。
请注意,此表再次对这些工具在回归测试中的使用进行了评级。
仅仅因为它们中的许多不适合回归测试,并不意味着它们毫无用处,事实上,其中许多已经多次证明了它们的价值。18只是不适合回归测试。
然而,这种情况可能会改变。毕竟,形式验证工具在2010年代取得了令人印象深刻的进展。如果这种进步继续下去,形式验证很可能成为并行程序员验证工具箱中不可或缺的工具。
功能编程在并行应用程序中的奇怪失败。
马尔特·斯卡鲁普克
当我上世纪80年代初上第一堂函数式编程课时,教授断言无副作用的函数式编程风格非常适合简单的并行化和分析。三十年后,这一观点依然成立,但主流生产中并行函数语言的使用却很少,这种情况可能与教授的另一主张不无关系,即程序既不应维护状态也不应进行输入输出。尽管像Erlang这样的函数式语言有小众用途,且其他几种函数式语言也增加了多线程支持,但在主流生产中,这些语言仍主要由C、C++、Java和Fortran等过程性语言主导(通常会结合OpenMP、MPI或协数组)。
这种情况自然会引出一个问题:“如果分析是目标,为什么不在进行分析之前将过程语言转换为函数语言呢?”当然,对于这种方法有许多反对意见,我只列举其中的三个:
1.过程语言通常大量使用全局变量,这些变量可以由不同的函数独立更新,更糟糕的是,还可以被多个线程更新。请注意,Haskell的单子是为了处理单线程的全局状态而发明的,而多线程访问全局状态则对函数模型造成了额外的破坏。
2.多线程过程语言通常使用诸如锁、原子操作和事务等同步原语,这给函数模型带来了额外的暴力。
3.过程语言可以别名函数参数,例如通过两个不同的参数向同一函数调用传递同一个结构的指针。这可能导致函数在不知情的情况下通过两个不同的(可能重叠的)代码序列更新该结构,从而大大增加了分析的复杂性。
当然,考虑到全局状态、同步原语和别名的重要性,聪明的函数编程专家已经提出了许多尝试来调和函数编程模型与它们的关系,单子就是一个很好的例子。
另一种方法是将并行过程程序编译成函数式程序,然后使用函数式编程工具分析结果。但可以做得更好,因为任何实际计算都是一个具有有限输入的大型有限状态机,在有限的时间间隔内运行。这意味着任何实际程序都可以转换为表达式,尽管这个表达式可能非常庞大[DHK12]。
然而,许多并行算法的低级内核可以转换为足够小的表达式,轻松地适应现代计算机的内存。如果这样的表达式与一个断言结合,检查该断言是否会触发就变成了一个可满足性问题。尽管可满足性问题是NP完全问题,但它们通常可以在远少于生成完整状态空间所需的时间内解决。此外,解决方案时间似乎仅弱依赖于底层的内存模型,因此运行在弱有序系统上的算法也可以进行验证[AKT13]。
总体方法是将程序转换为单静态赋值(SSA)形式,使得每个变量的赋值都会创建该变量的一个独立版本。这适用于所有活动线程中的赋值,从而使最终表达式包含该代码的所有可能执行方式。添加断言意味着询问任何输入和初始值组合是否会导致断言触发,正如上文所述,这正是可满足性问题。
一个可能的反对意见是它不能优雅地处理任意循环结构。
然而,在许多情况下,这可以通过展开循环有限次来处理。此外,也许一些循环也将被证明适合通过归纳方法进行折叠。
另一个可能的反对意见是,自旋锁涉及任意长的循环,任何有限的展开都无法完全捕捉到自旋锁的行为。事实证明,这一反对意见很容易克服。与其建模一个完整的自旋锁,不如建模一个尝试获取锁的尝试锁,并在无法立即成功时中止。断言必须精心设计,以避免在自旋锁因未立即可用而中止的情况下触发。由于逻辑表达式与时间无关,所有可能的并发行为都将通过这种方法被捕捉到。
最后一个反对意见是,这种技术不太可能处理像Linux内核这样庞大的软件实体,其中包含数百万行代码。这可能是事实,但重要的是,对Linux内核中每个较小的并行原语进行详尽验证仍然非常有价值。事实上,领导这一方法的研究人员已经将其应用于非平凡的实际代码,包括Linux内核中的Tree RCU实现[LMKM16,KS17a]。
这项技术的应用范围还有待观察,但它是形式验证领域中较为有趣的创新之一。尽管函数式编程的支持者们最终可能正确地断言函数式编程的必然主导地位,但显然,这种长期被推崇的方法论开始在其形式验证的主场受到可信的竞争。因此,我们有理由继续怀疑函数式编程主导地位的必然性。
本章简要探讨了多种可能的未来,包括多核、事务内存、形式验证作为回归测试以及并发函数式编程。这些未来中的任何一个都可能发生,但更有可能的是,正如过去一样,未来会比我们所能想象的更加奇异。