高性能编程艺术正在复苏。我开始编程的时候,程序员必须知道每一位数据的去向(有时确实如此——通过前面板上的开关)。现在,计算机已经具有足够的能力来完成日常任务。当然,总是有一些领域永远不够计算能力。但大多数程序员可以写出效率低下的代码。顺便说一句,这并不是一件坏事:摆脱了性能约束,程序员可以专注于以其他方式改进代码。
有五个组成部分,五个元素共同决定了程序的性能。首先,我们深入探讨细节,探索一切性能的低级基础:我们的计算硬件(没有开关——承诺,那些日子已经过去了)。从个别组件——处理器和内存——我们逐步过渡到多处理器计算系统。在这一过程中,我们了解了内存模型、数据共享的成本,甚至无锁编程。
高性能编程的第二个组成部分是对编程语言的有效使用。在这一点上,本书变得更加具体于C++(其他语言有它们自己的喜爱的低效性)。紧随其后的是第三个元素,即帮助编译器改进程序性能的技能。
第四个组成部分是设计。可以说,这应该是第一个:如果设计没有将性能作为明确目标之一,几乎不可能事后再添加良好的性能。然而,我们最后学习设计性能,因为这是一个高层概念,它汇集了我们之前所学到的所有知识。
高性能编程的最终第五要素是你,读者。你的知识和技能最终将决定结果。为了帮助你学习,本书包含许多示例,可用于实践探索和自学。学习在你翻过最后一页后并不需要停止。
这本书适用于有经验的开发人员和程序员,他们在性能关键项目上工作,并希望学习改进其代码性能的不同技术。属于计算机建模、算法交易、游戏、生物信息学、基于物理的模拟、计算机辅助设计、计算基因组学或计算流体动力学社区的程序员可以从本书中学习各种技术,并将其应用于他们的工作领域。
尽管本书使用C++语言,但书中演示的概念可以轻松转移或应用于其他编译语言,如C、C#、Java、Rust、Go等。
第一章,性能和并发性简介,讨论了我们关心程序性能的原因,特别是关于为什么良好性能不是自然而然发生的原因。我们了解到,为了实现最佳性能,甚至是足够的性能,重要的是了解影响性能的不同因素以及程序特定行为的原因,无论是快速还是慢速执行。
第二章《性能测量》是关于测量的。性能通常是非直观的,所有涉及效率的决策,从设计选择到优化,都应该由可靠的数据来指导。本章描述了不同类型的性能测量,解释了它们的区别以及何时应该使用它们,并教授了如何在不同情况下正确地测量性能。
第三章《CPU架构、资源和性能影响》帮助我们开始研究硬件以及如何有效地使用它以实现最佳性能。本章致力于学习CPU资源和能力,以及最佳的使用方式,未能充分利用CPU资源的更常见原因,以及如何解决这些问题。
第四章《内存架构和性能》帮助我们了解现代内存架构,它们固有的弱点以及对抗或至少隐藏这些弱点的方法。对于许多程序来说,性能完全取决于程序员是否利用了旨在提高内存性能的硬件功能,本章教授了必要的技能来做到这一点。
第五章《线程、内存和并发》帮助我们继续研究内存系统及其对性能的影响,但现在我们将研究扩展到多核系统和多线程程序的领域。事实证明,内存,已经是性能的“长杆”,在添加并发时会更加成为问题。虽然硬件施加的基本限制无法克服,但大多数程序甚至远未达到这些限制,熟练的程序员有很大的空间来提高他们代码的效率;本章为读者提供了必要的知识和工具来做到这一点。
第六章《并发和性能》帮助您了解开发高性能并发算法和数据结构以用于线程安全程序。一方面,为了充分利用并发,我们必须对问题和解决方案策略进行高层次的考虑:数据组织、工作分区,有时甚至解决方案的定义都会对程序的性能产生重大影响。另一方面,正如我们在上一章中所看到的,性能受到低级因素的极大影响,比如数据在缓存中的排列,甚至最佳设计也可能被糟糕的实现所破坏。
第七章《并发数据结构》解释了并发程序中数据结构的性质,以及当数据结构在多线程上下文中使用时,“栈”和“队列”等熟悉的数据结构的含义会有所不同。
第八章《C++中的并发》描述了最近在C++17和C++20标准中添加的并发编程功能。虽然现在谈论使用这些功能实现最佳性能的最佳实践还为时过早,但我们可以描述它们的功能,以及当前编译器支持的情况。
第九章《高性能C++》将我们的注意力从硬件资源的最佳利用转移到了特定编程语言的最佳应用。虽然我们迄今为止学到的一切都可以应用于任何语言的任何程序,通常都很简单明了,但本章涉及了C++的特性和怪癖。读者将了解C++语言的哪些特性可能会导致性能问题,以及如何避免这些问题。本章还将涵盖非常重要的编译器优化问题,以及程序员如何帮助编译器生成更高效的代码。
第十章《C++编译器优化》涵盖了编译器优化以及程序员如何帮助编译器生成更高效的代码。
除了特定于C++效率的章节外,本书不依赖于任何神秘的C++知识。所有示例都是用C++编写的,但关于硬件性能、高效数据结构和性能设计的教训适用于任何编程语言。要跟随这些示例,您至少需要具备中级的C++知识。
每一章都提到了编译和执行示例所需的额外软件(如果有的话)。在大多数情况下,任何现代C++编译器都可以与这些示例一起使用,除了第八章《C++并发》,它需要最新版本才能通过协程部分工作。
我们还提供了一个PDF文件,其中包含本书中使用的屏幕截图和图表的彩色图像。您可以在这里下载:static.packt-cdn.com/downloads/9781800208117_ColorImages.pdf。
本书中使用了许多文本约定。
文本中的代码:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟URL、用户输入和Twitter用户名。例如:"值得注意的是一个新功能,允许可移植地确定L1缓存的缓存行大小,std::hardware_destructive_interference_size和std::hardware_constructive_interference_size。"
代码块设置如下:
std::vector
Mainthread:140003570591552Coroutinestartedonthread:140003570591552Mainthreaddone:140003570591552Coroutineresumedonthread:140003570587392Coroutinedoneonthread:140003570587392粗体:表示一个新术语、一个重要词或者屏幕上看到的词。例如,菜单或对话框中的词以粗体显示。例如:"当CPU1看到由CPU0执行的带释放内存顺序的原子写操作的结果时,可以保证CPU1看到的内存状态已经反映了在这个原子操作之前由CPU0执行的所有操作。"
提示或重要说明
像这样出现。
在本节中,您将学习关于研究程序性能的方法论,该方法论基于测量、基准测试和分析。您还将学习确定每个计算系统性能的主要硬件组件:处理器、内存及它们的交互。
本节包括以下章节:
动机是学习的关键因素;因此,您必须了解为什么在计算机技术取得了所有进步的情况下,程序员仍然必须努力使其代码获得足够的性能,以及成功需要深刻理解计算硬件、编程语言和编译器能力。本章的目的是解释为什么今天仍然需要这种理解。
本章讨论了我们关心程序性能的原因,特别是关于良好性能并非“自然而然”发生的原因。我们将了解为什么为了实现最佳性能,有时甚至是足够的性能,重要的是要了解影响性能的不同因素,以及程序特定行为的原因,无论是快速执行还是缓慢执行。
在本章中,我们将涵盖以下主要主题:
在计算机早期,编程是困难的。处理器速度慢,内存有限,编译器原始,没有付出重大努力就无法取得任何成就。程序员必须了解CPU的架构,内存的布局,当编译器无法胜任时,关键代码必须用汇编语言编写。
以前的常识,比如CPU有多少寄存器以及它们的名称是什么,变得神秘而深奥。曾经,“大型代码库”是指需要用双手才能搬动的卡片组;现在,“大型代码库”是指超出版本控制系统容量的代码库。以前几乎不需要为特定处理器或内存系统编写专门的代码,可移植代码成为了常态。
至于汇编语言,实际上很难超越编译器生成的代码,这对大多数程序员来说是难以企及的任务。对于许多应用程序及其编写者来说,已经有了“足够的性能”,程序员职业的其他方面变得更加重要(明确地说,程序员可以专注于代码的可读性,而不必担心添加一个有意义名称的函数是否会使程序变得无法接受地慢)。
然后,突然间,“性能自行解决”的免费午餐结束了。看似不可阻挡的计算能力不断增长的进展突然停止了。
从前面的图表可以明显看出,不是所有的进展措施在2005年停滞不前:单芯片上的晶体管数量不断增加。那么,如果不是让芯片变得更快,他们在做什么呢?答案是双重的,其中一部分由底部曲线揭示:设计师不是让单个处理器变得更大,而是不得不将多个处理器核心放在同一块芯片上。当然,所有这些核心的计算能力随着核心数量的增加而增加,但前提是程序员知道如何使用它们。"伟大的晶体管之谜"的第二部分(所有的晶体管都去哪了?)是它们进入了处理器能力的各种非常先进的增强功能,这些增强功能可以用来提高性能,但同样,只有程序员努力利用它们。
我们刚刚看到的处理器进展的变化通常被认为是并发编程进入主流的原因。但这种变化甚至更加深刻。在本书中,您将了解到,为了获得最佳性能,程序员再次需要了解处理器和内存架构及其相互作用的复杂性。出色的性能不再是“自然而然”发生的。与此同时,我们在编写清晰表达需要完成的任务而不是如何完成的代码方面取得的进展不应该被撤销。我们仍然希望编写可读性强、易于维护的代码,而且(而且不是但是)我们也希望它高效。
幸运的是,我们不必通过在黑暗的存储室里翻阅腐烂的穿孔卡片堆来重新发现一些失落的性能艺术。任何时候,仍然存在着困难的问题,短语计算能力永远不够对许多程序员来说是真实的。随着计算能力的指数增长,对它的需求也在增加。极限性能的艺术在那些需要它的领域中得以保持。在这一点上,一个这样的领域的例子可能是有启发性和有启发性的。
如果我们将2010年用于设计、模拟或验证特定微芯片的计算,并自那时起每年运行相同的工作负载,我们会看到类似于这样的情况:
但事实甚至比这更糟,上面的图表并没有显示一切。从2010年到2018年,当年制造的最大处理器可以在一夜之间(大约12小时)得到验证,使用的是去年制造的最大处理器的计算机。但我们忘了问有多少这样的处理器?好吧,现在是完整的真相:
图1.4-前一图表,标注了每次计算的CPU数量
每年,配备着不断增长数量的最新、最强大处理器的最强大计算机,运行着最新的软件版本(经过优化以利用越来越多的处理器并更有效地使用每一个处理器),完成了建造下一年最强大计算机所需的工作,而每年,这项任务都处于几乎不可能的边缘。我们没有掉下这个边缘,这在很大程度上是硬件和软件工程师的成就,前者提供了不断增长的计算能力,后者以最大效率使用它。本书将帮助您学习后者的技能。
我们现在理解了本书的主题的重要性。在我们深入细节之前,进行高层次的概述会有所帮助;可以说是对勘探活动将展开的领域的地图的审查。
我们已经谈论了程序的性能;我们提到了高性能软件。但是当我们说这个词时,我们是什么意思呢?直观地,我们理解高性能程序比性能差的程序更快,但这并不意味着更快的程序总是具有好的性能(两个程序可能都性能差)。
一方面,高效的程序不会让可用资源空闲:如果有一个需要完成的计算和一个空闲的处理器,那么该处理器应该执行等待执行的代码。这个想法更深入:处理器内部有许多计算资源,高效的程序试图尽可能同时利用这些资源。另一方面,高效的程序不会浪费资源做不必要的工作:它不会执行不需要完成的计算,不会浪费内存来存储永远不会被使用的数据,不会发送不需要的数据到网络等等。简而言之,高效的程序不会让可用的硬件空闲,也不会做任何不必要的工作。
但问题的上下文很重要,我们忽略了该程序是在手机等电池供电设备上运行,功耗也很重要。
这是四个程序在计算过程中消耗的功率:
图1.6-相同算法的四种不同实现的功耗(相对单位)
同样,这是一个诡计问题,如果不知道完整的上下文。该程序不仅在移动设备上运行,而且执行实时计算:它用于音频处理。这应该更注重实时更快地获得结果,对吗?并不完全是这样。
实时程序必须始终跟上它正在处理的事件。音频处理器必须特别跟上语音。如果程序可以比人说话的速度快十倍处理音频,那对我们毫无用处,我们可能还不如把注意力转向功耗。
另一方面,如果程序偶尔落后,一些声音甚至单词将被丢弃。这表明实时或速度在一定程度上很重要,但必须以可预测的方式交付。
图1.7-相同算法的四种不同实现的95%延迟(百分比)
那么,哪个程序最好?当然,答案取决于应用,甚至在这种情况下可能并不明显。
我们现在明白,与效率不同,性能总是针对特定的度量标准定义的,这些度量标准取决于我们正在解决的应用和问题,对于某些度量标准来说,存在“足够好”的概念,当其他度量标准成为前景时。效率,反映了计算资源的利用,是实现良好性能的方式之一,也许是最常见的方式,但不是唯一的方式。
正如我们刚刚看到的,度量的概念对性能概念至关重要。有了度量,总是隐含着测量的可能性和必要性:如果我们说“我们有一个度量”,那就意味着我们有一种量化和测量某事的方法,而了解度量的值的唯一方法就是测量它。
测量性能的重要性不言而喻。人们常说,性能的第一定律是永远不要猜测性能。本书的下一章专门讨论性能测量、测量工具、如何使用它们以及如何解释结果。
不幸的是,对性能的猜测太过普遍。像“避免在C++中使用虚函数,它们很慢”这样过于笼统的陈述也是如此。这类陈述的问题不在于它们不精确,即它们没有提及虚函数相对于非虚函数慢多少的度量标准。作为读者的练习,这里有几个可供选择的量化答案:
哪个答案是正确的?如果您选择了其中任何一个答案,恭喜您:您选择了正确答案。没错,每个答案在特定情况和特定上下文中都是正确的(要了解原因,您将不得不等到第九章,高性能C++)。
不幸的是,通过接受几乎不可能直觉或猜测性能的真相,我们面临着另一个陷阱:将其作为写出效率低下的代码的借口“以后进行优化”的借口,因为我们不猜测性能。虽然这是真的,但后一种最大化可能会走得太远,就像流行的格言不要过早优化一样。
描述“不要过早优化”的规则的限制的另一种方法是通过说“是的,但也不要故意使性能变差”。识别两者之间的区别需要对良好设计实践的了解,以及对高性能编程的不同方面的理解。
那么,作为开发人员/程序员,为了精通开发高性能应用程序,您需要学习和了解什么?在下一节中,我们将从一个简略的目标列表开始,然后详细讨论每个目标。
什么使程序高性能?我们可以说“效率”,但首先,这并不总是正确的(尽管通常是),其次,这只是在回避问题,因为下一个明显的问题是,好吧,什么使程序高效?我们需要学习什么才能编写高效或高性能的程序?让我们列出所需的技能和知识:
实现高性能最重要的因素是选择一个好的算法。不能通过优化实现来“修复”一个糟糕的算法。然而,这也是本书范围之外的因素。算法是特定于问题的,这不是一本关于算法的书。您将不得不进行自己的研究,以找到最适合您所面临问题的最佳算法。
在这本书中,我们将从单个处理器开始,学会高效地利用其计算资源。然后,我们将扩大视野,不仅包括处理器,还包括其内存。然后,自然地,我们将研究如何同时使用多个处理器。
但是,高性能程序的必要品质之一是高效地使用硬件:高效地完成本来可以避免的工作对我们没有好处。不创造不必要的工作的关键是有效地使用编程语言,对我们来说是C++(我们学到的大部分关于硬件的知识都可以应用到任何语言,但一些语言优化技术非常特定于C++)。此外,编译器位于我们编写的语言和我们使用的硬件之间,因此我们必须学会如何使用编译器来生成最有效的代码。
你可以从这本书中学到这些技能。我们将学习硬件架构,以及一些编程语言特性背后的隐藏内容,以及如何像编译器一样看待我们的代码。这些技能很重要,但更重要的是理解为什么事情会以这样的方式运作。计算硬件经常发生变化,语言不断发展,编译器的新优化算法也在不断发明。因此,任何这些领域的具体知识都有相当短的保质期。然而,如果你不仅理解了使用特定处理器或编译器的最佳方法,还理解了我们得出这些知识的方式,你将能够很好地准备重复这个发现过程,因此继续学习。
在这个介绍性的章节中,我们讨论了为什么尽管现代计算机的原始计算能力迅速增长,但对软件性能和效率的兴趣却在上升。具体来说,我们了解了为什么为了理解限制性能的因素以及如何克服它们,我们需要回到计算的基本元素,并了解计算机和程序在低级别上的工作方式:理解硬件并高效地使用它,理解并发性,理解C++语言特性和编译器优化,以及它们对性能的影响。
这种低级知识必然非常详细和具体,但我们有一个处理这个问题的计划:当我们学习处理器或编译器的具体事实时,我们也会学习到我们得出这些结论的过程。因此,从最深层次来看,这本书是关于学习如何学习的。
我们进一步了解到,如果不定义衡量绩效的指标,绩效的概念就毫无意义。对特定指标评估绩效的需要意味着任何绩效工作都是由数据和测量驱动的。事实上,下一章将专门讨论绩效的测量。
无论是编写新的高性能程序还是优化现有程序,您面临的第一个任务之一将是定义代码在当前状态下的性能。您的成功将取决于您能够提高其性能的程度。这两种陈述都意味着性能指标的存在,即可以进行测量和量化的东西。上一章最有趣的结果之一是发现甚至没有一个适用于所有需求的性能定义:在您想要量化性能时,您所测量的内容取决于您正在处理的问题的性质。
但测量远不止于简单地定义目标和确认成功。您性能优化的每一步,无论是现有代码还是您刚刚编写的新代码,都应该受到测量的指导和启发。
性能的第一条规则是永远不要猜测性能,并且值得在本章的第一部分致力于说服您牢记这条规则,不容置疑。在摧毁您对直觉的信任之后,我们必须给您提供其他东西来依靠:用于测量和了解性能的工具和方法。
还有许多其他可用的性能分析工具,包括免费和商业工具。它们都基本上提供相同的信息,但以不同的方式呈现,并具有许多不同的分析选项。通过本章的示例,您可以了解性能分析工具的预期和可能的限制;您使用的每个工具的具体情况都需要自己掌握。
安装了所有必要的工具后,我们准备进行我们的第一个性能测量实验。
还有一个隐藏的目的:在本节结束时,您将相信您不应该猜测性能。
您可能需要分析和优化的任何真实程序可能足够大,以至于在本书中需要很多页,因此我们将使用一个简化的例子。这个程序对一个非常长的字符串中的子字符串进行排序:假设我们有一个字符串S,比如"abcdcba"(这并不算很长;我们实际的字符串将有数百万个字符)。我们可以从这个字符串的任何字符开始得到一个子字符串,例如,子字符串S0从偏移0开始,因此其值为"abcdcba"。子字符串S2从偏移2开始,其值为"cdcba",而子字符串S5的值为"ba"。如果我们使用常规的字符串比较对这些子字符串按降序排序,子字符串的顺序将是S2,然后是S5,最后是S0(按照第一个字符'c','b'和'a'的顺序)。
我们可以使用STL排序算法std::sort对子字符串进行排序,如果我们用字符指针表示它们:现在交换两个子字符串只涉及交换指针,而基础字符串保持不变。以下是我们的示例程序:
该示例定义了一个字符串,用作要排序的子字符串的基础数据,以及子字符串的向量(字符指针),但我们还没有展示数据本身是如何创建的。然后,使用带有自定义比较函数的std::sort对子字符串进行排序:一个lambda表达式调用比较函数本身compare()。我们使用lambda表达式来适应compare()函数的接口,该函数接受两个指针和最大字符串长度,以符合std::sort期望的接口(只有两个指针)。这被称为适配器模式。
在我们的例子中,lambda表达式有第二个作用:除了调用比较函数外,它还计算比较调用的次数。由于我们对排序的性能感兴趣,如果我们想比较不同的排序算法,这些信息可能会有用(我们现在不打算这样做,但这是一种技术,您可能会在自己的性能优化工作中发现有用)。
boolcompare(constchar*s1,constchar*s2,unsignedintl){if(s1==s2)returnfalse;for(unsignedinti1=0,i2=0;i1
我们示例中唯一缺少的是字符串的实际数据。子字符串排序在许多实际应用程序中是一个相当常见的任务,每个应用程序都有自己获取数据的方式。在我们的人工示例中,数据将不得不同样人工。例如,我们可以生成一个随机字符串。另一方面,在许多实际应用程序中,子字符串排序中有一个字符出现的频率比其他任何字符都要高。
我们也可以模拟这种类型的数据,方法是用一个字符填充字符串,然后随机更改其中的一些字符:
现在我们的示例已经准备好编译和执行了:
图2.1
你得到的结果取决于你使用的编译器、运行的计算机,当然还取决于数据语料库。
现在我们已经有了第一个性能测量,你可能会问的第一个问题是,我们该如何优化它?然而,这并不是你应该问的第一个问题。真正的第一个问题应该是,“我们需要优化吗?”要回答这个问题,你需要有性能的目标和目标,以及关于程序其他部分相对性能的数据;例如,如果实际字符串是从需要十个小时的模拟生成的,那么排序它需要的一百秒几乎不值得注意。当然,我们仍然在处理人工示例,除非我们假设是的,否则在本章中我们不会有太大进展,我们必须改善性能。
我们将在下一节中更多地了解性能分析工具。现在,可以说以下命令行将编译和执行程序,并使用GperfTools包中的Google分析器收集其运行时配置文件:
图2.2
图2.3
boolcompare(constchar*s1,constchar*s2,unsignedintl){if(s1==s2)returnfalse;for(unsignedinti1=0,i2=0;i1
然后是一个循环(循环体逐个比较字符),我们必须这样做是因为我们不知道哪个字符可能不同。循环本身运行直到我们找到一个不同之处,或者直到我们比较了最大可能数量的字符。很容易看出后一种情况不可能发生:字符串以空字符结尾,因此,即使两个子字符串中的所有字符都相同,迟早我们会到达较短子字符串的末尾,将其末尾的空字符与另一个子字符串中的非空字符进行比较,较短的子字符串将被认为是两者中较小的。
唯一可能读取字符串末尾之外的情况是当两个子字符串从同一位置开始,但我们在函数的开头就检查了这一点。这很好:我们发现了一些不必要的工作,因此我们可以优化代码,摆脱每次循环迭代中的一个比较操作。考虑到循环体中没有太多其他操作,这应该是显著的。
代码的改变很简单:我们可以只删除比较(我们也不再需要将长度传递给比较函数):
图2.4
说这并不是按计划进行的将是一个严重的低估。原始代码花了98毫秒来解决同样的问题(图2.1)。尽管“优化”代码做的工作更少,但花了210毫秒(请注意,并非所有编译器在这个例子上都表现出这种性能异常,但我们使用的是真正的生产编译器;这里没有任何诡计,这也可能发生在你身上)。
为了总结这个例子,实际上是一个大大简化的真实程序的例子,我要告诉您,当我们试图优化代码片段时,另一位程序员正在代码的另一个部分工作,也需要一个子字符串比较函数。当分别开发的代码片段放在一起时,只保留了这个函数的一个版本,而这恰好是我们没有编写的版本;另一位程序员几乎写了完全相同的代码:
boolcompare(constchar*s1,constchar*s2){if(s1==s2)returnfalse;for(inti1=0,i2=0;;++i1,++i2){if(s1[i1]!=s2[i2])returns1[i1]>s2[i2];}returnfalse;}检查这段代码片段和前面的代码片段,看看你能否发现其中的区别。
唯一的区别是循环变量的类型:之前,我们使用了unsignedint,这并没有错:索引从0开始并递增;我们不期望出现任何负数。最后的代码片段使用了int,不必要地放弃了可能的索引值范围的一半。
在这次代码整合之后,我们可以再次运行我们的基准测试,这次使用新的比较函数。结果又是意想不到的:
图2.5
最新版本花费了74毫秒,比我们原始版本快(98毫秒,图2.1),比几乎相同的第二个版本快得多(210毫秒,图2.2)。
关于这个特定的谜团的解释,您将不得不等到下一章。本节的目标是说服您永远不要猜测性能:所谓的“显而易见”的优化——用更少的代码进行完全相同的计算——出乎意料地失败了,而本来根本不应该有任何影响的微不足道的改变——在一个所有值都是非负的函数中使用有符号整数而不是无符号整数——竟然成为了一种有效的优化。
如果性能结果在这个非常简单的例子中都如此反直觉,那么做出关于性能的良好决策的唯一方法必须是基于测量的方法。在本章的其余部分,我们将看到一些用于收集性能测量的最常用工具,学习如何使用它们以及如何解释它们的结果。
在对代码进行基准测试时,通常有助于从多个计时器中报告测量结果。在最简单的情况下,即单线程程序进行不间断计算时,所有三个计时器应该返回相同的结果:
另一方面,如果程序没有进行太多计算,而是等待用户输入,或者从网络接收数据,或者进行其他不需要太多CPU资源的工作,我们将看到非常不同的结果。观察这种行为的最简单方法是调用sleep()函数而不是我们之前使用的计算:
{timespecrt0,ct0,tt0;clock_gettime(CLOCK_REALTIME,&rt0);clock_gettime(CLOCK_PROCESS_CPUTIME_ID,&ct0);clock_gettime(CLOCK_THREAD_CPUTIME_ID,&tt0);sleep(1);timespecrt1,ct1,tt1;clock_gettime(CLOCK_REALTIME,&rt1);clock_gettime(CLOCK_PROCESS_CPUTIME_ID,&ct1);clock_gettime(CLOCK_THREAD_CPUTIME_ID,&tt1);cout<<"Realtime:"< Realtime:1.000s,CPUtime:3.23e-05s,Threadtime:3.32e-05s对于在套接字或文件上被阻塞或等待用户操作的程序,情况也应该是如此。 到目前为止,我们还没有看到两个CPU计时器之间的任何差异,除非您的程序使用线程,否则您也不会看到任何差异。我们可以让我们的计算密集型程序执行相同的工作,但使用单独的线程: 大多数情况下,如果我们要使用线程进行计算,目标是更快地进行更多的计算,因此我们将使用多个线程并在它们之间分配工作。让我们修改前面的例子,也在主线程上进行计算: 但是,如果你不知道从哪里开始怎么办?如果你继承了一个没有为任何基准测试进行仪器化的代码库怎么办?或者,也许你将性能瓶颈隔离到了一个大段代码中,但里面没有更多的定时器了怎么办?一种方法是继续对代码进行仪器化,直到你有足够的数据来分析问题。但这种蛮力方法很慢,所以你会希望得到一些关于在哪里集中努力的指导。这就是性能分析的作用:它让你可以为一个没有手动进行简单基准测试的程序收集性能数据。我们将在下一节学习有关性能分析的知识。 有许多不同的分析工具可用,包括商业和开源的。在本节中,我们将研究两种在Linux系统上流行的分析器。我们的目标不是让你成为某个特定工具的专家,而是让你了解你选择使用的分析器可以期望什么以及如何解释其结果。 首先,让我们指出有几种不同类型的分析器: 我们对程序执行的采样越频繁,我们收集的数据就越多,但开销也越大。基于硬件的分析器在某些情况下可能对程序的运行时没有任何不利影响,如果采样不是太频繁的话。 运行这个性能分析器的最简单方法是收集整个程序的计数器值;这是使用perfstat命令完成的: 图2.6 事实证明,现代CPU可以收集许多不同类型的事件的统计信息,但一次只能收集少数类型;在前面的例子中,报告了八个计数器,因此我们可以假设这个CPU有八个独立的计数器。然而,这些计数器中的每一个都可以被分配来计算许多事件类型中的一个。性能分析器本身可以列出所有已知的事件,并且可以对其进行计数: 图2.7 图2.7中的列表是不完整的(打印输出会继续很多行),并且可用的确切计数器会因CPU而异(如果您使用虚拟机,则还会受到hypervisor的类型和配置的影响)。我们在图2.6中收集的性能分析运行结果只是默认的计数器集,但我们可以选择其他计数器进行性能分析: 图2.8 在图2.8中,我们测量CPU周期和指令,以及分支、分支丢失、缓存引用和缓存丢失。这些计数器及其监视的事件的详细解释将在下一章中介绍。 "分支"是条件指令:每个if语句和每个带有条件的for循环至少生成一个这样的指令。分支丢失将在下一章中详细解释;现在我们只能说,从性能角度来看,这是一个昂贵且不希望发生的事件。 掌握了CPU和内存的工作原理,您将能够利用这些测量来评估程序的整体效率,并确定限制其性能的因素类型。 到目前为止,我们只看到了整个程序的测量。图2.8中的测量可能告诉我们是什么在阻碍我们代码的性能:例如,如果我们暂时接受“缓存未命中”对性能不利,我们可以推断出这段代码的主要问题是其低效的内存访问(十次内存访问中有一次是慢的)。然而,这种类型的数据并不告诉我们代码的哪些部分负责性能不佳。为此,我们需要收集数据不仅在程序执行之前和之后,还在程序执行期间。让我们看看如何使用perf来做到这一点。 分析器的数据收集运行并不比整体测量运行更困难。请注意,在运行时,指令地址被收集;要将这些转换为原始源代码中的行号,程序必须使用调试信息进行编译。如果您习惯于两种编译模式,“优化”和“非优化调试”,那么编译器选项的这种组合可能会让您感到惊讶:调试和优化都已启用。后者的原因是我们需要对将在生产中运行的相同代码进行分析,否则数据大多是无意义的。考虑到这一点,我们可以为分析编译代码并使用perfrecord命令运行分析器: 图2.9 就像perfstat一样,我们可以指定一个计数器或一组计数器来监视,但是这次,我们接受默认计数器。我们没有指定采样的频率;同样,也有一个默认值,但我们也可以明确指定:例如,perfrecord-c1000每秒记录1000个样本。 程序运行,产生常规输出,以及来自分析器的消息。最后一个告诉我们,分析样本已经捕获在名为perf.data的文件中(同样,这是可以更改的默认值)。要可视化来自此文件的数据,我们需要使用分析工具,它也是同一perftools套件的一部分,具体来说是perfreport命令。运行此命令将启动此屏幕: 图2.10 图2.11 if(s1==s2)returnfalse;为什么出现两次?原始源代码中只有一行。原因是从这一行生成的指令不都在同一个地方;优化器将它们与来自其他行的指令重新排序。因此,分析器在这条线附近显示了两次机器指令。 图2.12 perf分析器有更多的选项和功能来分析、过滤和聚合结果,所有这些都可以从其文档中学习。此外,还有几个GUI前端用于这个分析器。接下来,我们将快速看一下另一个分析器,来自Google性能工具的分析器。 GoogleCPU分析器也使用硬件性能计数器。它还需要对代码进行链接时插装(但不需要编译时插装)。为了准备代码进行分析,你必须将其与分析器库链接: 图2.13 在图2.13中,库由命令行选项-lprofiler指定。与perf不同,这个分析器不需要任何特殊的工具来调用程序;必要的代码已经链接到可执行文件中。插装的可执行文件不会自动开始分析自身。我们必须通过设置环境变量CPUPROFILE为我们想要存储结果的文件名来激活分析。其他选项也是通过环境变量而不是命令行选项来控制的,例如,变量CPUPROFILE_FREQUENCY设置每秒的样本数: 图2.14 再次,我们看到了程序本身和分析器的输出,并且得到了我们必须分析的配置文件。分析器有交互模式和批处理模式;交互模式是一个简单的文本用户界面: 图2.15 图2.16 正如你所看到的,这个分析器采用了稍微不同的方法,并没有立即将我们深入到机器代码中(尽管也可以生成带注释的汇编代码)。然而,这种表面上的简单有些欺骗性:我们之前描述的注意事项仍然适用,优化编译器仍然对代码进行转换。 不同的性能分析工具由于作者采取的不同方法而具有不同的优势和劣势。我们不想把本章变成性能分析工具的手册,因此在本节的其余部分,我们将展示在收集和分析性能分析结果时可能遇到的一些常见问题。 首先,我们必须修改我们的例子,以便我们可以从多个位置调用某个函数。让我们首先进行两次sort调用: std::sort(vs.begin(),vs.end(),&{++count;returncompare1(a,b,L);});std::sort(vs.begin(),vs.end(),&{++count;returncompare2(a,b,L);});这些调用只在比较函数上有所不同;在我们的例子中,第一个比较函数和之前一样,而第二个则产生了相反的顺序。这两个函数都和我们旧的比较函数一样,在子字符串字符上有相同的循环: boolcompare1(constchar*s1,constchar*s2,unsignedintl){if(s1==s2)returnfalse;for(unsignedinti1=0,i2=0;i1 intcompare(charc1,charc2){if(c1>c2)return1;if(c1 现在我们准备生成一个调用图,它将显示字符比较的成本是如何在两次对sort的调用之间分配的。我们使用的两个性能分析工具都可以生成调用图;在本节中,我们将使用Google性能分析工具。对于这个性能分析工具,数据收集已经包括了调用链信息;我们只是到目前为止还没有尝试去可视化它。 我们编译代码并运行性能分析器,就像我们之前做的那样(为了简单起见,我们将每个函数放在自己的源文件中): Figure2.17 性能分析工具可以以几种不同的格式显示调用图(Postscript、GIF、PDF等)。例如,要生成PDF输出,我们将运行以下命令: google-pprof--pdf./exampleprof.data>prof.pdf我们现在感兴趣的信息在调用图的底部: 图2.18 我们已经看到编译器优化在解释性能分析时会使情况变得复杂:所有的性能分析最终都是在编译后的机器代码上进行的,而我们看到的程序是以源代码形式呈现的。编译器优化使这两种形式之间的关系变得模糊。在重新排列源代码方面,最具侵略性的优化之一是编译时函数调用的内联。 内联要求函数的源代码在调用点可见,因此,为了向您展示这是什么样子,我们必须将整个源代码合并到一个文件中: boolcompare(constchar*s1,constchar*s2,unsignedintl){if(s1==s2)returnfalse;for(unsignedinti1=0,i2=0;i1 让我们看看我们之前使用的性能分析工具如何处理内联代码。运行Google性能分析器对带注释的源代码行产生了这份报告: 图2.19 正如您所见,性能分析器知道compare()函数被内联,但仍显示其原始名称。源代码中的行对应于函数的代码编写位置,而不是调用位置,例如,第23行是这样的: if(s1[i1]!=s2[i2])returns1[i1]>s2[i2];另一方面,perf性能分析器并不容易显示内联函数: 图2.20 图2.21 不幸的是,没有简单的方法来消除优化对性能分析的影响。内联、代码重排和其他转换将详细的性能分析变成了一个随着实践而发展的技能。因此,现在需要一些关于有效使用性能分析的实际建议。 也许会有诱惑力认为性能分析是解决所有性能测量需求的终极解决方案:在性能分析器下运行整个程序,收集所有数据,并获得对代码中发生的一切情况的完整分析。不幸的是,情况很少会如此顺利。有时,工具的限制会成为阻碍。通常,大量数据中包含的信息复杂性太过压倒性。那么,你应该如何有效地使用性能分析呢? 我们已经有了这样的工具:整个程序正在执行这段代码,并且我们有方法来衡量它的性能。但我们现在真的对程序的其余部分不感兴趣了,至少在我们解决了已经确定的性能问题之前是这样。 为了优化程序中的几行代码而使用大型程序有以下两个主要缺点: 简而言之,微基准测试只是我们刚才说我们想要做的事情的一种方式:运行一小段代码并测量其性能。在我们的情况下,只是一个函数,但也可以是一个更复杂的代码片段。重要的是,这个代码片段可以在正确的起始条件下轻松调用:对于一个函数,只是参数,但对于一个更大的片段,可能需要重新创建一个更复杂的内部状态。 该程序看起来将完全符合我们的需求...至少直到我们运行它为止: 图2.22 intmain(){constexprunsignedintN=1<<20;constexprintNI=1<<11;unique_ptr 图2.23 实际上太快了,但为什么?让我们在调试器中逐步执行程序,看看它实际上做了什么: 图2.24 我们在main中设置断点,因此程序一启动就会暂停,然后我们逐行执行程序...除了我们编写的所有行之外!其他代码在哪里?我们可以猜想是编译器的问题,但为什么?我们需要更多了解编译器优化。 标准规定,只要这些更改的效果不会改变可观察行为,编译器可以对程序进行任何更改。标准还非常明确地规定了什么构成了可观察行为: 有了这种新的理解,让我们再来看一下基准代码:字符串比较的结果以任何方式都不会影响可观察行为,因此整个计算可以由编译器自行决定是否执行或省略。这一观察还给了我们解决这个问题的方法:我们必须确保计算的结果影响可观察行为。其中一种方法是利用先前描述的volatile语义: intmain(){constexprunsignedintN=1<<20;constexprintNI=1<<11;unique_ptr 图2.25 将我们的计算与一些可观察行为纠缠在一起的第二个选项是简单地打印出结果。然而,这可能会有点棘手。考虑直接的尝试: intmain(){constexprunsignedintN=1<<20;constexprintNI=1<<11;unique_ptr 图2.26 这带来了一个有趣的问题:为什么编译器没有发现循环的第二次迭代给出了与第一次相同的结果,并优化掉了除第一次之外的每次对比函数调用?如果优化器足够先进,它本来可以这样做,然后我们将不得不做更多工作来解决这个问题:通常,将函数编译为单独的编译单元足以防止任何此类优化,尽管一些编译器能够进行整个程序的优化,因此在运行微基准测试时可能需要关闭它们。 要使用谷歌基准库,我们必须编写一个小程序,准备输入并执行我们想要进行基准测试的代码。这是一个用于测量我们的字符串比较函数性能的基本谷歌基准程序: #include"benchmark/benchmark.h"usingstd::unique_ptr;boolcompare_int(constchar*s1,constchar*s2){charc1,c2;for(inti1=0,i2=0;;++i1,++i2){c1=s1[i1];c2=s2[i2];if(c1!=c2)returnc1>c2;}}voidBM_loop_int(benchmark::State&state){constunsignedintN=state.range(0);unique_ptr 我们需要为每个代码片段(例如我们想要进行基准测试的函数)创建一个固定装置。在每个基准装置中,我们要做的第一件事是设置我们需要用作代码输入的数据。更一般地说,我们可以说我们需要重新创建此代码的初始状态,以表示在真实程序中的情况。在我们的情况下,输入是字符串,因此我们需要分配和初始化字符串。我们可以将字符串的大小硬编码到基准测试中,但也有一种方法可以将参数传递给基准测试装置。我们的装置使用一个参数,即字符串长度,它是一个整数,通过state.range(0)访问。可以传递其他类型的参数,请参阅谷歌基准库的文档了解详情。 仅仅定义了一个基准测试装置并不会有任何作用,我们需要在库中注册它。这是使用BENCHMARK宏完成的;宏的参数是函数的名称。顺便说一下,该名称并没有什么特别之处,它可以是任何有效的C++标识符;我们的名称以BM_开头只是我们在本书中遵循的命名约定。BENCHMARK宏也是您将指定要传递给基准测试装置的任何参数的地方。使用重载的箭头运算符传递基准测试的参数和其他选项,例如: BENCHMARK(BM_loop_int)->Arg(1<<20);这行代码使用一个参数1<<20注册了基准测试装置BM_loop_int,可以通过调用state.range(0)在装置内检索到。在本书中,我们将看到更多不同参数的示例,甚至可以在库文档中找到更多。 您还会注意到在前面的代码清单中没有main();相反,有另一个宏,BENCHMARK_MAIN()。main()不是我们编写的,而是由GoogleBenchmark库提供的,它完成了设置基准测试环境、注册基准测试和执行基准测试的所有必要工作。 让我们回到我们想要测量的代码并更仔细地检查一下: for(auto_:state){benchmark::DoNotOptimize(compare_int(s1,s2));}benchmark::DoNotOptimize(…)包装函数的作用类似于我们之前使用的volatilesink:它确保编译器不会优化掉对compare_int()的整个调用。请注意,它实际上并没有关闭任何优化;特别是括号内的代码通常会像我们想要的那样进行优化。它所做的只是告诉编译器,表达式的结果,在我们的情况下是比较函数的返回值,应该被视为“已使用”,就像它被打印出来一样,不能简单地被丢弃。 现在我们准备编译和运行我们的第一个微基准测试: 图2.27 这些数字每次运行时有多大的变化?如果我们使用正确的命令行参数启用统计信息收集,基准库可以为我们计算出来。例如,要重复进行基准测试十次并报告结果,我们会这样运行基准测试: 图2.28 看起来测量结果相当准确;标准偏差相当小。现在我们可以对比不同变体的子字符串比较函数,并找出哪一个是最快的。但在我们这样做之前,我必须告诉你一个大秘密。 当你开始运行越来越多的微基准测试时,你很快就会发现这一点。起初,结果是合理的,你进行了良好的优化,一切看起来都很好。然后你做了一些小改变,得到了一个完全不同的结果。你回去调查,现在你已经运行过的相同测试给出了完全不同的数字。最终,你找到了两个几乎相同的测试,结果完全相反,你意识到你不能相信微基准测试。它会摧毁你对微基准测试的信心,我现在唯一能做的就是以一种可控的方式摧毁它,这样我们还能从废墟中挽救一些东西。 相反,基准测试有它自己的上下文。基准测试库的作者并不对这个问题一无所知,并且他们尽力去对抗它。例如,你看不到的是,Google基准库对每个测试进行了烧入:最初的几次迭代可能具有与运行的其余部分非常不同的性能特征,因此库会忽略初始测量,直到结果"稳定"。但这也定义了一个特定的上下文,可能与每次调用函数只重复一次的真实程序不同(另一方面,有时我们确实会在程序运行中多次使用相同的参数调用相同的函数,所以这可能是一个不同的上下文)。 然而,仍然存在这样的可能性,即编译器可能会发现每次调用函数都返回相同的结果。此外,由于函数定义与调用点在同一个文件中,编译器可以内联整个函数,并使用它可以收集到的关于参数的任何信息来优化函数代码。当函数从另一个编译单元调用时,在一般情况下这样的优化是不可用的。 为了更准确地在我们的微基准测试中表示真实情况,我们可以将比较函数移动到它自己的文件中,并将其单独编译。现在我们有一个文件(编译单元),其中只有基准测试的固定装置: 图2.29 代码的初始版本使用了unsignedint索引和循环中的边界条件(最后一行);简单地删除那个完全不必要的边界条件检查导致了令人惊讶的性能下降(中间行);最后,将索引更改为signedint恢复了丢失的性能,甚至提高了性能(第一行)。 通常,将代码片段分别编译就足以避免任何不需要的优化。不太常见的是,您可能会发现编译器对同一文件中的特定代码块进行不同的优化,这取决于文件中还有什么其他内容。这可能只是编译器中的一个错误,但也可能是某种启发式的结果,在编译器编写者的经验中,这种启发式更常常是正确的。如果您观察到结果取决于根本没有执行的某些代码,只是编译了的代码,这可能就是原因。一个解决方案是使用真实程序中的编译单元,然后只调用您想要进行基准测试的函数。当然,您将不得不满足编译和链接的依赖关系,这就是编写模块化代码和最小化依赖关系的另一个原因。 我们需要重建的状态越复杂,就越难在部分基准测试中重现真实程序的性能行为。请注意,这个问题在某种程度上类似于编写单元测试的问题:如果程序无法分解为具有简单状态的较小单元,编写单元测试也会更加困难。再次看到了良好设计的软件系统的优势:具有良好单元测试覆盖率的代码库通常更容易进行微基准测试,逐步进行测试。 当我们开始本节时,您已经被警告,这部分内容旨在部分恢复您对微基准测试的信心。它们可以是一个有用的工具,正如我们在本书中多次看到的那样。它们也可能误导您,有时甚至误导得很远。现在您了解了一些原因,更有准备去尝试从结果中恢复有用的信息,而不是完全放弃小规模基准测试。 本章介绍的工具都不是解决所有问题的解决方案;它们也不是用来解决所有问题的。通过使用这些工具以各种方式收集信息,它们可以相互补充,从而实现最佳结果。 在本章中,您学到了整本书中可能最重要的一课:谈论性能而不参考具体的测量是没有意义的,甚至是不切实际的。其余的内容主要是技艺:我们介绍了几种测量性能的方法,从整个程序开始,逐渐深入到单行代码。 一个大型高性能项目将会看到本章中学到的每种工具和方法被使用不止一次。粗略的测量-对整个程序或其大部分进行基准测试和分析-指向了需要进一步调查的代码区域。通常会跟随额外的基准测试轮次或更详细的分析。最终,你会确定需要优化的代码部分,问题变成了,“我该如何更快地做到这一点?”在这一点上,你可以使用微基准测试或其他小规模基准测试来尝试优化的代码。你甚至可能会发现你对这段代码的理解并不如你想象的那么多,需要对其性能进行更详细的分析;不要忘记你可以对微基准进行分析! 最终,你将拥有一个在小型基准测试中看起来有利的性能关键代码的新版本。但是,不要假设任何事情:现在你必须测量你的优化或增强对整个程序的性能。有时,这些测量将确认你对问题的理解并验证其解决方案。在其他时候,你会发现问题并不是你所想象的那样,而优化虽然本身有益,但对整个程序的效果并不如预期(甚至可能使情况变得更糟)。现在你有了一个新的数据点,你可以比较旧解决方案和新解决方案的分析,并在这种比较中揭示的差异中寻找答案。 高性能程序的开发和优化几乎从来都不是一个线性的、一步一步的过程。相反,它经历了许多从高层概述到低层详细工作再返回的迭代。在这个过程中,你的直觉起着一定的作用;只是确保始终测试和确认你的期望,因为在性能方面,没有什么是真正明显的。 在下一章中,我们将看到我们之前遇到的谜团的解决方案:删除不必要的代码会使程序变慢。为了做到这一点,我们必须了解如何有效地利用CPU以获得最佳性能,整个下一章都致力于此。 通过本章,我们开始探索计算硬件:我们想知道如何最佳地使用它,并从中挤出最佳性能。我们首先要了解的硬件组件是中央处理器。CPU执行所有计算,如果我们没有有效地使用它,那么没有什么能拯救我们慢速、性能不佳的程序。本章致力于学习CPU资源和能力,最佳使用它们的方式,未能充分利用CPU资源的更常见原因,以及如何解决它们。 这带我们来到下一个问题:比如说,一秒钟内可以做多少计算?答案当然取决于你拥有什么硬件,有多少硬件,以及你的程序能够有效地使用多少。任何程序都需要多个硬件组件:处理器和内存,显然,但对于任何分布式程序还需要网络,对于操纵大量外部数据的任何程序还需要存储和其他I/O通道,可能还需要其他硬件,具体取决于程序的功能。但一切都始于处理器,因此,我们的高性能编程探索也必然从这里开始。此外,在本章中,我们将限制自己在单个执行线程上;并发将在后面讨论。 在这个更狭窄的焦点下,我们可以定义本章的主题:如何使用单个线程最佳地利用CPU资源。要理解这一点,我们首先需要探索CPU具有哪些资源。当然,不同世代和不同型号的处理器将具有不同的硬件能力组合,但本书的目标是双重的:首先,给你一个对主题的一般理解,其次,为你提供获取更详细和具体知识所需的工具。任何现代CPU上可用的计算资源的一般概述可概括为它很复杂。为了说明这一点,考虑一下英特尔CPU的芯片图像: 前一节的结果可能让你有些畏缩:处理器非常复杂,显然需要程序员大量辅助才能达到最高效率。让我们从小处着手,看看处理器可以多快地执行一些基本操作。为此,我们将使用上一章中使用过的GoogleBenchmark工具。这是一个用于简单数组相加的基准测试: #include"benchmark/benchmark.h"voidBM_add(benchmark::State&state){srand(1);constunsignedintN=state.range(0);std::vector 请注意,这是微基准的一个不太常见的应用:通常情况下,我们有一小段代码,我们想知道它有多快,以及如何使它更快。在这里,我们使用微基准来了解处理器的性能,通过调整代码的方式来获得一些见解。 基准测试应该在打开优化的情况下进行编译。运行此基准测试将产生类似以下的结果(确切的数字当然取决于您的CPU): 图3.2 为了分析我们的代码的性能,我们必须以处理器看到的方式来看待它,这里发生的事情远不止简单的加法。两个输入数组存储在内存中,但加法或乘法操作是在寄存器中的值之间执行的(或者可能是在寄存器和内存位置之间执行,对于某些操作)。这就是处理器逐步看到我们循环的一次迭代。在迭代开始时,索引变量i在一个CPU寄存器中,两个对应的数组元素v1[i]和v2[i]在内存中: 图3.3-第i次循环迭代开始时的处理器状态 在我们做任何事情之前,我们必须将输入值移入寄存器。必须为每个输入分配一个寄存器,再加上一个寄存器用于结果。在给定的循环迭代中,第一条指令将一个输入加载到寄存器中: 图3.4-第i次迭代的第一条指令后的处理器状态 读取(或加载)指令使用包含索引i和数组v1位置的寄存器来访问值v1[i]并将其复制到寄存器中。下一条指令类似地加载第二个输入: 图3.5-第i次迭代的第二条指令后的处理器状态 现在我们终于准备好执行加法或乘法等操作了: 图3.6-第i次循环迭代结束时的处理器状态 这行简单的代码在转换为硬件指令后产生了所有这些步骤(以及推进到循环的下一个迭代所需的操作): 图3.7–单条指令和两条指令的基准测试 令人惊讶的是,这里的一加一等于一。我们可以在一个迭代中添加更多的指令: 图3.8–每次迭代最多四条指令的循环基准测试 似乎我们对处理器一次执行一条指令的观点需要修订: 图3.9–处理器在单个步骤中执行多个操作 只要操作数已经在寄存器中,处理器就可以同时执行多个操作。这被称为指令级并行性(ILP)。当然,可以执行的操作数量是有限的:处理器只有那么多能够进行整数计算的执行单元。尽管如此,通过在一个迭代中添加越来越多的指令来尝试推动CPU的极限是很有教育意义的: for(size_ti=0;i 图3.10–每次迭代八条指令的基准测试 现在您可以欣赏到我们原始代码在硬件利用方面是多么低效:CPU显然可以在每次迭代中执行五到七个不同的操作,因此我们的单个乘法甚至没有占用其四分之一的能力。事实上,现代处理器的能力更加令人印象深刻:除了我们一直在进行实验的整数计算单元之外,它们还具有专门用于执行double或float值的指令的独立浮点硬件,以及同时执行MMX、SSE、AVX和其他专门指令的矢量处理单元! 第一步是使用分析器标记代码,以选择要分析的代码部分: #defineMCA_START__asmvolatile("#LLVM-MCA-BEGIN");#defineMCA_END__asmvolatile("#LLVM-MCA-END");…for(size_ti=0;i 我们现在可以运行分析器了: 图3.11 图3.12 现在让我们添加另一个使用已经从内存中读取的值p1[i]和p2[i]的操作: 图3.13 您可能还注意到,到目前为止,我们所有的基准测试都增加了对相同输入值的操作次数(加法、减法、乘法等)。我们得出结论,这些额外的操作在运行时来说是免费的(在一定程度上)。这是一个重要的一般性教训:一旦您在寄存器中有了一些值,对相同的值进行计算可能不会给您带来任何性能损失,除非您的程序已经非常高效,并且已经极限地利用了硬件。不幸的是,这个实验和结论的实际价值有限。有多少次您的所有计算都只是在少数几个输入上进行,下一次迭代使用自己的输入,并且您可以找到更多有用的计算来处理相同的输入?并不是从来没有,但很少。任何试图扩展我们对CPU计算能力的简单演示的尝试都将遇到一个或多个复杂性。第一个是数据依赖:循环的顺序迭代通常不是独立的;相反,每次迭代都需要来自前几次迭代的一些数据。我们将在下一节中探讨这种情况。 for(size_ti=0;i 图3.14-循环评估中的数据依赖 图3.15-流水线:行对应于连续的迭代;同一行中的所有操作同时执行 这种代码的转换被称为流水线:一个复杂的表达式被分解成阶段,并在流水线中执行,前一个表达式的第二阶段与下一个表达式的第一阶段同时运行(更复杂的表达式会有更多的阶段,需要更深的流水线)。如果我们的期望是正确的,只要有很多次迭代,CPU就能够像单个乘法一样快速计算我们的两阶段加减乘表达式:第一次迭代将需要两个周期(先加减,然后乘),这是无法避免的。同样地,最后一次迭代将以单个乘法结束,我们无法同时做其他事情。然而,在中间的所有迭代中,将同时执行三个操作。我们已经知道我们的CPU可以同时进行加法、减法和乘法。乘法属于循环的不同迭代这个事实并不重要。 图3.16 如预期的那样,两个循环的运行速度基本相同。我们可以得出结论,流水线完全抵消了数据依赖造成的性能损失。请注意,流水线并没有消除数据依赖;每个循环迭代仍然需要在两个阶段中执行,第二阶段依赖于第一阶段的结果。然而,通过交错计算不同阶段的计算,流水线确实消除了由此依赖引起的低效(至少在理想情况下,这是我们目前的情况)。通过机器代码分析器的结果,我们可以更直接地确认这一点: 重要的是要明白,我们并不是寄存器用尽了:CPU的寄存器比我们目前看到的要多得多。问题在于,每次迭代的代码看起来都和下一次迭代的代码一模一样,包括寄存器的名称,但是在每次迭代中,不同的值必须存储在寄存器中。看起来我们假设和观察到的流水线似乎是不可能的:下一次迭代必须等待前一次迭代停止使用它所需的寄存器。这并不是真正发生的情况,这个明显的矛盾的解决方案是硬件技术称为rsi,不是真正的寄存器名称,它们由CPU映射到实际的物理寄存器。相同的名称rsi可以映射到不同的寄存器,它们都具有相同的大小和功能。 当处理器在流水线中执行代码时,第一次迭代中引用rsi的指令实际上将使用一个我们称之为rsi1的内部寄存器(这不是它的真实名称,但是寄存器的实际硬件名称不是你会遇到的,除非你是在设计处理器)。第二次迭代也有引用rsi的指令,但需要在那里存储不同的值,因此处理器将使用另一个寄存器rsi2。除非第一次迭代不再需要存储在rsi中的值,否则第三次迭代将不得不使用另一个寄存器,依此类推。这种寄存器重命名是由硬件完成的,与编译器完成的寄存器分配非常不同(特别是对于分析目标代码的任何工具,如LLVM-MCA或分析器,它是完全不可见的)。最终的效果是循环的多个迭代现在被执行为代码的线性序列,就好像s[i]和s[i+1]确实是指向不同的寄存器一样。 我们可以做出另一个重要的观察:CPU执行我们的代码的顺序实际上并不是指令编写的顺序。这被称为乱序执行,并对多线程程序有重要影响。 我们已经看到处理器如何避免数据依赖所施加的执行效率限制:对数据依赖的解药就是流水线。然而,故事并不会在那里结束,到目前为止我们已经设计的非常简单的循环的执行方案缺少了一些重要的东西:循环必须在某个时刻结束。在下一节中,我们将看到这会使事情变得更加复杂,以及解决方案是什么。 到目前为止,我们对处理器的高效使用的理解是:首先,CPU可以同时执行多个操作,比如同时进行加法和乘法。不充分利用这种能力就像把免费的计算能力留在桌子上一样。其次,限制我们最大化效率的因素是我们能够产生数据以供这些操作的速度有多快。具体来说,我们受到数据依赖的限制:如果一个操作计算出下一个操作用作输入的值,那么这两个操作必须按顺序执行。解决这种依赖的方法是流水线:在执行循环或长序列的代码时,处理器会交错进行单独的计算,比如循环迭代,只要它们至少有一些可以独立执行的操作。 然而,流水线也有一个重要的前提。流水线if(condition)语句,我们将执行true分支或false分支,但在评估condition之前我们不知道哪个分支会执行。就像数据依赖是指令级并行性的祸根一样,条件执行或分支是流水线的祸根。 流水线被打断后,我们可以预期程序的效率会显著降低。修改我们之前的基准测试以观察条件语句的有害影响应该是非常容易的。例如,不是写: a1+=p1[i]+p2[i];我们可以这样写: a1+=(p1[i]>p2[i])p1[i]:p2[i];现在我们已经将数据依赖重新引入为代码依赖: 图3.18-分支指令对流水线的影响 没有明显的方法将这段代码转换为线性指令流来执行,条件跳转无法避免。 现实情况要复杂一些:像我们刚刚建议的基准测试可能会或可能不会显示出性能的显著下降。原因是许多处理器都有某种条件移动甚至条件加法指令,编译器可能会决定使用它们。如果发生这种情况,我们的代码就变成了完全顺序的,没有跳转或分支,可以完美地进行流水线处理: 图3.19-有条件代码与cmove进行流水线处理 x86CPU具有条件移动指令cmove(尽管并非所有编译器都会使用它来实现前面图中的:运算符)。具有AVX或AVX2指令集的处理器具有一组强大的掩码加法和乘法指令,可以用来实现一些条件代码。因此,在对带有分支的代码进行基准测试和优化时,非常重要的是要检查生成的目标代码,并确认代码确实包含分支,并且它们确实影响了性能。还有一些性能分析工具可以用于此目的,我们马上就会看到其中一个。 虽然分支和条件在大多数实际程序中随处可见,但当程序被简化为用于基准测试的几行代码时,它们可能会消失。一个原因是编译器可能决定使用我们之前提到的条件指令之一。另一个常见的原因是在构建不良的基准测试时,编译器可能能够在编译时弄清楚条件的评估结果。例如,大多数编译器将完全优化掉任何类似if(true)或if(false)的代码:在生成的代码中没有这个语句的痕迹,任何永远不会被执行的代码也被消除了。为了观察分支对循环流水线的不利影响,我们必须构建一个测试,使编译器无法预测条件检查的结果。在您的实际基准测试中,您可能会从实际程序中提取数据集。对于下一个演示,我们将使用随机值: 图3.20 如您所见,条件代码比顺序代码慢大约五倍。这证实了我们的预测,即当下一条指令依赖于前一条指令的结果时,代码无法有效地进行流水线处理。 然而,一个敏锐的读者可能会指出,我们刚刚描述的情况不可能是完整的,甚至不是真实的:让我们回到刚才的线性代码,比如我们在上一节中广泛使用的循环: for(size_ti=0;i 图3.21–在宽度为w的流水线中执行的循环 在图3.21中,我们展示了三个交错的迭代,但可能会有更多,流水线的总宽度是w,理想情况下,w足够大,以至于在每个周期,CPU正好执行与其同时执行的指令一样多(在实践中很少可能达到这种峰值效率)。然而,可能无法在计算和存储和p1[i]+p2[i]的同时访问v[i+2]:没有保证循环还有两次迭代,如果没有,元素v[i+2]就不存在,访问它会导致未定义的行为。在前面的代码中有一个隐藏的条件:在每次迭代中,我们必须检查i是否小于N,只有在这种情况下才能执行第i次迭代的指令。 因此,我们在图3.20中的比较是错误的:我们并没有比较流水线顺序执行和不可预测的条件执行。事实上,这两个基准都是条件代码的例子,它们都有分支。 完整的真相在其中。要理解这一点,我们必须了解条件执行的解药,它会破坏流水线,并且本身是数据依赖的解药。在分支存在的情况下保持流水线的方法是尝试将条件代码转换为顺序代码。如果我们事先知道分支将采取的路径,就可以进行这种转换:我们只需消除分支并继续执行下一个要执行的指令。当然,如果我们事先知道条件是什么,甚至不需要编写这样的代码。但是,考虑循环终止条件。假设循环执行多次,很可能条件i 处理器使用称为分支预测的技术进行同样的赌博。它分析代码中每个分支的历史,并假设行为在未来不会改变。对于循环结束条件,处理器将很快学会,大多数情况下,它必须继续下一次迭代。因此,正确的做法是流水线下一次迭代,就好像我们确定它会发生一样。当然,我们必须推迟将结果写入内存,直到我们评估条件并确认迭代确实发生;处理器有一定数量的写缓冲区来保存这些未经确认的结果,在提交到内存之前。 现在我们对流水线的工作原理有了更全面的了解:为了在并行执行更多指令,处理器查看循环的下一次迭代的代码,并开始与当前迭代同时执行。如果代码包括条件分支,使得不可能确定将执行哪个指令,处理器会根据过去检查相同条件的结果做出合理的猜测,并继续推测性地执行代码。如果预测被证明是正确的,流水线的效果可以和无条件代码一样好。如果预测是错误的,处理器必须丢弃不应该被评估的每条指令的结果,获取先前假定不需要的指令,并代替评估它们。这个事件被称为流水线刷新,这确实是一个昂贵的事件。 我们应该能够通过直接实验来验证我们的理解:我们只需要将随机条件更改为始终为true的条件。唯一的问题是我们必须以编译器无法理解的方式来做到这一点。一种常见的方法是将条件向量的初始化更改为以下内容: c1[i]=rand()>=0;编译器不知道函数rand()总是返回非负随机数,并且不会消除条件。CPU的分支预测电路将很快学习到条件if(b1[i])总是评估为true,并将推测性地执行相应的代码。我们可以将预测良好的分支的性能与不可预测的分支进行比较: 图3.22 在这里,我们可以看到预测良好的分支的成本是最小的,比预测不佳的分支快得多,即使是完全相同的代码。 在本章中,我们将再次使用perf分析器。作为第一步,我们可以运行此分析器以收集整个基准程序的整体性能指标: $perfstat./benchmark这是仅运行BM_branch_not_predicted基准的程序的perf结果(其他基准已在此测试中注释掉): 图3.23-具有预测不佳分支的基准的概要 如您所见,所有分支中有11%被错误预测(报告的最后一行)。请注意,此数字是所有分支的累积值,包括循环条件的完全可预测结尾,因此总共11%相当糟糕。我们应该将其与我们的其他基准BM_branch_predicted进行比较,该基准与此基准相同,只是条件始终为真: 图3.24-具有良好预测分支的基准的配置文件 这一次,不到0.1%的分支没有被正确预测。 $perfrecord-ebranches,branch-misses./benchmark在我们的情况下,我们可以从perfstat的输出中复制计数器的名称,因为它恰好是默认情况下测量的计数器之一,但是完整列表可以通过运行perf--list来获取。 分析器运行程序并收集指标。我们可以通过生成配置文件来查看它们: $perfreport报告分析器是交互式的,让我们可以导航到每个函数的分支错误预测计数器: 图3.25-针对错误预测分支的详细配置文件 超过99%的错误预测分支发生在一个函数中。由于该函数很小,找到负责的条件操作不应该很难。在较大的函数中,我们需要查看逐行分析。 for(size_ti=0;i 图3.26-“真-假”模式的分支预测率 我们几乎准备好了应用我们如何有效使用处理器的知识。但首先,我必须承认我们忽视了一个重大的潜在问题。 我们现在了解了流水线如何使CPU保持忙碌,以及通过预测条件分支的结果并在我们确定必须执行之前就进行预期代码的执行,我们允许条件代码进行流水线处理。图3.21说明了这种方法:假设循环条件在当前迭代之后不会发生,我们可以将下一次迭代的指令与当前迭代的指令交错,因此我们可以并行执行更多指令。 再次考虑图3.21:如果第i次迭代是循环中的最后一次迭代,那么下一次迭代就不应该发生。当然,我们可以丢弃值a[i+1]并且不将其写入内存。但是,为了进行任何流水线处理,我们必须读取v1[i+1]的值。我们无法丢弃我们读取它的事实:我们在检查迭代i是否是最后一次迭代之前就访问了v1[i+1],并且无法否认我们确实访问了它。但是元素v1[i+1]在为向量分配的有效内存区域之外;即使读取它也会导致未定义的行为。 隐藏在“推测执行”无辜标签背后的危险的更有说服力的例子是这个非常常见的代码: intf(int*p){if(p){return*p;}else{return0;}}让我们假设指针p很少是NULL,所以分支预测器学习到if(p)语句的true分支通常被执行。当函数最终以p==NULL被调用时会发生什么?分支预测器会像往常一样假设相反,并且true分支会被推测执行。它首先会对NULL指针进行解引用。我们都知道接下来会发生什么:程序会崩溃。后来,我们会发现糟糕,非常抱歉,我们一开始不应该选择那个分支,但是如何撤销一个崩溃呢? 从像我们的函数f()这样的代码非常常见且不会遭受意外随机崩溃的事实,我们可以得出结论,要么推测执行实际上并不存在,要么有一种方法可以撤销崩溃,或者类似的。我们已经看到一些证据表明推测执行确实发生并且对提高性能非常有效。我们将在下一章中看到更多直接证据。那么,当我们试图推测执行一些不可能的事情时,比如对NULL指针进行解引用,它是如何处理的呢?答案是,对于这种潜在灾难的灾难性响应必须被暂时保留,既不被丢弃也不被允许成为现实,直到分支条件实际被评估,并且处理器知道推测执行是否应该被视为真正的执行。在这方面,故障和其他无效条件与普通的内存写入没有什么不同:只要导致该操作的指令仍然是推测的,任何无法撤销的操作都被视为潜在操作。CPU必须有特殊的硬件电路,比如缓冲区,来暂时存储这些事件。最终结果是,处理器确实对NULL指针进行解引用或者在推测执行期间读取不存在的向量元素v[i+1],然后假装这从未发生过。 现在我们了解了分支预测和推测执行如何使处理器能够在数据和代码依赖性造成的不确定性的情况下高效运行,我们可以把注意力转向优化我们的程序。 非常重要的是要理解,硬件分支预测是基于处理器执行的条件指令。因此,处理器对于条件的理解可能与我们的理解不同。以下示例有力地证明了这一点: 图3.27-“假”分支的分支预测概况 分析器显示的分支预测率与真正随机分支一样糟糕。性能基准证实了我们的期望: 图3.28 假分支(实际上根本不是分支)的性能与真正随机、不可预测的分支一样糟糕,远远不如预测良好的分支。 在真实的程序中,不应该遇到这种不必要的条件语句。然而,非常常见的是一个复杂的条件表达式,几乎总是基于不同的原因评估为相同的值。例如,我们可能有一个很少为假的条件: 必须按特定顺序逐步进行这种评估的原因是,C++标准(以及之前的C标准)规定逻辑操作(如&&和||)是短路的:一旦整个表达式的结果已知,评估剩下的表达式应该停止。当条件具有副作用时,这一点尤为重要: if(f1()||f2()){…truebranch…}else{…falsebranch…}现在,只有在f1()返回false时才会调用函数f2()。在前面的示例中,条件只是布尔变量c1、c2和c3。编译器可以检测到没有副作用,并且评估整个表达式到最后不会改变可观察的行为。一些编译器会进行这种优化;如果我们的假分支基准是用这样的编译器编译的,它将显示出预测良好分支的性能。不幸的是,大多数编译器不会将这视为潜在问题(实际上,编译器无法知道整个表达式通常评估为真,即使它的部分不是)。因此,这是程序员通常必须手动进行的优化。 假设程序员知道if()语句的两个分支中的一个经常被执行。例如,else分支可能对应于错误情况或其他异常情况,必须正确处理,但在正常操作下不应该出现。让我们还假设我们做对了事情,并且使用分析器验证了组成复杂布尔表达式的各个条件指令的预测不准确。我们如何优化代码呢? 第一个冲动可能是将条件评估移出if()语句: constboolc=c1&&c2)||c3;if(c){…}else{…}然而,这几乎肯定不会起作用,原因有两个。首先,条件表达式仍在使用逻辑&&和||操作,因此评估仍必须被短路,并且需要单独且不可预测的分支。其次,编译器可能通过删除不必要的临时变量c来优化此代码,因此生成的目标代码可能根本不会改变。 在循环遍历条件变量数组的情况下,类似的转换可能是有效的。例如,这段代码可能会受到较差的分支预测的影响: for(size_ii=0;i for(size_ii=0;i 通常有效的另一个优化是用加法和乘法或位&和|操作替换逻辑&&和||操作。在执行此操作之前,必须确保&&和||操作的参数是布尔值(值为零或一),而不是整数:即使2的值被解释为true,表达式2&1的结果与bool(2)&bool(1)的结果不同。前者评估为0(false),而后者给出了预期和正确的答案1(或true)。 我们可以在基准测试中比较所有这些优化的性能: 图3.29 正如你所看到的,通过引入临时变量BM_false_branch_temp来优化falsebranch的天真尝试是完全无效的。使用临时向量给出了预期的完全预测分支的性能,因为临时向量的所有元素都等于true,这是分支预测器学到的内容(BM_false_branch_vtemp)。用算术加法(+)或位|替换逻辑||产生类似的结果。 您应该记住,最后两种转换,即使用算术或位操作代替逻辑操作,会改变代码的含义:特别是,表达式中操作的所有参数都会被评估,包括它们的副作用。由您决定这种改变是否会影响程序的正确性。如果这些副作用也很昂贵,那么整体性能变化可能最终不利于您。例如,如果评估f1()和f2()非常耗时,将表达式f1()||f2()中的逻辑||替换为等效的算术加法(f1()+f2())可能会降低性能,即使它改善了分支预测。 总的来说,在falsebranches中优化分支预测没有标准方法,这也是为什么编译器很难进行有效的优化。程序员必须使用特定于问题的知识,例如特定条件是否可能发生,并结合分析测量结果得出最佳解决方案。 到目前为止,我们已经学到了:为了有效地使用处理器,我们必须提供足够的代码,以便并行执行多条指令。我们之所以没有足够的指令来让CPU忙碌的主要原因是数据依赖性:我们有代码,但我们无法运行它,因为输入还没有准备好。我们通过流水线处理代码来解决这个问题,但为了这样做,我们必须提前知道哪些指令将被执行。如果我们不提前知道执行的路径,我们就无法做到这一点。我们处理这个问题的方式是根据评估这个条件的历史来做出一个合理的猜测,即猜测条件分支是否会被执行,猜测越可靠,性能就越好。有时,没有可靠的猜测方式,性能就会受到影响。 所有这些性能问题的根源是条件分支,即在运行时无法知道要执行的下一条指令。解决问题的一个激进方法是重写我们的代码,不使用分支,或者至少减少分支的数量。这就是所谓的无分支计算。 事实上,这个想法并不是特别新颖。现在你已经了解了分支如何影响性能的机制,你可以认识到循环展开这一众所周知的技术,作为减少分支数量的早期代码转换的一个例子。让我们回到我们的原始代码示例: for(size_ti=0;i for(size_ti=0;i 循环展开是一个非常具体的优化,编译器已经学会了这样做。将这个想法概括为无分支计算是一个最近的进展,可以产生惊人的性能提升。我们将从一个非常简单的例子开始: unsignedlong*p1=...;//Databool*b1=...;//Unpredictableconditionunsignedlonga1=0,a2=0;for(size_ti=0;i unsignedlong*p1=...;//Databool*b1=...;//Unpredictableconditionunsignedlonga1=0,a2=0;unsignedlong*a[2]={&a2,&a1};for(size_ti=0;i 这种转换将对两种可能指令的条件跳转替换为对两种可能内存位置的条件访问。因为这种条件内存访问可以进行流水线处理,无分支版本提供了显著的性能改进: 图3.30 在这个例子中,无分支版本的代码快了3.5倍。值得注意的是,一些编译器在可能的情况下使用查找数组来实现:运算符,而不是条件分支。有了这样的编译器,我们可以通过将我们的循环体重写如下来获得相同的性能优势: for(size_ti=0;i 前面的例子涵盖了无分支计算的所有基本要素:不是有条件地执行这段代码或那段代码,而是将程序转换为在所有情况下都相同的代码,并且条件逻辑由索引操作实现。我们将通过几个更多的例子来强调一些值得注意的考虑和限制。 大多数时候,取决于条件的代码并不像写入结果那样简单。通常,我们必须根据一些中间值以不同的方式进行计算: unsignedlong*p1=...,*p2=...;//Databool*b1=...;//Unpredictableconditionunsignedlonga1=0,a2=0;for(size_ti=0;i 为了在没有分支的情况下计算相同的结果,我们必须从由条件变量索引的内存位置中获取正确表达式的结果。这意味着由于我们决定不基于条件改变执行哪些代码,因此将评估两个表达式。在这种理解下,转换为无分支形式是直接的: unsignedlonga1=0,a2=0;unsignedlong*a[2]={&a2,&a1};for(size_ti=0;i 图3.31 必须强调的是,你可以进行多少额外的计算并且仍然优于条件代码是有限制的。在这里,甚至没有一个好的一般性的经验法则可以让你做出明智的猜测(而且你绝对不应该猜测性能)。这种优化的有效性必须进行测量:它高度依赖于代码和数据。例如,如果分支预测非常有效(可预测的条件而不是随机的条件),条件代码将优于无分支版本: 图3.32 也许我们可以从图3.31和图3.32中学到的最显著的结论是流水线刷新(错误预测的分支)有多么昂贵,以及CPU可以同时进行多少计算。后者可以从完全预测的分支(图3.32)和无分支实现(图3.31)之间性能差异相对较小来推断。无分支计算依赖于这种隐藏且大部分未使用的计算能力储备,我们可能在我们的例子中还没有耗尽这个储备。展示同一代码的无分支转换的另一种变体是很有教育意义的,这种变体不是使用数组来选择正确的结果变量,而是如果我们不想实际改变结果,我们总是同时增加两个值: unsignedlonga1=0,a2=0;for(size_ti=0;i 图3.33-图3.31的结果,另一种无分支实现被添加为"BM_branchless1" 了解无分支转换的局限性并不要得意忘形是很重要的。我们已经看到了第一个局限性:无分支代码通常执行更多指令;因此,如果分支预测器最终运行良好,少量的流水线刷新可能不足以证明这种优化。 无分支转换不能如预期那样执行的第二个原因与编译器有关:在某些情况下,编译器可以进行等效或者更好的优化。例如,考虑所谓的夹紧循环: unsignedchar*c=...;//Randomvaluesfrom0to255for(size_ti=0;i unsignedchar*c=...;//Randomvaluesfrom0to255unsignedcharLUT[256]={0,1,…,127,128,128,…128};for(size_ti=0;i 还有一种情况下无分支转换可能不划算,那就是循环体的开销明显大于分支,即使是错误预测的分支。这种情况很重要,因为它通常描述了进行函数调用的循环: unsignedlongf1(unsignedlongx,unsignedlongy);unsignedlongf2(unsignedlongx,unsignedlongy);unsignedlong*p1=...,*p2=...;//Databool*b1=...;//Unpredictableconditionunsignedlonga=0;for(size_ti=0;i decltype(f1)*f[]={f1,f2};for(size_ti=0;i 尽管如此,有时查找表是一种值得优化的方法:对于只有两个选择的情况几乎从不值得,但如果我们必须根据单个条件从许多函数中进行选择,函数指针表比链式if-else语句更有效。值得注意的是,这个例子与所有现代编译器用来实现虚函数调用的实现非常相似;这样的调用也是使用函数指针数组而不是一系列比较来分派的。当需要优化根据运行时条件调用多个函数的代码时,您应该考虑是否值得使用多态对象进行重新设计。 在本章中,我们学习了主处理器的计算能力以及如何有效地使用它们。高性能的关键是充分利用所有可用的计算资源:同时计算两个结果的程序比稍后计算第二个结果的程序更快(假设计算能力可用)。正如我们所了解的,CPU具有各种类型计算的许多计算单元,其中大多数在任何给定时刻都是空闲的,除非程序经过高度优化。 我们已经看到,有效利用CPU指令级并行性的主要限制通常是数据依赖性:简单地说,没有足够的并行工作来让CPU保持忙碌。这个问题的硬件解决方案是流水线:CPU不仅仅执行程序中当前点的代码,而是从没有未满足数据依赖性的未来中获取一些计算,并并行执行它们。只要未来是已知的,这种方法就有效:如果CPU无法确定这些计算是什么,它就无法执行未来的计算。每当CPU必须等待确定下一条要执行的机器指令时,流水线就会停顿。为了减少这种停顿的频率,CPU具有特殊的硬件,可以预测最有可能的未来,通过条件代码的路径,以及推测性地执行该代码。因此,程序的性能关键取决于这种预测的准确性。 在本章中,我们一直忽略了每个计算最终必须执行的一步:访问内存。任何表达式的输入都驻留在内存中,并且必须在其余计算发生之前被带入寄存器。中间结果可以存储在寄存器中,但最终,某些东西必须被写回内存,否则整个代码就没有持久的效果。事实证明,内存操作(读取和写入)对性能有显著影响,并且在许多程序中是阻止进一步优化的限制因素。下一章将致力于研究CPU与内存的交互。 在CPU之后,内存通常是限制整体程序性能的硬件组件。在本章中,我们首先学习现代内存架构,它们固有的弱点以及对抗或至少隐藏这些弱点的方法。对于许多程序来说,性能完全取决于程序员是否利用了旨在提高内存性能的硬件特性,本章将教授必要的技能。 然而,您可能已经注意到,这些基准测试和示例具有一个相当不寻常的特性。考虑以下示例: for(size_ti=0;i 这并不意味着除非您运行类似之前示例的奇异代码,否则CPU的整个计算潜力都会被浪费。指令级并行性是流水线处理的计算基础,我们可以同时执行循环不同迭代的操作。无分支计算完全是为了将条件指令换成无条件计算,因此几乎完全依赖于通常情况下我们通常可以免费获得更多计算的事实。 然而,问题仍然存在:为什么我们要限制CPU基准测试的方式呢?毕竟,如果我们只是增加更多的输入,那么在之前的示例中想出八种不同的事情会更容易得多: for(size_ti=0;i 图4.1 应该指出,还有另一个原因会影响性能,那就是增加更多的独立变量、输入或输出可能会影响性能:CPU可能会用尽寄存器来存储这些变量进行计算。虽然这是许多实际程序中的一个重要问题,但在这里并非如此。这段代码并不复杂到足以用完现代CPU的所有寄存器(确认这一点最简单的方法是检查机器代码,不幸的是)。 显然,访问更多的数据似乎会降低代码的速度。但是为什么呢?从非常高的层面上来说,原因是内存根本跟不上CPU。有几种方法可以估计这种内存差距的大小。最简单的方法在现代CPU的规格中就可以看出来。如我们所见,CPU今天的时钟频率在3GHz到4GHz之间,这意味着一个周期大约是0.3纳秒。在适当的情况下,CPU每秒可以执行多个操作,因此每纳秒执行十次操作并不是不可能的(尽管在实践中很难实现,并且是一个非常高效程序的明确迹象)。另一方面,内存速度要慢得多:例如,DDR4内存时钟的工作频率为400MHz。您还可以找到高达3200MHz的值;但是,这不是内存时钟,而是数据速率,要将其转换为类似内存速度的东西,您还必须考虑列访问脉冲延迟,通常称为CAS延迟或CL。粗略地说,这是RAM接收数据请求、处理数据请求并返回值所需的周期数。没有一个单一的内存速度定义在所有情况下都是有意义的(本章后面我们将看到一些原因),但是,第一次近似地,具有3.2GHz数据速率和CAS延迟15的DDR4模块的内存速度约为107MHz,或者每次访问需要9.4纳秒。 无论从哪个角度来看,CPU每秒可以执行的操作比内存提供的输入值要多得多,或者存储结果。所有程序都需要以某种方式使用内存,内存访问的细节将对性能产生重大影响,有时甚至会限制性能。然而,这些细节非常重要:内存差距对性能的影响可以从微不足道到内存成为程序的瓶颈。我们必须了解内存在不同条件下对程序性能的影响以及原因,这样我们才能利用这些知识来设计和实现最佳性能的代码。 我们有充分的证据表明,与内存中的数据相比,CPU可以更快地处理寄存器中已有的数据。处理器和内存速度的规格单独就至少暗示了一个数量级的差异。然而,我们现在已经学会了不要在没有通过直接测量验证之前对性能进行任何猜测或假设。这并不意味着对系统架构的任何先前知识以及我们可以基于该知识做出的任何假设都没有用。这些假设可以用来指导实验并设计正确的测量方法。我们将在本章中看到,偶然发现的过程只能让你走得更远,甚至可能导致错误。测量本身可能是正确的,但往往很难确定到底在测量什么以及我们可以从结果中得出什么结论。 测量内存访问速度似乎应该是相当琐碎的。我们只需要一些内存来读取,并且一种计时读取的方法,就像这样: 要正确理解如何测量内存性能,我们必须更多地了解现代处理器的内存架构。对于我们的目的来说,内存系统最重要的特性是它是分层的。CPU不直接访问主内存,而是通过一系列缓存层次结构: 图4.2-内存层次结构图 当CPU第一次从主内存中读取数据值时,该值通过所有缓存级别传播,并且它的副本留在缓存中。当CPU再次读取相同的值时,它不需要等待该值从主内存中获取,因为相同值的副本已经在快速的L1缓存中可用。 用于顺序读取大数组的简单基准测试可以如下所示: template Wordfill={};//Default-constructedfor(auto_:state){for(volatileWord*p=p0;p!=p1;){REPEAT(benchmark::DoNotOptimize(*p++=fill);)}benchmark::ClobberMemory();}我们写入数组的值不应该有影响;如果你担心零有些特殊,你可以用任何其他值初始化fill变量。 宏REPEAT用于避免手动复制基准测试代码多次。我们仍然希望在每次迭代中执行多次内存读取:一旦我们开始报告每秒读取的次数,避免每次迭代0纳秒的报告就不那么关键了,但是对于像我们这样非常便宜的迭代来说,循环本身的开销是非常重要的,因此最好手动展开这个循环。我们的REPEAT宏将循环展开32次: #defineREPEAT2(x)xx#defineREPEAT4(x)REPEAT2(x)REPEAT2(x)#defineREPEAT8(x)REPEAT4(x)REPEAT4(x)#defineREPEAT16(x)REPEAT8(x)REPEAT8(x)#defineREPEAT32(x)REPEAT16(x)REPEAT16(x)#defineREPEAT(x)REPEAT32(x)当然,我们必须确保我们请求的内存大小足够大,可以容纳32个Word类型的值,并且总数组大小可以被32整除;这两者对我们的基准测试代码都不是重大限制。 说到Word类型,这是我们第一次使用TEMPLATE基准测试。它用于生成多种类型的基准测试,而不是复制代码。调用这样的基准测试有一点不同: #defineARGS->RangeMultiplier(2)->Range(1<<10,1<<30)BENCHMARK_TEMPLATE1(BM_read_seq,unsignedint)ARGS;BENCHMARK_TEMPLATE1(BM_read_seq,unsignedlong)ARGS;如果CPU支持,我们可以使用SSE和AVX指令以更大的块读取和写入数据,例如在x86CPU上一次移动16或32字节。在GCC或Clang中,有这些更大类型的库头文件: 前面的基准测试按顺序访问内存范围,从开始到结束,依次,每次一个字。内存的大小会变化,由基准参数指定(在本例中,从1KB到1GB,每次加倍)。复制完内存范围后,基准测试会再次进行,从开始,直到积累足够的测量。 在以随机顺序访问内存速度时,必须更加小心。天真的实现会导致我们测量类似这样的代码: 按随机顺序写入内存的附加代码可以如下所示: 读取的基准测试当然非常相似,只是内部循环必须改变: REPEAT(benchmark::DoNotOptimize(*(p0+*ind++));)我们有测量主要测量内存访问成本的基准代码。推进索引所需的算术运算是不可避免的,但是加法最多需要一个周期,并且我们已经看到CPU可以同时执行多个加法,因此数学不会成为瓶颈(而且无论如何,任何访问数组中的内存的程序都必须执行相同的计算,因此实际上重要的是访问速度)。现在让我们看看我们的努力的结果。 现在我们有了测量读取和写入内存速度的基准测试代码,我们可以收集结果并看看在访问内存中的数据时如何获得最佳性能。我们首先从随机访问开始,其中我们读取或写入的每个值的位置是不可预测的。 图4.3-内存大小的随机读取速度 每当我们需要读取或写入某个变量,并且在缓存中找到它时,我们称之为缓存命中。然而,如果没有找到,那么我们就会注册缓存未命中。当然,L1缓存未命中可能会成为L2命中。L3缓存未命中意味着我们必须一直到主存储器。 最后,我们可以测量写入内存的速度: 本章中我们将看到几种提高内存性能的技术。在我们研究如何改进我们的代码之前,让我们看看我们可以从硬件本身得到什么帮助。 到目前为止,我们已经测量了在随机位置访问内存的速度。当我们这样做时,每次内存访问实际上都是新的。我们正在读取的整个数组被加载到它可以容纳的最小缓存中,然后我们的读写随机访问该缓存中的不同位置。如果数组无法适应任何缓存,那么我们将随机访问内存中的不同位置,并在每次访问时产生7纳秒的延迟(对于我们使用的硬件)。 顺序内存访问的性能是完全不同的。以下是顺序写入的结果: 我们可以得出的最后一个观察是,虽然与缓存大小对应的曲线上的步骤仍然可见,但它们不那么明显,也没有那么陡峭。我们有了结果,也有了观察。这一切意味着什么呢? 当然,这假设硬件知道我们要顺序访问整个数组以及数组的大小。实际上,硬件并不知道这些,但就像我们在上一章中学习的条件指令一样,内存系统中有学习电路来做出合理的猜测。在我们的情况下,我们遇到了被称为预取的硬件技术。一旦内存控制器注意到CPU连续访问了几个地址,它就假设模式将继续,并准备访问下一个内存位置,将数据传输到L1缓存(对于读取)或为写入在L1缓存中腾出空间。理想情况下,预取技术将允许CPU始终以L1缓存速度访问内存,因为在CPU需要每个数组元素时,它已经在L1缓存中。现实是否符合这种理想情况取决于CPU在访问相邻元素之间需要多少工作。在我们的基准测试中,CPU几乎没有做任何工作,预取落后了。即使预期线性顺序访问,它也无法以足够快的速度在主内存和L1缓存之间传输数据。然而,预取非常有效地隐藏了内存访问的延迟。 预取不是基于对内存访问将如何进行的预见或先验知识(有一些特定于平台的系统调用允许程序通知硬件即将按顺序访问一段内存,但它们不具有可移植性,在实践中很少有用)。相反,预取试图检测内存访问中的模式。因此,预取的有效性取决于它能够多么有效地确定模式并猜测下一个访问的位置。 有很多信息,其中很多是过时的,关于预取模式检测的限制。例如,在旧的文献中,你可以读到,按正向顺序访问内存(对于数组a,从a[0]到a[N-1])比反向访问更有效。这对于任何现代CPU来说都不再成立,也已经多年如此。如果我开始准确描述哪些模式在预取方面是有效的,哪些不是有效的,这本书可能会陷入同样的陷阱。最终,如果你的算法需要特定的内存访问模式,并且你想找出你的预取是否能够处理它,最可靠的方法是使用类似我们在本章中用于随机内存访问的基准代码来进行测量。 总的来说,我可以告诉你,预取对于按递增和递减顺序访问内存同样有效。然而,改变方向会导致一些惩罚,直到预取适应新的模式。使用步长访问内存,比如在数组中访问每四个元素,将被检测和预测,就像密集的顺序访问一样有效。预取可以检测多个并发步长(即访问每三个和每七个元素),但在这里,我们进入了一个领域,你必须收集自己的数据,因为硬件能力从一个处理器到另一个处理器会发生变化。 硬件采用的另一种性能优化技术是非常成功的流水线或硬件循环展开。我们已经在上一章中看到了它的应用,用于隐藏条件指令造成的延迟。同样,流水线也用于隐藏内存访问的延迟。考虑这个循环: 在测量内存速度和呈现结果方面,我们已经涵盖了基础知识,并了解了内存系统的一般特性。任何更详细或具体的测量都留给读者自行练习,你应该有足够的能力收集所需的数据,以便对你特定应用程序的性能做出明智的决策。现在我们转向下一步:我们知道内存是如何工作的,以及我们可以期望从中获得的性能,但我们可以做些什么来改善具体程序的性能呢? 当许多程序员学习了上一节的材料后,他们通常的第一反应是:“谢谢,我现在明白为什么我的程序慢了,但我必须处理我拥有的数据量,而不是理想的32KB,算法也是固定的,包括复杂的数据访问模式,所以我无能为力。”如果我们不学会如何为我们需要解决的问题获得更好的内存性能,那么本章就没有多大价值。在本节中,我们将学习可以用来改善内存性能的技术。 或者,我们可以使用列表来存储相同的数字。std::list是一个节点集合,每个节点都有值和指向下一个和上一个节点的两个指针。因此,整个列表使用了24MB的内存。此外,每个节点都是通过单独调用operatornew来分配的,因此不同的节点可能位于非常不同的地址,特别是如果程序同时进行其他内存分配和释放。在遍历列表时,我们需要访问的地址不会有任何模式,因此要找到列表的性能,我们只需要在曲线上找到对应于24MB内存范围的点,这给出了每个值超过5纳秒,几乎比在数组中访问相同数据慢一个数量级。 在这一点上要求证明的人,从上一章中学到了宝贵的东西。我们可以轻松地构建一个微基准测试,比较将数据写入列表和相同大小的向量。这是向量的基准测试: template 图4.10-列表与向量基准测试 为什么有人会选择链表而不是数组(或std::vector)?最常见的原因是,在创建时,我们不知道将要有多少数据,而且由于涉及到复制,增长向量是非常低效的。有几种解决这个问题的方法。有时可以相对廉价地预先计算数据的最终大小。例如,我们可能需要扫描一次输入数据来确定为结果分配多少空间。如果输入数据组织得很有效,可能值得对输入进行两次遍历:首先是计数,其次是处理。 如果不可能预先知道最终数据大小,我们可能需要一个更智能的数据结构,它结合了向量的内存效率和列表的调整效率。这可以通过使用块分配的数组来实现: 图4.11-块分配的数组(deque)可以就地增长 这种数据结构以固定数量的块分配内存,通常足够小,可以适应L1缓存(通常使用2KB到16KB之间)。每个块都被用作数组,因此在每个块内,元素是按顺序访问的。块本身是以列表的形式组织的。如果需要扩展这种数据结构,只需分配另一个块并将其添加到列表中。访问每个块的第一个元素可能会导致缓存未命中,但一旦预取检测到顺序访问的模式,块中的其余元素可以被高效地访问。在每个块中的元素数量上摊销,随机访问的成本可以变得非常小,由此产生的数据结构几乎可以表现得与数组或向量相同。在STL中,我们有这样的数据结构:std::deque(不幸的是,大多数STL版本中的实现并不特别高效,对deque的顺序访问通常比相同大小的向量要慢一些)。 另一个偏好列表而不是数组(单块或分配的)的原因是列表允许在任何位置快速插入,而不仅仅是在末尾。如果需要这样做,那么必须使用列表或另一个节点分配的容器。在这种情况下,通常最好的解决方案是不要尝试选择适用于所有要求的单个数据结构,而是将数据从一个数据结构迁移到另一个数据结构。例如,如果我们想使用列表存储数据元素,一次一个,同时保持排序顺序,一个问题要问的是,我们是否需要顺序始终保持排序,只在插入所有元素后,或者在构建过程中的某些时候但不是一直? 通常,特定数据结构或数据组织的效率是相当明显的。例如,如果我们有一个包含数组或向量的类,并且这个类的接口只允许一种数据访问方式,即从开始到结束的顺序迭代(在STL语言中为前向迭代器),那么我们可以相当肯定地说,数据在内存级别上被访问得尽可能高效。我们无法确定算法的效率:例如,在数组中进行特定元素的线性搜索是非常低效的(每次内存读取当然是高效的,但读取次数很多;我们知道更好的数据组织方式来进行搜索)。 $perfstat-e\cycles,instructions,L1-dcache-load-misses,L1-dcache-loads\./program缓存测量计数器不是默认计数器集的一部分,必须显式指定。可用计数器的确切集合因CPU而异,但始终可以通过运行perflist命令查看。在我们的示例中,我们在读取数据时测量L1缓存未命中。术语dcache代表数据缓存(发音为dee-cache);CPU还有一个单独的指令缓存或icache(发音为ay-cache),用于从内存中加载指令。 我们可以使用这个命令行来对我们的内存基准进行分析,以便随机地址读取内存。当内存范围较小,比如16KB时,整个数组可以适应L1缓存,几乎没有缓存未命中: 图4.12-使用L1缓存良好的程序概况 将内存大小增加到128MB意味着缓存未命中非常频繁: 图4.13-使用L1缓存不佳的程序概况 我们现在已经学会了如何检测和识别那些低效的内存访问模式对性能产生负面影响的数据结构,以及一些替代方案。不幸的是,替代的数据结构通常没有相同的特性或性能:如果元素必须在数据结构的生命周期中的任意位置插入,那么列表就不能用向量来替换。通常情况下,不是数据结构本身,而是算法本身需要低效的内存访问。在这种情况下,我们可能需要改变算法。 算法的内存性能经常被忽视。算法通常是根据它们的算法性能或执行的操作或步骤数量来选择的。内存优化通常需要做出违反直觉的选择:做更多的工作,甚至做一些不必要的工作,以改善内存性能。这里的关键是要用一些计算来换取更快的内存操作。内存操作很慢,所以我们用于额外工作的预算相当大。 在本章中,我将向您展示一种更有趣的技术,我们通过更多的内存访问来节省一些其他内存访问。这里的权衡是不同的:我们希望减少慢速的随机访问,但我们要付出的代价是增加快速的顺序访问。由于顺序内存流大约比随机访问快一个数量级,我们再次有一个可观的预算来支付我们必须做的额外工作,以减少慢速内存访问。 演示需要一个更复杂的例子。假设我们有一组数据记录,比如字符串,程序需要对其中一些记录应用一组变更。然后我们得到另一组变更,依此类推。每个集合都会对一些记录进行更改,而其他记录保持不变。这些变更通常会改变记录的大小以及内容。每个集合中被更改的记录子集是完全随机和不可预测的。下面是一个显示这一点的图表: 图4.14-记录编辑问题。在每个变更集中,用*标记的记录被编辑,其余保持不变 解决这个问题最直接的方法是将记录存储在它们自己的内存分配中,并将它们组织在一些数据结构中,允许每个记录被新记录替换(旧记录被释放,因为新记录通常大小不同)。数据结构可以是树(在C++中设置)或列表。为了使示例更具体,让我们使用字符串作为记录。我们还必须更具体地说明变更集的指定方式。让我们说它不指向需要更改的特定记录;相反,对于任何记录,我们可以说它是否需要更改。这样的字符串变更集的最简单示例是一组查找和替换模式。现在我们可以勾画出我们的实现: std::list 这种算法的弱点在于我们使用了一个列表,更糟糕的是,我们不断地在内存中移动字符串。对新字符串的每次访问都会导致缓存未命中。现在,如果字符串非常长,那么初始的缓存未命中并不重要,剩下的字符串可以使用快速的顺序访问来读取。结果类似于我们之前看到的块分配数组,内存性能良好。但是如果字符串很短,整个字符串可能会在单个加载操作中被读取,而每次加载都是在随机地址上进行的。 我们的整个算法只是在随机地址上进行加载和存储。正如我们所见,这几乎是访问内存的最糟糕方式。但是我们还能做什么呢?我们不能将字符串存储在一个巨大的数组中:如果数组中间的一个字符串需要增长,那么内存从哪里来呢?就在那个字符串之后是下一个字符串,所以没有空间可以增长。 提出替代方案需要进行范式转变。执行所需操作的算法按照指定的方式也对内存组织施加了限制:更改记录需要在内存中移动它们,只要我们希望能够更改任何一条记录而不影响其他任何内容,我们就无法避免记录在内存中的随机分布。我们必须侧面解决问题,并从限制开始。我们真的希望按顺序访问所有记录。在这种约束下,我们能做些什么?我们可以非常快速地读取所有记录。我们可以决定记录是否必须更改;这一步与以前相同。但是如果记录必须增长,我们该怎么办?我们必须将其移动到其他地方,没有足够的空间来增长。但我们同意记录将保持按顺序分配,一个接一个。然后前一条记录和下一条记录也必须移动,以便它们仍然存储在我们新记录的前后。这是替代算法的关键:所有记录在每个更改集中都会移动,无论它们是否被更改。现在我们可以将所有记录存储在一个巨大的连续缓冲区中(假设我们知道总记录大小的上限): 图4.15–顺序处理所有记录 在复制过程中,算法需要分配相同大小的第二个缓冲区,因此峰值内存消耗是数据大小的两倍: char*buffer=get_huge_buffer();…initializeNrecords…char*new_buffer=get_huge_buffer();constchar*s=buffer;char*s1=new_buffer;for(size_ti=0;i 这种实现的明显缺点是使用了巨大的缓冲区:我们必须在选择其大小时持悲观态度,以便为可能遇到的最大记录分配足够的内存。峰值内存大小的翻倍也令人担忧。我们可以通过将这种方法与我们之前看到的可增长数组数据结构相结合来解决这个问题。我们可以将记录存储在一系列固定大小的块中,而不是分配一个连续的缓冲区: 图4.16–使用块缓冲区编辑记录 为了简化图表,我们绘制了相同大小的所有记录,但这个限制并非必要:记录可以跨越多个块(我们将块视为连续的字节序列,仅此而已)。在编辑记录时,我们需要为编辑后的记录分配一个新的块。一旦编辑完成,包含旧记录的块(或块)就可以被释放;我们不必等待整个缓冲区被读取。但我们甚至可以做得更好:我们可以将最近释放的块放回空块列表,而不是将其返回给操作系统。我们即将编辑下一条记录,我们将需要一个空的新块来存放结果。我们碰巧有一个:它就是曾经包含我们上次编辑的最后一条记录的块;它位于我们最近释放的块列表的开头,并且最重要的是,该块是我们最后访问的内存,因此它很可能仍然在缓存中! 乍一看,这个算法似乎是一个非常糟糕的主意:我们每次都要复制所有记录。但让我们仔细分析这两种算法。首先,阅读的数量是相同的:两种算法都必须读取每个字符串以确定是否必须更改。第二种算法在性能上已经领先:它在单个顺序扫描中读取所有数据,而第一种算法则在内存中跳来跳去。如果字符串被编辑,那么两种算法都必须将新字符串写入新的内存区域。第二种算法再次领先,因为它的内存访问模式是顺序的(而且它不需要为每个字符串进行内存分配)。权衡出现在字符串未被编辑时。第一种算法什么都不做;第二种算法进行复制。 通过这种分析,我们可以定义每种算法的优劣情况。如果字符串很短,并且每次更改集中有大部分字符串被更改,顺序访问算法会获胜。如果字符串很长,或者很少有字符串被更改,随机访问算法会获胜。然而,确定什么是长以及有多少是大部分的唯一方法是进行测量。 现在我们已经拥有了确定我们的应用程序更好算法的一切所需。这通常是性能设计的方式:我们确定了性能问题的根源,想出了消除问题的方法,以代价做其他事情,然后我们必须拼凑出一个原型,让我们能够测量聪明的技巧是否真的值得。 在结束本章之前,我想向您展示缓存和其他硬件提供的性能改进的完全不同的“用法”。 在过去的两章中,我们已经了解到,在现代计算机上,从初始数据到最终结果的路径有多么复杂。有时,机器确实按照代码规定的方式执行:从内存中读取数据,按照指令进行计算,将结果保存回内存。然而,更常见的情况是,它经历了一些我们甚至不知道的奇怪中间状态。从内存中读取并不总是从内存中读取:CPU可能决定执行其他东西,因为它认为你会需要它,等等。我们已经尝试通过直接性能测量来确认所有这些事情确实存在。出于必要,这些测量总是间接的:硬件优化和代码转换旨在提供正确的结果,毕竟只是更快。 在本节中,我们展示了更多本来应该隐藏的硬件操作的可观察证据。这是一个重大发现:2018年的发现引发了一场短暂的网络安全恐慌,并导致硬件和软件供应商发布了大量补丁。当然,我们谈论的是Spectre和Meltdown安全漏洞家族。 在本节中,我们将详细演示Spectre攻击的早期版本,即Spectre版本1。这不是一本关于网络安全的书;然而,Spectre攻击是通过仔细测量程序的性能来执行的,并且依赖于我们在本书中学习的两种性能增强硬件技术:推测执行和内存缓存。这使得攻击在致力于软件性能的工作中具有教育意义。 Spectre背后的想法是这样的。我们早些时候已经了解到,当CPU遇到条件跳转指令时,它会尝试预测结果,并继续执行假设预测是正确的指令。这被称为推测执行,如果没有它,我们在任何实际有用的代码中都不会有流水线。推测执行的棘手部分是错误处理:在推测执行的代码中经常发生错误,但在预测被证明正确之前,这些错误必须保持不可见。最明显的例子是空指针解引用:如果处理器预测指针不为空并执行相应的分支,那么每次分支被错误预测并且指针实际上为空时,都会发生致命错误。由于代码被正确编写以避免对空指针进行解引用,它也必须正确执行:潜在错误必须保持潜在。另一个常见的推测性错误是数组边界读取或写入: inta[N];…if(i Spectre攻击利用了这个侧信道: 图4.17–设置Spectre攻击 然而,这整个系列事件的一个可观察的后果是:数组t的一个元素比其余元素访问速度要快得多: 图4.18–Spectre攻击后的内存和缓存状态 Spectre攻击需要几个部分来组合;我们将逐一介绍它们,因为总的来说,这对于一本书来说是一个相当大的编码示例(这个特定的实现是根据2018年CPPCon上ChandlerCarruth给出的示例进行的变体)。 我们需要的一个组件是一个准确的计时器。我们可以尝试使用C++高分辨率计时器: constexprconstsize_tnum_val=256;structtiming_element{chars[1024];};statictiming_elementtiming_array[num_val];::memset(timing_array,1,sizeof(timing_array));在这里,我们将只使用timing_element的第一个字节;其余的是为了在内存中强制距离。1024字节的距离并没有什么神奇之处;它只是足够大,但对于你来说,这是需要通过实验来确定的:如果距离太小,攻击就会变得不可靠。计时数组中有256个元素。这是因为我们将逐字节读取秘密内存。因此,在我们之前的例子中,数组a[i]将是一个字符数组(即使实际的数据类型不是char,我们仍然可以逐字节读取它)。初始化计时数组在严格意义上来说并不是必要的;没有任何东西依赖于这个数组的内容。 我们需要的数组是我们将要越界读取的。 size_tsize=…;constchar*data=…;size_tevil_index=…;这里size是data的真实大小,evil_index大于size:它是数据数组之外的秘密值的索引。 接下来,我们将训练分支预测器:我们需要它学会更有可能的分支是访问数组的分支。为此,我们生成一个始终指向数组的有效索引(我们马上就会看到确切的方法)。这就是我们的ok_index: constsize_tok_index=…;//Lessthansizeconstexprconstsize_tn_read=100;for(size_ti_read=0;i_read 访问内存的函数,在概念上只是一个内存读取。实际上,我们必须应对聪明的优化编译器,它会尝试消除多余或不必要的内存操作。这是一种方法,它使用内在的汇编语言(读取指令实际上是由编译器生成的,因为位置*p被标记为输入): 在执行此操作之前,我们看到代码中有几个遗漏。首先,它假设定时数组值尚未在缓存中。即使在我们开始时是真的,但在成功窥视第一个秘密字节之后,它也不会是真的。我们必须在攻击下一个要读取的字节之前每次都从缓存中清除定时数组: for(size_ti=0;i std::unique_ptr 以下函数攻击数据数组之外的单个字节: 一旦最佳分数和次佳分数之间出现足够大的差距,我们就知道我们已经可靠地检测到了定时数组的快元素,即由secret字节的值索引的元素(如果我们在达到最大迭代次数之前没有得到可靠的答案,攻击就失败了,尽管我们可以尝试使用到目前为止最好的猜测)。 template template intmain(){constexprconstsize_tsize=4096;char*constdata=newchar[2*size];strcpy(data,"Innocuousdata");strcpy(data+size,"Top-secretinformation");for(size_ti=0;i 在本章中,我们学习了内存系统的工作原理:简而言之,缓慢。CPU和内存性能的差异造成了内存差距,快速的CPU受到内存性能低下的限制。但内存差距中也蕴含着潜在解决方案的种子:我们可以用多个CPU操作来交换一个内存访问。 我们还进一步了解到,内存系统非常复杂和分层,并且它没有单一的速度。如果最终陷入最坏情况,这可能会严重影响程序的性能。但是,再次强调,关键是将其视为一种机会而不是负担:优化内存访问所带来的收益可能会远远超过开销。 正如我们所看到的,硬件本身提供了几种工具来改善内存性能。除此之外,我们必须选择内存高效的数据结构,如果仅靠这一点还不够,还要选择内存高效的算法来提高性能。和往常一样,所有性能决策都必须受到测量的指导和支持。 到目前为止,我们所做的和测量的一切都是使用单个CPU。实际上,自介绍的前几页以来,我们几乎没有提到今天几乎每台计算机都有多个CPU核心,通常还有多个物理处理器。这样做的原因非常简单:我们必须学会有效地使用单个CPU,然后才能转向更复杂的多CPU问题。从下一章开始,我们将把注意力转向并发问题,以及如何有效地使用大型多核和多处理器系统。 到目前为止,我们已经研究了单个CPU执行一个程序,一个指令序列的性能。在第一章的介绍中,性能和并发性简介,我们提到这不再是我们生活的世界,然后再也没有涉及这个主题。相反,我们研究了单线程程序在单个CPU上运行的性能的每个方面。现在我们已经学会了关于单个线程性能的所有知识,并准备好研究并发程序的性能。 今天所有高性能计算机都有多个CPU或多个CPU核心(单个封装中的独立处理器)。即使大多数笔记本电脑也至少有两个,通常是四个核心。正如我们多次提到的,在性能方面,效率就是不让任何硬件空闲;如果程序只使用了计算能力的一部分,比如多个CPU核心中的一个,那么它就不能高效或高性能。程序要同时使用多个处理器的唯一方法是:我们必须运行多个线程或进程。顺便说一句,这并不是利用多个处理器为用户带来好处的唯一方法:例如,很少有笔记本电脑用于高性能计算。相反,它们使用多个CPU来更好地同时运行不同和独立的程序。这是一个完全合理的使用模式,只是不是我们在高性能计算的背景下感兴趣的。HPC系统通常一次在每台计算机上运行一个程序,甚至在分布式计算的情况下,一次在许多计算机上运行一个程序。一个程序如何使用多个CPU?通常,程序运行多个线程。 线程是一系列指令,可以独立于其他线程执行。多个线程在同一个程序中同时运行。所有线程共享同一内存,因此,根据定义,同一进程的线程在同一台机器上运行。我们已经提到HPC程序也可以由多个进程组成。分布式程序在多台机器上运行,并利用许多独立的进程。分布式计算的主题超出了本书的范围:我们正在学习如何最大化每个进程的性能。 所有进行大量计算的线程都需要足够的资源,如果目标是使程序整体更加高效。通常,当我们考虑线程的资源时,我们会想到多个处理器或处理器核心。但通过并发性也有其他增加资源利用率的方法,我们将很快看到。 我们在整本书中多次提到,处理器有大量的计算硬件,大多数程序很少(如果有的话)会全部使用:程序中的数据依赖限制了处理器在任何时候可以进行多少计算。如果处理器有多余的计算单元,它不能同时执行另一个线程以提高效率吗?这就是对称多线程(SMT)的理念,也被称为超线程。 支持SMT的处理器有一组寄存器和计算单元,但有两个(或更多)程序计数器,以及维护运行线程状态的额外副本的任何其他硬件(具体实现因处理器而异)。最终结果是:单个处理器对操作系统和程序来说看起来像是两个(通常)或更多个独立的处理器,每个都能运行一个线程。实际上,所有在一个CPU上运行的线程都竞争共享的内部资源,比如寄存器。如果每个线程没有充分利用这些共享资源,SMT可以提供显著的性能提升。换句话说,它通过运行多个这样的线程来弥补一个线程的低效率。 实际上,大多数支持SMT的处理器可以运行两个线程,性能提升的幅度差异很大。很少见到100%的加速(两个线程都以全速运行)。通常,实际的加速在25%到50%之间(第二个线程实际上以四分之一到半的速度运行),但有些程序根本没有加速。在本书中,我们不会特别对待SMT线程:对于程序来说,SMT处理器看起来就像两个处理器,我们对两个真实线程在不同核心上运行的性能所说的任何事情同样适用于在同一个核心上运行的两个线程的性能。最终,你必须测量运行比物理核心更多的线程是否为程序提供了任何加速,并根据这一点决定要运行多少线程。 无论我们是共享整个物理核心还是由SMT硬件创建的逻辑核心,并发程序的性能在很大程度上取决于线程能够独立工作的程度。这首先取决于算法和工作在线程之间的分配;这两个问题有数百本专门的书籍来讨论,但超出了本书的范围。相反,我们现在专注于影响线程交互并决定特定实现成功或失败的基本因素。 这种理想的情况确实会发生,但并不经常;更重要的是,如果发生了,你已经准备好从你的程序中获得最佳性能:你知道如何优化单个线程的性能。 编写高效并发程序的难点在于当不同线程执行的工作不完全独立时,线程开始竞争资源。但如果每个线程都充分利用其CPU,那么还有什么可以竞争的呢?剩下的就是内存,它在所有线程之间共享,因此是一个共同的资源。这就是为什么对多线程程序性能的探索几乎完全集中在线程之间通过内存交互引起的问题上。 编写高性能并发程序的另一个方面是在组成程序的线程和进程之间分配工作。但要了解这一点,你必须找一本关于并行编程的书。 事实证明,内存,已经是性能的长杆,在添加并发性后更加成为问题。虽然硬件施加的基本限制是无法克服的,但大多数程序的性能远未接近这些限制,而且熟练的程序员有很大的空间来提高其代码的效率;本章为读者提供了必要的知识和工具。 让我们首先检查在存在线程的情况下内存系统的性能。我们以与上一章相同的方式进行,通过测量读取或写入内存的速度,只是现在我们使用多个线程同时读取或写入。我们从每个线程都有自己的内存区域来访问的情况开始。我们不在线程之间共享任何数据,但我们在共享硬件资源,比如内存带宽。 内存基准本身与我们之前使用的基本相同。实际上,基准函数本身完全相同。例如,要对顺序读取进行基准测试,我们使用这个函数: template #defineARGS->RangeMultiplier(2)->Range(1<<10,1<<30)\->Threads(1)->Threads(2)BENCHMARK_TEMPLATE1(BM_read_seq,unsignedlong)ARGS;您可以为不同的线程计数指定尽可能多的运行次数,或者使用ThreadRange()参数生成1、2、4、8、...线程的范围。您必须决定要使用多少个线程;对于HPC基准测试,一般来说,没有理由超过您拥有的CPU数量(考虑SMT)。其他内存访问模式的基准测试,比如随机访问,也是以相同的方式进行的;您已经在上一章中看到了代码。对于写入,我们需要一些内容来写入;任何值都可以: Wordfill;::memset(&fill,0xab,sizeof(fill));for(auto_:state){for(volatileWord*p=p0;p!=p1;){REPEAT(benchmark::DoNotOptimize(*p++=fill);)}benchmark::ClobberMemory();}现在是展示结果的时候了。例如,这是顺序写入的内存吞吐量: 图5.1-64位整数的顺序写入的内存吞吐量(每纳秒字数)作为内存范围的函数,为1到16个线程 随着我们超过L2缓存的大小并进入L3缓存,然后是主内存,图片发生了巨大的变化:在这个系统上,L3缓存是所有CPU核心共享的。主内存也是共享的,尽管不同的内存bank更接近不同的CPU(非均匀内存架构)。对于1、2甚至4个线程,吞吐量继续随着线程数量增加而增加:主内存似乎有足够的带宽,可以支持最多4个处理器以全速写入。然后情况变得更糟:当我们从6个线程增加到16个线程时,吞吐量几乎不再增加。我们已经饱和了内存总线:它无法更快地写入数据。 如果这还不够糟糕,请考虑这些结果是在撰写时的最新硬件上获得的(2020年)。在2018年,作者在他的一堂课上呈现的同一张图表如下: 图5.2-较旧(2018年)CPU的内存吞吐量 这个系统有一个内存总线,只需两个线程就可以完全饱和。让我们看看这个事实对并发程序性能的影响。 相同的结果可以以不同的方式呈现:通过绘制每个线程的内存速度与相对于一个线程的线程数量的图,我们专注于并发对内存速度的影响。 图5.3-内存吞吐量,相对于单个线程的吞吐量,与线程计数 通过对内存速度进行归一化,使得单个线程的速度始终为1,我们更容易看到对于适合L1或L2缓存的小数据集,每个线程的内存速度几乎保持不变,即使对于16个线程(每个线程的写入速度为其单线程速度的80%)。然而,一旦我们跨入L3缓存或超过其大小,速度在4个线程后下降。从8到16个线程只提供了极小的改善。系统中没有足够的带宽来快速写入数据到内存。 不同内存访问模式的结果看起来很相似,尽管读取内存的带宽通常比写入内存的带宽略微好一些。 我们可以看到,如果我们的程序在单线程情况下受到内存限制,因此其性能受到将数据移动到和从主内存的速度的限制,那么我们可以期望从并发中获得的性能改进有一个相当严格的限制。如果你认为这不适用于你,因为你没有昂贵的16核处理器,那么请记住,更便宜的处理器配备了更便宜的内存总线,因此大多数4核系统也没有足够的内存带宽来满足所有核心。 对于多线程程序来说,避免成为内存限制更加重要。在这里有用的实现技术包括分割计算,这样更多的工作可以在适合L1或L2缓存的较小数据集上完成;重新排列计算,这样更多的工作可以通过更少的内存访问完成,通常会重复一些计算;优化内存访问模式,使内存按顺序访问而不是随机访问(尽管两种访问模式都可以饱和,但顺序访问的总带宽要大得多,因此对于相同数量的数据,如果使用随机访问,程序可能会受到内存限制,而如果使用顺序访问,则根本不受内存速度限制)。如果仅靠实现技术是不够的,无法产生期望的性能改进,下一步就是调整算法以适应并发编程的现实:许多问题有多种算法,它们在内存需求上有所不同。单线程程序的最快算法通常可以被另一个更适合并发性的算法超越:虽然我们在单线程执行速度上失去了一些,但我们通过可扩展执行的蛮力来弥补。 到目前为止,我们假设每个线程都完全独立于所有其他线程地完成自己的工作。线程之间的唯一交互是间接的,由于争夺内存带宽等有限资源。这是最容易编写的程序类型,但大多数现实生活中的程序都不允许这种限制。这带来了一整套全新的性能问题,现在是我们学习它们的时候了。 最后一节讨论了在同一台机器上运行多个线程而这些线程之间没有任何交互。如果你可以以一种使这种实现成为可能的方式来分割程序的工作,那么请务必这样做。你无法击败这种尴尬并行程序的性能。 往往,线程必须相互交互,因为它们正在为一个共同的结果做出贡献。这种交互是通过线程通过它们共享的唯一资源——内存——相互通信来实现的。我们现在必须了解这种交互的性能影响。 让我们从一个简单的例子开始。假设我们想要计算许多值的总和。我们有许多数字要相加,但最终只有一个结果。我们有这么多数字要相加,以至于我们想要在几个线程之间分割添加它们的工作。但只有一个结果值,所以线程必须在添加到这个值时相互交互。 我们可以在微基准中重现这个问题: 当然,这个程序的问题远不止全局变量:这个程序是错误的,其结果是未定义的。问题在于我们有两个线程增加相同的值。增加一个值是一个3步过程:程序从内存中读取值,在寄存器中增加它,然后将新值写回内存。完全有可能两个线程同时读取相同的值(0),在每个处理器上分别增加它(1),然后写回。第二个写入的线程简单地覆盖了第一个线程的结果,经过两次增加,结果是1而不是2。这两个线程竞争写入同一内存位置的情况被称为数据竞争。 现在你明白了为什么这样的无保护并发访问是一个问题,你可能会忘记它;相反,遵循这个一般规则:如果一个程序从多个线程访问相同的内存位置而没有同步,并且其中至少有一个访问是写入的,那么该程序的结果是未定义的。这是非常重要的:你不需要确切地弄清楚为了结果是不正确而必须发生的操作序列。事实上,在这种推理中根本没有任何收获。任何时候你有两个或更多的线程访问相同的内存位置,除非你能保证两件事中的一件:要么所有访问都是只读的,要么所有访问都使用正确的内存同步(我们还要学习)。 我们计算总和的问题要求我们将答案写入结果变量,因此访问肯定不是只读的。内存访问的同步通常由互斥锁提供:每次访问线程之间共享的变量都必须由互斥锁保护(当然,对于所有线程来说,必须是相同的互斥锁)。 unsignedlongx{0};std::mutexm;{//Concurrentaccesshappensherestd::lock_guard 锁是确保多线程程序正确性的最简单方法,但从性能的角度来看,它们并不是最容易研究的东西。它们是相当复杂的实体,通常涉及系统调用。我们将从一个在这种特定情况下更容易分析的同步选项开始:原子变量。 对于我们的例子,原子增量就是我们需要的。必须强调的是,无论我们决定使用什么样的同步机制,所有线程都必须使用相同的机制来并发访问特定的内存位置。如果我们在一个线程上使用原子操作,只要所有线程都使用原子操作,就不会有数据竞争。如果另一个线程使用互斥锁或非原子访问,所有的保证都将失效,结果再次是未定义的。 让我们重写我们的基准测试来使用C++原子操作: 如果我们使用更传统的互斥锁,结果甚至更糟: 首先,正如我们预期的那样,即使在一个线程上,锁定互斥锁也是一个相当昂贵的操作:使用互斥锁的增量需要23纳秒,而原子增量只需要7纳秒。随着线程数量的增加,性能会更快地下降。 然而,在基于观察和实验的任何行动之前,准确理解我们测量了什么以及可以得出什么结论至关重要。 我们缺少的测量是这样的:对受保护数据的非共享访问。当然,我们不需要保护只被一个线程访问的数据,但我们试图准确理解共享数据访问为何如此昂贵:是因为它是共享的还是因为它是原子的(或者受锁保护)。我们必须一次只做一个改变,所以让我们保持原子访问并移除数据共享。至少有两种简单的方法可以做到这一点。第一种方法是创建一个原子变量的全局数组,并让每个线程访问自己的数组元素: voidBM_not_shared(benchmark::State&state){std::atomic 这一结果表明,数据共享成本高的原因比我们之前假设的更加复杂:在伪共享的情况下,每个数组元素只有一个线程在操作,因此它不必等待任何其他线程完成递增。然而,线程显然彼此等待。要理解这种异常,我们必须更多地了解缓存的工作方式。 多核或多处理器系统中数据在处理器和内存之间的传输方式如图5.7所示。 图5.7-多核系统中CPU和内存之间的数据传输 处理器操作数据以单个字节或取决于变量类型的单词;在我们的情况下,unsignedlong是一个8字节的单词。原子递增读取指定地址的单个单词,递增它,然后将其写回。但是从哪里读取?CPU只能直接访问L1缓存,因此它从那里获取数据。数据如何从主内存传输到缓存?它通过更宽的内存总线复制。可以从内存复制到缓存和反复制的最小数据量称为缓存行。在所有x86CPU上,一个缓存行是64字节。当CPU需要锁定内存位置进行原子事务(如原子递增)时,它可能只写入一个单词,但必须锁定整个缓存行:如果允许两个CPU同时将同一个缓存行写入内存,其中一个将覆盖另一个。请注意,为简单起见,我们在图5.7中只显示了一级缓存层次结构,但这并没有影响:数据以缓存行长度的块通过所有缓存级别传输。 另一方面,真正的无共享基准在每个线程的堆栈上分配原子变量。这些是完全独立的内存分配,相隔多个缓存行。通过内存没有任何交互,这些线程完全独立地运行。 正如我们所看到的,两个线程是否尝试访问相同的内存位置并不重要,只要它们竞争访问同一个缓存行。这种独占缓存行访问是共享变量高成本的根源。 我们现在明白了为什么同时访问共享数据的成本如此之高。这种理解给了我们两个重要的教训。第一个教训是在尝试创建非共享数据时要避免伪共享。伪共享如何潜入我们的程序?考虑我们在本章中一直研究的简单示例:并发累积总和。我们的一些方法比其他方法慢,但它们都非常慢(比单线程程序慢,或者最多也不会更快)。我们明白访问共享数据是昂贵的。那么什么更便宜呢?当然是不访问共享数据!或者至少不那么频繁地访问。我们没有理由每次想要向总和添加东西时都访问共享总和值:我们可以在线程上本地进行所有的加法,然后在最后一次将它们添加到共享累加器值上。代码看起来会像这样: //Global(shared)resultsstd::atomic 图5.8-带有和不带有伪共享的总和累积 正如您在图5.8中所看到的,我们的程序在线程数量很大时扩展性非常差。另一方面,如果我们通过确保每个线程的部分和至少相隔64字节(或者在我们的情况下简单地使用本地变量)来消除虚假共享,那么它的扩展性就完美,正如预期的那样。当我们使用更多线程时,两个程序都变得更快,但没有虚假共享负担的实现大约快两倍。 第二个教训在后面的章节中将变得更加重要:由于并发访问共享变量相对来说非常昂贵,因此使用更少共享变量的算法或实现通常会执行得更快。 这个陈述可能在这一刻令人困惑:由于问题的性质,我们有一些必须共享的数据。我们可以进行像刚刚做的优化,并消除对这些数据的不必要访问。但一旦这样做了,剩下的就是我们需要访问以产生期望结果的数据。那么,共享变量可能会更多,或更少,吗?要理解这一点,我们必须意识到编写并发程序不仅仅是保护对所有共享数据的访问。 正如读者在本章前面提醒过的,任何访问任何共享数据的程序,如果没有访问同步(通常是互斥锁或原子访问),都会产生未定义行为,通常称为数据竞争。这在理论上似乎很简单。但我们的激励性例子太简单了:它只有一个在线程之间共享的变量。并发性不仅仅是锁定共享变量,我们将很快看到。 现在考虑一下这个例子,被称为生产者-消费者队列。假设我们有两个线程。第一个线程,生产者,通过构造对象来准备一些数据。第二个线程,消费者,处理数据(对每个对象进行操作)。为了简单起见,假设我们有一个大的内存缓冲区,最初未初始化,生产者线程在缓冲区中构造新对象,就好像它们是数组元素一样: size_tN;//CountofinitializedobjectsT*buffer;//Only[0]…[N-1]areinitialized为了生产(构造)一个对象,生产者线程通过在数组的每个元素上放置new运算符来调用构造函数,从N==0开始: new(buffer+N)T(…arguments…);现在数组元素buffer[N]已初始化,并且可以被消费者线程访问。生产者通过增加计数器N来发出信号,然后继续初始化下一个对象: ++N;消费者线程在计数器N增加到大于i之前,不能访问数组元素buffer[i]: for(size_ti=0;keep_consuming();++i){while(N<=i){};//Waitforthei-thelementconsume(buffer[i]);}为了简单起见,让我们忽略内存耗尽的问题,并假设缓冲区足够大。此外,我们现在不关心终止条件(消费者如何知道何时继续消费?)。此刻,我们对生产者-消费者握手协议感兴趣:消费者如何在没有任何竞争的情况下访问数据? 一般规则规定,对共享数据的任何访问都必须受到保护。显然,计数器N是一个共享变量,因此访问它需要更多的注意: size_tN;//Countofinitializedobjectsstd::mutexmN;//MutextoguardN…Producer…{std::lock_guardl(mN);++N;}…Consumer…{size_tn;do{std::lock_guardl(mN);n=N;}while(n<=i);}但这足够吗?仔细看:我们的程序中有更多的共享数据。对象数组T在两个线程之间是共享的:每个线程都需要访问每个元素。但是,如果我们需要锁定整个数组,我们可能会回到单线程实现:两个线程中的一个始终会被锁定。根据经验,每个编写过任何多线程代码的程序员都知道,在这种情况下,我们不需要锁定数组,只需要锁定计数器。事实上,锁定计数器的整个目的是我们不需要以这种方式锁定数组:数组的任何特定元素都不会被同时访问。首先,它只能在生产者在计数器递增之前访问。然后,它只能在计数器递增后由消费者访问。这是已知的。但是,本书的目标是教会你如何理解事情为什么会发生,因此,为什么锁定计数器足够?是什么保证事件确实按我们想象的顺序发生? 顺便说一句,即使这个平凡的例子也变得不那么平凡了。保护消费者对计数器N的访问的天真方法如下: std::lock_guardl(mN);while(N<=i){};这是一个保证的死锁:一旦消费者获取锁,它就会等待元素i被初始化,然后才会释放锁。生产者无法取得任何进展,因为它正在等待获取锁,然后才能递增计数器N。两个线程现在都永远在等待。很容易注意到,如果我们只是使用原子变量来计数,我们的代码将简单得多: std::atomic 正如我们意识到的那样,能够安全地访问共享变量并不足以编写任何非平凡的并发程序。我们还必须能够推断事件发生的顺序。在我们的生产者和消费者示例中,整个程序都建立在一个假设上:我们可以保证第N个数组元素的构造,将计数器递增到N+1,以及消费者线程访问第N个元素的顺序。 但访问共享数据还有另一个方面,即内存顺序。就像访问本身的原子性一样,它是硬件的一个特性,使用特定的机器指令(通常是原子指令本身的属性或标志)来激活。 内存顺序有几种形式。最不受限制的是松散内存顺序。当使用松散顺序执行原子操作时,我们唯一的保证是操作本身是原子执行的。这是什么意思?让我们首先考虑执行原子操作的CPU。它运行包含其他操作的线程,既有非原子操作,也有原子操作。其中一些操作修改内存;这些操作的结果可以被其他CPU看到。其他操作读取内存;它们观察其他CPU执行的操作的结果。运行我们线程的CPU按照一定顺序执行这些操作。它可能不是程序中编写的顺序:编译器和硬件都可以重新排序指令,通常是为了提高性能。但这是一个明确定义的顺序。现在让我们从执行不同线程的另一个CPU的角度来看。第二个CPU可以看到内存内容随着第一个CPU的工作而改变。但它不一定以与原子操作相同的顺序看到它们,也不一定以与彼此相同的顺序看到它们: 图5.9-使用松散内存顺序的操作的可见性 这就是我们之前谈论的可见性:一个CPU按照一定顺序执行操作,但它们的结果以非常不同的顺序对其他CPU可见。为了简洁起见,我们通常谈论操作的可见性,并不是每次都提到结果。 如果我们对共享计数器N的操作使用松散内存顺序执行,我们将陷入深深的麻烦:使程序正确的唯一方法是锁定它,以便只有一个线程,生产者或消费者,可以同时运行,并且我们无法从并发中获得性能改进。 幸运的是,我们可以使用其他内存顺序保证。最重要的是获取-释放内存顺序。当使用此顺序执行原子操作时,我们保证任何访问内存的操作在执行原子操作之前,并在另一个线程执行相同原子变量的原子操作之前变得可见。同样,所有在原子操作之后执行的操作只有在相同变量上的原子操作之后才变得可见。再次强调,当我们谈论操作的可见性时,我们真正意味着它们的结果对其他CPU变得可观察。这在图5.10中是显而易见的:在左边,我们有CPU0执行的操作。在右边,我们有CPU1看到的相同操作。特别要注意的是,右边显示的原子操作是原子写。但CPU1并没有执行原子写:它执行原子读以查看CPU0执行的原子写的结果。其他所有操作也是如此:在左边,顺序是由CPU0执行的。在右边,顺序是由CPU1看到的。 图5.10-使用获取-释放内存顺序的操作的可见性 获取-释放顺序保证是一个简洁的陈述,包含了许多重要信息,让我们详细阐述一些不同的观点。首先,该顺序是相对于两个线程在同一个原子变量上执行的操作而定义的。直到两个线程以原子方式访问相同的变量,它们的“时钟”相对于彼此来说是完全任意的,我们无法推断出某件事情发生在另一件事情之前或之后,这些词语是没有意义的。只有当一个线程观察到另一个线程执行的原子操作的结果时,我们才能谈论“之前”和“之后”。在我们的生产者-消费者示例中,生产者原子地增加计数器N。消费者原子地读取相同的计数器。如果计数器没有改变,我们对生产者的状态一无所知。但是,如果消费者看到计数器已经从N变为N+1,并且两个线程都使用获取-释放内存顺序,我们知道生产者在增加计数器之前执行的所有操作现在对消费者可见。这些操作包括构造现在驻留在数组元素buffer[N]中的对象所需的所有工作,因此,消费者可以安全地访问它。 第二个显著的观点是,当访问原子变量时,两个线程都必须使用获取-释放内存顺序。如果生产者使用此顺序来增加计数,但消费者以松散的内存顺序读取它,那么对任何操作的可见性就没有任何保证。 最后一点是,所有顺序保证都是以原子变量上的操作“之前”和“之后”来给出的。同样,在我们的生产者-消费者示例中,当消费者看到计数器改变时,我们知道生产者执行的操作结果构造第N个对象对消费者是可见的。对这些操作变得可见的顺序没有任何保证。你可以在图5.10中看到这一点。当然,这对我们来说不重要:在对象构造之前我们不能触摸任何部分,一旦构造完成,我们也不关心它是以什么顺序完成的。具有内存顺序保证的原子操作充当着其他操作无法移动的屏障。你可以想象在图5.10中有这样一个屏障,将整个程序分成两个不同的部分:在计数增加之前发生的一切和之后发生的一切。因此,通常方便将这样的原子操作称为内存屏障。 让我们假设一下,在我们的程序中,对计数器N的所有原子操作都有获取-释放屏障。这肯定会保证程序的正确性。然而,请注意,获取-释放顺序对我们的需求来说有些过度。对于生产者来说,它给了我们一个保证,即在我们将计数增加到N+1之前构造的所有对象buffer[0]到buffer[N]在消费者看到计数从N变为N+1时将对其可见。我们需要这个保证。但我们也有保证,为了构造剩余的对象buffer[N+1]及更多对象而执行的操作尚未变得可见。我们不关心这一点:消费者在看到下一个计数值之前不会访问这些对象。同样,在消费者方面,我们有保证,消费者看到计数变为N+1后执行的所有操作的效果(内存访问)将发生在该原子操作之后。我们需要这个保证:我们不希望CPU重新排序我们的消费者操作,并在准备好之前执行一些访问对象buffer[N]的指令。但我们也有保证,消费者处理之前的对象如buffer[N-1]的工作已经完成并对所有线程可见,然后消费者才会移动到下一个对象。同样,我们不需要这个保证:没有什么依赖它。 拥有比严格必要的更强保证有什么害处?在正确性方面,没有。但这是一本关于编写快速程序的书(也是正确的)。为什么首先需要顺序保证?因为在自己的设备上,编译器和处理器几乎可以任意重新排序我们的程序指令。为什么他们会这样做?通常是为了提高性能。因此,可以推断出,我们对执行重新排序的能力施加的限制越多,对性能的不利影响就越大。因此,一般来说,我们希望使用足够严格以确保程序正确性的内存顺序,但不要更严格。 对于我们的生产者-消费者程序,给我们提供了确切所需的内存顺序如下。在生产者方面,我们需要获取-释放内存屏障提供的保证的一半:在具有屏障的原子操作之前执行的所有操作必须在执行相应的原子操作之前对其他线程可见。这被称为释放内存顺序: 图5.11–释放内存顺序 当CPU1看到由CPU0执行的具有释放内存顺序的原子写操作的结果时,可以保证,根据CPU1看到的内存状态,已经反映了在这个原子操作之前由CPU0执行的所有操作。请注意,我们没有提到原子操作之后由CPU0执行的操作。正如我们在图5.11中看到的,这些操作可能以任何顺序变得可见。原子操作创建的内存屏障只在一个方向上有效:在屏障之前执行的任何操作都不能越过它,并在屏障之后被看到。但是屏障在另一个方向上是可渗透的。因此,释放内存屏障和相应的获取内存屏障有时被称为半屏障。 获取内存顺序是我们在消费者方面需要使用的。它保证了屏障后执行的所有操作在屏障后对其他线程可见,如图5.12所示: 图5.12–获取内存顺序 现在我们明白了仅仅在共享数据上进行原子操作是不够的,您可能会问我们的生产者-消费者程序是否实际上有效。事实证明,无论是锁版本还是无锁版本都是正确的,即使我们没有明确说明内存顺序。那么,在C++中如何控制内存顺序呢? 首先,让我们回想一下我们的生产者-消费者程序的无锁版本,即具有原子计数器的版本: std::atomic 如果您认为这太过分了,您可以将保证减少到您需要的部分,但您必须明确说明。原子操作也可以通过调用std::atomic类型的成员函数来执行,并且在那里您可以指定内存顺序。消费者线程需要一个带有获取屏障的加载操作: while(N.load(std::memory_order_acquire)<=i);生产者线程需要一个带有释放屏障的增量操作(就像增量运算符一样,成员函数也返回增量之前的值): N.fetch_add(1,std::memory_order_release);在我们继续之前,我们必须意识到我们在优化中跳过了一个非常重要的步骤。开始上一段的正确方式是,“如果您认为这太过分,您必须通过性能测量来证明,然后才能将保证减少到您需要的部分”。即使在使用锁时编写并发程序也很困难;使用无锁代码,尤其是显式内存顺序,必须得到证明。 说到锁,它们提供了什么内存顺序保证?我们知道由锁保护的任何操作将被稍后获取锁的任何其他线程看到,但其他内存呢?锁的使用强制执行的内存顺序如图5.13所示: 图5.13-互斥锁的内存顺序保证 互斥锁内部至少有两个原子操作。锁定互斥锁相当于使用获取内存顺序的读操作(这解释了名称:这是我们在获取锁时使用的内存顺序)。该操作创建了一个半屏障,任何在此之前执行的操作都可以在屏障之后看到,但在获取锁之后执行的任何操作都不能被观察到。当我们解锁互斥锁或释放锁时,释放内存顺序是有保证的。在此屏障之前执行的任何操作将在屏障之前变得可见。您可以看到,获取和释放的一对屏障充当了它们之间代码部分的边界。这被称为临界区:在临界区内执行的任何操作,也就是在线程持有锁时执行的操作,将在其他线程进入临界区时变得可见。没有操作可以离开临界区(变得更早或更晚可见),但来自外部的其他操作可以进入临界区。至关重要的是,没有这样的操作可以穿过临界区:如果外部操作进入临界区,它就无法离开。因此,CPU0在其临界区之前执行的任何操作都保证在CPU1在其临界区之后变得可见。 对于我们的生产者-消费者程序,这转化为以下保证: …Producer…new(buffer+N)T(…arguments…);{//Criticalsectionstart–acquirelockstd::lock_guardl(mN);++N;}//Criticalsectionend-Releaselock…Consumer…{//Criticalsection–acquirelockstd::lock_guardl(mN);n=N;}//Criticalsection–releaselockconsume(buffer[N]);生产者执行的所有操作以构造第N个对象为例都在生产者进入临界区之前完成。它们将在消费者离开其临界区并开始消费第N个对象之前对消费者可见。因此,程序是正确的。 我们需要一种更系统和严格的方式来描述线程通过内存的交互,它们对共享数据的使用以及对并发应用的影响。这种描述被称为内存模型。内存模型描述了线程访问相同内存位置时存在的保证和限制。 在C++11标准之前,C++语言根本没有内存模型(标准中没有提到线程这个词)。为什么这是个问题?再次考虑我们的生产者-消费者示例(让我们专注于生产者方面): std::mutexmN;size_tN=0;…new(buffer+N)T(…arguments…);{//Criticalsectionstart–acquirelockstd::lock_guardl(mN);++N;}//Criticalsectionend-releaselocklock_guard只是一个围绕互斥锁的RAII包装器,所以我们不会忘记解锁它,所以代码可以简化为这样: std::mutexmN;size_tN=0;…new(buffer+N)T(…arguments…);//NmN.lock();//mN++N;//NmN.unlock();//mN请注意,此代码的每一行都使用变量N或对象nM,但它们从不在一次操作中同时使用。从C++的角度来看,这段代码类似于以下代码: size_tn,m;++m;++n;在这段代码中,操作的顺序并不重要,编译器可以自由地重新排序它们,只要可观察的行为不发生变化(可观察的行为是输入和输出,改变内存中的值不是可观察的行为)。回到我们最初的例子,为什么编译器不会重新排序那里的操作呢? mN.lock();//mNmN.unlock();//mN++N;//N这将是非常糟糕的,然而,在C++标准中(直到C++11之前)没有任何东西阻止编译器这样做。 当然,早在2011年之前,我们就已经在C++中编写了多线程程序,那么它们是如何工作的呢?显然,编译器并没有进行这样的优化,但是为什么呢?答案在于内存模型:编译器提供了一些超出C++标准的保证,并在标准不要求的情况下提供了某种内存模型。基于Windows的编译器遵循Windows内存模型,而大多数基于Unix和Linux的编译器提供了POSIX内存模型和相应的保证。 C++11标准改变了这一点,并为C++提供了自己的内存模型。我们已经在前一节中利用了它:伴随原子操作的内存顺序保证,以及锁,都是这个内存模型的一部分。C++内存模型现在保证了跨平台的可移植性,以前的平台根据其内存模型提供了不同的一组保证。此外,C++内存模型提供了一些特定于语言的保证。 我们已经在不同的内存顺序规范中看到了这些保证:relaxed、acquire、release和acquire-release。C++还有一种更严格的内存顺序,称为std::memory_order_seq_cst,这是当你不指定顺序时默认的顺序:不仅每个指定此顺序的原子操作都有一个双向内存屏障,而且整个程序都满足顺序一致性要求。这个要求规定程序的行为就好像所有处理器执行的所有操作都是按照一个全局顺序执行的。此外,这个全局顺序具有一个重要的特性:考虑在一个处理器上执行的任意两个操作A和B,使得A在B之前执行。这两个操作必须以A在前、B在后的顺序出现在全局顺序中。你可以把一个顺序一致的程序想象成这样:想象每个处理器都有一副牌,牌就是操作。然后我们将这些牌堆在一起,而不混洗它们;一副牌的牌会在另一副牌的牌之间滑动,但是同一副牌的牌的顺序永远不会改变。合并后的一副牌就是程序中操作的明显全局顺序。顺序一致性是一个理想的特性,因为它使得并发程序的正确性更容易推理。然而,它通常会以性能的代价为代价。我们可以在一个非常简单的基准测试中展示这个代价,比较不同的内存顺序: voidBM_order(benchmark::State&state){for(auto_:state){x.store(1,memory_order);…unrolltheloop32timesforbetteraccuracy…x.store(1,memory_order);benchmark::ClobberMemory();}state.SetItemsProcessed(32*state.iterations());}我们可以使用不同的内存顺序来运行这个基准测试。结果当然会取决于硬件,但以下结果并不罕见: 图5.14-acquire-release与顺序一致性内存顺序的性能 C++内存模型还有很多内容,不仅仅是原子操作和内存顺序。例如,当我们之前研究了伪共享时,我们假设从多个线程同时访问数组的相邻元素是安全的。这是有道理的:这些是不同的变量。然而,语言甚至编译器采用的额外限制也不能保证这一点。在大多数硬件平台上,访问整数数组的相邻元素确实是线程安全的。但对于更小的数据类型,比如bool数组,情况绝对不是这样。许多处理器使用掩码整数写入来写入单个字节:它们加载包含此字节的整个4字节字,将字节更改为新值,然后将字写回。显然,如果两个处理器同时对共享相同4字节字的两个字节执行此操作,第二个写入将覆盖第一个写入。C++11内存模型要求,如果没有两个线程访问相同的变量,那么写入任何不同的变量,比如数组元素,都是线程安全的。在C++11之前,很容易编写一个程序来证明从两个线程写入两个相邻的bool或char变量是不安全的。我们之所以在本书中没有这个演示,是因为即使您将标准级别指定为C++03(这并不是保证,编译器可能会使用掩码写入以在C++03模式下写入单个字节,但大多数编译器在C++11模式下使用与C++11模式相同的指令),今天可用的编译器也不会回退到C++03行为的这一方面。 C++内存模型的最后一个例子也包含了一个有价值的观察:语言和编译器并不是定义内存模型的全部。硬件有一个内存模型,操作系统和运行时环境有它们的内存模型,程序运行的硬件/软件系统的每个组件都有一个内存模型。整体内存模型,程序可用的所有保证和限制的总集,是所有这些内存模型的叠加。有时您可以利用这一点,比如在编写特定于处理器的代码时。然而,任何可移植的C++代码只能依赖于语言本身的内存模型,而且往往其他底层内存模型会带来复杂性。 由于语言和硬件的内存模型差异,会出现两种问题。首先,您的程序可能存在无法在特定硬件上检测到的错误。考虑我们为生产者-消费者程序使用的获取-释放协议。如果我们犯了一个错误,在生产者端使用了释放内存顺序,但在消费者端使用了松散内存顺序(根本没有屏障),我们会期望程序会间歇性地产生错误结果。然而,如果您在x86CPU上运行此程序,它看起来是正确的。这是因为x86架构的内存模型是这样的,每个存储都伴随着一个释放屏障,每个加载都有一个隐式获取屏障。我们的程序仍然有一个错误,如果我们将其移植到比如iPad中的基于ARM的处理器上,它会让我们遇到麻烦。但是在x86硬件上找到这个bug的唯一方法是使用类似GCC和Clang中可用的ThreadSanitizer(TSAN)这样的工具。 第二个问题是第一个问题的反面:降低内存顺序的限制并不总是会带来更好的性能。正如您刚刚所学到的,从释放到松散的内存顺序在x86处理器上的写操作上并不会带来任何好处,因为整体内存模型仍然保证释放顺序(理论上,编译器可能会对松散内存顺序进行更多优化,而不是释放内存顺序,但是大多数编译器根本不会跨原子操作优化代码)。 内存模型为讨论程序如何与内存系统交互提供了科学基础和共同语言。内存屏障是程序员在代码中实际使用的工具,用于控制内存模型的特性。通常情况下,通过使用锁隐式地调用这些屏障,但它们总是存在的。合理使用内存屏障可以极大地提高某些高性能并发程序的效率。 在本章中,我们了解了C++内存模型以及它给程序员的保证。结果是对多个线程通过共享数据进行交互时发生的低级细节有了深入的理解。 在多线程程序中,未同步和无序的内存访问会导致未定义的行为,必须尽一切可能避免。然而,通常情况下代价是性能。虽然我们总是更看重正确的程序而不是快速但不正确的程序,但在内存同步方面,很容易为了正确性而付出过高的代价。我们已经看到了管理并发内存访问的不同方式,它们的优势和权衡。最简单的选择是锁定对共享数据的所有访问。另一方面,最复杂的实现使用原子操作,并尽可能限制内存顺序。 性能的第一准则在这里完全适用:性能必须被测量,而不是猜测。这对于并发程序来说更加重要,因为聪明的优化可能由于多种原因而无法产生可衡量的结果。另一方面,你始终可以保证的是,使用锁的简单程序更容易编写,而且更有可能是正确的。 掌握了影响数据共享性能的基本因素,你可以更好地理解测量结果,以及在何时尝试优化并发内存访问时有一些感觉:受内存顺序限制影响的代码部分越大,放宽这些限制就越有可能提高性能。另外,要记住,一些限制来自硬件本身。 总的来说,这比你在前几章中需要处理的任何内容都要复杂得多(这并不奇怪,总的来说,并发性本身就很难)。下一章将展示一些你可以在程序中管理这种复杂性的方法,而不放弃性能优势。你还将看到你在这里学到的知识的实际应用。 本节将探讨使用并发来实现高性能的更高级方面。您将学习使用互斥锁实现线程安全的最佳方法,以及何时避免使用它们,而选择无锁同步。您还将了解C++并发特性的最新补充:协程和并行算法。 在上一章中,我们了解了影响并发程序性能的基本因素。现在是时候将这些知识付诸实践,学习开发高性能并发算法和数据结构,以实现线程安全的程序。 一方面,要充分利用并发,必须对问题和解决方案策略进行高层次的考虑:数据组织、工作分区,有时甚至是解决方案的定义,这些选择对程序的性能产生重要影响。另一方面,正如我们在上一章中所看到的,性能受低级因素的影响很大,比如缓存中数据的排列,甚至最佳设计也可能被糟糕的实现破坏。这些低级细节通常很难分析,在代码中很难表达,并且需要非常小心的编码。这不是您希望散布在程序中的代码类型,因此封装棘手的代码是必要的。我们将不得不考虑最佳的封装这种复杂性的方法。 从根本上讲,利用并发来提高性能非常简单:您只需要做两件事。第一件事是为并发线程和进程提供足够的工作,以便它们始终保持忙碌状态。第二件事是减少对共享数据的使用,因为正如我们在上一章中所看到的,同时访问共享变量非常昂贵。其余的只是实现的问题。 不幸的是,实现往往相当困难,而且当期望的性能增益更大,硬件变得更强大时,困难程度会增加。这是由于阿姆达尔定律,这是每个处理并发的程序员都听说过的东西,但并非每个人都完全理解其影响的全部范围。 法律本身非常简单。它规定,对于具有并行(可扩展)部分和单线程部分的程序,最大可能的加速度s如下: 第一个目标,使计算并发,从选择算法开始,但许多设计决策都会影响结果,因此我们应该更多地了解它。第二个目标,减少数据共享的成本,是上一章的主题的延续:当所有线程都在等待访问某个共享变量或锁(锁本身也是一个共享变量)时,程序实际上是单线程的,只有当前具有访问权限的线程在运行。这就是为什么全局锁和全局共享数据对性能特别不利。但即使是在几个线程之间共享的数据,如果同时访问,也会限制这些线程的性能。 正如我们之前多次提到的,数据共享的需求基本上是由问题本身的性质驱动的。任何特定问题的数据共享量都可能受算法、数据结构的选择以及其他设计决策的极大影响,同时也受实现的影响。一些数据共享是实现的产物或者是数据结构选择的结果,但其他共享数据则是问题本身固有的。如果我们需要计算满足某种属性的数据元素的数量,最终只有一个计数,所有线程都必须将其更新为共享变量。然而,实际发生了多少共享以及对总程序加速的影响如何,这取决于实现。 一旦我们接受了一些数据共享是不可避免的,我们也必须接受对共享数据的并发访问进行同步的需求。请记住,任何对相同数据的并发访问,如果没有这样的同步,都会导致数据竞争和未定义的行为。 保护共享数据最常见的方法是使用互斥锁: std::mutexm;size_tcount;//Guardedbym…onthethreads…{std::lock_guardl(m);++count;}在这里,我们利用了C++17模板类型推导的std::lock_guard;在C++14中,我们需要指定模板类型参数。 使用互斥锁通常是相当简单的:任何访问共享数据的代码都应该在临界区内,也就是在锁定和解锁互斥锁的调用之间。互斥锁的实现带有正确的内存屏障,以确保临界区中的代码不能被硬件或编译器移出它(编译器通常根本不会在锁定操作之间移动代码,但理论上,它们可以进行这样的优化,只要它们遵守内存屏障的语义)。 通常在这一点上提出的问题是:“互斥锁的成本有多高?”然而,这个问题并没有很好地定义:我们当然可以给出绝对的答案,以纳秒为单位,针对特定的硬件和给定的互斥锁实现,但这个值意味着什么?它肯定比没有互斥锁更昂贵,但没有互斥锁,程序将不正确(而且有更简单的方法使不正确的程序运行得非常快)。因此,“昂贵”只能与替代方案进行比较来定义,这自然地引出了另一个问题,那就是替代方案是什么? 最明显的替代方案是将计数设为原子的: std::atomic 具有原子增量的程序没有锁,也不需要任何锁。然而,它依赖于特定的硬件能力:处理器具有原子增量指令。这类指令的集合相当小。如果我们需要一个没有原子指令的操作,我们会怎么做?我们不必为一个例子走得太远:在C++中,没有原子乘法(我不知道有哪个硬件具有这样的能力;当然,在X86或ARM或任何其他常见的CPU架构上都找不到)。 幸运的是,有一种“通用”的原子操作可以用来构建各种不同难度的读-修改-写操作。这个操作被称为compare_exchange。它有两个参数:第一个是原子变量的预期当前值,第二个是期望的新值。如果实际当前值与预期值不匹配,什么也不会发生,原子变量不会发生变化。然而,如果当前值与预期值匹配,期望的值将被写入原子变量。C++的compare_exchange操作返回true或false,表示写入是否发生(如果发生则为true)。如果变量与预期值不匹配,则实际值将在第一个参数中返回。通过比较和交换,我们可以以以下方式实现我们的原子增量操作: std::atomic 让我们分析一下这个实现是如何工作的。首先,我们原子地读取计数的当前值c。递增的值当然是c+1,但我们不能简单地将其分配给计数,因为另一个线程在我们读取它之后但在我们更新它之前可能已经递增了计数。因此,我们必须进行条件写入:如果计数的当前值仍然是c,则用期望的值c+1替换它。否则,用新的当前值更新c(compare_exchange_strong为我们做到了这一点),然后重试。只有当我们最终捕捉到一个时刻,即原子变量在我们最后一次读取它和我们尝试更新它之间没有发生变化时,循环才会退出。当然,当我们有原子增量操作时,没有理由做任何这些来增加计数。但这种方法可以推广到任何计算:我们可以使用任何其他表达式而不是c+1,程序仍然可以正常工作。 尽管代码的三个版本都执行相同的操作,即增加计数,但它们之间存在根本的区别,我们必须更详细地探讨这些区别。 第一个版本,使用互斥锁,是最容易理解的:任何时候只有一个线程可以持有锁,因此该线程可以增加计数而无需进一步预防措施。一旦锁被释放,另一个线程可以获取它并增加计数,依此类推。在任何时候,最多只有一个线程可以持有锁并取得任何进展;所有需要访问的剩余线程都在等待锁。但即使持有锁的线程通常也不能保证向前进行:如果它在完成工作之前需要访问另一个共享变量,它可能在等待由其他线程持有的锁。这是常见的基于锁的程序,通常不是最快的,但是最容易理解和推理。 理解最后一个程序的行为需要更多的努力。没有锁;然而,有一个重复未知次数的循环。在这方面,实现类似于锁:任何等待锁的线程也被困在类似的循环中,试图并失败地获取锁。然而,有一个关键的区别:在基于锁的程序中,当一个线程未能获取锁并且必须重试时,我们可以推断其他线程持有锁。我们无法确定该线程是否会很快释放锁,或者实际上是否在完成工作并释放它持有的锁(例如,它可能正在等待用户输入)。在基于比较和交换的程序中,我们的线程失败更新共享计数的唯一方式是因为其他线程首先更新了它。因此,我们知道,在同时尝试增加计数的所有线程中,至少有一个始终会成功。这种程序被称为无锁。 我们刚刚看到了三种主要类型的并发程序的示例: 理论上,这三个程序之间的差异是明显的。但我打赌每个读者都想知道同一个问题的答案:哪一个更快?我们可以在Google基准测试中运行代码的每个版本。例如,这是基于锁的版本: 图6.1-共享计数增量的性能:基于互斥锁,无锁(比较和交换,或CAS),无等待(原子) 这里唯一可能出乎意料的结果是基于锁的版本表现得有多糟糕。然而,这只是一个数据点,而不是整个故事。特别是,虽然所有互斥锁都是锁,但并非所有锁都是互斥锁。我们可以尝试提出更有效的锁实现(至少对我们的需求来说更有效)。 我们刚刚看到,当使用标准的C++互斥锁来保护对共享变量的访问时,其性能非常差,特别是当有许多线程同时尝试修改此变量时(如果所有线程都在读取变量,则根本不需要保护它;并发只读访问不会导致任何数据竞争)。但是,锁的效率低是因为其实现,还是因为锁的性质固有的问题?根据我们在上一章中学到的知识,我们可以预期任何锁都会比原子递增计数器要低效一些,因为基于锁的方案使用了两个共享变量,即锁和计数器,而原子计数器只使用了一个共享变量。然而,操作系统提供的互斥锁通常对于锁定非常短的操作(比如我们的计数增量)并不特别高效。 对于这种情况,最简单且最有效的锁之一是基本自旋锁。自旋锁的想法是:锁本身只是一个标志,可以有两个值,比如0和1。如果标志的值为0,则锁未被锁定。任何看到这个值的线程都可以将标志设置为1并继续;当然,读取标志并将其设置为1的整个操作必须是一个单一的原子操作。任何看到值为1的线程都必须等待,直到值再次变为0,表示锁可用。最后,当将标志从0更改为1的线程准备释放锁时,将值再次更改为0。 实现此锁的代码如下: 请注意,锁定标志不使用条件交换:我们总是将1写入标志。它能够工作的原因是,如果标志的原始值为0,则交换操作将其设置为1并返回0(循环结束),这正是我们想要的。但是,如果原始值为1,则它被替换为1,也就是根本没有变化。 另外,请注意两个内存屏障:锁定伴随着获取屏障,而解锁则使用释放屏障。这些屏障一起限定了临界区,并确保在调用lock()和unlock()之间编写的任何代码都留在那里。 您可能期望看到此锁与标准互斥锁的比较基准,但我们不打算展示它:这个自旋锁的性能很糟糕。为了使其有用,需要进行几项优化。 首先要注意的是,如果标志的值为1,我们实际上不需要将其替换为1,我们可以让它保持不变。为什么这很重要?交换是一个读-修改-写操作。即使它将旧值更改为相同的值,它也需要独占访问包含标志的缓存行。我们不需要独占访问只是为了读取标志。这在以下情况下很重要:锁定了一个锁,拥有锁的线程没有改变它(它正忙于工作),但所有其他线程都在检查锁,并等待值更改为0。如果它们不尝试写入标志,缓存行就不需要在不同的CPU之间反弹:它们都有内存的相同副本在它们的缓存中,并且这个副本是当前的,不需要将任何数据发送到任何地方。只有当其中一个线程实际更改值时,硬件才需要将内存的新内容发送到所有CPU。这是我们刚刚描述的优化,在代码中完成: classSpinlock{voidlock(){while(flag_.load(std::memory_order_relaxed)||flag_.exchange(1,std::memory_order_acquire)){}}}这里的优化是,我们首先读取标志,直到看到0,然后将其与1交换。如果另一个线程首先获得了锁,那么在我们进行检查和交换之间,值可能已经更改为1。另外,请注意,在预先检查标志时,我们根本不关心内存屏障,因为最终的确定性检查总是使用交换及其内存屏障完成。 线程释放CPU的方式有几种,大多数是通过系统函数调用完成的。没有一种通用的最佳方法。在Linux上,通过调用nanosleep()似乎能够产生最佳结果,通常比调用sched_yield()更好,后者是另一个系统函数,用于让出CPU访问权。所有系统调用与硬件指令相比都很昂贵,因此不要经常调用它们。最佳平衡是当我们尝试多次获取锁,然后将CPU让给另一个线程,然后再次尝试: classSpinlock{voidlock(){for(inti=0;flag_.load(std::memory_order_relaxed)||flag_.exchange(1,std::memory_order_acquire);++i){if(i==8){lock_sleep();i=0;}}}voidlock_sleep(){staticconsttimespecns={0,1};//1nanosecondnanosleep(&ns,NULL);}}在释放CPU之前获取锁的最佳尝试次数取决于硬件和线程数量,但通常,8到16之间的值效果很好。 现在我们准备进行第二轮基准测试,以下是结果: 图6.2-共享计数增量的性能:基于自旋锁、无锁(比较和交换,或CAS)和无等待(原子)的性能比较 自旋锁表现非常出色:它明显优于比较和交换实现,并给无等待操作带来了激烈的竞争。 这些结果给我们留下了两个问题:首先,如果自旋锁如此快,为什么不所有的锁都使用自旋锁?其次,如果自旋锁如此出色,为什么我们甚至需要原子操作(除了用于实现锁之外)? 第二个问题,“锁还有其他缺点吗?”将我们带到下一节。 当谈论无锁编程的优势时,第一个论点通常是“它更快”。正如我们刚才看到的,这并不一定是真的:如果针对特定任务进行了优化,锁的实现可以非常高效。然而,锁定方法的另一个固有的缺点并不取决于实现。 第一个也是最臭名昭著的是可怕的死锁的可能性。当程序使用多个锁时,比如lock1和lock2时,死锁发生。线程A持有lock1并需要获取lock2。线程B已经持有lock2并需要获取lock1。两个线程都无法继续进行,并且都将永远等待,因为唯一能释放它们需要的锁的线程本身也被锁定。 如果两个锁同时被获取,死锁可以通过始终以相同的顺序获取锁来避免;C++有一个用于此目的的实用函数std::lock()。然而,通常无法同时获取锁:当线程A获取lock1时,无法知道我们将需要lock2,因为这个信息本身是隐藏在由lock1保护的数据中。我们将在下一章中讨论并发数据结构时,在后面的例子中看到。 处理多个锁的基本问题是互斥锁不可组合:没有好的方法将两个或多个锁合并为一个。 即使没有活锁和死锁的危险,基于锁的程序仍然存在其他问题。其中一个更频繁且难以诊断的问题称为护航。它可能发生在多个锁或只有一个锁的情况下。护航的情况是这样的:假设我们有一个由锁保护的计算。线程A当前持有锁并在共享数据上进行工作;其他线程正在等待进行他们的工作。然而,工作不是一次性的:每个线程有许多任务要做,每个任务的一部分需要对共享数据进行独占访问。线程A完成一个任务,释放锁,然后快速进行下一个任务,直到再次需要锁。锁已经被释放,任何其他线程都可以获取它,但它们仍在唤醒,而线程A正在CPU上“热”。因此,线程A再次获取锁只是因为竞争者还没有准备好。线程A的任务像车队一样快速执行,而其他线程上什么也没做。 现在我们明白了锁的问题不仅限于性能,让我们看看无锁程序在同样的复杂情况下会表现如何。首先,在无锁程序中,至少有一个线程保证不会被阻塞:在最坏的情况下,当所有线程同时到达一个比较和交换(CAS)操作,并且期望的当前原子变量值相同时,其中一个线程保证会看到期望的值(因为它可以改变的唯一方式是通过成功的CAS操作)。所有剩下的线程将不得不丢弃他们的计算结果,重新加载原子变量,并重复计算,但成功进行CAS的一个线程可以继续下一个任务。这可以防止死锁的可能性。没有死锁和避免死锁的尝试,我们也不需要担心活锁。由于所有线程都在忙于计算通向原子操作(如CAS)的方式,高优先级线程更有可能首先到达并提交其结果,而低优先级线程更有可能失败CAS并不得不重新做工作。同样,单个成功提交结果并不会使“获胜”的线程对其他所有线程有任何优势:准备尝试执行CAS的线程是成功的。这自然地消除了护航。 第二个缺点完全不同。虽然大多数并发程序不容易编写或理解,但无锁程序设计和实现起来非常困难。基于锁的程序只需保证构成单个逻辑事务的任何操作集在锁下执行。当存在多个逻辑事务时,某些但不是所有共享数据是几个不同事务共有的时,情况就会变得更加困难。这就是我们遇到多个锁的问题。尽管如此,推理基于锁的程序的正确性并不那么困难:如果我在你的代码中看到一块共享数据,你必须向我展示哪个锁保护了这些数据,并证明没有线程可以在未先获取此锁的情况下访问这些数据。如果不是这样,你就会出现数据竞争,即使你还没有发现它。如果满足这些要求,就不会出现数据竞争(尽管可能会出现死锁和其他问题)。 另一方面,无锁程序有几乎无限种类的数据同步方案。由于没有线程会被暂停,我们必须确信,无论线程以何种顺序执行原子操作,结果都是正确的。此外,没有明确定义的临界区,我们必须担心程序中所有数据的内存顺序和可见性,而不仅仅是原子变量。我们必须问自己,有没有一种方法可以使一个线程更改数据,而另一个线程可以看到旧版本,因为内存顺序要求不够严格? 并发程序的开发通常非常困难。有几个因素可能使其变得更加困难:例如,编写需要正确和高效的并发程序要困难得多(换句话说,所有这些都是)。具有许多互斥锁或无锁程序的复杂程序更加困难。 正如上一节的结论所说,管理这种复杂性的唯一希望是将其限制在代码或模块的小而明确定义的部分中。只要接口和要求清晰,这些模块的客户端就不需要知道实现是无锁还是基于锁的。这会影响性能,因此模块可能对特定需求太慢,直到优化为止,但我们会根据需要进行这些优化,并且这些优化限于特定模块。 数据结构在并发程序中扮演着更加重要的角色,因为它们决定了算法可以依赖的保证和限制。哪些并发操作可以安全地在相同的数据上进行?不同线程看到的数据视图有多一致?如果我们没有这些问题的答案,我们就不能写太多的代码,而这些答案是由我们选择的数据结构决定的。 同时,设计决策,比如接口和模块边界的选择,可以在编写并发程序时对我们的选择产生关键影响。并发不能作为事后的想法添加到设计中;设计必须从一开始就考虑并发,特别是数据的组织。 我们通过定义一些基本术语和概念来开始探索并发数据结构。 使用多个线程的并发程序需要线程安全的数据结构。这似乎是显而易见的。但什么是线程安全,什么使一个数据结构是线程安全的?乍一看,这似乎很简单:如果一个数据结构可以被多个线程同时使用而不会发生任何数据竞争(在线程之间共享),那么它就是线程安全的。 然而,这个定义结果太过简单: 但我们能确定一个类或数据结构在多线程程序中是安全的吗,即使每个对象从未在线程之间共享?不一定:仅仅因为我们在接口层面上看不到任何共享,并不意味着在实现层面上没有共享。多个对象可能在内部共享相同的数据:静态成员和内存分配器只是一些可能性(我们倾向于认为所有需要内存的对象都通过调用malloc()来获得内存,并且malloc()是线程安全的,但一个类也可以实现自己的分配器)。 另一方面,许多数据结构在多线程代码中使用起来是完全安全的,只要没有线程修改对象。虽然这似乎是显而易见的,但我们必须再次考虑实现:接口可能是只读的,但实现可能仍然修改对象。如果你认为这是一个奇特的可能性,考虑一下标准的C++共享指针std::shared_ptr:当你复制一个共享指针时,复制的对象没有被修改,至少不是显而易见的(它通过const引用传递给新指针的构造函数)。与此同时,你知道对象中的引用计数必须被增加,这意味着被复制的对象已经改变了(在这种情况下,共享指针是线程安全的,但这并不是偶然发生的,也不是免费的,这是有性能成本的)。 最重要的是,我们需要一个更细致的线程安全定义。不幸的是,对于这个非常常见的概念,没有共同的词汇,但有几个流行的版本。线程安全的最高级别通常被称为const类的成员函数,其次,任何具有对对象的独占访问权的线程都可以执行任何其他有效的操作,无论其他线程同时做什么。不提供任何此类保证的对象根本不能在多线程程序中使用:即使对象本身没有被共享,其实现中的某些部分也容易受到其他线程的修改。 在本书中,我们将使用强和弱线程安全保证的语言。提供强保证的类有时被简单地称为const成员函数。最后,根本不提供任何保证的类被称为线程敌意,通常根本不能在多线程程序中使用。 在实践中,我们经常遇到强和弱保证的混合:接口的一个子集提供了强保证,但其余部分只提供了弱保证。 这引出了一个非常重要的结论:线程安全从设计阶段开始。程序使用的数据结构和接口必须明智选择,以便它们在线程交互发生的层次上代表适当的抽象级别和正确的事务。 有了这个想法,本章的其余部分应该从两个方面来看:一方面,我们展示如何设计和实现一些基本的线程安全数据结构,这些数据结构可以作为更复杂(并且无限多样)的数据结构的构建模块。另一方面,我们还展示了构建线程安全类的基本技术,这些类可以用于设计这些更复杂的数据结构。 最简单的线程安全对象之一是一个普通的计数器或者更一般的形式,一个累加器。计数器简单地计算一些可以在任何线程上发生的事件。所有线程可能需要增加计数器或者访问当前值,因此存在竞争条件的可能性。 为了有价值,我们需要在这里提供强线程安全保证:弱保证是微不足道的;读取一个没有人在改变的值总是线程安全的。我们已经看到了实现的可用选项:某种类型的锁,原子操作(如果有的话),或者无锁CAS循环。 原子指令提供了良好的性能,但操作的选择相当有限:在C++中,你可以原子地向整数添加,但不能,例如,将其乘以。这对于基本计数器已经足够了,但对于更一般的累加器可能不够(累加操作不必局限于求和)。然而,如果有一个可用,你就无法击败原子操作的简单性。 CAS循环可以用于实现任何累加器,无论我们需要使用的操作是什么。然而,在大多数现代硬件上,它并不是最快的选择,并且被自旋锁(见图6.2)所超越。 自旋锁可以进一步优化,用于访问单个变量或单个对象的情况。我们可以使锁本身成为守护的对象的唯一引用,而不是通用标志。原子变量将是一个指针,而不是整数,但锁定机制保持不变。lock()函数是非标准的,因为它返回指向计数器的指针。 template 指针自旋锁的一个明显优势是,只要它提供了访问受保护对象的唯一方式,就不可能意外地创建竞争条件并在没有锁的情况下访问共享数据。第二个优势是,这个锁往往比常规自旋锁稍微更快。自旋锁是否也优于原子操作取决于硬件。同样的基准测试在不同处理器上产生非常不同的结果: 图6.3-共享计数增量的性能:常规自旋锁、指针自旋锁、无锁(比较和交换,或CAS)、无等待(原子)对不同硬件系统(a)和(b)的影响 一般来说,较新的处理器更好地处理锁和忙等待,而且旋转锁更有可能在最新的硬件上提供更好的性能(在图6.3中,系统b使用的是IntelX86处理器,比系统a的处理器晚一代)。 坏消息是,无论实现方式如何,多个线程同时访问共享数据的成本都会随着线程数量的增加呈指数级增长,至少当我们有很多线程时是这样(请注意图6.4中的y轴刻度是对数刻度)。然而,效率在不同实现之间差异很大,至少对于最有效的实现来说,指数增长实际上直到至少八个线程才会真正开始。请注意,结果将再次因硬件系统而异,因此选择必须考虑目标平台,并且只能在测量完成后进行。 无论选择哪种实现方式,线程安全的累加器或计数器都不应该暴露出来,而是应该封装在一个类中。一个原因是为了为类的客户提供稳定的接口,同时保留优化实现的自由。 我们在上一章中研究了内存可见性,当时它可能看起来是一个主要是理论性的问题,但现在不是了。从上一章我们知道,我们控制可见性的方式是通过限制内存顺序或使用内存屏障(谈论同一件事的两种不同方式)。多线程程序中计数和索引之间的关键区别在于索引提供了额外的保证:如果将索引从N-1增加到N的线程在增加索引之前已经完成了数组元素N的初始化,那么读取索引并得到值N(或更大)的任何其他线程都保证能够在数组中看到至少N个完全初始化和安全可读的元素(当然假设没有其他线程写入这些元素)。这是一个非平凡的保证,不要轻易忽视它:多个线程在访问内存中的同一位置(数组元素N)而没有任何锁,并且其中一个线程写入这个位置,然而,访问是安全的,没有数据竞争。如果我们不能使用共享索引来安排这个保证,我们将不得不锁定对数组的所有访问,只有一个线程能够每次读取它。相反,我们可以使用这个原子索引类: classAtomicIndex{std::atomic classAtomicCount{std::atomic 我们试图解决的一般问题在数据结构设计中非常常见,通过扩展,也是并发程序的开发:一个线程正在创建新数据,而程序的其余部分必须在数据准备好时能够看到这些数据,但在此之前不能看到。前一个线程通常被称为写入线程或生产者线程。所有其他线程都是读取或消费者线程。 最明显的解决方案是使用锁,并严格遵循避免数据竞争的规则。如果多个线程(检查)必须访问同一内存位置(检查),并且至少有一个线程在该位置写入(在我们的情况下确切地是一个线程-检查),那么所有线程在访问该内存位置之前都必须获取锁,无论是读取还是写入。这种解决方案的缺点是性能:在生产者完成并且不再有写入发生之后,所有消费者线程仍然互相阻止并发地读取数据。现在,只读访问根本不需要任何锁定,但问题是,我们需要在程序中有一个保证的点,使得所有写入在此点之前发生,所有读取在此点之后发生。然后我们可以说所有消费者线程在只读环境中操作,不需要任何锁定。挑战在于保证读取和写入之间的边界:请记住,除非我们进行某种同步,否则内存可见性是不被保证的:仅仅因为写入者已经完成了对内存的修改,并不意味着读取者看到了该内存的最终状态。锁包括适当的内存屏障,正如我们之前所见;它们界定了临界区,并确保在临界区之后执行的任何操作都会看到在临界区之前或期间发生的所有对内存的更改。但现在我们希望在没有锁定的情况下获得相同的保证。 这个无锁解决方案依赖于生产者和消费者线程之间传递信息的一个非常具体的协议: 这个过程有时被称为发布协议,因为它允许生产者线程发布信息供其他线程消费,以一种保证没有数据竞争的方式。正如我们所说,发布协议可以使用任何允许访问内存的句柄来实现,只要这个句柄可以被原子地改变。指针是最常见的句柄,当然,其次是数组索引。 被发布的数据可以是简单的或复杂的;这并不重要。它甚至不必是单个对象或单个内存位置:根指针指向的对象本身可以包含指向更多数据的指针。发布协议的关键要素如下: 用于实现发布协议的原子读写当然不应该散布在整个代码中。我们应该实现一个发布指针类来封装这个功能。在下一节中,我们将看到这样一个类的简单版本。 并发(线程安全)数据结构的挑战在于如何以一种保持特定线程安全保证的方式添加、删除和更改数据。发布协议为我们提供了一种向所有线程发布新数据的方法,通常是向任何此类数据结构添加新数据的第一步。因此,毫无疑问,我们将学习的第一个类是封装了这个协议的指针。 这是一个基本的发布指针,还包括唯一或拥有指针的功能(所以我们可以称之为线程安全的唯一指针): template structA{…arbitraryobjectfortesting…};ts_unique_ptrp(newA(…));voidBM_ptr_deref(benchmark::State&state){Ax;for(auto_:state){benchmark::DoNotOptimize(x=*p);}state.SetItemsProcessed(state.iterations());}BENCHMARK(BM_ptr_deref)->Threads(1)->UseRealTime();…repeatfordesirednumberofthreads…BENCHMARK_MAIN();运行这个基准测试可以让我们了解我们的无锁发布指针的解引用速度有多快: 图6.5-发布指针的性能(消费者线程) 应该将结果与解引用原始指针进行比较,我们也可以在多个线程上执行此操作: 图6.6-原始指针的性能,用于与图6.5进行比较 性能数字非常接近。我们也可以比较发布的速度,但通常来说,消费者端更重要:每个对象只发布一次,但会被访问多次。 同样重要的是要理解发布指针不做的事情。首先,在指针的构造中没有线程安全性。我们假设生产者和消费者线程共享对已构造的指针的访问权,该指针初始化为null。谁构造并初始化了指针?通常,在任何数据结构中,都有一个根指针,通过它可以访问整个数据结构;它是由构造初始数据结构的任何线程初始化的。然后有一些指针,它们作为某个数据元素的根,并且它们本身包含在另一个数据元素中。现在,想象一个简单的单链表,其中每个列表元素的“下一个”指针是下一个元素的根,列表的头是整个列表的根。生产列表元素的线程必须在其他事情之间将“下一个”指针初始化为null。然后,另一个生产者可以添加一个新元素并发布它。请注意,这与一般规则不同,即一旦发布的数据就是不可变的。然而,这是可以的,因为对线程安全的唯一指针的所有更改都是原子的。无论如何,关键是在构造指针时没有线程可以访问它(这是一个非常常见的限制,大多数构造都不是线程安全的,甚至它们的线程安全性的问题都是不合适的,因为对象直到构造出来才存在,所以不能给出任何保证)。 我们的指针接下来没有做的事情是:它不为多个生产者线程提供任何同步。如果两个线程尝试通过相同的指针发布它们的新数据元素,结果是未定义的,并且存在数据竞争(一些消费者线程将看到一组数据,而其他线程将看到不同的数据)。如果有多个生产者线程在特定数据结构上操作,它们必须使用另一种同步机制。 最后,虽然我们的指针实现了线程安全的发布协议,但它并没有安全地“取消发布”和删除数据。它是一个拥有指针,所以当它被删除时,它指向的数据也会被删除。然而,任何消费者线程都可以使用它之前获取的值来访问数据,即使指针已被删除。数据所有权和生命周期的问题必须以其他方式处理。理想情况下,我们的程序中会有一个点,整个数据结构或其中的一部分被认为不再需要;没有消费者线程应该尝试访问这些数据,甚至保留任何指向它的指针。在那时,根指针和通过它可访问的任何内容都可以安全地删除。安排执行中的这种点是完全不同的事情;通常由整体算法控制。 有时我们需要一个指针以线程安全的方式管理数据的创建和删除。在这种情况下,我们需要一个线程安全的共享指针。 如果我们不能保证程序中有一个已知的点可以安全地删除数据,我们必须跟踪有多少消费者线程持有数据的有效指针。如果我们想删除这些数据,我们必须等到整个程序中只有一个指向它的指针;然后,才能安全地删除数据和指针本身(或者至少将其重置为null)。这是共享指针的典型工作:它对同一对象的指针在程序中还有多少进行引用计数;数据由最后一个这样的指针删除。 谈论线程安全的共享指针时,准确理解指针需要什么保证是非常重要的。C++标准共享指针std::shared_ptr经常被称为线程安全。具体来说,它提供了以下保证:如果多个线程操作指向同一对象的不同共享指针,那么即使两个线程同时导致计数器发生变化,对引用计数器的操作也是线程安全的。例如,如果一个线程正在复制其共享指针,而另一个线程正在删除其共享指针,并且在这些操作开始之前引用计数为N,那么计数器将增加到N+1,然后返回到N(或者先减少,然后增加,取决于实际的执行顺序),最终将具有相同的值N。中间值可以是N+1或N-1,但没有数据竞争,行为是明确定义的,包括最终状态。这一保证意味着对引用计数器的操作是原子的;实际上,引用计数器是一个原子整数,并且实现使用fetch_add()来原子地增加或减少它。 这一切都很好,但如果我们不能保证两个线程不会尝试访问同一个共享指针怎么办?这种访问的第一个例子是我们的发布协议:消费者线程正在读取指针的值,而生产者线程可能正在更改它。我们需要共享指针本身的操作是原子的。在C++20中,我们可以做到这一点:它让我们编写std::atomic 如果您没有符合C++20标准的编译器和相应的标准库,或者无法在您的代码中使用C++20,您仍然可以在std::shared_ptr上执行原子操作,但必须明确这样做。为了使用在所有线程之间共享的指针p_发布对象,生产者线程必须这样做: std::shared_ptr std::shared_ptr 值得注意的是,虽然方便,std::shared_ptr并不是特别高效的指针,而原子访问使其变得更慢。我们可以比较使用上一节中的线程安全发布指针与显式原子访问的共享指针发布对象的速度: 图6.7-原子共享发布指针的性能(消费者线程) 即使需要共享所有权(有一些并发数据结构确实很难在没有共享所有权的情况下设计),通常情况下,如果设计自己的具有有限功能和最佳实现的引用计数指针,通常可以做得更好。一种非常常见的方法是使用侵入式引用计数。侵入式共享指针将其引用计数存储在其指向的对象中。当为特定对象设计时,例如我们特定数据结构中的列表节点,对象是以共享所有权为目的设计的,并包含一个引用计数器。否则,我们可以为几乎任何类型使用包装类,并用引用计数器增强它: template 即使是最简单的原子共享指针的实现也相当冗长;本章的示例代码中可以找到一个非常基本的示例。再次强调,该示例仅包含使指针能够正确执行发布对象和多个线程同时访问同一指针等多项任务所必需的最低限度。该示例的目的是使实现这种指针的基本要素更容易理解(即使如此,代码也有几页长)。 除了使用侵入式引用计数外,特定于应用程序的共享指针可以放弃std::shared_ptr的其他功能。例如,许多应用程序不需要弱指针,但即使从未使用过,支持它也会带来开销。一个最简化的引用计数指针可以比标准指针高出几倍效率: 图6.8-自定义原子共享发布指针的性能(消费者线程) 对于指针的赋值和重新赋值、两个指针的原子交换以及指针的其他原子操作,这样做同样更有效。即使这种共享指针仍然比唯一指针效率低得多,所以如果可以明确管理数据所有权而不使用引用计数,那么请这样做。 现在我们几乎可以构建任何数据结构的两个关键构件:我们可以添加新数据并发布它(向其他线程公开),甚至可以跨线程跟踪所有权(尽管这是有代价的)。 在本章中,我们已经了解了任何并发程序的基本构建块的性能。所有对共享数据的访问都必须受到保护或同步,但在实现这种同步时有很多选择。虽然互斥锁是最常用和最简单的选择,但我们还学习了其他几种性能更好的选择:自旋锁及其变体,以及无锁同步。 高效并发程序的关键是尽可能将数据局部化到一个线程,并最小化对共享数据的操作。每个问题特定的要求通常决定了这些操作不能完全被消除,因此本章重点是使并发数据访问更加高效。 我们学习了如何在多个线程之间计数或累积结果,有锁和无锁的情况下。了解数据依赖性问题使我们发现了发布协议及其在几种线程安全的智能指针中的实现,适用于不同的应用程序。 我们现在已经准备好将我们的研究提升到下一个水平,并将其中几个构建块组合成更复杂的线程安全数据结构。在下一章中,您将学习如何使用这些技术来设计并发程序的实用数据结构。 在上一章中,我们详细探讨了可以用来确保并发程序正确性的同步原语。我们还研究了这些程序的最简单但有用的构建块:线程安全计数器和指针。 在本章中,我们将继续研究并发程序的数据结构。本章的目的是双重的:一方面,你将学习如何设计几种基本数据结构的线程安全变体。另一方面,我们将指出一些对于设计自己的数据结构用于并发程序以及评估组织和存储数据的最佳方法的一般原则和观察是重要的。 在我们开始学习线程安全数据结构之前,我们必须知道它们是什么。如果这似乎是一个简单的问题–可以同时被多个线程使用的数据结构–你还没有认真思考这个问题。我无法过分强调每次开始设计新的数据结构或用于并发程序中的算法时都要问这个问题有多么重要。如果这句话让你警惕并让你停下来,那是有充分理由的:我刚刚暗示线程安全数据结构没有适合每个需求和每个应用的单一定义。这确实是这样,也是一个非常重要的观点。 让我们从一个显而易见但在实践中经常被忘记的事情开始:高性能设计的一个非常普遍的原则是不做任何工作总是比做一些工作更快。对于这个主题,这个一般原则可以缩小到你是否需要任何形式的线程安全来处理这个数据结构?确保线程安全,无论采取什么形式,都意味着计算机需要做一定量的工作。问问自己,我真的需要吗?我能安排计算,让每个线程都有自己的数据集来操作吗? 一个简单的例子是我们在上一章中使用的线程安全计数器。如果需要所有线程始终看到计数器的当前值,那么这就是正确的解决方案。然而,假设我们只需要计算在多个线程中发生的某个事件,比如在被线程之间分割的大量数据集中搜索某些内容。一个线程不需要知道计数的当前值来进行搜索。当然,它需要知道计数的最新值来递增它,但只有在我们尝试在所有线程上递增单个共享计数时才是如此: std::atomic 图7.1–如果计数是共享的,则多个线程计数不会扩展 假设我们只关心计算结束时的计数值,一个更好的解决方案当然是在每个线程上维护本地计数,并且只增加共享计数一次: unsignedlongcount;std::mutexM;//Guardscount…//Oneachthreadunsignedlonglocal_count=0;for(…countingloop…){…search…if(…found…)++local_count;}std::lock_guard 如果每个线程在到达结尾之前多次增加本地计数,然后必须增加共享计数,那么扩展几乎是完美的: 图7.2-多线程计数与每个线程计数完美扩展 当涉及到线程安全时,您可以说整个本节都是一种逃避。从某种角度来看,确实如此,但在实践中经常发生的情况是,在不必要的情况下使用共享数据结构,而性能收益可能如此显著,以至于需要强调这一点。现在是时候转向真正的线程安全,其中数据结构必须在多个线程之间共享。 假设我们确实需要同时从多个线程访问特定数据结构。现在我们必须讨论线程安全。但仍然没有足够的信息来确定这个线程安全意味着什么。我们已经在上一章中讨论了强和弱线程安全保证。我们将在本章中看到,即使这种划分还不够,但它让我们走上了正确的道路:我们应该描述数据结构提供的一组保证,以便进行并发访问。 通常情况下,您希望提供尽可能少的保证,以使程序正确,而不是更多:即使不使用,额外的线程安全功能通常也会非常昂贵并产生开销。 考虑到这一点,让我们开始探索具体的数据结构,看看提供不同级别的线程安全保证需要做些什么。 从并发性的角度来看,最简单的数据结构之一是堆栈。堆栈上的所有操作都涉及顶部元素,因此(至少在概念上)需要针对竞争进行保护的单个位置。 C++标准库为我们提供了std::stack容器,因此它是一个很好的起点。所有C++容器,包括堆栈,都提供了弱线程安全保证:只读容器可以被多个线程安全地访问。换句话说,只要没有线程调用任何非const方法,任意数量的线程可以同时调用任何const方法。虽然这听起来很容易,几乎是简单的,但这里有一个微妙的地方:在对象的最后修改和被认为是只读的程序部分之间必须有某种同步事件和内存屏障。换句话说,写访问实际上并没有完成,直到所有线程执行内存屏障:写入者必须至少执行一个释放,而所有读取者必须获取。任何更强的屏障也可以工作,锁也可以,但每个线程都必须采取这一步。 现在,如果至少有一个线程正在修改堆栈,我们需要更强的保证怎么办?提供一个最直接的方法是用互斥锁保护类的每个成员函数。这可以在应用程序级别完成,但这样的实现并不强制执行线程安全,因此容易出错。它也很难调试和分析,因为锁与容器没有关联。 更好的选择是用我们自己的类来包装堆栈类,就像这样: template 我们的线程安全或多线程堆栈(这就是mt的含义)现在具有push功能,并且已经准备好接收数据。我们只需要接口的另一半,pop。我们当然可以按照前面的例子包装pop()方法,但这还不够:STL堆栈使用三个单独的成员函数来从堆栈中移除元素。pop()移除顶部元素但不返回任何内容,所以如果你想知道堆栈顶部是什么,你必须先调用top()。如果堆栈为空,调用这两个方法是未定义行为,所以你必须先调用empty()并检查结果。好吧,我们可以包装所有三个方法,但这对我们来说毫无意义。在下面的代码中,假设堆栈的所有成员函数都受到锁的保护: mt_stack 这很可能是整本书中最重要的一课:为了提供可用的线程安全功能,接口必须考虑线程安全。更一般地说,不可能在现有设计的基础上添加线程安全。相反,设计必须考虑线程安全。原因是:您可能选择在设计中提供某些保证和不变量,这些保证和不变量在并发程序中是不可能维护的。例如,std::stack提供了这样的保证,如果您调用empty()并且它返回false,则只要在这两次调用之间不对栈进行其他操作,您就可以安全地调用top()。在多线程程序中,几乎没有实用的方法来维护这个保证。 幸运的是,由于我们无论如何都要编写自己的包装器类,我们并不受约束,必须逐字使用包装类的接口。那么,我们应该做什么呢?显然,整个pop操作应该是一个单一的成员函数:它应该从栈中移除顶部元素并将其返回给调用者。一个复杂之处在于当栈为空时该怎么办。在这里我们有多个选择。我们可以返回值和一个布尔标志的对,指示栈是否为空(在这种情况下,值必须是默认构造的)。我们可以仅返回布尔值,并通过引用传递值(如果栈为空,则值保持不变)。在C++17中,自然的解决方案是返回std::optional,如下面的代码所示。它非常适合保存可能不存在的值的工作: template 如果对象必须经过一些不够定义的中间状态的转换,比如在调用empty()之后但在调用pop()之前的状态,那么这些状态必须对调用者隐藏。调用者将被呈现一个单一的原子事务:要么返回顶部元素,要么通知调用者没有顶部元素。这确保了程序的正确性;现在,我们可以看看性能。 我们的栈的性能如何?考虑到每个操作从头到尾都被锁定,我们不应该期望栈成员函数的调用会有任何扩展。最好的情况是,所有线程将按顺序执行它们的栈操作,但实际上,我们应该期望锁定会带来一些开销。如果我们将多线程栈的性能与单线程上的std::stack的性能进行比较,我们可以在基准测试中测量这种开销。 mt_stack 图7.3-互斥保护的堆栈的性能 请注意,“项”在这里是推送后跟弹出,因此“每秒项数”的值显示了我们可以每秒通过堆栈发送多少数据元素。作为比较,同样的堆栈在单个线程上的性能比没有任何锁的情况下快了10多倍: 图7.4-std::stack的性能(与图7.3进行比较) 正如我们所看到的,使用互斥锁实现的堆栈的性能相当差。然而,你不应该急于寻找或设计一些聪明的线程安全堆栈,至少现在还不是。你应该首先问的问题是,“这重要吗?”应用程序对堆栈上的数据做什么?比如说,每个数据元素是一个需要几秒钟的模拟参数,堆栈的速度可能并不重要。另一方面,如果堆栈是某个实时事务处理系统的核心,它的速度很可能是整个系统性能的关键。 顺便说一句,对于任何其他数据结构,如列表、双端队列、队列和树,其结果可能会类似,其中单个操作比互斥锁的操作快得多。但在我们尝试提高性能之前,我们必须考虑我们的应用程序需要什么样的性能。 在本章的其余部分,让我们假设数据结构的性能对你的应用程序很重要。现在,我们能看到最快的堆栈实现了吗?同样,还没有。我们还需要考虑使用模型;换句话说,我们对堆栈做什么,什么需要快。 例如,正如我们刚刚看到的,互斥保护的堆栈性能不佳的主要原因是其速度基本上受到互斥本身的限制。对堆栈操作进行基准测试几乎与对互斥锁进行基准测试相同。提高性能的一种方法是改进互斥锁的实现或使用另一种同步方案。另一种方法是更少地使用互斥锁;这种方式需要我们重新设计客户端代码。 例如,很多时候,调用者有多个项目必须推送到堆栈上。同样,调用者可能能够一次从堆栈中弹出多个元素并处理它们。在这种情况下,我们可以使用数组或另一个容器实现批量推送或批量弹出,一次复制多个元素到堆栈中或从堆栈中。由于锁的开销很大,我们可以期望使用一个锁/解锁操作将1,024个元素推送到堆栈上比分别在单独的锁下推送每个元素更快。事实上,基准测试显示情况是这样的: 图7.5-批处理堆栈操作的性能(每个锁1,024个元素) 还有其他情景,寻找更有限的、特定于应用程序的解决方案可以获得远高于任何改进的通用解决方案的性能增益。例如,在一些应用程序中,一个单独的线程预先将大量数据推送到堆栈上,然后多个线程从堆栈中移除数据并处理它,可能还会将更多数据推送到堆栈上。在这种情况下,我们可以实现一个未锁定的推送,仅在单线程上下文中使用。虽然责任在于调用者永远不要在多线程上下文中使用这种方法,但未锁定的堆栈比锁定的堆栈快得多,可能值得复杂化。 更复杂的数据结构提供了各种使用模型,但即使堆栈也可以用于更多的简单推送和弹出。我们还可以查看顶部元素而不删除它。std::stack提供了top()成员函数,但同样,它不是事务性的,所以我们必须创建自己的。它与事务性的pop()函数非常相似,只是不删除顶部元素: 我们不能在top()中省略锁的原因是我们无法确定另一个线程是否同时调用push()或pop()。但即使如此,我们也不需要锁定两次对top()的调用;它们可以同时进行。只有修改堆栈的操作需要被锁定。有一种锁提供了这样的功能;它通常被称为top()方法使用共享锁,因此任意数量的线程可以同时执行它,但push()和pop()方法需要唯一锁: template 图7.6–使用std::shared_mutex的堆栈性能;只读操作 甚至更糟的是,需要唯一锁的操作的性能与常规互斥锁相比会进一步下降: 图7.7–使用std::shared_mutex的堆栈性能;写操作 更长的临界区观察非常重要:如果我们的栈元素更大,并且复制起来非常昂贵,那么锁的性能就不那么重要了,与复制大对象的成本相比,我们会开始看到扩展。然而,假设我们的总体目标是使程序快速,而不是展示可扩展的栈实现,我们将通过完全消除昂贵的复制并使用指针栈来优化整个应用程序。 尽管我们在读写锁方面遭受了挫折,但我们对更高效的实现思路是正确的。但在我们设计之前,我们必须更详细地了解栈操作的确切内容以及在每一步可能发生的数据竞争。 当我们试图改进线程安全栈(或任何其他数据结构)的性能超出简单的锁保护实现时,我们首先必须详细了解每个操作涉及的步骤以及它们如何与在不同线程上执行的其他操作交互。这一部分的主要价值不在于更快的栈,而在于这种分析:事实证明,这些低级步骤对许多数据结构都是共同的。让我们从推送操作开始。大多数栈实现都是建立在某种类似数组的容器之上,因此让我们将栈顶视为连续的内存块: 图7.8-推送操作的栈顶 栈上有N个元素,因此元素计数也是下一个元素将放置的第一个空槽的索引。推送操作必须将顶部索引(也是元素计数)从N增加到N+1来保留其槽,然后在槽N中构造新元素。请注意,这个顶部索引是数据结构的唯一部分,其中进行推送的线程可以相互交互:只要索引增量操作是线程安全的,只有一个线程可以看到索引的每个值。执行推送的第一个线程将顶部索引提升到N+1并保留第N个槽,下一个线程将索引增加到N+2并保留第N+1个槽,依此类推。关键点在于这里对槽本身没有竞争:只有一个线程可以获得特定的槽,因此它可以在那里构造对象,而不会有其他线程干扰。 这表明推送操作的非常简单的同步方案:我们只需要一个用于顶部索引的原子值: std::atomic constsize_ttop=top_.fetch_add(1);new(&data[top])Element(…constructorarguments…);再次强调,没有必要保护构建步骤免受其他线程的影响。原子索引是我们使推送操作线程安全所需要的一切。顺便说一句,如果我们使用数组作为堆栈内存,这也是正确的。如果我们使用std::deque这样的容器,我们不能简单地在其内存上构建一个新元素:我们必须调用push_back来更新容器的大小,即使deque不需要分配更多的内存,这个调用也不是线程安全的。因此,超出基本锁的数据结构实现通常也必须管理自己的内存。说到内存,到目前为止,我们假设数组有空间添加更多的元素,并且我们不会用尽内存。让我们暂时坚持这个假设。 到目前为止,我们已经找到了一种非常高效的方法来在特定情况下实现线程安全的推送操作:多个线程可以将数据推送到堆栈,但在所有推送操作完成之前没有人读取它。 如果我们有一个已经推送元素的堆栈,并且需要弹出它们(并且不再添加新元素),相同的想法也适用。图7.8也适用于这种情况:一个线程原子递减顶部计数,然后将顶部元素返回给调用者。 constsize_ttop=top_.fetch_sub(1);returnstd::move(data[top]);原子递减保证只有一个线程可以访问每个数组槽作为顶部元素。当然,这仅在堆栈不为空时才有效。我们可以将顶部元素索引从无符号整数更改为有符号整数;然后,当索引变为负数时,我们就知道堆栈为空了。 这是再次在非常特殊的条件下实现线程安全的弹出操作的非常高效的方法:堆栈已经被填充,并且没有添加新元素。在这种情况下,我们也知道堆栈上有多少元素,因此很容易避免尝试弹出空堆栈。 在某些特定的应用中,这可能具有一定的价值:如果堆栈首先由多个线程填充而没有弹出,并且程序中有一个明确定义的切换点,从添加数据到删除数据,那么我们对问题的每一半都有一个很好的解决方案。但让我们继续讨论更一般的情况。 我们非常高效的推送操作,不幸的是,在从堆栈中读取时没有帮助。让我们再次考虑如何实现弹出顶部元素的操作。我们有顶部索引,但它告诉我们的只是当前正在构建的元素数量;它并没有告诉我们最后一个构建完成的元素的位置(图7.9中的第N-3个元素): 图7.9-推送和弹出操作的堆栈顶部 当然,进行推送和因此构建的线程知道何时完成。也许我们需要另一个计数,显示有多少元素完全构建了。遗憾的是,如果只是那么简单就好了。在图7.9中,假设线程A正在构建元素N-2,线程B正在构建元素N-1。显然,线程A首先增加了顶部索引。但这并不意味着它也会首先完成推送。线程B可能会先完成构建。现在,堆栈上最后构建的元素的索引是N-1,所以我们可以将构建计数提高到N-1(注意我们跳过了仍在构建中的元素N-2)。现在我们想弹出顶部元素;没问题,元素N-1已经准备好了,我们可以将其返回给调用者并从堆栈中删除它;构建计数现在减少到N-2。接下来应该弹出哪个元素?元素N-2仍然没有准备好,但我们的堆栈中没有任何内容告诉我们。我们只有一个用于完成元素的计数,它的值是N-1。现在我们在构建新元素的线程和尝试弹出它的线程之间存在数据竞争。 即使没有这场竞赛,还有另一个问题:我们刚刚弹出了元素N-1,这在当时是正确的。但与此同时,线程C请求了一个推送。应该使用哪个槽?如果我们使用槽N-1,我们就有可能覆盖线程A当前正在访问的相同元素。如果我们使用槽N,那么一旦所有操作完成,数组中就会有一个空洞:顶部元素是N,但下一个元素不是N-1:它已经被弹出,我们必须跳过它。这个数据结构中没有任何内容告诉我们我们必须这样做。 我们可以跟踪哪些元素是真实的,哪些是空洞的,但这变得越来越复杂(以线程安全的方式进行将需要额外的同步,这将降低性能)。此外,留下许多未使用的数组槽会浪费内存。我们可以尝试重用空洞来存放推送到堆栈上的新元素,但在这一点上,元素不再按顺序存储,原子顶部计数不再起作用,整个结构开始变得像一个列表。顺便说一句,如果你认为列表是实现线程安全堆栈的好方法,等到你看到本章后面实现线程安全列表需要付出的努力时再说吧。 在我们的设计中,我们必须暂停对实现细节的深入探讨,并再次审视问题的更一般方法。我们必须做两步:从我们对堆栈实现细节的更深入理解中得出结论,并进行一些性能估算,以对可能产生性能改进的解决方案有一个大致的了解。我们将从后者开始。 当然,这可能看起来像循环推理:为了估计性能,我们必须首先有一些东西来估计。但我们不希望在至少有一些保证努力会有所回报的情况下进行复杂的设计,这些保证需要性能估计。 幸运的是,我们可以回到我们之前学到的一般观察:并发数据结构的性能在很大程度上取决于有多少共享变量同时访问。让我们假设我们可以想出一个巧妙的方法来使用单个原子计数器实现堆栈。假设每次推送和弹出都至少要对这个计数器进行一次原子递增或递减(除非我们正在进行批量操作,但我们已经知道它们更快)。如果我们进行一个基准测试,将单线程堆栈上的推送和弹出与共享原子计数器上的原子操作相结合,我们可以得到一个合理的性能估计。由于没有同步进行,因此我们必须为每个线程使用一个单独的堆栈,以避免竞争条件: std::atomic 图7.10-具有单个原子计数器的假设堆栈的性能估计 结果看起来有点复杂:即使在最佳条件下,我们的堆栈也无法扩展。这主要是因为我们正在测试一个小元素的堆栈;如果元素很大且复制成本很高,我们会看到扩展,因为多个线程可以同时复制数据。但前面的观察仍然成立:如果复制数据变得如此昂贵,以至于我们需要许多线程来执行它,我们最好使用指针堆栈,根本不复制任何数据。 另一方面,原子计数器比基于互斥体的堆栈快得多。当然,这只是一个从上面估计出来的结果,但它表明无锁堆栈有一些可能性。然而,基于锁的堆栈也有:当我们需要锁定非常短的临界区时,有比std::mutex更有效的锁。在第六章中我们已经看到了这样一种锁,并发和性能,当我们实现了自旋锁。如果我们在基于锁的堆栈中使用这个自旋锁,那么,我们得到的结果不是图7.2,而是这些结果: 图7.11-基于自旋锁的堆栈的性能 将这个结果与图7.10进行比较,得出一个非常沮丧的结论:我们不可能设计出一个无锁设计,它能胜过一个简单的自旋锁。自旋锁之所以能在某些情况下胜过原子递增,是因为在这个特定硬件上不同原子指令的相对性能;我们不应该对此过分解读。 我们可以尝试使用原子交换或比较和交换来进行相同的估计,而不是原子增量。当您了解更多关于设计线程安全数据结构的知识时,您将对哪种同步协议可能有用以及哪些操作应该包括在估计中有所了解。此外,如果您使用特定的硬件,您应该运行简单的基准测试来确定哪些操作在其上更有效。到目前为止,所有结果都是在基于X86的硬件上获得的。如果我们在专门设计用于HPC应用的大型ARM服务器上运行相同的估计,我们将得到一个非常不同的结果。基于锁的栈的基准测试产生了这些结果: 图7.12-在ARMHPC系统上基于锁的栈的性能 ARM系统通常比X86系统具有更多的核心,而单个核心的性能较低。这个特定系统有两个物理处理器上的160个核心,当程序在两个CPU上运行时,锁的性能显著下降。对无锁栈性能的上限估计应该使用比原子增量更有效的比较和交换指令(后者在这些处理器上特别低效)。 图7.13-具有单个CAS操作的假设栈的性能估计(ARM处理器) 基于图7.13中的估计,对于大量的线程,我们有可能会得到比基于简单锁的栈更好的东西。我们将继续努力开发无锁栈。有两个原因:首先,这一努力最终将在某些硬件上得到回报。其次,这种设计的基本元素将在以后的许多其他数据结构中看到,而栈为我们提供了一个简单的测试案例来学习它们。 假设我们需要一个完全通用的堆栈,生产者-消费者交互的问题的本质可以通过一个非常简单的例子来理解。同样,我们假设堆栈是在数组或类似数组的容器之上实现的,并且元素是连续存储的。假设我们当前有N个元素在堆栈上。生产者线程P正在执行推送操作,消费者线程C同时正在执行弹出操作。结果应该是什么?虽然诱人的是尝试设计一个无等待的设计(就像我们为仅消费者或仅生产者所做的那样),但是任何允许两个线程在不等待的情况下继续进行的设计都将破坏我们关于元素存储方式的基本假设:线程C必须等待线程P完成推送或返回当前顶部元素N。同样,线程P必须等待线程C完成或在槽N+1中构造一个新元素。如果两个线程都不等待,结果就是数组中的一个空洞:最后一个元素的索引为N+1,但在槽N中没有存储任何东西,因此我们在从堆栈中弹出数据时必须以某种方式跳过它。 看起来我们必须放弃无等待堆栈实现的想法,并让其中一个线程等待另一个线程完成其操作。当顶部索引为零且消费者线程尝试进一步减少它时,我们还必须处理空堆栈的可能性。当顶部索引指向最后一个元素且生产者线程需要另一个槽时,也会出现类似的问题。 这两个问题都需要有界的原子递增操作:执行递增(或递减),除非值等于指定的边界。在C++中没有现成的原子操作(或者在当今任何主流硬件上都没有),但我们可以使用比较和交换(CAS)来实现它,如下所示: std::atomic 尽管这可能看起来像是一种锁,但有一个根本的区别:CAS比较在一个线程上失败的唯一方式是如果它在另一个线程上成功(并且原子变量被递增),所以每当共享资源存在争用时,至少一个线程保证能够取得进展。 inti=0;while(…){if(++i==8){staticconstexprtimespecns={0,1};i=0;nanosleep(&ns,NULL);}}相同的方法可以用来实现更复杂的原子事务,比如栈的推送和弹出操作。但首先,我们必须弄清楚需要哪些原子变量。对于生产者线程,我们需要数组中第一个空闲插槽的索引。对于消费者线程,我们需要最后一个完全构造的元素的索引。这是我们关于栈当前状态的所有信息,假设我们不允许数组中的“空洞”: ![图7.14–无锁栈:c_是最后一个完全构造的元素的索引,p_是数组中第一个空闲插槽的索引 图7.14–无锁栈:c_是最后一个完全构造的元素的索引,p_是数组中第一个空闲插槽的索引 首先,如果两个索引当前不相等,那么推送和弹出都无法进行:不同的计数意味着要么正在构造新元素,要么正在复制当前顶部元素。在这种状态下对栈进行修改可能导致数组中的空洞的创建。 如果两个索引相等,那么我们可以继续。要进行推送,我们需要原子地增加生产者索引p_(受数组当前容量的限制)。然后我们可以在刚刚保留的插槽中构造新元素(由旧值p_索引)。然后我们增加消费者索引c_,表示新元素已经可供消费者线程使用。请注意,另一个生产者线程甚至可以在构造完成之前抢占下一个插槽,但在允许任何消费者线程弹出元素之前,我们必须等待所有新元素都被构造。这样的实现是可能的,但它更加复杂,而且倾向于当前执行的操作:如果推送当前正在进行,弹出必须等待,但另一个推送可以立即进行。结果很可能是一堆推送操作在执行,而所有消费者线程都在等待(如果弹出操作正在进行,效果类似;它会倾向于另一个弹出)。 弹出的实现方式类似,只是我们首先将消费者索引c_减少到保留顶部插槽,然后在从栈中复制或移动对象之后再减少p_。 现在,我们只需要将前面的算法转换成代码: template 请注意,我们不能再在STL堆栈的基础上构建我们的线程安全堆栈:容器本身在线程之间是共享的,即使容器不再增长,对其进行push()和pop()操作也不是线程安全的。为简单起见,在我们的示例中,我们使用了一个deque,它初始化了足够大数量的默认构造元素。只要我们不调用任何容器成员函数,我们就可以独立地在不同的线程中操作容器的不同元素。请记住,这只是一个快捷方式,可以避免同时处理内存管理和线程安全:在任何实际实现中,您不希望预先默认构造所有元素(而且元素类型甚至可能没有默认构造函数)。通常,高性能的并发软件系统都有自己的自定义内存分配器。否则,您也可以使用一个与堆栈元素类型大小和对齐方式相同的虚拟类型的STL容器,但具有简单的构造函数和析构函数(实现足够简单,留给读者作为练习)。 推送操作实现了我们之前讨论的算法:等待索引变得相等,推进生产者索引p_,构造新对象,完成后推进消费者索引c_: voidpush(constT&v){counts_tn=n_.load(std::memory_order_relaxed);if(n.p_==cap_)abort();while(!n.equal(n_)||!n_.compare_exchange_weak(n,{n.p_+1,n.c_},std::memory_order_acquire,std::memory_order_relaxed)){if(n.p_==cap_){…allocatemorememory…}};++n.p_;new(&s_[n.p_])T(v);assert(n_.compare_exchange_strong(n,{n.p_,n.c_+1},std::memory_order_release,std::memory_order_relaxed);}除非我们的代码中有错误,否则最后的CAS操作不应该失败:一旦调用线程成功推进了p_,没有其他线程可以改变任何一个值,直到相同的线程推进了c_以匹配(正如我们已经讨论过的,这里存在一个低效性,但修复它会带来更高的复杂性成本)。另外,请注意,为了简洁起见,我们省略了循环内的nanosleep()或yield()调用,但在任何实际实现中都是必不可少的。 弹出操作类似,只是首先减少消费者索引c_,然后在从堆栈中移除顶部元素时,减少p_以匹配c_: std::optional 无锁堆栈是可能的最简单的无锁数据结构之一,而且它已经相当复杂。验证我们的实现是否正确所需的测试并不简单:除了所有单线程单元测试之外,我们还必须验证是否存在竞争条件。这项任务得到了最近GCC和CLANG编译器中可用的线程检测器(TSAN)等消毒工具的大大简化。这些消毒工具的优势在于它们可以检测潜在的数据竞争,而不仅仅是在测试期间实际发生的数据竞争(在小型测试中,观察到两个线程同时不正确地访问相同内存的机会相当渺茫)。 经过我们所有的努力,无锁堆栈的性能如何?如预期的那样,在X86处理器上,它并没有超越基于自旋锁的版本: 图7.15-X86CPU上无锁堆栈的性能(与图7.11进行比较) 作为比较,受自旋锁保护的堆栈可以在同一台机器上每秒执行约70M次操作。这与我们在上一节性能估计后的预期一致。然而,相同的估计表明,无锁堆栈在ARM处理器上可能更优秀。基准测试证实了我们的努力没有白费: 图7.16-ARMCPU上无锁堆栈的性能(与图7.12进行比较) 虽然基于锁的栈的单线程性能优越,但是如果线程数量很大,无锁栈的速度要快得多。如果基准测试包括大量的top()调用(即许多线程在一个线程弹出之前读取顶部元素)或者生产者和消费者线程是不同的(一些线程只调用push(),而其他线程只调用pop()),无锁栈的优势甚至更大。 总结这一部分,我们已经探讨了线程安全栈数据结构的不同实现。为了理解线程安全所需的内容,我们必须分析每个操作,以及多个并发操作的交互。以下是我们学到的教训: 到目前为止,我们已经回避了内存管理的问题:当栈的容量用完时,它被隐藏在模糊的分配更多内存之后。我们需要稍后回到这个问题。但首先,让我们探索更多不同的数据结构。 接下来我们要考虑的数据结构是队列。它是一个非常简单的数据结构,概念上是一个可以从两端访问的数组:数据被添加到数组的末尾,并从开头移除。在实现方面,队列和栈之间有一些非常重要的区别。也有许多相似之处,我们将经常参考前一节。 就像栈一样,STL有一个队列容器std::queue,在并发性方面存在相同的问题:删除元素的接口不是事务性的,它需要三个单独的成员函数调用。如果我们想要使用带锁的std::queue创建线程安全队列,我们将不得不像处理栈一样对其进行包装: 图7.17-自旋锁保护的std::queue的性能 作为比较,在相同的硬件上,没有任何锁的std::queue每秒可以传递大约280M个项目(项目是推送和弹出,因此我们测量每秒可以通过队列发送多少元素)。到目前为止,这个情况与我们之前在栈中看到的非常相似。为了比锁保护的版本更好,我们必须尝试提出一个无锁实现。 在我们深入设计无锁队列之前,重要的是对每个事务进行详细分析,就像我们为栈所做的那样。同样,我们将假设队列是建立在数组或类似数组的容器之上的(并且我们将推迟关于数组满时会发生什么的问题)。将元素推送到队列看起来就像为栈做的那样: 图7.18–向队列后端添加元素(生产者视图) 我们所需要的只是数组中第一个空槽的索引。然而,从队列中移除元素与从栈中进行相同操作是完全不同的。您可以在图7.19中看到这一点(与图7.9进行比较): 图7.19–从队列前端移除元素(消费者视图) 元素从队列的前端移除,因此我们需要第一个尚未被移除的元素的索引(队列的当前前端),并且该索引也会被增加。 现在我们来到队列和栈之间的关键区别:在栈中,生产者和消费者都在同一位置操作:栈的顶部。我们已经看到了这样做的后果:一旦生产者开始在栈顶构造新元素,消费者就必须等待它完成。弹出操作不能返回最后构造的元素而不在数组中留下空洞,也不能在构造完成之前返回正在构造的元素。 对于队列,情况则大不相同。只要队列不为空,生产者和消费者根本不会相互交互。推送操作不需要知道前端索引在哪里,弹出操作也不关心后端索引在哪里,只要它在前端之前的某个位置。生产者和消费者不会竞争访问同一内存位置。 每当我们有多种不同的方式来访问数据结构,并且它们(大多数情况下)不相互交互时,一般建议首先考虑这些角色分配给不同线程的情况。进一步简化可以从每种类型的一个线程开始;在我们的情况下,这意味着一个生产者线程和一个消费者线程。 消费者必须以相反的顺序操作:首先,检查大小以确保队列不为空。然后消费者可以从队列中取出第一个元素并增加前端索引。当然,在检查大小和访问前端元素之间大小可能会发生变化,但这不会造成任何问题:只有一个消费者线程,生产者线程只能增加大小。 在探索栈时,我们推迟了向数组添加更多内存的问题,并假设我们以某种方式知道栈的最大容量,并且不会超过它(如果超过了,我们也可以使推送操作失败)。对于队列,同样的假设是不够的:因为元素被添加和移除,前端和后端索引都会前进,并最终到达数组的末尾。当然,在这一点上,数组的第一个元素是未使用的,因此最简单的解决方案是将数组视为循环缓冲区,并对数组索引使用模运算: template pc_queue 图7.20-在ARM上整数的基于锁和无锁队列的性能 图7.21-在X86上大型元素的基于锁和无锁队列的性能 即使在我们施加了限制的情况下,这仍然是一个非常有用的数据结构:当我们知道可以入队的元素数量的上限,或者可以处理生产者在推送更多数据之前必须等待的情况时,这个队列可以用于在生产者和消费者线程之间传输数据。这个队列非常高效;对于一些应用程序来说更重要的是,它具有非常低且可预测的延迟:队列本身不仅是无锁的,而且是无等待的。一个线程永远不必等待另一个线程,除非队列已满。顺便说一句,如果消费者必须对从队列中取出的每个数据元素进行某些处理,并且开始落后直到队列填满,一个常见的方法是让生产者处理它无法入队的元素。这有助于延迟生产者线程,直到消费者赶上(这种方法并不适用于每个应用程序,因为它可能会无序处理数据,但通常情况下是有效的)。 我们的队列在有多个生产者或消费者线程的情况下的泛化将使实现更加复杂。基于原子大小的简单无等待算法即使我们将前后索引设为原子,也不再适用:如果多个消费者线程读取了一个非零大小的值,这对于所有这些线程来说已经不再足够让它们继续进行。对于多个消费者,大小可以在一个线程检查并发现非零值后减小并变为零(这只是意味着其他线程在第一个线程测试大小后,但在它尝试访问队列前弹出了所有剩余元素)。 一个通用的解决方案是使用我们为栈使用的相同技术:将前端和后端索引打包到一个64位原子字中,并使用比较和交换原子地访问它们两个。实现类似于栈的实现;在前一节理解了代码的读者已经准备好实现这个队列。在文献中还可以找到其他无锁队列解决方案;本章应该为您提供足够的背景来理解、比较和基准测试这些实现。 实现一个复杂的无锁数据结构是一个耗时的项目,需要技巧和注意力。在实现完成之前,最好能有一些性能估计,这样我们就可以知道努力是否有可能得到回报。我们已经看到了一种基准测试代码的方法,这个代码还不存在:一个模拟基准测试,结合了对非线程安全数据结构(每个线程本地的)的操作和对共享变量(锁或原子数据)的操作。目标是提出一个可以进行基准测试的计算等效代码片段;它永远不会完美,但是如果我们有一个关于一个具有三个原子变量和每个变量上的比较和交换操作的无锁队列的想法,并且我们发现估计的基准测试比自旋锁保护的队列慢几倍,那么实现真正的队列的工作可能不会得到回报。 还有另一种并发数据结构实现类型,通常可以非常高效。我们接下来要学习这种技术。 让我们首先重新审视一个简单的问题,队列是什么?当然,我们知道队列是什么:它是一种数据结构,使得首先添加的元素也是首先检索到的。在概念上和许多实现中,这是由元素添加到底层数组的顺序来保证的:我们有一个排队元素的数组,新条目添加到前面,而最老的条目从后面读取。 但是让我们仔细检查一下这个定义是否仍然适用于并发队列。当从队列中读取一个元素时执行的代码看起来像这样: Tpop(){Treturn_value;return_value=data[back];--back;returnreturn_value;}返回值可以用std::optional包装或通过引用传递;这并不重要。关键是,从队列中读取值,后面的索引递减,然后控制权返回给调用者。在多线程程序中,线程可以在任何时刻被抢占。完全有可能,如果我们有两个线程A和B,线程A从队列中读取最旧的元素,那么线程B首先完成pop()的执行并将其值返回给调用者。因此,如果我们按顺序将两个元素X和Y入队,然后有多个线程将它们出队并打印它们的值,程序会先打印Y然后是X。当多个线程将元素推送到队列时,也会发生相同类型的重新排序。最终结果是,即使队列本身保持严格的顺序(如果您暂停程序并检查内存中的数组,元素的顺序是正确的),程序观察到的出队元素的顺序也不能保证与它们入队的顺序完全一致。 当然,顺序也不是完全随机的:即使在并发程序中,栈看起来与队列非常不同。从队列中检索的数据的顺序大致上是添加值的顺序;重大的重新排列是罕见的(当一个线程因某种原因被延迟时会发生)。 我们的队列还保留了另一个非常重要的属性:顺序一致性。一个顺序一致的程序产生的输出与一个程序的输出是相同的,其中所有线程的操作都是依次执行的(没有任何并发),并且任何特定线程执行的操作的顺序不会改变。换句话说,等价程序接受所有线程执行的操作序列并将它们交错,但不会重新排列它们。 顺序一致性是一个方便的属性:分析这类程序的行为要容易得多。例如,在队列的情况下,我们保证如果两个元素X和Y由线程A入队,先是X,然后是Y,而它们恰好被线程B出队,它们将按正确的顺序出来。另一方面,我们可以争论说,实际上这并不重要:两个元素可能被两个不同的线程出队,这种情况下它们可以以任何顺序出现,因此程序必须能够处理它。 如果我们愿意放弃顺序一致性,这将开启一种全新的设计并发数据结构的方法。让我们以队列为例来探讨这个问题。基本思想是:我们可以有几个单线程子队列,而不是一个单一的线程安全队列。每个线程必须原子地获取这些子队列中的一个的独占所有权。实现这一点的最简单方法是使用原子指针数组指向子队列,如图7.22所示。为了获取所有权并同时防止任何其他线程访问队列,我们原子地将子队列指针与空值交换。 图7.22-基于原子指针访问的数组子队列的非顺序一致队列 需要访问队列的线程必须首先获取一个子队列。我们可以从指针数组的任何元素开始;如果它是空的,那么该子队列当前正在忙,我们尝试下一个元素,依此类推,直到我们保留一个子队列。在这一点上,只有一个线程在操作子队列,因此不需要线程安全(子队列甚至可以是std::queue)。操作(推送或弹出)完成后,线程通过将子队列指针原子地写回数组来将子队列的所有权返回给队列。 推送操作必须继续尝试保留子队列,直到找到一个(或者,我们可以允许推送在一定次数尝试后失败,并向调用者发出队列太忙的信号)。弹出操作可能只保留一个子队列,却发现它是空的。在这种情况下,它必须尝试从另一个子队列中弹出(我们可以保持队列中元素的原子计数,以优化如果队列为空则快速返回)。 当然,pop可能在一个线程上失败,并报告队列为空,而实际上并不是,因为另一个线程已经将新数据推送到队列中。但这可能发生在任何并发队列中:一个线程检查队列大小,发现队列为空,但在控制返回给调用者之前,队列变得非空。再次,顺序一致性对多个线程可以观察到的不一致性类型施加了一些限制,而我们的非顺序一致队列使输出元素的顺序变得不太确定。不过,平均而言,顺序仍然是保持的。 图7.23-非顺序一致队列的性能 在这个基准测试中,所有线程都进行推送和弹出操作,并且元素相当大(复制每个元素需要复制1KB的数据)。作为比较,使用自旋锁保护的std::queue在单个线程上提供相同的性能(约每秒170k个元素),但不会扩展(整个操作被锁定),性能会缓慢下降(由于锁定的开销)到每秒约130k个元素的最大线程数。 当然,如果愿意为了性能而接受非顺序一致程序的混乱,许多其他数据结构也可以从这种方法中受益。 当涉及到诸如堆栈和队列之类的并发顺序容器需要更多内存时,我们需要讨论的最后一个主题。 到目前为止,我们一直坚持在内存管理问题上进行推迟,并假设数据结构的初始内存分配足够,至少对于不使整个操作单线程化的无锁数据结构来说是如此。我们在本章中看到的受锁保护和非顺序一致的数据结构并没有这个问题:在锁或独占所有权下,只有一个线程在特定的数据结构上操作,因此内存是以通常的方式分配的。 对于无锁数据结构,内存分配是一个重大挑战。这通常是一个相对较长的操作,特别是如果数据必须复制到新位置。即使多个线程可能检测到数据结构的内存用尽,通常也只有一个线程可以添加新的内存(很难使该部分也多线程化),其余线程必须等待。对于这个问题没有很好的一般解决方案,但我们将提出几条建议。 首先,最好的选择是完全避免问题。在许多情况下,当需要无锁数据结构时,可以估计其最大容量并预先分配内存。例如,我们可能知道要入队的数据元素的总数。或者,可能可以将问题推迟给调用者:而不是添加内存,我们可以告诉调用者数据结构已经达到容量上限;在某些问题中,这可能是无锁数据结构的性能的可接受折衷。 如果需要添加内存,非常希望添加内存不需要复制整个现有数据结构。这意味着我们不能简单地分配更多内存并将所有内容复制到新位置。相反,我们必须以固定大小的内存块存储数据,就像std::deque所做的那样。当需要更多内存时,将分配另一个块,并且通常有一些指针需要更改,但不会复制数据。 在所有进行内存分配的情况下,这必须是一个不经常发生的事件。如果不是这样,那么我们几乎肯定最好使用由锁或临时独占所有权保护的单线程数据结构。这种罕见事件的性能并不重要,我们可以简单地锁定整个数据结构,并让一个线程进行内存分配和所有必要的更新。关键要求是使常见的执行路径,即我们不需要更多内存的路径,尽可能快。 这个想法非常简单:我们肯定不希望每次都在每个线程上获取内存锁,这会使整个程序串行化。我们也不需要这样做:大多数情况下,我们并不缺内存,也不需要这个锁。因此,我们将检查一个原子标志。只有在内存分配当前正在进行时,标志才会被设置,所有线程都必须等待。 std::atomic std::atomic 这种方法可以推广到处理任何特殊情况,这些情况发生得非常少,但是在无锁方式下实现起来比代码的其他部分要困难得多。在某些情况下,甚至可能对空队列等情况有用:正如我们所见,如果两组线程永远不必相互交互,那么处理多个生产者或多个消费者将需要一个简单的原子递增索引。如果在特定应用程序中,我们保证队列很少或几乎不会变为空,那么我们可以偏向于实现对非空队列非常快(无等待),但如果队列可能为空,则退回到全局锁的实现。 我们已经详细介绍了顺序数据结构。现在是时候学习下一个节点数据结构了。 在我们迄今为止研究的顺序数据结构中,数据存储在数组中(或者至少是由内存块组成的概念数组)。现在我们将考虑一种非常不同的数据结构类型,其中数据由指针连接在一起。最简单的例子是一个列表,其中每个元素都是单独分配的,但我们在这里学到的一切都适用于其他节点容器,如树、图或任何其他数据结构,其中每个元素都是单独分配的,并且数据由指针连接在一起。 为简单起见,我们将考虑一个单链表;在STL中,它可以作为std::forward_list使用: 图7.24-带迭代器的单链表 因为每个元素都是单独分配的,所以它也可以单独释放。通常,这些数据结构使用轻量级分配器,其中内存是在大块中分配的,然后被分成节点大小的片段。当一个节点被释放时,内存不会被返回给操作系统,而是被放在一个空闲列表中,以供下一个分配请求使用。对于我们的目的来说,内存是直接从操作系统分配还是由专门的分配器处理(尽管后者通常更有效)在很大程度上并不重要。 列表迭代器在并发程序中提出了额外的挑战。正如我们在图7.24中看到的,这些迭代器可以指向列表中的任何位置。如果从列表中删除一个元素,我们希望它的内存最终可以用于构造和插入另一个元素(如果我们不这样做,并且一直保留所有内存直到整个列表被删除,重复添加和删除几个元素可能会浪费大量内存)。然而,如果有一个迭代器指向它,我们就不能删除列表节点。这在单线程程序中也是如此,但在并发程序中管理起来通常要困难得多。由于可能有多个线程可能使用迭代器,我们通常无法通过操作的执行流来保证没有迭代器指向我们即将删除的元素。在这种情况下,我们需要迭代器来延长它们所指向的列表节点的生命周期。当然,这是引用计数智能指针(如std::shared_ptr)的工作。从现在开始,让我们假设列表中的所有指针,无论是将节点链接在一起的指针还是迭代器中的指针,都是智能指针(std::shared_ptr或具有更强线程安全性保证的类似指针)。 就像我们在顺序数据结构中所做的那样,我们对于线程安全数据结构的第一次尝试应该是一个带锁的实现。一般来说,除非你知道你需要一个,否则你不应该设计一个无锁的数据结构:开发无锁代码可能很酷,但尝试在其中找到错误绝对不是。 就像我们之前做的那样,我们必须重新设计接口的部分,以便所有操作都是事务性的:例如,pop_front()应该在列表为空或不为空时都能工作。然后我们可以用锁来保护所有操作。对于push_front()和pop_front()等操作,我们可以期望与之前观察到的堆栈或队列类似的性能。但是列表提出了我们直到现在都没有不得不面对的额外挑战。 首先,列表支持在任意位置插入;在std::forward_list的情况下,可以使用insert_after()在迭代器指向的元素之后插入一个新元素。如果我们在两个线程上同时插入两个元素,我们希望插入可以同时进行,除非两个位置靠近并影响同一个列表节点。但是我们无法通过一个单一的锁来保护整个列表来实现这一点。 当然,如果你从未遇到这些问题,这些问题就不是你的问题:如果你的列表只从前端和后端访问,那么一个带锁的列表可能完全足够。正如我们已经多次看到的,当设计并发数据结构时,不必要的泛化是你的敌人。只构建你需要的东西。 如果我们真的需要一个可以同时访问的列表或其他节点数据结构,我们必须想出一个无锁实现。正如我们已经看到的,无锁代码不容易编写,甚至更难正确编写。很多时候,更好的选择是想出一个不需要线程安全节点数据结构的不同算法。通常,这可以通过将全局数据结构的部分复制到一个特定于线程的数据结构中,然后由单个线程访问;在计算结束时,来自所有线程的片段再次放在一起。有时,更容易对数据结构进行分区,以便不会同时访问节点(例如,可能可以对图进行分区,并在一个线程上处理每个子图,然后处理边界节点)。但如果你真的需要一个线程安全的节点数据结构,下一节将解释挑战并为你提供一些实现选项。 无锁列表的基本思想,或者任何其他节点容器,都非常简单,基于使用比较和交换来操作节点的指针。让我们从更简单的操作开始:插入。我们将描述在列表头部的插入,但在任何其他节点之后的插入都是相同的方式进行。 图7.25-在单链表头部插入新节点 假设我们想要在图7.25a所示的列表头部插入一个新节点。第一步是读取当前的头指针,即指向第一个节点的指针。然后我们创建具有所需值的新节点;它的下一个指针与当前头指针相同,因此这个节点在当前第一个节点之前链接到列表中(图7.25b)。此时,新节点还不可访问给其他线程,因此数据结构可以同时访问。最后,我们执行CAS:如果当前头指针仍然未更改,我们就以原子方式将其替换为指向新节点的指针(图7.25c)。如果头指针不再具有我们最初读取时的值,我们就读取新值,将其写为新节点的下一个指针,并再次尝试原子CAS。 这是一个简单而可靠的算法。这是我们在上一章中看到的发布协议的泛化:新数据是在一个不关心线程安全的线程上创建的,因为它还不可访问给其他线程。作为最后的动作,线程通过原子方式改变根指针来发布数据,从而可以访问所有数据(在我们的情况下,是列表的头部)。如果我们要在另一个节点之后插入新节点,我们将原子地改变该节点的下一个指针。唯一的区别是多个线程可能同时尝试发布新数据;为了避免数据竞争,我们必须使用比较和交换。 现在,让我们考虑相反的操作,删除列表的前节点。这也是分三步完成的: 图7.26-单链表头部的无锁移除 首先,我们读取头指针,用它来访问列表的第一个节点,并读取它的下一个指针(图7.26a)。然后我们以原子方式将该下一个指针的值写入头指针(图7.26b),但前提是头指针没有改变(CAS)。此时,原来的第一个节点对其他线程不可访问,但我们的线程仍然具有头指针的原始值,并可以使用它来删除我们已经移除的节点(图7.26c)。这是简单而可靠的。但当我们尝试结合这两个操作时,问题就出现了。 假设两个线程同时在列表上操作。线程A正在尝试移除列表的第一个节点。第一步是读取头指针和下一个节点的指针;这个指针即将成为列表的新头部,但比较和交换还没有发生。目前,头部未更改,新头部是一个只存在于线程A的某个本地变量中的值head'。这一刻被捕捉在图7.27a中: 图7.27-单链表头部的无锁插入和移除 就在这时,线程B成功地移除了列表的第一个节点。然后它也移除了下一个节点,使列表处于图7.27b所示的状态(线程A没有取得任何进展)。然后线程B在列表的头部插入一个新节点(图7.27c);然而,由于两个删除节点的内存已被释放,节点T4的新分配重用了旧分配,因此节点T4被分配到了原来节点T1曾经占据的相同地址。只要删除节点的内存可用于新的分配,这种情况很容易发生;事实上,大多数内存分配器更倾向于返回最近释放的内存,因为它仍然在CPU的缓存中是“热点”。 现在,线程A终于再次运行,它即将执行的操作是比较和交换:如果头指针自上次线程A读取以来没有改变,新的头就变成head'。不幸的是,就线程A所能看到的情况而言,头指针的值仍然是相同的(它无法观察到所有的变化历史)。CAS操作成功,新的头指针现在指向了曾经是节点T2的未使用内存,而节点T4不再可访问(图7.27d)。整个列表已经损坏。 这种失败机制在无锁数据结构中非常常见,它有一个名字:A-B-A问题。这里的A和B指的是内存位置:问题是数据结构中的某个指针从A变为B,然后再变回A。另一个线程只观察到初始和最终值,并没有看到任何变化;比较和交换操作成功,执行了程序员假定数据结构未改变的路径。不幸的是,这个假设是不正确的:数据结构几乎可以任意改变,除了观察到的指针的值被恢复到它曾经的值。 问题的根源在于,如果内存被释放和重新分配,指针或内存中的地址不能唯一标识存储在该地址的数据。对于这个问题有多种解决方案,但它们都通过不同的方式实现了同样的目标:确保一旦读取了将被比较和交换使用的指针,该地址的内存在比较和交换完成(成功或不成功)之前不能被释放。如果内存没有被释放,那么另一个分配就不能发生在同一个地址上,您就不会遇到A-B-A问题。请注意,不释放内存并不等同于不删除节点:您当然可以使节点对于数据结构的其余部分不可访问(删除节点),甚至可以调用节点中存储的数据的析构函数;您只是不能释放节点占用的内存。 在本书中,我们将介绍另一种方法,它使用原子共享指针(std::shared_ptr本身不是原子的,但标准包含了对共享指针进行原子操作的必要函数,或者您可以为特定应用程序编写自己的函数并使其更快)。让我们重新审视图7.27b,但现在让所有指针都是原子共享指针。只要有至少一个这样的指针指向一个节点,该节点就不能被释放。在相同的事件序列中,线程A仍然拥有指向原始节点T1的旧头指针,以及指向节点T2的新头指针head'。 图7.28–具有共享指针的无锁插入和删除的单链表头部 线程B已经从列表中删除了两个节点(图7.28),但是内存还没有被释放。新节点T4被分配到了另一个地址,与当前分配的所有节点的地址不同。因此,当线程A恢复执行时,它会发现新的列表头与旧的头值不同;比较和交换将失败,线程A将再次尝试操作。此时,它将重新读取头指针(并获取节点T3的地址)。头指针的旧值现在已经消失;因为它是指向节点T1的最后一个共享指针,这个节点不再有引用并被删除。同样,一旦共享指针head'被重置为其新的预期值(节点T3的下一个指针),节点T2也会被删除。节点T1和T2都没有指向它们的共享指针,因此它们最终被删除。 当然,这解决了在前面插入的问题。为了允许在任何地方插入和删除,我们必须将所有节点的指针转换为共享指针。这包括所有节点的next指针以及隐藏在列表迭代器中的节点的指针。这样的设计还有另一个重要优势:它解决了与插入和删除同时发生的列表遍历(如搜索操作)的问题。 如果列表节点在有迭代器指向该列表时被移除(图7.29),该节点将保持分配,并且迭代器仍然有效。即使我们移除了下一个节点(T3),它也不会被释放,因为有一个指向它的共享指针(节点T2的next指针)。迭代器可以遍历整个列表。 图7.29–具有原子共享指针的无锁列表的线程安全遍历 这就结束了我们对并发编程高性能数据结构的探讨。现在让我们总结一下我们学到的东西。 本章最重要的教训是为并发设计数据结构很困难,你应该抓住每一个机会来简化它。对数据结构使用特定于应用程序的限制可以使它们变得更简单和更快。 你必须做出的第一个决定是你的代码的哪些部分需要线程安全,哪些不需要。通常,最好的解决方案是为每个线程提供自己的数据来处理:单个线程使用的任何数据根本不需要考虑线程安全。当这不是一个选择时,寻找其他特定于应用程序的限制:你是否有多个线程修改特定的数据结构?如果只有一个写入线程,实现通常会更简单。是否有任何应用程序特定的保证可以利用?你是否事先知道数据结构的最大大小?你是否需要同时从数据结构中删除数据和添加数据,还是可以将这些操作分开进行?是否有明确定义的时期,某些数据结构不会发生变化?如果是这样,你就不需要同步来读取它们。这些以及许多其他特定于应用程序的限制可以用来极大地提高数据结构的性能。 第二个重要的决定是:你将支持数据结构上的哪些操作?重新陈述上一段的另一种方式是“实现最小必要的接口”。你实现的任何接口都必须是事务性的:每个操作对于数据结构的任何状态都必须有明确定义的行为。如果某个操作仅在数据结构处于某种状态时有效,除非调用方使用客户端锁定将多个操作组合成单个事务(在这种情况下,这些操作应该从一开始就是一个操作),否则不能在并发程序中安全地调用。 本章总结了我们对并发性的探索。接下来,我们将学习C++语言本身如何影响我们程序的性能。 本章的目的是描述最近添加到语言中的并发编程功能:在C++17和C++20标准中。虽然现在讨论使用这些功能以获得最佳性能的最佳实践还为时过早,但我们可以描述它们的功能,以及编译器支持的当前状态。 阅读完本章后,您将了解C++提供的功能,以帮助编写并发程序。本章并不意味着是C++并发功能的全面手册,而是对可用语言设施的概述,作为您进一步探索感兴趣主题的起点。 在C++11之前,C++标准没有提及并发。当然,在实践中,程序员在2011年之前就已经使用C++编写了多线程和分布式程序。这是可能的原因是编译器编写者自愿采用了额外的限制和保证,通常是通过遵守C++标准(用于语言)和其他标准(如POSIX)来支持并发。 C++11通过引入C++内存模型改变了这一点。内存模型描述了线程如何通过内存进行交互。这是C++语言首次在并发方面有了坚实的基础。然而,其直接实际影响相当有限,因为新的C++内存模型与大多数编译器编写者已经支持的内存模型非常相似。这些模型之间存在一些微妙的差异,新标准最终保证了遇到这些黑暗角落的程序的可移植行为。 接下来,标准引入了几种用于控制并发访问内存的同步原语。语言提供了std::mutex,通常使用常规系统互斥量实现:在POSIX平台上,这通常是POSIX互斥量。标准提供了互斥量的定时和递归变体(再次遵循POSIX)。为了简化异常处理,应避免直接锁定和解锁互斥量,而应优先使用RAII模板std::lock_guard。 为了安全地锁定多个互斥锁,而不会出现死锁的风险,标准提供了std::lock()函数(虽然它保证不会出现死锁,但它使用的算法是未指定的,特定实现的性能差异很大)。另一个常用的同步原语是条件变量,std::condition_variable,以及相应的等待和信号操作。这个功能也非常接近对应的POSIX特性。 然后,还有对低级原子操作的支持:std::atomic,比如比较和交换,以及内存顺序说明符。我们已经在《第五章》、《线程、内存和并发》、《第六章》、《并发和性能》和《第七章》、《并发数据结构》中介绍了它们的行为和应用。 最后,该语言增加了对异步执行的支持:可以使用std::async异步调用函数(可能在另一个线程上)。虽然这可能会实现并发编程,但实际上,这个特性对于高性能应用几乎是完全无用的。大多数实现要么提供非常有限的并行性,要么在自己的线程上执行每个异步函数调用。大多数操作系统创建和加入线程的开销相当高(我见过的唯一一个使并发编程变得像“为每个任务启动一个线程,如果需要的话,可以有数百万个”简单的操作系统是AIX,在我知道的其他操作系统上,这是一种混乱的做法)。 总的来说,可以说,就并发而言,C++11在概念上是一个重大的进步,但在实际上提供了适度的即时实际收益。C++14的改进集中在其他地方,因此在并发方面没有什么值得注意的变化。接下来,我们将看看C++17带来了哪些新的发展。 值得注意的是一个新特性,可以可移植地确定L1缓存的缓存行大小,std::hardware_destructive_interference_size和std::hardware_constructive_interference_size。这些常量有助于创建避免伪共享的缓存最优数据结构。 现在我们来到了C++17中的主要新特性——std::for_each: std::vector std::vector 还有一个并行的无序策略,std::execution::par_unseq。两种并行策略之间的区别微妙但很重要。标准规定,无序策略允许计算在单个线程内交错进行,这允许额外的优化,比如矢量化。但是优化编译器可以在生成机器代码时使用矢量指令,比如AVX,并且这是在没有源C++代码的帮助下完成的:编译器只是找到矢量化机会,并用矢量指令替换常规的单字指令。那么这里有什么不同呢? 要理解无序策略的性质,我们必须考虑一个更复杂的例子。假设我们不仅仅是对每个元素进行操作,而是要进行一些使用共享数据的计算: doublemuch_computing(doublex);std::vector 现在我们已经了解了并行算法在理论上是如何工作的,那么在实践中呢?简短的答案是相当好,但有一些注意事项。继续阅读详细版本。 在实践中检查并行算法之前,您必须做一些准备工作来准备您的构建环境。通常,要编译C++程序,您只需要安装所需的编译器版本,比如GCC,然后就可以开始了。但是并行算法不是这样。在撰写本书时,安装过程有些繁琐。 足够新的GCC和Clang版本包括并行STL头文件(在某些安装中,Clang需要安装GCC,因为它使用由GCC提供的并行STL)。问题出现在更低的层次。这两个编译器使用的运行时线程系统是英特尔线程构建块(TBB),它作为一个带有自己一套头文件的库提供。两个编译器都没有在其安装中包含TBB。更复杂的是,每个编译器版本都需要相应版本的TBB:旧版本和更近版本都不起作用(失败可能会在编译和链接时都表现出来)。要运行与TBB链接的程序,您可能需要将TBB库添加到库路径中。 一旦你解决了所有这些问题并配置了编译器和必要库的工作安装,使用并行算法就不比使用任何STL代码更难。那么,它的扩展性如何?我们可以运行一些基准测试。 std::vector 图8.1-在2个CPU上并行std::foreach的基准测试 扩展性并不差。请注意,向量大小N相当大,有32K个元素。对于更大的向量,扩展性确实有所提高。但是,对于相对较小的数据量,并行算法的性能非常差: 图8.2-并行std::foreach进行短序列的基准 当然,并行算法改善性能的大小取决于硬件、编译器及其并行实现,以及每个元素的计算量。例如,我们可以尝试一个非常简单的每个元素计算: std::for_each(std::execution::par,v.begin(),v.end(),[](double&x){++x;});现在处理相同的32K元素向量不显示并行性的好处: 图8.3-并行std::foreach进行廉价的每个元素计算的基准 对于更大的向量大小,除非内存访问速度限制了单线程和多线程版本的性能(这是一个非常受内存限制的计算),并行算法可能会领先。 也许更令人印象深刻的是更难并行化的算法的性能,比如std::sort: std::vector 图8.4-并行std::sort的基准 再次,我们需要足够大量的数据才能使并行算法变得有效(对于1024个元素,单线程排序更快)。这是一个非常显著的成就:排序不是最容易并行化的算法,而双精度浮点数的每个元素计算(比较和交换)非常便宜。尽管如此,并行算法显示出非常好的加速,并且如果元素比较更昂贵,它会变得更好。 您可能想知道并行STL算法如何与您的线程交互,也就是说,如果同时在两个线程上运行两个并行算法会发生什么?首先,与在多个线程上运行的任何代码一样,您必须确保线程安全(在同一容器上并行运行两个排序无论使用哪种排序都是一个坏主意)。除此之外,您会发现多个并行算法可以很好地共存,但您无法控制作业调度:它们中的每一个都会尝试在所有可用的CPU上运行,因此它们会竞争资源。取决于每个算法的扩展性如何,您可能会或可能不会通过并行运行多个算法来获得更高的整体性能。 总的来说,我们可以得出结论,当它们在足够大的数据量上操作时,STL算法的并行版本提供非常好的性能,尽管“足够大”取决于特定的计算。可能需要额外的库来编译和运行使用并行算法的程序,并且配置这些库可能需要一些努力和实验。此外,并非所有STL算法都有其并行等价物(例如,std::accumulate没有)。 .... 我们现在准备翻动日历上的几页,并跳到C++20。 C++20在现有并发支持中增加了一些增强功能,但我们将专注于主要的新添加:协程。协程通常是可以中断和恢复的函数。它们在几个主要应用中非常有用:它们可以极大地简化编写事件驱动程序,对于工作窃取线程池几乎是不可避免的,而且它们使编写异步I/O和其他异步代码变得更加容易。 有两种风格的协程:堆栈式和无堆栈式。堆栈式协程有时也被称为纤程;它们类似于函数,它们的状态是在堆栈上分配的。无堆栈式协程没有对应的堆栈分配,它们的状态存储在堆上。一般来说,堆栈式协程更强大和灵活,但无堆栈式协程要高效得多。 在本书中,我们将专注于无堆栈式协程,因为这是C++20支持的。这是一个足够不寻常的概念,我们需要在展示C++特定的语法和示例之前进行解释。 voidf(){…}它有一个对应的堆栈帧。函数f()可能调用另一个函数g(): voidg(){…}voidf(){…g();…}函数g()在运行时也有一个堆栈帧。 参考以下图表: 图8.5–普通函数的堆栈帧 当函数g()退出时,它的堆栈帧被销毁,只剩下函数f()的帧。 voidg(){…}voidcoro(){//coroutine…g();…}voidf(){…std::coroutine_handle<>H;//Nottherealsyntaxcoro();…}相应的内存分配如下图所示: 图8.6–协程调用 函数f()创建一个协程句柄对象,它拥有激活帧。然后调用协程函数coro()。在这一点上有一些堆栈分配,特别是协程在堆栈上存储它如果被挂起时将返回的地址(记住协程是可以自己挂起的函数)。协程可以调用另一个函数g(),它在堆栈上分配g()的堆栈帧。在这一点上,协程不能再挂起自己:只能从协程函数的顶层挂起。函数g()无论是谁调用它都会以相同的方式运行,并最终返回,销毁它的堆栈帧。现在协程可以挂起自己,所以让我们假设它这样做了。 这是堆栈式和无堆栈式协程之间的关键区别:堆栈式协程可以在任何地方挂起,在任意深度的函数调用中恢复。但是这种灵活性在内存和特别是运行时方面代价很高:无堆栈式协程,由于它们有限的状态分配,要高效得多。 当一个协程挂起自己时,为了恢复它所需的状态的一部分被存储在激活帧中。然后协程的堆栈帧被销毁,控制返回给调用者,返回到协程被调用的地方。如果协程运行完成,同样也是这样,但是调用者有办法找出协程是挂起还是完成。 调用者继续执行并可能调用其他函数: voidh(){…}voidcoro(){…}//coroutinevoidf(){…std::coroutine_handle<>H;//Nottherealsyntaxcoro();h();//Calledaftercoro()issuspended…}现在内存分配如下: 图8.7–协程被挂起,执行继续 请注意,协程没有对应于堆栈帧,只有堆分配的激活帧。只要句柄对象存在,协程就可以恢复。不一定是调用和恢复协程的相同函数;例如,如果函数h()可以访问句柄,它也可以恢复它: voidh(H){H.resume();//Nottherealsyntax}voidcoro(){…}//coroutinevoidf(){…std::coroutine_handle<>H;//Nottherealsyntaxcoro();h(H);//Calledaftercoro()issuspended…}协程从暂停的地方恢复。它的状态从激活帧中恢复,任何必要的堆栈分配都会像往常一样发生: 图8.8-协程从不同的函数中恢复 以下是关于C++20协程的重要知识总结: 现在让我们看看在真正的C++中如何完成所有这些。 现在让我们看看用于使用协程编程的C++语言构造。 首要任务是获得支持此功能的编译器。GCC和Clang的最新版本都支持协程,但不幸的是,方式不同。对于GCC,需要11版或更高版本。对于Clang,部分支持是在10版中添加的,并在后续版本中得到改进,尽管仍然是“实验性的”。 首先,为了编译协程代码,您需要在命令行上使用编译器选项(仅使用--std=c++20选项启用C++20是不够的)。对于GCC,选项是-fcoroutines。对于Clang,选项是-stdlib=libc++-fcoroutines-ts。对于最新的VisualStudio,除了/std:c++20之外,不需要任何选项。 对于实际编程,你也应该这样做。然而,在本书中,我们展示的例子是用纯的C++编写的。我们这样做是因为我们不想引导你去使用特定的库,而且这样做会使人们对实际发生的事情的理解变得模糊。协程的支持非常新,库正在快速发展;你选择的库可能不会保持不变。我们希望你能理解C++级别的协程代码,而不是特定库提供的抽象级别。然后你可以根据自己的需求选择一个库并使用它的抽象。 第一个例子可能是C++中协程最常见的用法(也是标准提供了一些明确设计语法的用法)。我们将实现一个惰性生成器。生成器是生成数据序列的函数;每次调用生成器,都会得到序列的一个新元素。惰性生成器是一个按需计算元素的生成器,当调用时会计算元素。 这是一个基于C++20协程的惰性生成器: generator 最后,句柄是一个可调用对象。调用它会恢复协程,生成下一个值,并立即再次暂停,因为co_yield操作符在循环中。 所有这些都是通过定义协程的适当返回类型神奇地联系在一起的。就像STL算法一样,整个系统都受约定束缚:对于这个过程中涉及的所有类型都有期望,如果这些期望没有得到满足,某个地方将无法编译。现在让我们看看generator类型: 这些必需函数的主体对于不同的生成器可能会更复杂。我们将简要描述每个函数的作用。 第一个非空函数get_return_object()是样板代码的一部分,通常看起来与之前的函数完全相同;此函数从一个句柄构造一个新的生成器,而句柄又是从一个promise对象构造的。编译器调用它来获取协程的结果。 第二个非空函数yield_value()在每次调用co_yield操作符时被调用;它的参数是co_yield的值。将值存储在promise对象中通常是协程将结果传递给调用者的方式。 当编译器第一次遇到co_yield时,将调用initial_suspend()函数。在协程通过co_return产生最后一个结果后,将调用final_suspend()函数;之后无法再暂停。如果协程在没有co_return的情况下结束,将调用return_void()方法。最后,如果协程抛出一个从其主体中逃逸的异常,将调用unhandled_exception()方法。您可以自定义这些方法,以便特殊处理每种情况,尽管这很少被使用。 现在我们看到了如何将所有这些联系在一起,为我们提供了一个惰性生成器。首先,创建协程句柄。在我们的示例中,我们没有保留generator对象,只保留了句柄。这不是必需的:我们可以保留generator对象,并在其析构函数中销毁句柄。协程运行直到遇到co_yield并暂停;控制权由调用者返回,而co_yield的返回值被捕获在promise中。调用程序检索此值,并通过调用句柄恢复协程。协程从被暂停的地方继续运行,直到下一个co_yield。 我们的生成器可以永远运行(或者直到在我们的平台上达到最大整数值):序列永远不会结束。如果我们需要一个有限长度的序列,我们可以执行co_return或者在序列结束后退出循环。参考以下代码: generator 我们之前提到协程可以从代码中的任何位置恢复(当然是在被暂停后)。它甚至可以从不同的线程中恢复。在这种情况下,协程开始在一个线程上执行,被暂停,然后在另一个线程上运行其余的代码。让我们看一个例子: taskcoro(std::jthread&t){std::cout<<"Coroutinestartedonthread:"< 首先,让我们看看协程的返回类型: structtask{structpromise_type{taskget_return_object(){return{};}std::suspend_neverinitial_suspend(){return{};}std::suspend_neverfinal_suspend()noexcept{return{};}voidreturn_void(){}voidunhandled_exception(){}};};实际上,这是协程的最小可能返回类型:它包含所有必需的样板代码,没有其他内容。具体来说,返回类型是一个定义了嵌套类型promise_type的类。该嵌套类型必须定义几个成员函数,如此代码所示。我们在前一个示例中的生成器类型具有所有这些内容以及用于将结果返回给调用者的一些数据。当然,任务也可以根据需要具有内部状态。 前一个示例的第二个变化是任务被挂起的方式:我们使用co_await而不是co_yield。操作符co_await实际上是挂起协程的最通用方式:就像co_yield一样,它挂起函数并将控制返回给调用者。不同之处在于参数类型:co_yield返回一个结果,而co_await的参数是具有非常一般功能的等待对象。再次,对这个对象的类型有特定要求。如果满足要求,该类被称为awaitable,并且该类型的对象是有效的等待者(如果不满足要求,某处将无法编译)。这是我们的awaitable: 将所有这些放在一起,我们得到以下顺序: 我们程序的输出确认了这个顺序: Mainthread:140003570591552Coroutinestartedonthread:140003570591552Mainthreaddone:140003570591552Coroutineresumedonthread:140003570587392Coroutinedoneonthread:140003570587392如您所见,协程coro()首先在一个线程上运行,然后在执行过程中切换到另一个线程。如果有任何局部变量,它们将通过这个转换被保留。 我们提到co_await是用于挂起协程的通用操作符。的确,co_yieldx操作符等同于co_await的特定调用,如下所示: co_awaitpromise.yield_value(x);这里promise是与当前协程句柄关联的promise_type对象。之所以单独使用co_yield操作符,是因为在协程内部访问自己的promise会导致非常冗长的语法,因此标准添加了一个快捷方式。 总的来说,C++标准在多年忽视并发之后,正在迅速赶上,让我们总结一下最近的进展。 C++11是标准中首次承认线程存在的版本。它为记录C++程序在并发环境中的行为奠定了基础,并在标准库中提供了一些有用的功能。在这些功能中,基本的同步原语和线程本身是最有用的。随后的版本通过相对较小的增强扩展和完善了这些功能。 C++17带来了一个重大进步,即并行STL。性能当然取决于实现。只要数据语料库足够大,即使在像搜索和分区这样难以并行化的算法上,观察到的性能也相当不错。然而,如果数据序列太短,并行算法实际上会降低性能。 C++20增加了对协程的支持。您已经看到了无栈协程的工作原理,在理论上和一些基本示例中。然而,现在讨论C++20协程的性能和最佳实践还为时过早。 本章总结了我们对并发性的探索。接下来,我们将学习C++语言本身如何影响程序的性能。 在本节中,您将把迄今为止学到的知识应用于编写C++程序的实践中。您将学习哪些语言特性有助于实现更好的性能,哪些可能导致意想不到的低效,并且如何帮助编译器生成更好的目标代码。最后,您将学习以性能为重点设计程序的艺术。 在本章中,我们将把重点从硬件资源的最佳使用转移到特定编程语言的最佳应用。尽管到目前为止我们学到的一切都可以应用于任何语言的任何程序,但本章涉及C++的特性和特殊性。你将学会哪些C++语言特性可能会导致性能问题,以及如何避免它们。 你还需要一种方法来检查编译器生成的汇编代码:许多开发环境都有显示汇编的选项;GCC和Clang可以将汇编写出来而不是目标代码;调试器和其他工具可以从目标代码生成汇编(反汇编)。你可以根据个人喜好选择使用哪种工具。 程序员经常谈论一种语言是否高效。特别是C++,它的开发明确目标是效率,同时在某些领域却有低效的声誉。这是怎么回事呢? 效率在不同的上下文或不同的人看来可能有不同的含义。例如: 本章的目标是帮助您编写清晰表达您想让机器执行的代码。目的是双重的:您可能认为您的主要受众是编译器:通过精确描述您想要的内容以及编译器可以自由更改的内容,您给予编译器生成更有效代码的自由。但对于您程序的读者也可以这样说:他们只能推断您在代码中表达的内容,而不是您打算表达的内容。如果优化代码会改变其行为的某些方面,这样做是否安全?这种行为是有意的还是实现的意外可以改变的?我们再次被提醒,编程主要是与我们的同行交流的一种方式,然后才是与机器交流。 我们将从看似容易避免的简单低效开始,但即使是掌握了语言的其他方面的程序员的代码中也会出现这些问题。 对象的不必要复制可能是C++效率问题#1。主要原因是这样做很容易,很难注意到。考虑以下代码: std::vector voiddo_work(std::vector voiddo_work(std::vector voiddo_work(conststd::vector 另一方面,如果我们需要创建参数的副本作为满足函数要求的一部分,使用参数传递是一个很好的方式: voiddo_work(std::vector C++11引入了移动语义作为不必要复制的部分答案。在我们的例子中,我们观察到如果函数参数是一个r值,我们可以以任何方式使用它,包括改变它(调用完成后,调用者无法访问对象)。利用移动语义的常规方式是用r值引用版本重载函数: voiddo_work(std::vector voiddo_work(std::vector 现在我们已经看到了两个极端的例子。在第一种情况下,不需要参数的复制,创建一个纯粹是低效的。在第二种情况下,进行复制是一个合理的实现。并不是每种情况都属于这两个极端之一,正如我们将要看到的那样。 还有一种中间地带,选择的实现需要参数的复制,但实现本身并不是最佳的。例如,考虑下面需要按排序顺序打印向量的函数: voidprint_sorted(std::vector template 基准测试证实,对于整数来说,复制整个向量并对副本进行排序更快: 图9.1-对整数向量进行排序的基准测试,复制与指针间接 请注意,如果向量很小且所有数据都适合低级缓存,那么处理速度无论如何都非常快,速度几乎没有差异。如果对象很大且复制成本很高,那么间接引用相对更有效: 图9.2-对大对象向量进行排序的基准测试,复制与指针间接 在实现时,还有另一种特殊情况需要复制对象;我们将在下面考虑这种情况。 在C++中,我们可能会遇到另一种数据复制的特殊情况。它最常发生在类构造函数中,其中对象必须存储数据的副本,因此必须创建一个超出构造函数调用寿命的长期复制。考虑以下示例: classC{std::vector classC{std::vector classC{std::vector 另一种方法是通过值传递所有参数并从参数中移动,检查以下代码: classC{std::vector 到目前为止,我们已经专注于将数据传递给函数和对象的问题。但是在需要返回结果时,也可能发生复制。这些考虑是完全不同的,需要单独进行检查。 我们在本节开头的示例中包括了两种复制。特别是这一行: std::vector std::vector classC{inti_=0;public:explicitC(inti):i_(i){std::cout<<“C()@”< 图9.3-程序返回对象的输出 正如您所看到的,只构造和销毁了一个对象。这是编译器优化的结果。这里使用的特定优化被称为ctmp,无名临时返回值和最终结果c-都是相同类型。此外,我们编写的任何代码都不可能同时观察到这三个变量中的任何两个。因此,在不改变任何可观察行为的情况下,编译器可以使用相同的内存位置来存储所有三个变量。在调用函数之前,编译器需要分配内存,用于构造最终结果c的位置。编译器将这个内存地址传递给函数,在函数中用于在相同位置构造局部变量ctmp。结果是,当函数makeC结束时,根本没有什么需要返回的:结果已经在应该的地方。这就是RVO的要点。 尽管RVO看起来很简单,但它有几个微妙之处。 首先,要记住这是一种优化。这意味着编译器通常不必这样做(如果你的编译器不这样做,你需要一个更好的编译器)。然而,这是一种非常特殊的优化。一般来说,只要不改变可观察行为,编译器可以对你的程序做任何它想做的事情。可观察行为包括输入和输出以及访问易失性内存。然而,这种优化导致了可观察行为的改变:拷贝构造函数和匹配的析构函数的预期输出都不见了。事实上,这是一个例外,违背了通常的规则:即使这些函数具有包括可观察行为在内的副作用,编译器也允许消除对拷贝或移动构造函数以及相应析构函数的调用。这个例外并不局限于RVO。这意味着,一般来说,你不能指望拷贝和移动构造函数会被调用,只因为你写了一些看起来像是在进行拷贝的代码。这就是所谓的拷贝省略(或移动省略,对于移动构造函数)。 其次,要记住(再次)这是一种优化。在进行优化之前,代码必须能够编译。如果你的对象没有任何拷贝或移动构造函数,这段代码将无法编译,我们将永远无法进行将删除所有这些构造函数调用的优化步骤。如果我们在示例中删除所有拷贝和移动构造函数,这一点很容易看出: classC{…C(constC&c)=delete;C(C&&c)=delete;};编译现在会失败。确切的错误消息取决于编译器和C++标准级别;在C++17中,它会看起来像这样: 图9.4-使用C++17或C++20的Clang编译输出 有一种特殊情况,即使删除了拷贝和移动操作,我们的程序也会编译。让我们对makeC函数进行一些微小的更改: CmakeC(inti){returnC(i);}C++11或C++14中没有任何变化;然而,在C++17及以上版本中,这段代码可以成功编译。请注意与之前版本的细微差别:返回的对象以前是一个l-value,它有一个名字。现在它是一个r-value,一个没有名字的临时对象。这造成了很大的不同:虽然命名返回值优化(NRVO)仍然是一种优化,但自C++17以来,无名的返回值优化是强制性的,不再被视为拷贝省略。相反,标准规定首先不会请求任何拷贝或移动。 最后,你可能会想知道编译器是否必须内联函数,以便在编译函数本身时知道返回值的位置。通过简单的测试,你可以确信这并非如此:即使函数makeC在一个单独的编译单元中,RVO仍然会发生。因此,编译器必须在调用点将结果的地址发送给函数。如果你根本不从函数中返回结果,而是将结果的引用作为额外的参数传递,你也可以自己做类似的事情。当然,该对象必须首先被构造,而编译器生成的优化不需要额外的构造函数调用。 你可能会发现有人建议不要依赖RVO,而是强制移动返回值: CmakeC(inti){Cc(i);returnstd::move(c);}有人认为,如果RVO没有发生,你的程序将承受复制操作的性能损失,而移动操作无论如何都很便宜。然而,这个观点是错误的。要理解为什么,请仔细看图9.4中的错误消息:尽管ctmp是一个l值并且应该被复制,编译器却抱怨移动构造函数被删除。这不是编译器的错误,而是标准所要求的行为:在返回值优化可能发生的情况下,但编译器决定不这样做时,编译器必须首先尝试找到一个move构造函数来返回结果。如果找不到move构造函数,就会进行第二次查找;这一次,编译器会寻找一个复制构造函数。在这两种情况下,编译器实际上是在执行重载解析,因为可能有许多复制或move构造函数。因此,没有理由写一个显式的移动:编译器会为我们做一个。那么,有什么害处呢?害处在于使用显式移动会禁用RVO;你要求进行移动,所以你会得到一个。虽然移动可能需要很少的工作,但RVO根本不需要工作,没有工作总是比一些工作更快。 到目前为止,你一定已经看到了意外复制对象并破坏程序性能的危险隐藏在你的代码的每一个黑暗角落。你能做些什么来避免意外复制?我们马上会有一些建议,但首先,让我们回到我们已经简要使用过的一个方法:使用指针。 在传递对象时避免复制对象的一种方法是传递指针。如果我们不必管理对象的生命周期,这是最容易的。如果一个函数需要访问一个对象但不需要删除它,通过引用或原始指针传递对象是最好的方式(在这种情况下,引用实际上只是一个不能为null的指针)。 同样,我们可以使用指针从函数返回对象,但这需要更多的注意。首先,对象必须在堆上分配。你绝对不能返回指向局部变量的指针或引用。参考以下代码: C&makeC(inti){Cc(i);returnc;}//Neverdothis!其次,调用者现在负责删除对象,因此你的函数的每个调用者都必须知道对象是如何构造的(new操作符不是构造对象的唯一方式,只是最常见的一种)。这里最好的解决方案是返回一个智能指针: std::unique_ptr 说到共享指针,它们经常用于传递由智能指针管理生命周期的对象。除非意图是传递对象的所有权,否则这又是一个不必要和低效的复制的例子。复制共享指针并不便宜。那么,如果我们有一个由共享指针管理的对象和一个需要在不获取所有权的情况下对该对象进行操作的函数,我们使用原始指针: 同样,你可以创建原始指针的容器,只要对象的生命周期在其他地方管理。如果你希望容器管理其元素的生命周期,但又不想将对象存储在容器中,唯一指针的容器就可以胜任。 现在是时候提出一些通用的准则,帮助你避免不必要的拷贝和由此引起的低效率。 要减少意外的、无意的拷贝,你可以做的最重要的事情也许是确保所有的数据类型都是可移动的,如果移动的成本比拷贝更低的话。如果你有容器库或其他可重用的代码,确保它也是可移动的。 在向函数传递参数时,尽可能使用引用或指针。如果函数需要对参数进行拷贝,请考虑按值传递并从参数中移动。记住,这仅适用于可移动类型,并参考第一个准则。 我们关于传递函数参数的所有说法也适用于临时局部变量(毕竟,函数参数基本上就是函数范围内的临时局部变量)。除非你需要一个拷贝,否则这些应该是引用。这不适用于像整数或指针这样的内置类型:它们比间接访问更便宜。在模板代码中,你无法知道类型是大还是小,所以使用引用,并依赖于编译器优化来避免对内置类型的不必要的间接访问。 当从函数返回值时,你首选应该依赖于RVO和拷贝省略。只有当你发现编译器没有执行这种优化,并且在你的特定情况下这很重要时,你才应该考虑其他选择。这些替代方案包括:使用带有输出参数的函数和使用在动态分配内存中构造结果并返回拥有智能指针(如std::unique_ptr)的工厂函数。 最后,审查你的算法和实现,留意不必要的拷贝:记住,恶意的拷贝对性能的影响和无意的拷贝一样糟糕。 我们已经完成了C++程序中效率的第一个问题,即不必要的对象拷贝。接下来的问题是糟糕的内存管理。 C++中的内存管理这个主题可能值得一本专门的书。有数十甚至数百篇论文专门讨论STL分配器的问题。在本章中,我们将专注于影响性能最大的几个问题。有些问题有简单的解决方案;对于其他问题,我们将描述问题并概述可能的解决方案。 本节介绍的材料对于处理受限于内存的程序或频繁分配大量内存的程序的程序员来说是有帮助的。我们首先从内存分配本身的性能影响开始。 我们可以看到性能对一个非常简单的基准测试的影响: voidBM_make_str_new(benchmark::State&state){constsize_tNMax=state.range(0);for(auto_:state){constsize_tN=(random_number()%NMax)+1;char*buf=newchar[N];memset(buf,0xab,N);delete[]buf;}state.SetItemsProcessed(state.iterations());}这里的工作是通过初始化一个字符串来表示,random_number()函数返回随机整数值(它可以只是rand(),但如果我们预先计算并存储随机数以避免对随机数生成器进行基准测试,那么基准测试就会更干净)。你可能还需要欺骗编译器,使其不要优化结果:如果通常的benchmark::DoNotOptimize()不够用,你可能需要插入一个带有永远不会发生的条件的打印语句(但编译器不知道)比如rand()<0。 我们从基准测试中得到的数字本身是没有意义的:我们需要将它们与某些东西进行比较。在我们的情况下,基准很容易找到:我们必须做同样的工作,但没有任何分配。这可以通过将分配和释放移出循环来实现,因为我们知道最大内存大小: char*buf=newchar[NMax];for(auto_:state){…}delete[]buf;在这样的基准测试中,你观察到的性能差异在很大程度上取决于操作系统和系统库,但你可能会看到类似这样的情况(我们使用了最多1KB的随机大小的字符串): 图9.5-分配-释放模式的性能影响 应该注意,在微基准测试中,内存分配通常比在大型程序的上下文中更有效率,因为内存分配模式要复杂得多,因此频繁分配和释放的实际影响可能更大。即使在我们的小基准测试中,每次分配内存的实现速度只有分配最大可能内存量一次版本的40%。 当然,当我们在计算过程中需要的最大内存量事先知道时,预先分配并在下一次迭代中重复使用是一个简单的解决方案。这个解决方案也适用于许多容器:对于向量或双端队列,我们可以在迭代开始之前预留内存,并利用调整容器大小不会减小其容量的特性。 当我们事先不知道最大内存大小时,解决方案只是稍微复杂一些。这种情况可以用一个只增长不缩小的缓冲区来处理。这是一个简单的缓冲区,可以增长但永远不会缩小: classBuffer{size_tsize_;std::unique_ptr voidBM_make_str_buf(benchmark::State&state){constsize_tNMax=state.range(0);Bufferbuf(1);for(auto_:state){constsize_tN=(random_number()%NMax)+1;buf.resize(N);memset(buf.get(),0xab,N);}state.SetItemsProcessed(state.iterations());}再次强调,在一个真实的程序中,通过更智能的内存增长策略(略微超过请求的增长,这样你就不必经常增长内存-大多数STL容器都采用某种形式的这种策略)你可能会得到更好的结果。但是,对于我们的演示,我们希望尽可能地保持简单。在同一台机器上,基准测试的结果如下: 图9.6-仅增长缓冲区的性能(与图9.5进行比较) 增长型缓冲区比固定大小缓冲区慢,但比每次分配和释放内存要快得多。再次强调,更好的增长策略会使这个缓冲区变得更快,接近固定大小缓冲区的速度。 这还不是全部:在多线程程序中,良好的内存管理的重要性更大,因为对系统内存分配器的调用不会很好地扩展,并且可能涉及全局锁。在同一台机器上使用8个线程运行我们的基准测试产生了以下结果: 图9.7-多线程程序中分配-释放模式的性能影响 在这里,频繁分配的惩罚更大(仅增长缓冲区显示了剩余分配的成本,并且真的会受益于更智能的增长策略)。 关键是:尽量减少与操作系统的交互。如果你有一个需要在每次迭代中分配和释放内存的循环,那么在循环之前分配一次。如果分配的大小相同,或者你事先知道最大分配大小,那么就分配这个大小并保持它(当然,如果你使用多个缓冲区或容器,你不应该试图把它们塞进一个单一的分配中,而是预先分配每一个)。如果你不知道最大大小,使用一个可以增长但不会缩小或释放内存直到工作完成的数据结构。 操作系统提供的内存分配器是一个平衡多种需求的解决方案:在一台给定的机器上,只有一个操作系统,但有许多不同的程序,它们有自己独特的需求和内存使用模式。开发人员非常努力地使它在任何合理的用例中都不会失败;另一方面,它很少是任何用例的最佳解决方案。通常情况下,它足够好,特别是如果你遵循频繁请求内存的建议。 在并发程序中,内存分配变得更加低效。主要原因是任何内存分配器都必须维护一个相当复杂的内部数据结构来跟踪分配和释放的内存。在高性能分配器中,内存被划分为多个区域,以将相似大小的分配组合在一起。这增加了性能,但也增加了复杂性。结果是,如果多个线程同时分配和释放内存,那么这些内部数据的管理必须受到锁的保护。这是一个全局锁,适用于整个程序,如果分配器经常被调用,它可能会限制整个程序的扩展。 这个问题的最常见解决方案是使用具有线程本地缓存的分配器,比如流行的malloc()替代库TCMalloc。这些分配器为每个线程保留一定数量的内存:当一个线程需要分配内存时,首先从线程本地内存区域中取出。这不需要锁,因为只有一个线程与该区域交互。只有当该区域为空时,分配器才必须获取锁,并从所有线程共享的内存中分配。同样,当一个线程释放内存时,它会被添加到特定于线程的区域,而无需任何锁定。 线程本地缓存并非没有问题。 首先,它们往往会使用更多的内存:如果一个线程释放了大量内存,另一个线程分配了大量内存,那么最近释放的内存对于其他线程是不可用的(它是本地的)。因此,分配更多的内存,而未使用的内存对其他线程是可用的。为了限制这种内存浪费,分配器通常不允许每个线程的区域增长超过某个预定义的限制。一旦达到限制,线程本地内存就会返回到所有线程共享的主要区域(这个操作需要一个锁)。 第二个问题是,如果每个分配都由一个线程拥有,也就是说,同一个线程在每个地址分配和释放内存,那么这些分配器就能很好地工作。如果一个线程分配了一些内存,但另一个线程必须释放它,这种跨线程的释放是困难的,因为内存必须从一个线程的本地区域转移到另一个线程的本地区域(或共享区域)。简单的基准测试显示,使用标准分配器(如malloc()或TCMalloc)进行跨线程释放的性能至少比线程拥有的内存差一个数量级。这很可能对任何利用线程本地缓存的分配器都是如此,因此应尽量避免线程之间的内存转移。 到目前为止,我们讨论了将内存从一个线程转移到另一个线程以便释放的问题。那么简单地使用另一个线程分配的内存呢?这种内存访问的性能在很大程度上取决于硬件能力。对于一个具有少量CPU的简单系统,这可能不是问题。但更大的系统有多个内存银行,CPU和内存之间的连接不对称:每个内存银行更接近一个CPU。这被称为非一致内存架构(NUMA)。NUMA的性能影响因不重要到快两倍而变化很大。有方法可以调整NUMA内存系统的性能,以及使程序内存管理对NUMA细节敏感,但要注意,你可能在调整性能以适应特定的机器:关于NUMA系统的性能几乎没有什么可以说的。 现在我们回到了更有效地使用内存的问题,因为这对并发和串行程序的性能都是有益的。 一个困扰许多程序的问题是与内存分配系统的低效交互。假设程序需要分配1KB的内存。这块内存是从某个较大的内存区域中划分出来的,由分配器标记为已使用,并将地址返回给调用者。随后进行更多的内存分配,所以我们1KB的内存块之后的内存现在也被使用了。然后程序释放第一个分配的内存,并立即请求2KB的内存。有1KB的空闲块,但不足以满足这个新的请求。可能在其他地方有另一个1KB的块,但只要这两个块不相邻,它们对于2KB的分配就没有用处: 图9.8-内存碎片化:存在2KB的空闲内存,但对于单个2KB的分配是无用的 这种情况被称为malloc(),但对于快速消耗内存的程序,可能需要更极端的措施。 其中一种措施是块分配器。其思想是所有内存都以固定大小的块分配,比如64KB。你不应该一次从操作系统中分配这么大的单个块,而是应该分配更大的固定大小的块(比如8MB),然后将它们细分为更小的块(在我们的例子中是64KB)。处理这些请求的内存分配器是程序中的主要分配器,直接与malloc()交互。因为它只分配一个大小的块,所以它可以非常简单,我们可以专注于最有效的实现(并发程序的线程本地缓存,实时系统的低延迟等)。当然,你不希望在代码的各个地方都处理这些64KB的块。这是次要分配器的工作,如下图图9.9所示: 图9.9-固定大小块分配 你可以有一个分配器进一步将64KB的块细分为更小的分配。特别高效的是统一分配器(只分配一个大小的分配器):例如,如果你想为单个64位整数分配内存,你可以做到没有任何内存开销(相比之下,malloc()通常至少需要16字节的开销)。你还可以有容器分配内存在64KB的块中并用它来存储元素。你不会使用向量,因为它们需要单个大的连续分配。你想要的类似数组的容器是deque,它分配内存在固定大小的块中。当然,你也可以有节点容器。如果STL分配器接口满足你的需求,你可以使用STL容器;否则,你可能需要编写自己的容器库。 固定大小块分配的关键优势在于它不会受到碎片化的影响:从malloc()分配的所有分配都是相同大小的,因此主分配器的所有分配也是如此。每当一个内存块被返回给分配器,它都可以被重用来满足下一个内存请求。参考下图: 图9.10-固定大小分配器中的内存重用 首先进先出的特性也是一个优势:最后的64KB内存块很可能是最近使用的内存,仍然在缓存中。立即重用这个块可以改善内存引用局部性,因此更有效地利用缓存。分配器将返回给它的块管理为一个简单的空闲列表(图9.10)。这些空闲列表可以按线程维护,以避免锁定,尽管它们可能需要定期重新平衡,以避免一个线程积累了许多空闲块,而另一个线程正在分配新的内存。 当然,将我们的64KB块细分为更小尺寸的分配器仍然容易受到碎片化的影响,除非它们也是统一(固定大小)的分配器。然而,如果它必须处理一个小的内存范围(一个块)和少量不同的大小,编写自动整理分配器会更容易。 很可能整个程序都受到使用块内存分配的影响。例如,分配大量小数据结构,使得每个数据结构使用64KB块的一部分并且剩下的部分未使用会变得非常昂贵。另一方面,一个数据结构本身是一组较小的数据结构(一个容器),这样它可以将许多较小的对象打包到一个块中,变得更容易编写。甚至可以编写压缩容器,用于长期保留数据,然后逐块解压缩以进行访问。 块大小本身也不是一成不变的。一些应用程序使用较小的块会更有效,因为如果块部分未使用,则浪费的内存较少。其他应用则可以从需要较少分配的较大块中受益。 应用特定分配器的文献非常丰富。例如,板块分配器是我们刚刚看到的块分配器的一般化;它们有效地管理多种分配大小。还有许多其他类型的自定义内存分配器,其中大多数可以在C++程序中使用。使用适合特定应用的分配器通常会带来显著的性能改进,通常以严重限制程序员在数据结构实现中的自由为代价。 效率不高的另一个常见原因更微妙,也更难处理。 在不必要的计算和内存使用效率低下之后,编写未能充分利用可用计算资源大部分的低效代码的最简单方法可能是无法进行良好流水线处理的代码。我们已经在第三章,CPU架构、资源和性能影响中看到了CPU流水线处理的重要性。我们还了解到,最大的流水线破坏者通常是条件操作,特别是硬件分支预测器无法猜测的条件操作。 不幸的是,优化条件代码以获得更好的流水线是最困难的C++优化之一。只有在分析器显示预测不良的分支时才应该进行优化。但是,请注意,预测不准的分支数量不必很大才能被认为是“不良的”:一个好的程序通常会有不到0.1%的预测不准的分支。1%的错误预测率是相当大的。而且,要在不检查编译器输出(机器代码)的情况下预测源代码优化的效果也是相当困难的。 如果分析器显示有一个预测不良的条件操作,下一步是确定哪个条件被错误预测。我们已经在第三章,CPU架构、资源和性能影响中看到了一些例子。例如,这段代码: if(a[i]||b[i]||c[i]){…dosomething…}即使整体结果是可预测的,也可能产生一个或多个预测不良的分支。这与C++中布尔逻辑的定义有关:操作符||和&&是短路的:表达式从左到右进行评估,直到结果变为已知。例如,如果a[i]是true,则代码不得访问数组元素b[i]和c[i]。有时,这是必要的:实现的逻辑可能是这些元素不存在。但通常,布尔表达式会因无故引入不必要的分支。前面的if()语句需要3个条件操作。另一方面,这个语句: if(a[i]+b[i]+c[i]){…dosomething…}相当于最后一个,如果值a、b和c是非负的,但需要进行单个条件操作。同样,这不是您应该预先进行的优化类型,除非您有测量结果证实需要进行优化。 这是另一个例子。考虑这个函数: voidf2(boolb,unsignedlongx,unsignedlong&s){if(b)s+=x;}如果b的值是不可预测的,那么效率非常低。只需进行简单的更改,性能就会大大提高: voidf2(boolb,unsignedlongx,unsignedlong&s){s+=b*x;}这种改进可以通过对原始的有条件的实现与无分支实现进行简单的基准测试来确认: BM_conditional176.304Mitems/sBM_branchless498.89Mitems/s正如你所看到的,无分支实现几乎快了3倍。 不要过度追求这种类型的优化是很重要的。它必须始终受到测量的驱动,有几个原因: 例如,手动优化这种非常常见的代码几乎从来都不是有用的: intf(intx){return(x>0)x:0;}它看起来像是有条件的代码,如果x的符号是随机的,那么预测是不可能的。然而,很可能分析器不会显示出大量的错误预测分支。原因是大多数编译器不会使用条件跳转来实现这一行。在x86上,一些编译器会使用CMOVE指令,它执行条件移动:根据条件,它将两个源寄存器中的一个值移动到目的地。这个指令的条件性质是良性的:记住有条件的代码的问题在于CPU无法提前知道下一条指令要执行什么。有了条件移动的实现,指令序列是完全线性的,它们的顺序是预先确定的,所以没有什么需要猜测的。 另一个常见的例子,不太可能从无分支优化中受益的是有条件的函数调用: if(condition)f1(…args…)elsef2(…args…);可以使用函数指针数组实现无分支。 usingfunc_ptr=int(*)(…params…);staticconstfunc_ptrf[2]={&f1,&f2};(*f[condition])(…args…);如果函数最初是内联的,用间接函数调用替换它们会导致性能下降。如果它们不是,这种改变可能几乎没有任何作用:跳转到另一个在编译期间不知道地址的函数,其效果与错误预测的分支非常相似,因此这段代码无论如何都会导致CPU刷新流水线。 我们现在已经学到了很多关于C++程序中许多潜在的低效性以及改进它们的方法。我们总结一些优化代码的整体指导方针。 在本章中,我们已经涵盖了C++效率的两个大领域中的第一个:避免低效的语言构造,这归结为不做不必要的工作。我们学习过的许多优化技术与我们早期学习的材料相契合,比如访问内存的效率以及在并发程序中避免虚假共享。 每个程序员面临的一个大困境是应该投入多少工作来编写高效的代码,以及什么应该留给增量优化。让我们首先说,高性能始于设计阶段:设计架构和接口,不锁定低性能和低效实现是开发高性能软件中最重要的工作。 两者之间的界限并不总是清晰的,因此你必须权衡几个因素。你必须考虑改变对程序的影响:它是否使代码更难阅读,更复杂,或者更难测试?通常情况下,你不想冒着为了性能而增加更多bug的风险,除非测量告诉你你必须这样做。另一方面,有时更可读或更直接的代码也是更高效的代码,那么优化就不能被认为是过早的。 C++效率的第二个主要领域与帮助编译器生成更高效的代码有关。我们将在下一章中介绍这个问题。 在上一章中,我们已经了解了C++程序中效率低下的主要原因。消除这些低效性的责任大部分落在程序员身上。然而,编译器也可以通过许多方式使您的程序运行更快。这就是我们现在要探讨的内容。 本章将涵盖编译器优化的非常重要的问题,以及程序员如何帮助编译器生成更高效的代码。 您还需要一种方法来检查编译器生成的汇编代码。许多开发环境都有显示汇编代码的选项,GCC和Clang可以写出汇编而不是目标代码,调试器和其他工具可以从目标代码生成汇编(反汇编)。您可以根据个人偏好选择使用哪种工具。 优化编译器对于实现高性能至关重要。只需尝试运行一个完全没有优化的程序,就能体会到编译器的作用:未经优化的程序(优化级别为零)的运行速度通常比启用所有优化的程序慢一个数量级。 然而,通常情况下,优化器可能需要程序员的一些帮助。这种帮助可能采取非常微妙且常常反直觉的变化形式。在我们查看一些特定的技术来改进代码优化之前,了解编译器如何看待您的程序会有所帮助。 关于优化,您必须理解的最重要的一点是,任何正确的代码都必须保持正确。正确在这里与您对正确的看法无关:程序可能存在错误并给出您认为错误的答案,但编译器必须保留这个答案。唯一的例外是一个程序是不明确的或者调用了未定义的行为:如果程序在标准的眼中是不正确的,编译器可以随意做任何事情。我们将在下一章中探讨这一点的影响。目前,我们将假设程序是明确定义的,并且仅使用有效的C++。当然,编译器在进行更改时受到限制,要求答案在任何输入组合下都不得改变。后者非常重要:您可能知道某个输入值始终为正,或者某个字符串永远不会超过16个字符长,但编译器不知道(除非您找到一种告诉它的方法)。只有在可以证明此转换会导致完全等效的程序时,编译器才能进行优化转换:一个对于任何输入都产生相同输出的程序。实际上,编译器在放弃之前能够管理多复杂的证明也是受限的。 理解重要的不是你知道什么,而是你能证明什么是成功与编译器通过代码进行交互以实现更好优化的关键。基本上,本章的其余部分展示了您可以使得更容易证明某些理想的优化不会改变程序结果的不同方法。 编译器在程序方面也受到限制。它必须仅使用编译时已知的信息,对任何运行时数据一无所知,并且必须假设在运行时可能出现任何合法状态。 这是一个简单的例子,用来说明这一点。首先,考虑这段代码: 与此代码形成对比: intv[16];…fillvwithdata…for(int&x:v)++x;现在编译器确切地知道循环中处理了多少个整数。它可以展开循环,甚至用操作多个数字的向量指令替换单个整数的增量(例如,x86上的AVX2指令集可以一次添加8个整数)。 如果您知道向量始终有16个元素,可能并不重要。重要的是编译器是否知道这一点,并且能够确定地证明。这比你想象的要困难。例如,考虑这段代码: 从程序员的角度来看,很容易高估编译器的知识,基于程序员对程序实际运行情况的了解。还要记住,解谜通常不是编译器的长处。例如,您可以在循环之前添加一个assert: constexprsize_tN=16;std::vector 我们已经考虑的基本例子已经包含了用于帮助编译器优化代码的关键技术元素: 内联是编译器在用函数体的副本替换函数调用时进行的。为了实现这一点,内联必须是可能的:在调用代码的编译过程中,函数的定义必须是可见的,并且在编译时必须知道被调用的函数。第一个要求在一些进行整体程序优化的编译器中有所放宽(仍然不常见)。第二个要求排除了虚函数调用和通过函数指针进行的间接调用。并非每个可以内联的函数最终都会被内联:编译器必须权衡代码膨胀与内联的好处。不同的编译器对内联有不同的启发式。C++的inline关键字只是一个建议,编译器可以忽略它。 函数调用内联的最明显好处是消除了函数调用本身的成本。在大多数情况下,这也是最不重要的好处:函数调用并不那么昂贵。主要好处在于编译器在函数调用之间可以做的优化非常有限。考虑这个简单的例子: doublef(int&i,doublex){doubleres=g(x);++i;res+=h(x);res+=g(x);++i;res+=h(x);returnres;}以下是一个有效的优化吗? doublef(int&i,doublex){i+=2;return2*(g(x)+h(x));}如果你回答是,那么你仍然是从程序员的角度来看待这个问题,而不是从编译器的角度来看。这种优化可能会破坏代码的方式有很多(对于您可能编写的任何合理程序来说,这些方式可能都不成立,但编译器不能做出的假设是程序员是合理的)。 另一方面,如果函数g()和h()被内联,编译器可以清楚地看到发生了什么,例如: doublef(int&i,doublex){doubleres=x+1;//g(x);++i;res+=x–1;//h(x);res+=x+1;//g(x)++i;res+=x–1;//h(x);returnres;}整个函数f()现在是一个基本块,编译器只有一个限制:保留返回值。这是一个有效的优化: doublef(int&i,doublex){i+=2;return4*x;}内联对优化的影响可以*传递得很远。考虑STL容器的析构函数,比如std::vector 现在考虑使用简单聚合的向量: structS{longa;doublex;};std::vector structS{longa;doublex;~S()=default;};编译器应该能够对空析构函数进行相同的优化,但只有在内联的情况下才能这样做: 这是内联及其对优化的影响的关键:内联允许编译器看到在否则神秘的函数内部发生了什么不。内联还有另一个重要的作用:它创建了内联函数体的唯一克隆,可以根据调用者给定的特定输入进行优化。在这个唯一的克隆中,可能观察到一些对优化友好的条件,这些条件对于这个函数来说通常是不成立的。再次举个例子: boolpred(inti){returni==0;}…std::vector 这个特定的例子在不同的编译器上产生不同的结果:例如,GCC只会在最高优化设置下内联find_if()和pred()。其他编译器即使在那时也不会这样做。然而,还有另一种方法可以鼓励编译器内联函数调用,尽管这似乎有些反直觉,因为它会向程序添加更多的代码,并使嵌套函数调用链变得更长: boolpred(inti){returni==0;}…std::vector 当然,原始问题实际上并非毫无意义:如果我们有一个多态类,其中有一个虚函数,但在某些情况下,我们在编译时知道实际类型是什么呢?在这种情况下,比较虚函数调用和非虚函数调用是有意义的。我们还应该提到一个有趣的编译器优化:如果编译器可以在编译时找出对象的真实类型,并因此知道将调用虚函数的哪个重写,它将把调用转换为非虚函数,这就是所谓的去虚拟化。 那么,为什么这个讨论发生在一个专门讨论内联的部分呢?因为我们忽略了一个重要因素:虚函数对性能的最大影响是(除非编译器可以去虚拟化调用)它们无法被内联。一个简单的函数,比如intf(){returnx;}在内联后可能只有一条甚至零条指令,但非内联版本则有常规的函数调用机制,速度慢了几个数量级。现在再加上没有内联的情况下,编译器无法知道虚函数内部发生了什么,并且必须对每个外部可访问的数据做出最坏的假设,你就能看到,在最坏的情况下,虚函数调用可能会昂贵数千倍。 内联的两个效果,暴露函数的内容和创建一个独特的、专门的函数副本,都有助于优化器,因为它们增加了编译器对代码的了解程度。正如我们已经提到的,如果你想帮助编译器更好地优化你的代码,了解编译器真正知道什么是非常重要的。 现在我们将探讨编译器所遵循的不同限制,这样你就可以培养出识别错误约束的眼光:你知道是真的,但编译器不知道。 优化的最大限制可能是在代码执行期间可能发生的变化。为什么这很重要?再举一个例子: intf(conststd::vector boolflag=false;intg(inta){flag=a==0;return–a;}intf(conststd::vector 同样,如果函数g()被内联并且编译器可以看到它不修改任何全局变量,这种情况就不会发生。但你不能期望你的整个代码都被内联,所以在某个时候,你必须考虑如何帮助编译器确定它自己不知道的东西。在当前的例子中,最简单的方法是引入一个临时变量(当然,在这个简单的例子中,你可以手动进行优化,但在更复杂的现实代码中,这是不切实际的)。为了使这个例子稍微更加现实,我们将记住函数f()可能来自一个模板实例化。我们不想复制一个未知类型的参数b,但我们知道它必须可以转换为bool,所以这可以是我们的临时变量。 template 这里的教训很简单,理论上是这样的,但在实践中却相当困难:如果你知道关于你的程序的一些信息,而编译器无法知道这些信息是真实的,你必须以编译器可以使用的方式来断言它。这样做之所以难,是因为我们通常不会像编译器那样思考我们的程序,而且很难放弃你确信绝对正确的隐含假设。 所以这就是第二个教训,紧随第一个教训之后:如果你知道关于你的程序的一些信息,可以轻松地传达给编译器,那就这样做。这个建议确实违反了一个非常常见的建议:不要创建临时变量,除非它们使程序更易读-编译器无论如何都会摆脱它们。编译器确实可能会摆脱它们,但它确实保留(并使用)它们的存在所表达的附加信息。 另一个阻止编译器进行优化的非常常见的情况是可能的别名。这是一个初始化两个C风格字符串的函数的示例: voidinit(char*a,char*b,size_tN){for(size_ti=0;i voidinit(char*a,char*b,size_tN){std::memset(a,'0',N);std::memset(b,'1',N);}您可以手动编写此代码,但编译器永远不会为您执行此优化,了解原因很重要。当您看到此函数时,您期望它被按预期使用,即初始化两个字符数组。但是编译器必须考虑两个指针a和b指向同一个数组或重叠部分的可能性。对您来说,以这种方式调用init()可能毫无意义:两个初始化将互相覆盖。然而,编译器只关心一件事:如何不改变您代码的行为,无论那是什么。 同样的问题可能会发生在任何通过引用或指针接受多个参数的函数中。例如,考虑这个函数: voiddo_work(int&a,int&b,int&x){if(x<0)x=-x;a+=x;b+=x;}编译器无法进行任何优化,如果a和b和x绑定到同一个变量,那么这些优化就是无效的。这被称为在递增a之后从内存中读取x。为什么?因为a和x可能指向相同的值,编译器无法假设x保持不变。 如果您确定别名不会发生,您如何解决这个问题?在C中,有一个关键字restrict,它通知编译器特定指针是在当前函数范围内访问值的唯一方式: voidinit(char*restricta,char*restrictb,size_tN);在init()函数内部,编译器可以假定整个数组a只能通过这个指针访问。这也适用于标量变量。restrict关键字目前还不是C++标准的一部分。尽管如此,许多编译器支持此功能,尽管使用不同的语法(restrict,__restrict,__restrict__)。对于单个值(特别是引用),创建临时变量通常可以解决问题,如下所示: voiddo_work(int&a,int&b,int&x){if(x<0)x=-x;constinty=x;a+=y;b+=y;}编译器可能会消除临时变量(不为其分配任何内存),但现在它保证a和b都增加了相同的量。编译器是否会实际执行这种优化?最简单的方法是比较汇编输出如下: 图10.1-x86汇编输出在别名优化之前(左)和之后(右) 图10.1显示了由GCC生成的x86汇编,用于增量操作(我们省略了函数调用和分支,这两种情况下是相同的)。使用别名,编译器必须从内存中进行两次读取(mov指令)。使用手动优化,只有一次读取。 这些优化有多重要?这取决于许多因素,因此在着手消除代码中的所有别名之前,您不应该进行一些测量。对代码进行分析将告诉您哪些部分是性能关键的;在那里,您必须检查所有优化机会。最终帮助编译器提供额外知识的优化通常是最容易实现的(编译器做了艰苦的工作)。 向编译器提供关于程序的难以发现的信息的建议的反面是:不要担心编译器可以轻松解决的问题。这个问题出现在不同的上下文中,但其中一个更常见的情景是使用验证其输入的函数。在您的库中,您有一个在指针上工作的交换函数: template 图10.2-带有(左)和不带(右)多余指针测试的汇编输出 在图10.2的左侧是带有两个if()语句的程序生成的代码,一个在my_swap()内部,一个在外部。右侧是具有特殊的不测试版本my_swap()的程序的代码。您可以看到机器代码是完全相同的(如果您能阅读x86汇编,您还会注意到两种情况下只有两次比较,而不是四次)。 正如我们已经说过的,内联在这里起着至关重要的作用:如果my_swap()没有被内联,那么在函数f()中的第一个测试是好的,因为它避免了不必要的函数调用,并允许编译器更好地优化调用代码,以便在其中一个指针为空时更好地进行优化。现在my_swap()内部的测试是多余的,但是编译器将生成它,因为它不知道my_swap()是否在其他地方被调用,也许没有对输入的任何保证。性能差异仍然极不可能是可测量的,因为硬件对第二次测试是100%可预测的(我们在第三章中讨论过这一点,CPU架构、资源和性能影响)。 顺便说一句,这种情况最常见的例子可能是delete运算符:C++允许删除空指针(什么也不会发生)。然而,许多程序员仍然编写这样的代码: if(p)deletep;即使在理论上,它会影响性能吗?不会:你可以查看汇编输出,并确信,无论是否有额外的检查,只有一次与null的比较。 现在您对编译器如何看待您的程序有了更好的理解,让我们看看如何通过另一种有用的技术来获得更好的编译器优化。 我们将要讨论的方法归结为一件事:通过将运行时信息转换为编译时信息,为编译器提供有关程序的更多信息。在以下示例中,我们需要处理由Shape类表示的大量几何对象。它们存储在一个容器中(如果类型是多态的,它将是指针的容器)。处理包括执行两种操作之一:我们要么收缩每个对象,要么增长它。让我们看看: enumop_t{do_shrink,do_grow};voidprocess(std::vector 将分支移出循环对于我们简单的例子来说很容易重写,但如果代码很复杂,重构也是复杂的。如果我们愿意给予编译器帮助,我们可以从编译器那里得到一些帮助。这个想法是将运行时值转换为编译时值: template voidmeasure(conststd::vector template 在使用特定编译器时,了解其功能和优化是很重要的。这种细节超出了本书的范围,而且这是易变的知识——编译器发展迅速。相反,本章为理解编译器优化奠定了基础,并为您读者提供了进一步理解的参考框架。让我们回顾一下我们学到的主要要点。 在本章中,我们探讨了C++效率的主要领域之一:帮助编译器生成更高效的代码。 本书的目标是让你了解代码、计算机和编译器之间的交互,以便你能够凭借良好的判断力和扎实的理解做出这些决定。 帮助编译器优化你的代码最简单的方法是遵循有效优化的一般经验法则,其中许多也是良好设计的规则:最小化代码不同部分之间的接口和交互,将代码组织成块、函数和模块,每个模块都有简单的逻辑和明确定义的接口边界,避免全局变量和其他隐藏的交互等。这些也是最佳设计实践并非巧合:通常,对程序员易读的代码也易于编译器分析。 更高级的优化通常需要检查编译器生成的代码。如果你注意到编译器没有进行某些优化,考虑一下是否存在某种情况下该优化是无效的:不要考虑你的程序中发生了什么,而是考虑在给定的代码片段中可能发生了什么(例如,你可能知道你从不使用全局变量,但编译器必须假设你可能会使用)。 在下一章中,我们将探讨C++(以及软件设计一般)中一个非常微妙的领域,它可能与性能研究产生意想不到的重叠。 在本章中,我们将涵盖以下主题: 您将学会在(别人的)代码中遇到未定义行为时如何识别它,并了解未定义行为与性能的关系。本章还教会您如何通过有意允许未定义行为、记录它并在其周围设置保障措施来利用未定义行为。 comp.std.c的概念警告说,“当编译器遇到(未定义的结构)时,它可以合法地让恶魔从你的鼻子里飞出来。”在类似的情境中,还提到了发射核导弹和阉割你的猫(即使你没有猫)。本章的一个旁枝目标是揭开UB的神秘面纱:虽然最终目标是解释UB与性能之间的关系,并展示如何利用UB,但在我们能理性地讨论这个概念之前,我们无法做到这一点。 首先,在C++(或任何其他编程语言)的上下文中,什么是UB?标准中有特定的地方使用了“行为未定义”或“程序不合法”的词语。标准进一步指出,如果行为未定义,标准对结果“不做要求”。相应的情况被称为UB。例如,请参考以下代码: intf(intk){returnk+10;}标准规定,如果加法导致整数溢出(即,如果k大于INT_MAX-10),则上述代码的结果是未定义的。 当提到UB时,讨论往往会朝着两个极端之一发展。我们刚刚看到的第一个。夸大的语言可能是出于对UB危险的警告,但它也是对理性解释的障碍。你的鼻子对于编译器的愤怒是相当安全的,你的猫也是如此。编译器最终会从你的程序生成一些代码,你将运行这些代码。它不会给你的计算机带来任何超能力:这个程序做的任何事情,你都可以有意地完成,例如,通过在汇编语言中手动编写相同的指令序列。如果你没有办法执行导致发射核导弹的机器指令,你的编译器也无法做到这一点,无论有没有UB(当然,如果你正在编写导弹发射控制器,那就是完全不同的游戏了)。最重要的是,当你的程序行为是未定义的时候,根据标准,编译器可以生成你意料不到的代码,但这些代码不能做任何你已经做过的事情。 虽然夸大UB的危险是没有帮助的,但另一方面,有一种倾向于推理UB,这也是一种不幸的做法。例如,考虑这段代码: intk=3;k=k+++k;虽然C++标准逐渐收紧了执行这种表达式的规则,但在C++17中,这个特定表达式的结果仍然是未定义的。许多程序员低估了这种情况的危险。他们说,“编译器要么首先评估k++,要么首先评估k+k。”为了解释为什么这是错误和危险的,我们首先必须在标准中分清一些细微之处。 反驳常常是这样的,即使编译器在编译具有未定义行为的代码时会做些什么,它仍然必须以标准规定的方式处理代码的其余部分,所以(论点是)损害仅限于该特定行的可能结果之一。就像重视危险一样重要,理解为什么这个论点是错误的也很重要。编译器是在程序被定义良好的假设下编写的,并且在这种情况下只有在这种情况下才需要产生正确的结果。没有预设如果假设被违反会发生什么。描述这种情况的一种方式是说编译器不需要容忍未定义行为。让我们回到我们的第一个例子: intf(intk){returnk+10;}由于程序对于足够大的k来导致整数溢出是不明确的,编译器允许假设这永远不会发生。如果发生了呢?如果你单独编译这个函数(在一个单独的编译单元中),编译器将生成一些代码,为所有k<=INT_MAX-10产生正确的结果。如果你的编译器和链接器没有整个程序的转换,相同的代码将可能对更大的k执行,并且结果将是在这种情况下你的硬件所做的任何事情。编译器可以插入对k的检查,但它可能不会这样做(尽管有一些编译器选项,它可能会这样做)。 如果函数是较大编译单元的一部分呢?这就是事情变得有趣的地方:编译器现在知道f()函数的输入参数是受限制的。这种知识可以用于优化。例如,参考以下代码: intg(intk){if(k>INT_MAX-5)cout<<"Largek"< 那么我们的第二个例子呢?表达式k+++k的结果对于任何k的值都是未定义的。编译器能做什么?再次记住:编译器不需要容忍未定义行为。这个程序能保持良好定义的唯一方式是这行代码永远不被执行。编译器可以假设这是情况,并进行推理:包含这段代码的函数从未被调用,任何必要的条件都必须成立,等等,最终可能得出整个程序永远不会被执行的结论。 如果你认为真正的编译器不会做那种事情,我有一个惊喜给你: inti=1;intmain(){cout<<"Before"< 上面的例子非常有教育意义,可以帮助我们理解为什么我们会有未定义行为。在下一节中,我们将揭开面纱,解释未定义行为的原因。 从上一节中产生的明显问题是,为什么标准会有未定义行为?为什么它不为每种情况指定结果?一个稍微微妙的问题是,承认C++被用于各种硬件,具有非常不同的属性,这是为什么标准不退而使用实现定义的行为,而不是将其留在未定义状态? 上一节的最后一个例子为我们提供了一个完美的演示工具,解释了为什么存在未定义行为。说法是无限循环是未定义的;另一种说法是标准不要求进入无限循环的程序产生特定的结果(标准比这更微妙,某些形式的无限循环会导致程序挂起,但这些细节目前并不重要)。要理解为什么规则存在,考虑以下代码: size_tn1=0,n2=0;voidf(size_tn){for(size_tj=0;j!=n;j+=2)++n1;for(size_tj=0;j!=n;j+=2)++n2;}这两个循环是相同的,所以我们要支付两次循环的开销(循环变量的增量和比较)。编译器显然应该通过将循环折叠在一起来进行以下优化: voidf(size_tn){for(size_tj=0;j!=n;j+=2)++n1,++n2;}但是,请注意,此转换仅在第一个循环终止时才有效;否则,n2的计数根本不应该被递增。在编译期间不可能知道循环是否终止-这取决于n的值。如果n是奇数,则循环将永远运行(与有符号整数溢出不同,递增无符号类型size_t超过其最大值是良定义的,并且该值将回滚到零)。通常情况下,编译器无法证明特定循环最终会终止(这是一个已知的NP完全问题)。决定假设每个循环最终都会终止,并允许否则无效的优化。因为这些优化可能使具有无限循环的程序无效,这样的循环被视为UB,这意味着编译器不必保留具有无限循环的程序的行为。 在接下来的部分中,我们将看到更多关于编译器如何利用UB来实现优化的示例。 在前一节中,我们刚刚看到一个例子,通过假设程序中的每个循环最终都会终止,编译器能够优化某些循环和包含这些循环的代码。优化器使用的基本逻辑始终相同:首先,我们假设程序不会出现UB。然后,我们推断出必须满足的条件,以使这一假设成立,并假设这些条件确实总是成立。最后,任何在这些假设下有效的优化都可以进行。优化器生成的代码在违反这些假设时会执行某些操作,但我们无法知道它将执行什么操作(除了已经提到的限制,即仍然是同一台计算机执行某些指令的情况)。 标准中记录的几乎每种UB情况都可以转化为可能优化的示例(特定编译器是否利用这一点是另一回事)。我们现在将看到更多示例。 正如我们已经提到的,有符号整数溢出的结果是未定义的。编译器可以假设这种情况永远不会发生,并且通过正数递增有符号整数总是会得到更大的整数。编译器实际上执行了这种优化吗?让我们来看看。比较这两个函数,f()和g(): boolf(inti){returni+1>i;}boolg(inti){returntrue;}在良定义的行为范围内,这些函数是相同的。我们可以尝试对它们进行基准测试,以确定编译器是否优化了f()中的整个表达式,但是,正如我们在上一章中所看到的,有一种更可靠的方法。如果两个函数生成相同的机器代码,它们肯定是相同的。 图11.1-由GCC9生成的f()(左)和g()(右)函数的x86汇编输出 在图11.1中,我们可以看到,打开优化后,GCC确实为这两个函数生成了相同的代码(Clang也是如此)。汇编中出现的函数名称是所谓的manglednames:由于C++允许具有不同参数列表的函数具有相同的名称,因此必须为每个这样的函数生成唯一的名称。它通过将所有参数的类型编码到实际在目标代码中使用的名称中来实现。 如果您想验证此代码确实没有任何:运算符的痕迹,最简单的方法是将f()函数与使用无符号整数进行相同计算的函数进行比较。参考以下代码: boolf(inti){returni+1>i;}boolh(unsignedinti){returni+1>i;}无符号整数的溢出是明确定义的,并且通常并非总是i+1始终大于i。 图11.2-由GCC9生成的f()(左)和h()(右)函数的X86汇编输出 h()函数生成不同的代码,即使您不熟悉X86汇编,也可以猜到cmp指令进行比较。在左边,函数f()将常量值0x1的值加载到用于返回结果的寄存器EAX中。 这个例子也展示了试图推断未定义行为或将其视为实现定义的危险:如果你说程序将对整数进行某种加法,如果溢出,特定的硬件将执行它的操作,那么你将非常错误。编译器可能会生成根本没有递增指令的代码。 现在,我们终于有足够的知识来完全阐明这个谜团,这个谜团的种子从书的一开始就播下了,在第二章中,性能测量。在那一章中,我们观察到了同一函数的两个几乎相同的实现之间出乎意料的性能差异。该函数的工作是逐个字符比较两个字符串,并在第一个字符串在字典顺序上更大时返回true。这是我们最简洁的实现: 图11.3-使用compare1()函数进行字符串比较的排序基准 比较实现尽可能紧凑;在这段代码中没有多余的东西。然而,令人惊讶的结果是,这是代码的性能最差的版本之一。性能最佳的版本几乎相同: boolcompare2(constchar*s1,constchar*s2){if(s1==s2)returnfalse;for(inti1=0,i2=0;;++i1,++i2){if(s1[i1]!=s2[i2])returns1[i1]>s2[i2];}}唯一的区别是循环变量的类型:compare1()中是unsignedint,而compare2()中是int。由于索引永远不会是负数,这应该没有任何区别,但实际上有: 图11.4-使用compare2()函数进行字符串比较的排序基准 图11.5-由GCC生成的compare1()(左)和compare2()(右)函数的X86汇编代码 代码看起来非常相似,只有一个例外:在右边(compare2())可以看到add指令,用于将循环索引递增1(编译器通过用一个循环变量替换两个循环变量来优化代码)。在左边,没有看起来像加法或递增的东西。相反,有lea指令,它代表加载和扩展地址,但在这里用于将索引变量递增1(进行了相同的优化;只有一个循环变量)。 到目前为止,根据你学到的一切,你应该能够猜到编译器为什么必须生成不同的代码:尽管程序员期望索引永远不会溢出,但编译器通常不能做出这种假设。请注意,两个版本都使用32位整数,但代码是为64位机器生成的。如果32位有符号int溢出,结果是未定义的,所以在这种情况下,编译器确实假设溢出永远不会发生。如果操作没有溢出,add指令会产生正确的结果。对于unsignedint,编译器必须考虑溢出的可能性:递增UINT_MAX应该得到0。结果表明,x86-64上的add指令没有这些语义。相反,它扩展结果成为64位整数。在X86上进行32位无符号整数算术的最佳选项是lea指令;它可以完成任务,但速度要慢得多。 这个例子演示了通过从程序是良好定义的假设和UB永远不会发生的假设逆向推理,编译器可以实现非常有效的优化,最终使整个排序操作的速度提高了数倍。 现在我们了解了我们的代码中发生了什么,我们可以解释代码的其他版本的行为。首先,使用64位整数,有符号或无符号,将给我们与32位有符号整数相同的快速性能:在所有情况下,编译器都将使用add(对于64位无符号值,它确实具有正确的溢出语义)。其次,如果使用最大索引或字符串长度,编译器将推断索引不会溢出: boolcompare1(constchar*s1,constchar*s2,unsignedintlen){if(s1==s2)returnfalse;for(unsignedinti1=0,i2=0;i1 我们可以使用标准中描述的几乎任何其他未定义行为的情况来构造类似的演示(尽管不能保证特定的编译器会利用可能的优化)。这里是另一个使用指针解引用的例子: intf(int*p){++(*p);returnp*p:0;//Optimizedto:return*p}这是一个相当常见的情况的简化,程序员已经编写了指针检查来防止空指针,但并非在所有地方都这样做。如果输入参数是空指针,第二行(递增)就是UB。这意味着整个程序的行为是未定义的,因此编译器可以假设它永远不会发生。对汇编代码的检查显示,的确,第三行的比较被消除了: 图11.6-生成带有(左)和不带有(右):运算符的f()函数的X86汇编 如果我们首先进行指针检查,情况也是一样的: intf(int*p){if(p)++(*p);return*p;}再次,对汇编代码的检查将显示指针比较被消除了,尽管到目前为止程序的行为是良好定义的。推理是相同的:如果指针p不是空的,比较是多余的,可以省略。如果p为空,程序的行为是未定义的,这意味着编译器可以做任何它想做的事情,它想要省略比较。最终结果是,无论p是否为空,比较都可以被消除。 externvoidg();intf(int*p){if(p)g();return*p;}在这种情况下,编译器不会消除指针检查,这可以从生成的汇编代码中看出: 图11.7-用于f()函数的X86汇编代码(左)和不带指针检查的X86汇编代码(右) test指令对空(零)进行比较,然后是条件跳转-这就是汇编中if语句的样子。 为什么编译器没有优化掉这个检查?要回答这个问题,你必须弄清楚在什么条件下,这种优化会改变程序的良好定义行为。 使优化无效需要以下两个条件: boolf(intx){returnx+1>x;}优化编译器将会消除这个函数中的所有代码,并用returntrue替换它。现在我们将让函数做更多的工作: voidg(inty);boolf(intx){inty=x+1;g(y);returny>x;}当然,同样的优化是可能的,因为代码可以重写如下: voidg(inty);boolf(intx){g(x+1);returnx+1>x;}调用g()必须进行,但函数仍然返回true:比较不能产生其他结果,否则会陷入未定义的行为。再次强调,大多数编译器都会进行这种优化。我们可以通过比较从原始代码生成的汇编和从完全手动优化的代码生成的汇编来确认这一点: voidg(inty);boolf(intx){g(x+1);returntrue;}优化之所以可能是因为g()函数不会改变其参数。在同样的代码中,如果g()通过引用获取参数,那么这种优化就不再可能: voidg(int&y);boolf(intx){inty=x+1;g(y);returny>x;}现在g()函数可能会改变y的值,因此每次都必须进行比较。如果函数g()的意图不是改变其参数,当然我们可以通过值传递参数(正如我们已经看到的)。另一个选择是通过const引用传递;虽然对于小类型(如整数)没有必要这样做,但模板代码通常会生成这样的函数。在这种情况下,我们的代码如下: voidg(constint&y);boolf(intx){inty=x+1;g(y);returny>x;}快速检查汇编程序显示return语句没有被优化:它仍然进行比较。当然,某个特定编译器不执行某个优化并不能证明什么:没有优化器是完美的。但在这种情况下,是有原因的。尽管代码中这样写,但C++标准并不保证g()函数不会改变其参数!以下是一个完全符合标准的实现,阐明了这个问题: intx=0;constint&y=x;const_cast voidg(constint&y);boolf(intx){constinty=x+1;g(y);returny>x;}现在编译器可以假定该函数总是返回true:改变这一点的唯一方法是调用未定义行为,而编译器并不需要容忍未定义行为。在撰写本书时,我们并不知道有任何编译器实际上进行了这种优化。 有了这个想法,关于使用const来促进优化,可以推荐什么? 我们已经详细探讨了C++中未定义行为如何影响C++代码的优化。现在是时候扭转局面,学习如何利用未定义行为来优化您自己的程序。 在本节中,我们将讨论未定义行为,不是作为标准规定并适用于C++,而是作为您,程序员,规定并适用于您的软件。为了达到这个目的,首先从不同的角度考虑未定义行为是有帮助的。 到目前为止,我们所见过的所有未定义行为示例可以分为两种。第一种是诸如++k+k之类的代码。这些是错误,因为这样的代码根本没有定义的行为。第二种是诸如k+1之类的代码,其中k是有符号整数。这种代码随处可见,大多数情况下都能正常工作。它的行为是明确定义的,除了某些变量值。 换句话说,代码具有隐含的前提条件:只要这些前提条件得到满足,程序就会表现良好。请注意,在程序的更大上下文中,这些前提条件可能是隐含的,也可能不是:程序可能会验证输入或中间结果,并防范会导致未定义行为的值。无论哪种方式,程序员都与用户定义了一个合同:如果输入遵守某些限制,结果就保证是正确的;换句话说,程序的行为是明确定义的。 当违反限制时会发生什么? 有以下两种可能性: 我们刚刚描述了UB。 现在我们明白了UB只是程序在规定合同之外运行的行为,让我们想想它如何适用于我们的软件。 大多数足够复杂的程序都对其输入有前提条件,与用户有合同。有人可能会认为这些前提条件应该始终被检查并报告任何错误。然而,这可能是一个非常昂贵的要求。再次,让我们考虑一个例子。 我们想编写一个程序,扫描在纸上绘制的图像(或蚀刻在印刷电路板上),并将其转换为图形数据结构。程序的输入可能如下所示: 图11.8-图形绘制是图形构建程序的输入 该程序获取图像,识别矩形,从每个矩形创建图节点,识别线条,对于每条线条找出它连接的两个矩形,并在图中创建相应的边。 假设我们有一个图像获取和分析库,可以为我们提供一组形状(矩形和线条)及其所有坐标。现在我们所要做的就是弄清楚哪些线连接哪些矩形。我们已经有了所有坐标,所以从现在开始就是纯几何。表示这个图形的最简单方法之一是作为边的表格。我们可以使用任何容器(比如说,一个向量)来存储表格,如果我们为每个节点分配一个唯一的数字ID,那么一条边就是一对数字。我们可以使用任意数量的计算几何算法来检测线条和矩形之间的交点,并构建这个表格(以及图形本身)一条边一条边地。 听起来足够简单,我们有一个自然的数据表示,相当紧凑且易于处理。不幸的是,我们还与用户有一个隐含的合同:我们要求每条线都恰好与两个矩形相交(还有,矩形之间不相交,但一次只处理一个混乱)。 图11.9-图形识别程序的无效输入 首先,我们必须明确用户所承担的契约。我们应该清楚地指定和记录什么构成有效输入。之后,对于性能关键的程序,最佳实践是提供最佳性能。更广泛的契约(对限制较少的那种)总是比较窄的契约更好,因此,如果有一些无效输入,我们可以轻松检测并以最小的开销处理,那就应该这样做。除此之外,我们所能做的就是记录程序行为未定义的条件,就像C++标准中所做的那样。 C++编译器开发人员是否可以为我们程序员做出同样的额外努力,并为我们提供一个可选工具来检测代码中的UB,这不是很好吗?事实证明,开发人员也这样认为:如今许多编译器都有启用UBsanitizer(通常称为UBSan)的选项。它的工作原理如下。让我们从一些可能导致UB的代码开始: intg(intk){returnk+10;}编写一个调用此函数的程序,参数足够大(大于INT_MAX-10),并启用UBSan编译。对于Clang或GCC,选项是-fsanitize=undefined。以下是一个例子: clang++--std=c++17–O3–fsanitize=undefinedub.C运行程序,你会看到类似以下的内容: 我们已经了解了UB,为什么它有时是一个必要的恶,并且如何利用它来提高性能。在翻页之前,让我们回顾一下我们学到的内容。 首先,要理解当程序接收到超出规定程序行为的契约的输入时,就会发生UB。此外,规范还表示程序不需要检测此类输入并发出诊断。这适用于C++标准定义的UB以及您自己程序中的UB。 接下来,规范(或标准)未涵盖所有可能的输入并定义结果的原因主要与性能有关:当需要可靠地产生特定结果时,引入UB通常会非常昂贵。对于C++中的UB,处理器和内存架构的多样性也导致了难以统一处理的情况。在没有可行的方法来保证特定结果的情况下,标准将结果留空。 在设计软件时,你应该牢记这些考虑因素:始终希望有一个广泛的合同,为任何或几乎任何输入定义结果。但这样做可能会给只提供典型或“正常”输入的用户带来性能开销。当用户面临更快地执行所需任务和可靠地执行用户根本不想解决的任务之间的选择时,大多数用户会选择性能。作为一种妥协,你可以为用户提供一种验证输入的方式;如果这种验证是昂贵的,它应该是可选的。 当涉及到C++标准规定的UB时,情况就变了,你成了用户。重要的是要理解,如果程序包含具有UB的代码,整个程序就是不明确定义的,不仅仅是问题中的一行代码。这是因为编译器可以假设在运行时永远不会发生UB,并从此进行推理,以对代码进行相应的优化。现代编译器在某种程度上都这样做,未来的编译器只会更加积极地进行推理。 我们几乎到了书的结尾;在下一章,也就是最后一章中,我们将以考虑设计软件的含义和教训的眼光回顾我们所学到的一切。 你将学习如何将良好的性能作为设计目标之一,并如何设计高性能软件系统,以确保高效的实现不会成为对程序基本架构的挑战。 良好的设计是否有助于实现良好的性能,还是偶尔需要妥协最佳设计实践以实现最佳性能?这些问题在编程社区中争论不休。通常,设计传道者会认为,如果你认为你需要在良好的设计和良好的性能之间做出选择,那么你的设计还不够好。另一方面,黑客(我们在这里使用这个术语是指传统意义上的,即拼凑解决方案的程序员,与犯罪无关)经常将设计准则视为对最佳优化的限制。 本章的目的是要表明这两种观点在一定程度上都是有效的。但如果将它们视为“全部真相”,那也是错误的。否认许多设计实践在应用于特定软件系统时可能会限制性能是不诚实的。另一方面,许多实现和维护高效代码的指导方针也是可靠的设计建议,可以改善性能和设计质量。 我们对设计和性能之间的紧张关系持更加细致的观点。对于特定系统(你最感兴趣的是你的系统,你现在正在工作的那个系统),一些设计准则和实践确实可能导致低效和性能不佳。我们很难找到一个设计规则总是与效率相对立的例子,但对于特定系统,也许在某些特定情境下,这些规则和实践是相当普遍的。如果你采用了遵循这些规则的设计,你可能会将低效嵌入到软件系统的核心架构中,而要通过“优化”来纠正将会非常困难,除非对程序的关键部分进行全面重写。任何否认或淡化这种潜在严重性的人都不是为你着想。另一方面,任何声称这正好证明了放弃可靠的设计实践的人都是在提出错误、过于简化的选择。 如果你意识到某种设计方法遵循了良好的实践,提高了清晰度和可维护性,但降低了性能,正确的反应是选择另一种同样良好的设计方法。换句话说,虽然发现一些良好的设计会导致性能不佳是很常见的,但对于给定的软件系统来说,几乎不可能每种良好的设计都会导致低效。你需要做的就是从几种可能的高质量设计中选择一个也能实现良好性能的设计。 当然,这说起来容易做起来难,但希望这本书能够帮助你。在本章的其余部分,我们将专注于问题的两个方面。首先,当性能成为一个问题时,建议采用哪些设计实践?其次,当我们没有可以运行和测量的程序,而只有(可能不完整的)设计时,我们如何评估可能的性能影响? 如果你仔细阅读了最后两段,你会发现性能是一个设计考虑因素:就像我们在设计中考虑到"支持多用户"或"在磁盘上存储几千兆字节的数据"等需求一样,性能目标也是需求的一部分,应该在设计阶段明确考虑。这将引导我们到设计高性能系统的关键概念,即... 正如我们所说,性能是设计目标之一,与其他约束和要求同等重要。因此,对于"这种设计导致性能不佳"的问题,答案与如果问题是"这种设计没有提供我们需要的功能"是一样的。在这两种情况下,我们需要不同的设计,而不是更糟糕的设计。我们只是更习惯于根据设计的功能而不是速度来评估设计。 为了帮助你在第一次尝试时选择促进性能的设计实践,我们现在将介绍几条专门针对良好性能的设计指导原则。它们也是坚实的设计原则,有很好的理由去接受它们:遵循这些指导原则不会使你的设计变得更糟。 让我们从第一个指导原则开始:尽可能少地传递信息。这里上下文非常重要:具体来说,我们建议一个组件尽可能少地透露关于如何处理特定请求的信息。组件之间的交互受合同约束。当我们谈论类和函数的接口时,我们习惯于这个想法,但它是一个更广泛的概念。例如,用于两个进程之间通信的协议就是一个合同。 在任何这样的接口或交互中,做出并实现承诺的一方不得主动提供任何额外信息。让我们看一些具体的例子。我们将从实现基本队列的类开始,并问自己,从效率的角度来看,什么样的接口是良好的? 其中一个方法允许我们检查队列是否为空。请注意,调用者并没有询问队列有多少元素,只是询问它是否为空。虽然队列的一些实现可能会缓存大小并将其与零进行比较来解决这个请求,但对于其他实现来说,确定队列是否为空可能比计算元素更有效。合同说,“如果队列为空,我将返回true。”即使你认为你知道大小,也不要做出任何额外的承诺:不要自愿提供任何未被请求的信息。这样,你就可以自由地更改你的实现。 作为接口泄露太多实现信息的相反例子,考虑另一个STL容器,无序集合(或映射)。std::unordered_set容器具有一个接口,允许我们插入新元素并检查给定值是否已经在集合中(到目前为止,一切都很好)。根据定义,它缺乏元素的内部顺序,并且标准提供的性能保证清楚地表明数据结构使用了哈希。因此,明确涉及哈希的接口部分不能被视为多余的:特别是需要指定一个用户给定的哈希函数。但接口进一步提供了诸如bucket_count()之类的方法,暴露了底层实现必须是一个用于解决哈希冲突的分离链接哈希表的桶。因此,不可能使用开放寻址哈希表来创建一个完全符合STL的无序集合。这个接口限制了实现,可能会阻止你使用更有效的实现。 在我们用类设计简单示例时,同样的原则可以应用于更大模块的API、客户端-服务器协议以及系统组件之间的其他交互:在设计响应请求或提供服务的组件时,提供简洁的合同,只透露请求者所需的信息,而不透露其他信息。 揭示最少信息或最少承诺的设计准则本质上是类接口的一个流行准则的概括:接口不应该透露实现。此外,要考虑到,纠正这一准则的违反将会非常困难:如果你的设计泄漏了实现细节,客户端将依赖于它们,并且一旦你改变了实现,就会出现问题。因此,到目前为止,为性能而设计与一般的良好设计实践是一致的。在下一个准则中,我们开始暴露不同设计目标之间的紧张关系以及相应的最佳实践。 虽然满足请求的组件应该避免不必要地透露可能限制实现的任何内容,但对于发出请求的组件来说情况正好相反。请求者或调用者应该能够提供关于需要的具体信息。当然,只有在有适当的接口时,调用者才能提供这些信息,因此我们真正要说的是接口应该被设计成允许这样的“完整”请求。 特别是,为了提供最佳性能,了解请求背后的意图通常很重要。再举一个例子应该更容易理解这个概念。 让我们从一个随机访问序列容器开始。随机访问意味着我们可以访问容器的任意第i个元素,而不需要访问任何其他元素。通常的做法是使用索引运算符: T&operator[](size_ti){return…i-thelement…;}使用这个运算符,我们可以,例如,遍历容器: 我们不必走得太远来举例。让我们考虑std::deque:它是一个支持随机访问的块分配容器。为了访问任意元素i,我们必须首先计算包含该元素的块(通常是模运算)和块内元素的索引,然后在辅助数据结构(块指针表)中找到块的地址并索引到块内。即使在大多数情况下,元素将驻留在同一个块中,我们已经知道它的地址,这个过程也必须重复进行下一个元素。这是因为对任意元素的请求没有足够的信息:没有办法表达我们很快会要求下一个元素。因此,deque无法以最有效的方式处理遍历。 扫描整个容器的另一种方法是使用迭代器接口: for(autoit=cont.begin();it!=cont.end();++it){T&element=*it;…dosomeworkontheelement…}deque的实现者可以假设增加(或减少)迭代器是一个经常执行的操作。因此,如果你有一个迭代器it并访问相应的元素*it,很可能你会要求下一个元素。deque迭代器可以存储块指针或块指针表中正确条目的索引,这将使得在一个块内访问所有元素更加便宜。通过简单的基准测试,我们可以验证确实使用迭代器遍历deque比使用索引要快得多: voidBM_index(benchmark::State&state){constunsignedintN=state.range(0);std::deque 图12.1-使用索引与迭代器遍历std::deque 非常重要的一点是要指出设计性能和优化性能之间的关键区别。不能保证迭代器访问deque更快:特定的实现实际上可能使用索引运算符来实现迭代器。这样的保证只能来自优化的实现。在本章中,我们对设计感兴趣。设计实际上不能真正被“优化”,尽管,如果你谈论一个“高效的设计”,其他人可能会理解你的意思。设计可以允许或阻止某些优化,因此更准确地谈论“性能敌意”和“性能友好”设计(后者通常被称为高效设计)。 在我们的deque示例中,索引操作符接口对于随机访问来说是尽可能高效的,并且将顺序迭代视为随机访问的一种特殊情况。调用者无法说,“我可能会要求下一个相邻的元素。”相反,从迭代器的存在中,我们可以推断出它可能会被递增或递减。实现可以使这个增量操作更有效。 让我们进一步考虑我们的容器示例。这一次,我们考虑一个自定义容器,它基本上像一棵树一样运作,但与std::set不同的是,我们不在树节点中存储值。相反,我们将值存储在序列容器(数据存储)中,而树节点包含对该容器元素的指针。树本质上是数据存储的索引,因此它需要一个自定义比较函数:我们想要比较值,而不是指针。 template 为了看到这个索引树容器的性能优势,让我们来检查一下执行搜索满足给定谓词的元素的操作。假设我们的容器提供了迭代器,这样搜索就很容易进行;解引用操作符应该返回索引元素,而不是指针: template template 图12.2-使用迭代器和find()成员函数在索引数据存储中进行搜索 再次强调,重要的是退一步,重新评估这个例子作为软件设计的教训,而不是特定的优化技术。在这种情况下,在设计阶段重要的不是我们的find()成员函数比基于迭代器的搜索快多少。重要的是在适当的实现下它可能会更快。它可能更快的原因是调用者意图的了解。 比较调用者使用非成员和成员find()时提供的信息。当非成员find()函数调用容器接口时,我们告诉容器,“让我看到所有容器元素的值,一个接一个地,按顺序。”实际上我们并不需要大部分这些信息,但这是我们通过迭代器接口传递的唯一信息。另一方面,成员find()允许我们提出以下请求:“以任何顺序检查所有元素,并告诉我是否至少有一个与这个条件匹配。”这个请求施加了更少的限制:这是一个高级请求,将细节留给容器本身。在我们的例子中,实现者利用了这种自由来提供更好的性能。 在设计阶段,您可能不知道这样优化的实现是可能的。成员find()的第一个实现可能会运行迭代器循环或调用std::find_if。您可能永远也不会优化这个函数,因为在您的应用程序中,它很少被调用,也不是性能瓶颈。但软件系统往往比您预期的寿命更长,基本的重新设计是困难且耗时的。一个良好的系统架构不应该限制系统的演变,有时可能会持续数年,甚至几十年,即使添加了新功能并且性能要求发生变化。 这是一个更有争议的准则,有几个原因。首先,它明确违反了类设计的流行方法:不要为不需要特权访问并且可以完全通过现有公共API实现的任务实现(公共)成员函数。我们可以从几个方面来推理。首先,有人可能会说,“可以实现十倍慢”并不真正符合“可以实现”,因此该准则不适用。反驳的观点是,在设计阶段,您甚至可能不知道您需要这种性能。我们可能违反的另一个重要规则是“不要过早优化”,尽管这个规则不应该被简单地理解:特别是,这个规则的合理支持者经常会补充说,“但也不要过早悲观”。在设计的背景下,后者意味着做出削减未来优化机会的设计决策。 关于是否在一开始提供更丰富的信息界面的决定,取决于几个因素: 通常,这些选择并不是明确的,而是依赖于设计师的直觉和知识经验。这本书可以帮助前者,而实践则照顾后者。 设计并发组件及其接口的最重要规则是提供清晰的线程安全保证。注意,“清晰”并不意味着“强大”:事实上,为了获得最佳性能,通常最好在低级接口上提供较弱的保证。STL选择的方法是一个很好的示范:所有可能改变对象状态的方法都提供弱保证:只要一个线程在任何时候使用容器,程序就是定义良好的。 如果您想要更强的保证,可以在应用程序级别使用锁。一个更好的做法是创建自己的锁定类,为您想要的接口提供强有力的保证。有时,这些类只是锁定装饰器:它们在锁中包装被装饰对象的每个成员函数。更常见的情况是,有多个操作必须由单个锁保护。 为什么?因为允许客户端在“一半”操作完成后看到特定的数据结构是没有意义的。这带我们得出一个更一般的观察:作为规则,线程安全的接口也应该是事务性的。组件(类、服务器、数据库等)的状态在进行API调用之前和之后应该是有效的。接口合同承诺的所有不变量都应该得到维护。在执行请求的成员函数(对于类)期间,对象很可能经历了一个或多个状态,这些状态在客户端看来不被视为有效:它不维护指定的不变量。接口应该使另一个线程不可能观察到对象处于这种无效状态。让我们举个例子来说明。 回想一下我们在上一节中的索引树。如果我们想要使这棵树线程安全(这是提供强有力保证的简写),我们应该使插入新元素即使在同时从多个线程调用时也是安全的: template 如果您需要一个反例(在设计并发接口时不应该做什么),请回想一下第七章中对std::queue的讨论,并发数据结构。从队列中移除元素的接口不是事务性的:front()返回前面的元素但不移除它,而pop()移除前面的元素但不返回任何东西,如果队列为空,两者都会产生未定义的行为。单独锁定这些方法对我们没有好处,因此线程安全的API必须使用我们在第七章中考虑过的方法之一,并发数据结构,来构建一个事务并用锁保护它。 另外,请记住,并非所有数据都在同时访问。在设计良好的程序中,最大程度地减少共享状态的数量,大部分工作是在特定于线程的数据上进行的(对象和其他数据是专门针对一个线程的),对共享数据的更新相对不频繁。专门针对一个线程的对象不应该承担锁定或其他同步的开销。 现在似乎我们有一个矛盾:一方面,我们应该设计我们的类和其他组件具有线程安全的事务性接口。另一方面,我们不应该给这些接口增加锁定或其他同步机制,因为我们可能正在构建自己的锁定的更高级组件。 解决这一矛盾的一般方法是两者兼顾:提供可以用作更高级组件构建块的非锁定接口,并在有意义的地方提供线程安全接口。通常,后者是通过使用锁定保护来实现的。当然,这必须在合理范围内完成。首先,任何非事务性接口都专门用于单线程使用或用于构建更高级别的接口。无论哪种方式,它们都不需要被锁定。其次,有些组件和接口在特定设计中只在狭窄的上下文中使用。也许一个数据结构是专门为在每个线程上分别进行的工作而设计的;同样,没有理由为其添加并发的开销。一些组件可能是设计上仅用于并发使用的,并且是顶层组件-它们应该具有线程安全的事务性接口。这仍然留下许多类和其他组件,它们很可能以两种方式使用,并且需要锁定和非锁定的变体。 基本上有两种方法可以解决这个问题。第一种是设计一个单一组件,如果需要可以使用锁定,例如: template template template structlocking_policy{locking_policy(){m_.lock();}~locking_policy(){m_.unlock();}std::mutexm_;};structnon_locking_policy{};现在我们可以创建具有弱或强线程安全性保证的index_tree对象: index_tree 第二个选项是我们之前讨论过的,即锁定装饰器。在这个版本中,原始类(index_tree)只提供弱线程安全性保证。强保证由这个包装类提供: template 相同的方法可以应用于其他API:一个显式参数来控制锁定与装饰器。使用哪种取决于您设计的具体情况-它们都有其优缺点。请注意,即使与特定API调用的工作相比,锁定的开销微不足道,也可能有充分的理由避免不必要的锁定:特别是,这种锁定大大增加了应该经过审查可能发生死锁的代码量。 请注意,所有线程安全接口应该是事务性的准则与设计异常安全或更一般地说是错误安全接口的最佳实践之间有很多重叠。后者更为复杂,因为我们不仅要保证在调用接口之前和之后系统处于有效状态,还要保证在检测到错误后系统仍然处于良好定义的状态。 从性能的角度来看,错误处理本质上是额外开销:我们不希望错误发生频繁(否则,它们实际上不是错误,而是我们必须处理的经常发生的情况)。幸运的是,编写错误安全代码的最佳实践,比如使用RAII对象进行清理,也非常高效,很少会带来重大开销。然而,一些错误条件很难可靠地检测,正如我们在《第十一章》中所见,未定义行为和性能。 在本节中,我们学到了设计高效并发API的几个准则: 这些准则在设计健壮和清晰的API方面与其他最佳实践基本一致。因此,我们很少需要做设计权衡以实现更好的性能。 现在让我们离开并发问题,转向性能设计的其他领域。 这个讨论将是我们在《第九章》中涵盖的事项的概括,高性能C++,当我们谈论不必要的复制时。使用任何接口,不仅仅是C++函数调用,通常涉及发送或接收一些数据。这是一个非常普遍的概念,我们无法提供任何普遍适用的具体准则,除了同样普遍的“注意数据传输的成本”。我们可以为一些常见类型的接口稍作详细说明。 举个例子,假设我们有一个简单的结构用于存储三维位置和一些属性: structpoint{doublex,y,z;intcolor;…maybemoredata…};一个非常流行的准则说,我们应该避免只是访问相应数据成员的getter和setter方法;我们被劝阻这样做: classpoint{doublex,y,z;intcolor;public:doubleget_x()const{returnx;}voidset_x(doublex_in){x=x_in;}//Sameforyetc};我们将这些对象存储在一个点的集合中: classpoint{point_collection&coll_;size_tpoint_id_;public:doubleget_x()const{returncoll_[point_id_];}…};集合存储压缩数据,并且可以动态解压部分数据以访问由point_id_标识的点。 当然,一个更加压缩友好的接口将要求我们按顺序迭代整个点的集合。现在你应该意识到,我们刚刚重新审视了指导我们尽可能少地透露关于我们集合内部工作方式的准则。专注于压缩有助于为我们提供一个特定的观点。如果你考虑数据压缩的可能性,或者一般情况下,用于存储和传输的替代数据表示,你也必须考虑限制对这些数据的访问。也许你可以想出算法,可以在不使用对数据的随机访问的情况下执行所有所需的计算?如果你通过设计限制访问,你就保留了压缩数据的可能性(或以其他方式利用有限访问模式的可能性)。 一旦你明确描述了信息流,你就知道了每一步执行中存在的数据,并且被每个组件访问。你还知道了哪些数据必须在组件之间传输。现在你可以考虑组织这些数据的方法了。 在数据组织的设计阶段,您可以采取两种方法。一种方法是依赖接口提供数据的抽象视图,同时隐藏有关其真实组织的所有细节。这是本章一开始的第一个准则,最少信息原则,被推到了极致。如果有效,您可以根据需要稍后实现优化接口后面的数据结构。但警告是很少可能设计一个不以任何方式限制底层数据组织的接口,这样做通常代价高昂。例如,如果您有一个有序的数据集合,您是否希望允许在集合中间进行插入?如果答案是肯定的,数据将不会存储在类似数组的结构中,该结构需要移动一半的元素以在中间开辟空间(对实现的限制)。另一方面,如果您坚决拒绝允许任何限制实现的接口,您最终会得到一个非常有限的接口,并且可能无法使用最快的算法(不早期承诺特定数据组织的成本)。 第二种方法是将至少部分数据组织视为设计的一部分。这将减少实现的灵活性,但会放宽接口设计的一些限制。例如,您可以决定为了按特定顺序访问数据,将使用指向数据元素存储位置的索引。您将把间接访问的成本嵌入系统架构的基础,但会获得数据访问的灵活性:元素可以被最佳地存储,并且可以为任何类型的随机或有序访问构建正确的索引。我们的index_tree就是这种设计的一个简单例子。 通常最好的结果是通过结合两种方法:确定最重要的数据并设计出有效的组织方式。当然不是每一个细节,但通常来说,例如,如果您的程序在基本层面上多次搜索许多字符串,您可以决定将所有字符串存储在一个大的连续内存块中,并使用索引进行搜索和其他有针对性的访问。然后您会设计一个高级接口来构建索引并通过迭代器使用它,但这样的索引的确切组织方式留给实现。您的接口会施加一些限制:例如,您可以决定调用者在构建索引时可以请求随机访问或双向迭代器,这反过来会影响实现。 将数据流或知识流视为设计的一部分通常被遗忘,但实际上非常简单。更具体的指导方针是在设计过程中考虑数据组织限制和留有重要实现自由的接口的组合,通常被视为过早优化。许多程序员会坚持认为在设计阶段没有地方使用“缓存局部性”。这确实是我们在将性能视为设计目标之一时必须做出的妥协之一。在系统设计过程中,我们经常不得不权衡这样的竞争动机,这使我们进入了在设计性能时进行权衡的主题。 在本章的整个过程中,我们已经见证了尽可能少暴露实现的好处。但是,在获得优化自由与非常抽象接口成本之间存在一种紧张关系。 这种紧张关系需要在优化不同组件之间进行权衡:一个不以任何方式限制实现的接口通常会严重限制客户端。例如,让我们重新审视我们的点集合。在不限制其实现的情况下,我们能做些什么?我们不能允许除了在末尾之外的任何插入(实现可能是一个向量,复制一半的集合是不可接受的)。我们只能追加到末尾,这意味着我们无法保持排序顺序,例如。不能进行随机访问(集合可能存储在列表中)。如果集合被压缩,甚至可能无法提供反向迭代器。几乎不限制实现者自由的点集合只限于前向迭代器(流式访问)和可能的追加操作。甚至后者也是一种限制,一些压缩方案要求在可以读取数据之前对数据进行最终处理,因此集合可以处于只写状态或只读状态。 我们提供这个例子并不是为了证明对实现无关的API进行严格追求会对客户端造成不切实际的限制。恰恰相反:这是一个用于处理大量数据的有效设计。集合是通过追加到末尾来写入的;在写入完成之前,数据没有特定的顺序。最终处理可能包括排序和压缩。要读取集合,我们需要在读取时解压缩(如果我们的压缩算法一次处理多个点,我们需要一个缓冲区来保存未压缩的数据)。如果集合必须被编辑,我们可以使用我们在第四章《内存架构和性能》中首次介绍的算法,进行内存高效编辑或字符串:我们总是从头到尾读取整个集合;每个点根据需要进行修改,添加新的点等。我们将结果写入新的集合,最终删除原始集合。这种设计允许非常高效的数据存储,无论是在内存使用方面(高压缩)还是在内存访问方面(只有缓存友好的顺序访问)。它还要求客户端以流式访问和读取-修改-写入操作来实现所有操作。 你也可以从另一个角度得出相同的结论:如果你分析你的数据访问模式,并得出结论说你可以接受流式访问和读取-修改-写入更新,你可以将这部分纳入你的设计。当然不是特定的压缩方案,而是高级数据组织:在任何东西可以被读取之前,写入必须完成,而改变数据的唯一方式是将整个集合复制到一个新的集合中,在复制过程中根据需要修改其内容。 关于这种权衡的一个有趣观察是,我们不仅需要在性能要求与易用性或其他设计考虑之间取得平衡,而且通常需要做出关于性能的哪个方面更重要的决定。通常,应该优先考虑低级组件:它们的架构对整体设计更为基础,比高级组件中算法的选择更为重要。因此,它更难以后期更改,这使得做出明智的设计决策更为重要。请注意,在设计组件时,还有其他权衡需要做出。 我们刚刚看到,有时为了让一个组件通过设计具有很好的性能,就必须对其他组件施加限制,这就需要仔细选择算法和熟练实施。但这并不是我们必须做出的唯一权衡。 在性能设计中最常见的权衡之一是选择组件和模块的适当粒度级别。通常制作小组件是一个很好的设计实践,特别是在测试驱动设计中(但通常在任何将可测试性作为目标之一的设计中)。另一方面,将系统分割成太多具有受限交互的部分可能对性能不利。通常,将更大的数据和代码单元视为单个组件可以实现更高效的实现。同样,我们的点集合就是一个例子:如果我们不允许无限制地访问集合内的点对象,那么它会更有效率。 最终,这些决定应该通过考虑冲突的要求并利用解决矛盾的机会来做出。最好将一个点作为一个单独的单元,可测试且可重用于其他代码。但我们真的需要将点集合公开为这些点单元的集合吗?也许,我们可以将其视为存储在其中的点所包含的所有信息的集合,而点对象仅用于逐个读取和写入集合中的点。这种方法使我们能够保持良好的模块化并实现高性能。通常,接口是以清晰且可测试的组件实现的,而在内部,较大的组件以完全不同的格式存储数据。 应该避免的是在接口中创建“后门”,这些“后门”是专门用来解决由于遵循良好设计实践而导致性能限制的限制。这通常以一种临时的方式妥协了竞争设计目标。相反,最好重新设计涉及的组件。如果您看不到解决矛盾要求的方法,请擦除组件边界,并将较小的单元转换为内部、实现特定的子组件。 到目前为止,我们还没有考虑的另一个设计方面是错误处理,因此有必要多说几句。 错误处理是那些经常被视为事后思考的事情之一,但应该是设计决策中同等重要的因素。特别是,很难为没有考虑特定异常处理方法的程序添加异常安全性(以及由此延伸的错误安全性)。 错误处理始于接口:所有接口本质上都是规范组件之间交互的契约。这些契约应该包括对输入数据的任何限制:如果满足了某些外部条件,组件将按规定的方式运行。但是契约还应该指定如果条件不满足,组件无法履行契约(或者程序员决定这样做是不可取或太困难)会发生什么。 从性能的角度来看,通常最重要的考虑是在输入和结果正确且没有发生任何问题的常见情况下处理潜在错误的开销。通常简单地表达为“错误处理必须廉价”。 这意味着错误处理在正常情况下必须廉价。相反,当这种罕见事件实际发生时,我们通常不关心处理错误的开销。这究竟意味着什么在不同的设计之间差异很大。 例如,在处理事务的应用程序中,我们通常希望提交或回滚语义:每个事务要么成功,要么根本不执行任何操作。然而,这种设计的性能成本可能很高。通常情况下,即使事务失败,仍然可以影响一些更改,只要这些更改不改变系统的主要不变量。对于基于磁盘的数据库,浪费一些磁盘空间可能是可以接受的;然后,我们总是可以为事务分配空间并写入磁盘,但是,如果发生错误,我们将使这部分部分写入的区域对用户不可访问。 在设计阶段需要做出许多权衡,本章并不意味着是权衡的完整清单或者实现平衡的全面指南。相反,我们展示了一些常见的矛盾以及解决它们的可能方法。 为了在平衡性能设计目标与其他目标和动机时做出明智的决策,有一些性能估计是很重要的。但是在设计阶段如何获得性能指标呢?这是我们尚未讨论的设计性能中最后、也是某种意义上最困难的部分。 不仅在权衡决策时,我们需要站在良好性能数据的坚实基础上。毕竟,如果我们不知道按照缓存最佳顺序访问数据与按照某种随机顺序访问数据的成本有多大,我们又如何能够做出关于为了高效的内存访问而设计数据结构的决策呢?这又回到了性能的第一法则,你现在应该已经记住了:永远不要猜测性能。如果我们的程序存在于白板上的设计图的零散状态,这就更容易说而难做了。 请注意我们刚刚做了什么:我们使用现有程序作为运行一些近似未来程序行为的新代码的框架。换句话说,我们构建了一个原型。原型是另一种获取性能估计以做出设计决策的方法。当然,为性能构建原型与制作基于特性的原型有所不同。在后一种情况下,我们希望快速组合一个演示所需行为的系统,通常不考虑实现的性能或质量。性能原型应该给我们合理的性能数字,因此低级实现必须高效。我们可以忽略边缘情况和错误处理。只要我们原型的功能能够执行我们想要基准测试的代码,我们也可以跳过许多功能。有时,我们的原型根本没有任何功能:相反,在代码的某个地方,我们将硬编码一个条件,这在真实系统中发生时会触发某些功能。我们在此类原型设计期间必须创建的高性能代码通常构成以后低级库的基础。 应该指出,所有模型都是近似的,即使你对代码的性能有完整和最终的实现,这些模型仍然是近似的。微基准测试通常比较大的框架不够准确,这就产生了“微基准测试是谎言”的吸引人的标题。微基准测试和其他性能模型之所以不总是与最终结果相匹配的主要原因是任何程序的性能都受其环境的影响。例如,你可能对代码进行了最佳内存访问的基准测试,结果发现它通常是在完全饱和内存总线的其他线程旁边运行。 就像了解模型的局限性一样重要,不要过度反应也同样重要。基准测试提供了有用的信息。被测软件越完整和真实,结果就越准确。如果基准测试显示一段代码比另一段快几倍,这种差异在代码最终运行的环境中不太可能完全消失。但是,试图从除了在真实数据上运行的最终版本之外的任何地方获得最后5%的效率是愚蠢的。 原型——对真实程序的近似,能够以某种程度的准确性复制我们感兴趣的属性——使我们能够得到不同设计决策所带来的性能合理估计。它们可以是微基准测试,也可以是对大型、现有程序的实验,但它们都有一个共同的目标:将性能设计从猜测的领域转移到基于合理测量决策的基础上。 我们书的最后一章回顾了我们学到的关于性能以及决定性能的因素,然后利用这些知识提出了高性能软件系统的设计指南。我们提出了几条关于设计接口、数据组织、组件和模块的建议,并描述了在我们有可以测量性能的实现之前如何做出知情的设计决策。 我们必须再次强调,性能设计并不会自动带来良好的性能:它只是为高性能实现提供可能性。另一种选择是敌对性能的设计,它锁定了决策,限制和阻止了高效的代码和数据结构。 这本书是一次旅程:我们从学习单个硬件组件的性能开始,然后研究它们之间的相互作用以及它们如何影响我们对编程语言的使用。最终,这条道路引领我们到了性能设计的概念。这是书中的最后一章,但并非你旅程的最后一步:现在是将你的知识应用于等待你的实际问题的广阔而令人兴奋的领域。v;这一次,类型T有一个析构函数,编译器再次知道它的作用(毕竟编译器生成了它)。再一次,析构函数什么都不做,整个销毁循环被消除。对于default析构函数也是一样的: