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

233 阅读1小时+

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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第八章:C++中的并发

本章的目的是描述最近添加到语言中的并发编程功能:在 C++17 和 C++20 标准中。虽然现在讨论使用这些功能以获得最佳性能的最佳实践还为时过早,但我们可以描述它们的功能,以及编译器支持的当前状态。

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

  • 在 C++11 中将并发引入 C++语言

  • C++17 中的并行 STL 算法

  • C++20 中的协程

阅读完本章后,您将了解 C++提供的功能,以帮助编写并发程序。本章并不意味着是 C++并发功能的全面手册,而是对可用语言设施的概述,作为您进一步探索感兴趣主题的起点。

技术要求

如果您想尝试最近 C++版本提供的语言功能,您将需要一个非常现代的编译器。对于某些功能,您可能还需要安装其他工具;当我们描述特定的语言功能时,我们会指出这一点。本章附带的代码可以在github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter08找到。

C++11 中的并发支持

在 C++11 之前,C++标准没有提及并发。当然,在实践中,程序员在 2011 年之前就已经使用 C++编写了多线程和分布式程序。这是可能的原因是编译器编写者自愿采用了额外的限制和保证,通常是通过遵守 C++标准(用于语言)和其他标准(如 POSIX)来支持并发。

C++11 通过引入C++内存模型改变了这一点。内存模型描述了线程如何通过内存进行交互。这是 C++语言首次在并发方面有了坚实的基础。然而,其直接实际影响相当有限,因为新的 C++内存模型与大多数编译器编写者已经支持的内存模型非常相似。这些模型之间存在一些微妙的差异,新标准最终保证了遇到这些黑暗角落的程序的可移植行为。

更直接的实际用途是几种直接支持多线程的语言特性。首先,标准引入了线程的概念。关于线程行为的保证明显很少,但大多数实现通常使用系统线程来支持 C++线程。这在实现的最低级别是可以的,但对于除了最简单的程序之外的任何程序都不够。例如,试图为程序必须执行的每个独立任务创建一个新线程几乎肯定会失败:启动新线程需要时间,很少有操作系统能够有效地处理数百万个线程。另一方面,对于实现其线程调度程序的程序员来说,C++线程接口并不提供足够对线程行为的控制(大多数线程属性是特定于操作系统的)。

接下来,标准引入了几种用于控制并发访问内存的同步原语。语言提供了std::mutex,通常使用常规系统互斥量实现:在 POSIX 平台上,这通常是 POSIX 互斥量。标准提供了互斥量的定时和递归变体(再次遵循 POSIX)。为了简化异常处理,应避免直接锁定和解锁互斥量,而应优先使用 RAII 模板std::lock_guard

为了安全地锁定多个互斥锁,而不会出现死锁的风险,标准提供了std::lock()函数(虽然它保证不会出现死锁,但它使用的算法是未指定的,特定实现的性能差异很大)。另一个常用的同步原语是条件变量,std::condition_variable,以及相应的等待和信号操作。这个功能也非常接近对应的 POSIX 特性。

然后,还有对低级原子操作的支持:std::atomic,比如比较和交换,以及内存顺序说明符。我们已经在《第五章》、《线程、内存和并发》、《第六章》、《并发和性能》和《第七章》、《并发数据结构》中介绍了它们的行为和应用。

最后,该语言增加了对异步执行的支持:可以使用std::async异步调用函数(可能在另一个线程上)。虽然这可能会实现并发编程,但实际上,这个特性对于高性能应用几乎是完全无用的。大多数实现要么提供非常有限的并行性,要么在自己的线程上执行每个异步函数调用。大多数操作系统创建和加入线程的开销相当高(我见过的唯一一个使并发编程变得像“为每个任务启动一个线程,如果需要的话,可以有数百万个”简单的操作系统是 AIX,在我知道的其他操作系统上,这是一种混乱的做法)。

总的来说,可以说,就并发而言,C++11 在概念上是一个重大的进步,但在实际上提供了适度的即时实际收益。C++14 的改进集中在其他地方,因此在并发方面没有什么值得注意的变化。接下来,我们将看看 C++17 带来了哪些新的发展。

C++17 中的并发支持

C++17 带来了一个重大进步和几个与并发相关的次要调整。让我们先快速介绍后者。在 C++11 中引入的std::lock()函数现在有了相应的 RAII 对象,std::scoped_lock。另外,添加了一个共享互斥锁,std::shared_mutex,也称为读-写互斥锁(再次匹配相应的 POSIX 特性)。这个互斥锁允许多个线程继续进行,只要它们不需要对锁定资源进行独占访问。通常,这些线程执行只读操作,而写线程需要独占访问,因此称为读-写锁。这在理论上是一个聪明的想法,但大多数实现的性能都很差。

值得注意的是一个新特性,可以可移植地确定 L1 缓存的缓存行大小,std::hardware_destructive_interference_sizestd::hardware_constructive_interference_size。这些常量有助于创建避免伪共享的缓存最优数据结构。

现在我们来到了 C++17 中的主要新特性——std::for_each

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

在 C++17 中,我们可以要求库在所有可用的处理器上并行进行这个计算:

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

STL 算法的并行版本有一个新的第一个参数:执行策略。请注意,执行策略不是单一类型,而是一个模板参数。标准提供了几种执行策略;我们之前使用的并行策略std::execution::par允许算法在多个线程上执行。线程的数量以及计算在线程内的分区方式是未指定的,取决于实现。顺序策略std::execution::seq在单个线程上执行算法,就像没有任何策略(或在 C++17 之前)执行的方式一样。

还有一个并行的无序策略,std::execution::par_unseq。两种并行策略之间的区别微妙但很重要。标准规定,无序策略允许计算在单个线程内交错进行,这允许额外的优化,比如矢量化。但是优化编译器可以在生成机器代码时使用矢量指令,比如 AVX,并且这是在没有源 C++代码的帮助下完成的:编译器只是找到矢量化机会,并用矢量指令替换常规的单字指令。那么这里有什么不同呢?

要理解无序策略的性质,我们必须考虑一个更复杂的例子。假设我们不仅仅是对每个元素进行操作,而是要进行一些使用共享数据的计算:

double much_computing(double x);
std::vector<double> v;
… add data to v … 
double res = 0;
std::mutex res_lock;
std::for_each(std::execution::par, v.begin(), v.end(),
  &{ 
    double term = much_computing(x);
    std::lock_guard guard(res_lock);
    res += term;
  });

在这里,我们对每个向量元素进行一些计算,然后累加结果的总和。计算本身可以并行进行,但累加必须受到锁的保护,因为所有线程都会增加相同的共享变量res。并行执行策略是安全的,多亏了锁。然而,我们不能在这里使用无序策略:如果同一个线程同时处理多个向量元素(交错),它可能会尝试多次获取相同的锁。这是一个保证的死锁:如果一个线程持有锁并尝试再次锁定它,第二次尝试将被阻塞,线程无法继续到达解锁锁的点。标准称我们最后一个示例的代码为不安全的矢量化,并且规定不应该在无序策略下使用这样的代码。

现在我们已经了解了并行算法在理论上是如何工作的,那么在实践中呢?简短的答案是相当好,但有一些注意事项。继续阅读详细版本。

在实践中检查并行算法之前,您必须做一些准备工作来准备您的构建环境。通常,要编译 C++程序,您只需要安装所需的编译器版本,比如 GCC,然后就可以开始了。但是并行算法不是这样。在撰写本书时,安装过程有些繁琐。

足够新的 GCC 和 Clang 版本包括并行 STL 头文件(在某些安装中,Clang 需要安装 GCC,因为它使用由 GCC 提供的并行 STL)。问题出现在更低的层次。这两个编译器使用的运行时线程系统是英特尔线程构建块TBB),它作为一个带有自己一套头文件的库提供。两个编译器都没有在其安装中包含 TBB。更复杂的是,每个编译器版本都需要相应版本的 TBB:旧版本和更近版本都不起作用(失败可能会在编译和链接时都表现出来)。要运行与 TBB 链接的程序,您可能需要将 TBB 库添加到库路径中。

一旦你解决了所有这些问题并配置了编译器和必要库的工作安装,使用并行算法就不比使用任何 STL 代码更难。那么,它的扩展性如何?我们可以运行一些基准测试。

让我们从std::for_each开始,没有任何锁,并且对每个元素进行了大量的计算(函数work()很昂贵,对于我们目前关注的扩展性来说,确切的操作并不重要):

std::vector<double> v(N);
std::for_each(std::execution::par,
              v.begin(), v.end(),[](double& x){ work(x); });

以下是在 2 个线程上运行的顺序与并行版本的性能:

图 8.1 - 在 2 个 CPU 上并行 std::foreach 的基准测试

图 8.1 - 在 2 个 CPU 上并行 std::foreach 的基准测试

扩展性并不差。请注意,向量大小N相当大,有 32K 个元素。对于更大的向量,扩展性确实有所提高。但是,对于相对较小的数据量,并行算法的性能非常差:

图 8.2 - 用于短序列的并行 std::foreach 的基准测试

图 8.2-并行 std::foreach 进行短序列的基准

对于 1024 个元素的向量,并行版本比顺序版本慢。原因是执行策略在每个并行算法的开始时启动所有线程,并在结束时加入它们。启动新线程需要显着的时间,因此当计算很短时,开销会压倒并行性带来的任何加速。这不是标准强加的要求,而是 GCC 和 Clang 当前实现的并行 STL 与 TBB 系统交互的方式。

当然,并行算法改善性能的大小取决于硬件、编译器及其并行实现,以及每个元素的计算量。例如,我们可以尝试一个非常简单的每个元素计算:

std::for_each(std::execution::par,
              v.begin(), v.end(),[](double& x){ ++x; });

现在处理相同的 32K 元素向量不显示并行性的好处:

图 8.3-并行 std::foreach 进行廉价的每个元素计算的基准

图 8.3-并行 std::foreach 进行廉价的每个元素计算的基准

对于更大的向量大小,除非内存访问速度限制了单线程和多线程版本的性能(这是一个非常受内存限制的计算),并行算法可能会领先。

也许更令人印象深刻的是更难并行化的算法的性能,比如std::sort

std::vector<double> v(N);
std::sort(std::execution::par, v.begin(), v.end();

这是它的输出:

图 8.4-并行 std::sort 的基准

图 8.4-并行 std::sort 的基准

再次,我们需要足够大量的数据才能使并行算法变得有效(对于 1024 个元素,单线程排序更快)。这是一个非常显著的成就:排序不是最容易并行化的算法,而双精度浮点数的每个元素计算(比较和交换)非常便宜。尽管如此,并行算法显示出非常好的加速,并且如果元素比较更昂贵,它会变得更好。

您可能想知道并行 STL 算法如何与您的线程交互,也就是说,如果同时在两个线程上运行两个并行算法会发生什么?首先,与在多个线程上运行的任何代码一样,您必须确保线程安全(在同一容器上并行运行两个排序无论使用哪种排序都是一个坏主意)。除此之外,您会发现多个并行算法可以很好地共存,但您无法控制作业调度:它们中的每一个都会尝试在所有可用的 CPU 上运行,因此它们会竞争资源。取决于每个算法的扩展性如何,您可能会或可能不会通过并行运行多个算法来获得更高的整体性能。

总的来说,我们可以得出结论,当它们在足够大的数据量上操作时,STL 算法的并行版本提供非常好的性能,尽管“足够大”取决于特定的计算。可能需要额外的库来编译和运行使用并行算法的程序,并且配置这些库可能需要一些努力和实验。此外,并非所有 STL 算法都有其并行等价物(例如,std::accumulate没有)。

....

我们现在准备翻动日历上的几页,并跳到 C++20。

C++20 中的并发支持

C++20 在现有并发支持中增加了一些增强功能,但我们将专注于主要的新添加:协程。协程通常是可以中断和恢复的函数。它们在几个主要应用中非常有用:它们可以极大地简化编写事件驱动程序,对于工作窃取线程池几乎是不可避免的,而且它们使编写异步 I/O 和其他异步代码变得更加容易。

协程的基础

有两种风格的协程:堆栈式无堆栈式。堆栈式协程有时也被称为纤程;它们类似于函数,它们的状态是在堆栈上分配的。无堆栈式协程没有对应的堆栈分配,它们的状态存储在堆上。一般来说,堆栈式协程更强大和灵活,但无堆栈式协程要高效得多。

在本书中,我们将专注于无堆栈式协程,因为这是 C++20 支持的。这是一个足够不寻常的概念,我们需要在展示 C++特定的语法和示例之前进行解释。

一个普通的 C++函数总是有一个对应的堆栈帧。这个堆栈帧存在的时间与函数运行的时间一样长,这就是所有局部变量和其他状态存储的地方。这里有一个简单的函数f()

void f() {
  …
}

它有一个对应的堆栈帧。函数f()可能调用另一个函数g()

void g() {
  …
}
void f() {
  …
  g();
  …
}

函数g()在运行时也有一个堆栈帧。

参考以下图表:

图 8.5 – 普通函数的堆栈帧

图 8.5 – 普通函数的堆栈帧

当函数g()退出时,它的堆栈帧被销毁,只剩下函数f()的帧。

相比之下,无堆栈式协程的状态不是存储在堆栈上而是存储在堆上:这种分配被称为激活帧。激活帧与协程句柄相关联,它是一个充当智能指针的对象。可以进行函数调用和返回,但只要句柄没有被销毁,激活帧就会持续存在。

协程也需要堆栈空间,例如,如果它调用其他函数。这个空间是在调用者的堆栈上分配的。它是如何工作的(真正的 C++语法不同,所以现在把与协程相关的行当作伪代码来考虑):

void g() {
  …
}
void coro() { // coroutine
  …
  g();
  …
}
void f() {
  …
  std::coroutine_handle<???> H; // Not the real syntax
  coro();
  …
}

相应的内存分配如下图所示:

图 8.6 – 协程调用

图 8.6 – 协程调用

函数f()创建一个协程句柄对象,它拥有激活帧。然后调用协程函数coro()。在这一点上有一些堆栈分配,特别是协程在堆栈上存储它如果被挂起时将返回的地址(记住协程是可以自己挂起的函数)。协程可以调用另一个函数g(),它在堆栈上分配g()的堆栈帧。在这一点上,协程不能再挂起自己:只能从协程函数的顶层挂起。函数g()无论是谁调用它都会以相同的方式运行,并最终返回,销毁它的堆栈帧。现在协程可以挂起自己,所以让我们假设它这样做了。

这是堆栈式和无堆栈式协程之间的关键区别:堆栈式协程可以在任何地方挂起,在任意深度的函数调用中恢复。但是这种灵活性在内存和特别是运行时方面代价很高:无堆栈式协程,由于它们有限的状态分配,要高效得多。

当一个协程挂起自己时,为了恢复它所需的状态的一部分被存储在激活帧中。然后协程的堆栈帧被销毁,控制返回给调用者,返回到协程被调用的地方。如果协程运行完成,同样也是这样,但是调用者有办法找出协程是挂起还是完成。

调用者继续执行并可能调用其他函数:

void h() {
  …
}
void coro() {…} // coroutine
void f() {
  …
  std::coroutine_handle<???> H; // Not the real syntax
  coro();
  h(); // Called after coro() is suspended
  …
}

现在内存分配如下:

图 8.7 – 协程被挂起,执行继续

图 8.7 – 协程被挂起,执行继续

请注意,协程没有对应于堆栈帧,只有堆分配的激活帧。只要句柄对象存在,协程就可以恢复。不一定是调用和恢复协程的相同函数;例如,如果函数h()可以访问句柄,它也可以恢复它:

void h(H) {
  H.resume(); // Not the real syntax
}
void coro() {…} // coroutine
void f() {
  …
  std::coroutine_handle<???> H; // Not the real syntax
  coro();
  h(H); // Called after coro() is suspended
  …
}

协程从暂停的地方恢复。它的状态从激活帧中恢复,任何必要的堆栈分配都会像往常一样发生:

图 8.8 - 协程从不同的函数中恢复

图 8.8 - 协程从不同的函数中恢复

最终,协程完成,并且句柄被销毁;这会释放与协程相关的所有内存。

以下是关于 C++20 协程的重要知识总结:

  • 协程是可以自行暂停的函数。这与操作系统暂停线程不同:协程的暂停是由程序员显式完成的(协作式多任务处理)。

  • 与关联堆栈帧的常规函数不同,协程具有句柄对象。只要句柄存在,协程状态就会持续存在。

  • 协程暂停后,控制权返回给调用者,继续以与协程完成相同的方式运行。

  • 协程可以从任何位置恢复;不一定是调用者本身。此外,协程甚至可以从不同的线程中恢复(我们将在本节后面看到一个示例)。协程从暂停点恢复并继续运行就好像什么都没发生(但可能在不同的线程上运行)。

现在让我们看看在真正的 C++中如何完成所有这些。

C++协程语法

现在让我们看看用于使用协程编程的 C++语言构造。

首要任务是获得支持此功能的编译器。GCC 和 Clang 的最新版本都支持协程,但不幸的是,方式不同。对于 GCC,需要 11 版或更高版本。对于 Clang,部分支持是在 10 版中添加的,并在后续版本中得到改进,尽管仍然是“实验性的”。

首先,为了编译协程代码,您需要在命令行上使用编译器选项(仅使用--std=c++20选项启用 C++20 是不够的)。对于 GCC,选项是-fcoroutines。对于 Clang,选项是-stdlib=libc++ -fcoroutines-ts。对于最新的 Visual Studio,除了/std:c++20之外,不需要任何选项。

然后,您需要包含协程头文件。在 GCC 和 Visual Studio 中(以及根据标准),头文件是#include <coroutine>,它声明的所有类都在命名空间std中。不幸的是,在 Clang 中,头文件是#include <experimental/coroutine>,命名空间是std::experimental

声明协程没有特殊的语法:协程在语法上只是常规的 C++函数。使它们成为协程的是使用暂停操作符co_await或其变体co_yield。然而,在函数体中调用其中一个操作符是不够的:C++中的协程对其返回类型有严格要求。标准库在声明这些返回类型和其他与协程一起工作的类方面没有提供帮助。语言只提供了一个用于使用协程的框架。因此,直接使用 C++20 构造的协程代码非常冗长、重复,并包含大量样板代码。实际上,所有使用协程的人都是使用几种可用的协程库。

对于实际编程,你也应该这样做。然而,在本书中,我们展示的例子是用的 C++编写的。我们这样做是因为我们不想引导你去使用特定的库,而且这样做会使人们对实际发生的事情的理解变得模糊。协程的支持非常新,库正在快速发展;你选择的库可能不会保持不变。我们希望你能理解 C++级别的协程代码,而不是特定库提供的抽象级别。然后你可以根据自己的需求选择一个库并使用它的抽象。

与协程相关的语法构造的彻底描述将非常不直观:它是一个框架,而不是一个库。因此,我们用例子来完成剩下的演示。如果你真的想知道协程的所有语法要求,你必须查阅最近的出版物(或者阅读标准)。但是例子应该给你足够的理解,让你可以查阅你喜欢的协程库的文档,并在你的程序中使用它。

协程示例

第一个例子可能是 C++中协程最常见的用法(也是标准提供了一些明确设计语法的用法)。我们将实现一个惰性生成器。生成器是生成数据序列的函数;每次调用生成器,都会得到序列的一个新元素。惰性生成器是一个按需计算元素的生成器,当调用时会计算元素。

这是一个基于 C++20 协程的惰性生成器:

generator<int> coro(){
  for (int i = 0;; ++i) {
    co_yield i;
  }
}
int main() {
  auto h = coro().h_;
  auto& promise = h.promise();
  for (int i = 0; i < 3; ++i) {
    std::cout << "counter: " << promise.value_ << 
      std::endl;
    h();
  }
  h.destroy();
}

正如承诺的那样,这是非常低级的 C++,你很少看到这样的代码,但它使我们能够解释所有的步骤。首先,协程coro()看起来像任何其他函数,除了co_yield操作符。这个操作符暂停协程并将值i返回给调用者。因为协程被暂停而不是终止,操作符可以被执行多次。就像任何其他函数一样,当控制流到达闭括号时,协程终止;在这一点上,它不能被恢复。可以通过调用co_return操作符(不应该使用常规的return操作符)在任何时候退出协程。

其次,协程的返回类型generator是一个我们即将定义的特殊类型。它对它有很多要求,这导致了冗长的样板代码(任何协程库都会为你预定义这样的类型)。我们已经可以看到generator包含一个嵌套的数据成员h_;那就是协程句柄。创建这个句柄也会创建激活帧。句柄与promise对象相关联;这与 C++11 的std::promise完全无关。事实上,它根本不是标准类型之一:我们必须根据标准中列出的一组规则来定义它。在执行结束时,句柄被销毁,这也会销毁协程状态。因此,句柄类似于指针。

最后,句柄是一个可调用对象。调用它会恢复协程,生成下一个值,并立即再次暂停,因为co_yield操作符在循环中。

所有这些都是通过定义协程的适当返回类型神奇地联系在一起的。就像 STL 算法一样,整个系统都受约定束缚:对于这个过程中涉及的所有类型都有期望,如果这些期望没有得到满足,某个地方将无法编译。现在让我们看看generator类型:

template <typename T> struct generator {
  struct promise_type {
    T value_ = -1;
    generator get_return_object() {
      using handle= std::coroutine_handle<promise_type>;
      return generator{handle::from_promise(*this)};
    }
    std::suspend_never initial_suspend() { return {}; }
    std::suspend_never final_suspend() noexcept { return 
      {}; }
    void unhandled_exception() {}
    std::suspend_always yield_value(T value) {
      value_ = value;
      return {};
    }
  };
  std::coroutine_handle<promise_type> h_;
};

首先,return类型不必从模板生成。我们可以只声明一个整数的生成器。通常,它是一个模板,参数化为生成序列中元素的类型。其次,名称generator在任何方面都不是特殊的:您可以将此类型命名为任何您想要的名称(大多数库提供类似的模板并将其称为generator)。另一方面,嵌套类型generator::promise_type 必须 被称为promise_type,否则程序将无法编译。通常,嵌套类型本身被称为其他名称,并且使用类型别名:

template <typename T> struct generator {
  struct promise { … };
  using promise_type = promise;
};

promise_type类型必须是generator类(或者一般来说,协程返回的任何类型)的嵌套类型。但promise类不一定是一个嵌套类:通常是这样,但也可以在外部声明。

强制的是promise类型的一组必需成员函数,包括它们的签名。请注意,其中一些成员函数声明为noexcept。这也是要求的一部分:如果省略了这个规范,程序将无法编译。当然,如果不需要声明为noexcept的任何函数不会抛出异常,也可以声明为这样。

这些必需函数的主体对于不同的生成器可能会更复杂。我们将简要描述每个函数的作用。

第一个非空函数get_return_object()是样板代码的一部分,通常看起来与之前的函数完全相同;此函数从一个句柄构造一个新的生成器,而句柄又是从一个 promise 对象构造的。编译器调用它来获取协程的结果。

第二个非空函数yield_value()在每次调用co_yield操作符时被调用;它的参数是co_yield的值。将值存储在 promise 对象中通常是协程将结果传递给调用者的方式。

当编译器第一次遇到co_yield时,将调用initial_suspend()函数。在协程通过co_return产生最后一个结果后,将调用final_suspend()函数;之后无法再暂停。如果协程在没有co_return的情况下结束,将调用return_void()方法。最后,如果协程抛出一个从其主体中逃逸的异常,将调用unhandled_exception()方法。您可以自定义这些方法,以便特殊处理每种情况,尽管这很少被使用。

现在我们看到了如何将所有这些联系在一起,为我们提供了一个惰性生成器。首先,创建协程句柄。在我们的示例中,我们没有保留generator对象,只保留了句柄。这不是必需的:我们可以保留generator对象,并在其析构函数中销毁句柄。协程运行直到遇到co_yield并暂停;控制权由调用者返回,而co_yield的返回值被捕获在 promise 中。调用程序检索此值,并通过调用句柄恢复协程。协程从被暂停的地方继续运行,直到下一个co_yield

我们的生成器可以永远运行(或者直到在我们的平台上达到最大整数值):序列永远不会结束。如果我们需要一个有限长度的序列,我们可以执行co_return或者在序列结束后退出循环。参考以下代码:

generator<int> coro(){
  for (int i = 0; i < 10; ++i) {
    co_yield i;
  }
}

现在我们有了一个包含 10 个元素的序列。在尝试恢复协程之前,调用者必须检查句柄成员函数done()的结果。

我们之前提到协程可以从代码中的任何位置恢复(当然是在被暂停后)。它甚至可以从不同的线程中恢复。在这种情况下,协程开始在一个线程上执行,被暂停,然后在另一个线程上运行其余的代码。让我们看一个例子:

task coro(std::jthread& t) {
  std::cout << "Coroutine started on thread: " <<
    std::this_thread::get_id() << '\n';
  co_await awaitable{t};
  std::cout << "Coroutine resumed on thread: " <<
    std::this_thread::get_id() << '\n';
  std::cout << "Coroutine done on thread: " <<
    std::this_thread::get_id() << '\n';
}
int main() {
  std::cout << "Main thread: " <<
    std::this_thread::get_id() << '\n';
  std::jthread t;
  coro(t);
  std::cout << "Main thread done: " << 
    std::this_thread::get_id() << std::endl;
}

首先,让我们解决一个细节:std::jthread是 C++20 的一个补充,它只是一个可连接的线程 - 它在对象的析构函数中连接(几乎所有使用线程的人都写过一个类似的东西,但现在我们有了一个标准的)。现在我们可以转向重要的部分 - 协程本身。

首先,让我们看看协程的返回类型:

struct task{
  struct promise_type {
    task get_return_object() { return {}; }
    std::suspend_never initial_suspend() { return {}; }
    std::suspend_never final_suspend() noexcept { return 
      {}; }
    void return_void() {}
    void unhandled_exception() {}
  };
};

实际上,这是协程的最小可能返回类型:它包含所有必需的样板代码,没有其他内容。具体来说,返回类型是一个定义了嵌套类型promise_type的类。该嵌套类型必须定义几个成员函数,如此代码所示。我们在前一个示例中的生成器类型具有所有这些内容以及用于将结果返回给调用者的一些数据。当然,任务也可以根据需要具有内部状态。

前一个示例的第二个变化是任务被挂起的方式:我们使用co_await而不是co_yield。操作符co_await实际上是挂起协程的最通用方式:就像co_yield一样,它挂起函数并将控制返回给调用者。不同之处在于参数类型:co_yield返回一个结果,而co_await的参数是具有非常一般功能的等待对象。再次,对这个对象的类型有特定要求。如果满足要求,该类被称为awaitable,并且该类型的对象是有效的等待者(如果不满足要求,某处将无法编译)。这是我们的awaitable

struct awaitable {
  std::jthread& t;
  bool await_ready() { return false; }
  void await_suspend(std::coroutine_handle<> h) {
    std::jthread& out = t;
    out = std::jthread([h] { h.resume(); });
  }
  void await_resume() {}
  ~awaitable() {}
  awaitable(std::jthread& t) : t(t) {}
};

awaitable的必需接口是我们在这里看到的三种方法。第一个是await_ready():在协程挂起后调用它。如果返回true,则协程的结果已准备就绪,实际上不需要挂起它。在实践中,它几乎总是返回false,这导致协程被挂起:协程的状态(例如局部变量和挂起点)存储在激活帧中,并且控制返回给调用者或恢复者。第二个函数是await_resume(),它在协程在恢复后继续执行之前调用。如果它返回结果,那就是整个co_await操作符的结果(在我们的示例中没有结果)。最有趣的函数是await_suspend()。当此协程被挂起时,它使用当前协程的句柄进行调用,并且可以具有几种不同的返回类型和值。如果它返回void,就像我们的示例中一样,协程被挂起,并且控制返回给调用者或恢复者。不要被我们示例中await_suspend()的内容所迷惑:它不会恢复协程。相反,它创建一个将执行可调用对象的新线程,而正是这个对象恢复了协程。协程可以在await_suspend()完成后或在其仍在运行时恢复:此示例演示了异步操作的协程使用。

将所有这些放在一起,我们得到以下顺序:

  1. 主线程调用一个协程。

  2. 协程被co_await操作符挂起。这个过程涉及对awaitable对象的几个成员函数的调用,其中一个创建了一个新线程,其有效负载恢复了协程(通过移动分配线程对象的游戏完成,因此我们在主程序中删除新线程并避免了一些讨厌的竞争条件)。

  3. 控制返回给协程的调用者,因此主线程继续从协程调用后的行继续运行。如果在协程完成之前主线程在对象t的析构函数中阻塞,它将在那里。

  4. 新线程恢复了协程,并从co_await后的行继续在该线程上执行。由co_await构造的awaitable对象被销毁。协程在第二个线程上运行到结束。到达协程的结尾意味着它完成了,就像任何其他函数一样。现在运行协程的线程可以被加入。如果主线程正在等待线程t的析构函数完成,现在它将解除阻塞并加入线程(如果主线程尚未达到析构函数,它在达到时不会阻塞)。

我们程序的输出确认了这个顺序:

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

如您所见,协程coro()首先在一个线程上运行,然后在执行过程中切换到另一个线程。如果有任何局部变量,它们将通过这个转换被保留。

我们提到co_await是用于挂起协程的通用操作符。的确,co_yield x操作符等同于co_await的特定调用,如下所示:

co_await promise.yield_value(x);

这里promise是与当前协程句柄关联的promise_type对象。之所以单独使用co_yield操作符,是因为在协程内部访问自己的 promise 会导致非常冗长的语法,因此标准添加了一个快捷方式。

这些示例展示了 C++中协程的能力。协程被认为有用的情况包括工作窃取(您已经看到将协程的执行轻松转移到另一个线程有多容易)、惰性生成器和异步操作(I/O 和事件处理)。尽管如此,C++协程还没有存在足够长的时间以形成任何模式,因此社区还没有提出使用协程的最佳实践。同样,现在讨论协程的性能还为时过早;我们必须等待编译器支持成熟和开发更大规模的应用程序。

总的来说,C++标准在多年忽视并发之后,正在迅速赶上,让我们总结一下最近的进展。

总结

C++11 是标准中首次承认线程存在的版本。它为记录 C++程序在并发环境中的行为奠定了基础,并在标准库中提供了一些有用的功能。在这些功能中,基本的同步原语和线程本身是最有用的。随后的版本通过相对较小的增强扩展和完善了这些功能。

C++17 带来了一个重大进步,即并行 STL。性能当然取决于实现。只要数据语料库足够大,即使在像搜索和分区这样难以并行化的算法上,观察到的性能也相当不错。然而,如果数据序列太短,并行算法实际上会降低性能。

C++20 增加了对协程的支持。您已经看到了无栈协程的工作原理,在理论上和一些基本示例中。然而,现在讨论 C++20 协程的性能和最佳实践还为时过早。

本章总结了我们对并发性的探索。接下来,我们将学习 C++语言本身如何影响程序的性能。

问题

  1. 为什么 C++11 中并发编程的基础很重要?

  2. 我们如何使用并行 STL 算法?

  3. 什么是协程?

第三部分:设计和编码高性能程序

在本节中,您将把迄今为止学到的知识应用于编写 C++程序的实践中。您将学习哪些语言特性有助于实现更好的性能,哪些可能导致意想不到的低效,并且如何帮助编译器生成更好的目标代码。最后,您将学习以性能为重点设计程序的艺术。

本节包括以下章节:

  • 第九章, 高性能 C++

  • 第十章, C++中的编译器优化

  • 第十一章, 未定义行为和性能

  • 第十二章, 性能设计

第九章:高性能 C++

在本章中,我们将把重点从硬件资源的最佳使用转移到特定编程语言的最佳应用。尽管到目前为止我们学到的一切都可以应用于任何语言的任何程序,但本章涉及 C++的特性和特殊性。你将学会哪些 C++语言特性可能会导致性能问题,以及如何避免它们。

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

  • C++语言的效率和开销

  • 学会注意 C++语言结构的可能低效性

  • 避免低效的 C++代码

  • 优化内存访问和条件操作

技术要求

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

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

你还需要一种方法来检查编译器生成的汇编代码:许多开发环境都有显示汇编的选项;GCC 和 Clang 可以将汇编写出来而不是目标代码;调试器和其他工具可以从目标代码生成汇编(反汇编)。你可以根据个人喜好选择使用哪种工具。

编程语言的效率是什么?

程序员经常谈论一种语言是否高效。特别是 C++,它的开发明确目标是效率,同时在某些领域却有低效的声誉。这是怎么回事呢?

效率在不同的上下文或不同的人看来可能有不同的含义。例如:

  • C++的设计遵循零开销的原则:除了少数例外,如果你不使用某个特性,你就不需要为它付出任何运行时成本,即使它存在于语言中。从这个意义上说,它是一种高效的语言。

  • 显然,你必须为你使用的语言特性付出一些代价,至少如果它们转化为一些运行时工作的话。C++非常擅长不需要任何运行时代码来执行编译时可以完成的工作(尽管编译器和标准库的实现在效率上有所不同)。一种高效的语言不会给必须生成的代码增加任何开销,而在这方面,C++做得相当不错,但我们将在下面讨论一个主要的警告。

  • 如果前面的观点是正确的,那么为什么 C++会被认为是低效的?现在我们来看另一个效率的角度:在这种语言中编写高效的代码有多容易?或者,用一种看似自然但实际上是解决问题的非常低效的方式有多容易?一个密切相关的问题是我们在上一段提到的:C++在做你要求它做的事情时非常高效。但是在语言中表达你想要的并不总是容易的,而且,编写代码的自然方式有时会施加额外的要求或约束,程序员可能并不想要,也可能不知道。这些约束会产生运行时成本。

从语言设计师的角度来看,最后一个问题并不是语言的低效性:你让机器做 X 和 Y,做 X 和 Y 需要时间,我们并没有做超出你要求我们做的事情。但从程序员的角度来看,如果程序员只想做 X 而不关心 Y,这就是一种低效的语言。

本章的目标是帮助您编写清晰表达您想让机器执行的代码。目的是双重的:您可能认为您的主要受众是编译器:通过精确描述您想要的内容以及编译器可以自由更改的内容,您给予编译器生成更有效代码的自由。但对于您程序的读者也可以这样说:他们只能推断您在代码中表达的内容,而不是您打算表达的内容。如果优化代码会改变其行为的某些方面,这样做是否安全?这种行为是有意的还是实现的意外可以改变的?我们再次被提醒,编程主要是与我们的同行交流的一种方式,然后才是与机器交流。

我们将从看似容易避免的简单低效开始,但即使是掌握了语言的其他方面的程序员的代码中也会出现这些问题。

不必要的复制

对象的不必要复制可能是C++效率问题#1。主要原因是这样做很容易,很难注意到。考虑以下代码:

std::vector<int> v = make_v(… some args …);
do_work(v);

在这个程序中,向量v被复制了多少次?答案取决于函数make_v()do_work()的细节以及编译器的优化。这个小例子涵盖了我们将要讨论的几个语言细微差别。

复制和参数传递

我们将从第二个函数do_work()开始。这里重要的是声明:如果函数通过引用,const或不是,接受参数,那么不会进行复制。

void do_work(std::vector<int>& vr) {
  … vr is a reference to v …
}

如果函数使用按值传递,那么必须进行复制:

void do_work(std::vector<int> vc) {
  … vc is a copy of v …
}

如果向量很大,复制向量是一个昂贵的操作:必须复制向量中的所有数据。这是一个昂贵的函数调用。如果工作本身不需要向量的副本,那么它也是极其低效的。例如,如果我们只需要计算向量中所有元素的和(或其他函数),我们不需要复制。虽然乍一看似乎不太理想,调用本身并不告诉我们是否进行了复制,但这就是应该的。是否进行复制的决定属于函数的实现者,并且只有在考虑要求和算法选择之后才能做出。对于前面提到的累加所有元素和的问题,正确的决定显然是通过(const)引用传递向量如下:

void do_work(const std::vector<int>& v) {
  int sum = 0;
  for (int x: v) sum += x;
  … use sum … 
}

在这种情况下使用按值传递是如此明显的低效,以至于它可能被认为是一个错误,但它发生的频率比你想象的要多。特别是在模板代码中,作者只考虑了小型、轻量级的数据类型,但代码最终被更广泛地使用。

另一方面,如果我们需要创建参数的副本作为满足函数要求的一部分,使用参数传递是一个很好的方式:

void do_work(std::vector<int> v) {
  for (int& x : v) x = std::min(x, 255);
  … do computations on the new values …
}

在这里,我们需要在进一步处理数据之前应用所谓的夹紧循环。假设我们多次读取夹紧的值,为每次访问调用std::min()可能不如创建结果的缓存副本效率高。我们也可以做一个显式的复制,这可能稍微更有效,但这种优化不应该留给猜测;只有通过基准测试才能得到明确的答案。

C++11 引入了移动语义作为不必要复制的部分答案。在我们的例子中,我们观察到如果函数参数是一个 r 值,我们可以以任何方式使用它,包括改变它(调用完成后,调用者无法访问对象)。利用移动语义的常规方式是用 r 值引用版本重载函数:

void do_work(std::vector<int>&& v) {
  … can alter v data … 
}

然而,如果对象本身是可移动的,我们简单的按值传递版本在新的光芒下闪耀。参考以下代码:

void do_work(std::vector<int> v) {
  … use v destructively … 
}
std::vector<int> v1(…);
do_work(v1);                 // Local copy is made
do_work(std::vector<int>(…));    // R-value

do_work()的第一次调用使用了一个 l-value 参数,因此在函数内部进行了一个本地复制(参数是按值传递的!)。第二次调用使用了一个 r-value 或一个无名临时对象。由于向量具有移动构造函数,函数参数被移动(而不是复制!)到其参数中,移动向量非常快。现在,通过一个函数的单一实现,没有任何重载,我们可以高效地处理 r-value 和 l-value 参数。

现在我们已经看到了两个极端的例子。在第一种情况下,不需要参数的复制,创建一个纯粹是低效的。在第二种情况下,进行复制是一个合理的实现。并不是每种情况都属于这两个极端之一,正如我们将要看到的那样。

作为实现技术的复制

还有一种中间地带,选择的实现需要参数的复制,但实现本身并不是最佳的。例如,考虑下面需要按排序顺序打印向量的函数:

void print_sorted(std::vector<int> v) {
  std::sort(v.begin(), v.end());
  for (int x: v) std::cout << x << “\n”;
}

对于整数向量,这可能是最佳的方式。我们对容器本身进行排序,并按顺序打印它。由于我们不应该修改原始容器,我们需要一个副本,再次利用编译器进行复制也没有问题。

但是,如果向量的元素不是整数,而是一些大对象呢?在这种情况下,复制向量需要大量内存,并且对大对象进行排序需要大量时间。在这种情况下,更好的实现可能是创建并对指针向量进行排序,而不移动原始对象:

template <typename T>
void print_sorted(const std::vector<T>& v) {
  std::vector<const T*> vp; vp.reserve(v.size());
  for (const T& x: v) vp.push_back(&x);
  std::sort(vp.begin(), vp.end(), 
     [](const T* a, const T* b) { return *a < *b;});
  for (const T* x: vp) std::cout << *x << “\n”;
}

由于我们现在已经学会了永远不要猜测性能,直觉需要通过基准测试来确认。由于对已排序的向量进行排序不需要进行任何复制,我们希望在基准测试的每次迭代中都有一个新的、未排序的向量,如下所示:

void BM_sort(benchmark::State& state) {
   const size_t N = state.range(0);
   std::vector<int> v0(N); for (int& x: v0) x = rand();
   std::vector<int> v(N);
   for (auto _ : state) {
         v = v0;
         print_sorted(v);
   }
   state.SetItemsProcessed(state.iterations()*N);
 }

当然,我们应该禁用实际的打印,因为我们不关心对 I/O 进行基准测试。另一方面,我们应该对向量进行复制而不进行排序的基准测试,这样我们就知道测量时间的哪一部分用于设置测试。

基准测试证实,对于整数来说,复制整个向量并对副本进行排序更快:

图 9.1 - 对整数向量进行排序的基准测试,复制与指针间接

图 9.1 - 对整数向量进行排序的基准测试,复制与指针间接

请注意,如果向量很小且所有数据都适合低级缓存,那么处理速度无论如何都非常快,速度几乎没有差异。如果对象很大且复制成本很高,那么间接引用相对更有效:

图 9.2 - 对大对象向量进行排序的基准测试,复制与指针间接

图 9.2 - 对大对象向量进行排序的基准测试,复制与指针间接

在实现时,还有另一种特殊情况需要复制对象;我们将在下面考虑这种情况。

复制以存储数据

在 C++中,我们可能会遇到另一种数据复制的特殊情况。它最常发生在类构造函数中,其中对象必须存储数据的副本,因此必须创建一个超出构造函数调用寿命的长期复制。考虑以下示例:

class C {
  std::vector<int> v_;
  C(std::vector<int> ??? v) { … v_ is a copy of v … }
};

这里的意图是进行复制。低效的做法是进行多次中间复制或进行不必要的复制。实现这一点的标准方法是通过const引用获取对象,并在类内部进行复制:

class C {
  std::vector<int> v_;
  C(const std::vector<int>& v) : v_(v) { … }
};

如果构造函数的参数是一个 l-value,这是它可以达到的最高效率。但是,如果参数是一个 r-value(临时对象),我们更希望将其移动到类中,并且根本不进行复制。这需要为构造函数进行重载:

class C {
  std::vector<int> v_;
  C(std::vector<int>&& v) : v_(std::move(v)) { … }
};

缺点是需要编写两个构造函数,但如果构造函数需要多个参数,并且每个参数都需要被复制或移动,情况会变得更糟。按照这种模式,我们需要 6 个构造函数重载来处理 3 个参数。

另一种方法是通过值传递所有参数并从参数中移动,检查以下代码:

class C {
  std::vector<int> v_;
  C(std::vector<int> v) : v_(std::move(v)) 
  { … do not use v here!!! … }
};

非常重要的是要记住,参数v现在是一个 x 值(处于移动状态的对象),不应该在构造函数的主体中使用。如果参数是 l 值,将进行一次复制以构造参数v,然后移动到类中。如果参数是 r 值,则将其移动到参数v中,然后再移动到类中。如果对象移动起来很便宜,这种模式效果很好。但是,如果对象移动起来很昂贵,或者根本没有移动构造函数(因此会被复制),我们最终会做两次复制而不是一次。

到目前为止,我们已经专注于将数据传递给函数和对象的问题。但是在需要返回结果时,也可能发生复制。这些考虑是完全不同的,需要单独进行检查。

返回值的复制

我们在本节开头的示例中包括了两种复制。特别是这一行:

std::vector<int> v = make_v(… some args …);

这意味着生成的向量v是从另一个向量创建的,即函数make_v返回的向量:

std::vector<int> make_v(… some args …) {
  std::vector<int> vtmp;
  … add data to vtmp …
  return vtmp;
}

理论上,这里可能会产生多个副本:局部变量vtmp被复制到函数make_v的(无名)返回值中,然后又被复制到最终结果v中。实际上,这是不会发生的。首先,函数make_v的无名临时返回值被移动而不是复制到v中。但是,最有可能的是,这也不会发生。如果您尝试使用自己的类而不是std::vector来运行此代码,您会发现既没有使用复制构造函数也没有使用移动构造函数:

class C {
  int i_ = 0;
  public:
  explicit C(int i) : i_(i) { 
   std::cout << “C() @” << this << std::endl;
  }
  C(const C& c) : i_(c.i_) {
     std::cout << “C(const C&) @” << this << std::endl;
  }
  C(C&& c) : i_(c.i_) {
     std::cout << “C(C&&) @” << this << std::endl;
  }
  ~C() { cout << “~C() @” << this << endl; }
  friend std::ostream& operator<<( std::ostream& out,
                                  const C& c) {
     out << c.i_; return out;
  }
 };  
 C makeC(int i) { C ctmp(i); return ctmp; }
 int main() {
   C c = makeC(42);
   cout << c << endl;
}

这个程序打印出类似以下内容的东西(在大多数编译器上,必须打开一定级别的优化):

图 9.3 - 程序返回对象的输出

图 9.3 - 程序返回对象的输出

正如您所看到的,只构造和销毁了一个对象。这是编译器优化的结果。这里使用的特定优化被称为ctmp,无名临时返回值和最终结果c - 都是相同类型。此外,我们编写的任何代码都不可能同时观察到这三个变量中的任何两个。因此,在不改变任何可观察行为的情况下,编译器可以使用相同的内存位置来存储所有三个变量。在调用函数之前,编译器需要分配内存,用于构造最终结果c的位置。编译器将这个内存地址传递给函数,在函数中用于在相同位置构造局部变量ctmp。结果是,当函数makeC结束时,根本没有什么需要返回的:结果已经在应该的地方。这就是 RVO 的要点。

尽管 RVO 看起来很简单,但它有几个微妙之处。

首先,要记住这是一种优化。这意味着编译器通常不必这样做(如果你的编译器不这样做,你需要一个更好的编译器)。然而,这是一种非常特殊的优化。一般来说,只要不改变可观察行为,编译器可以对你的程序做任何它想做的事情。可观察行为包括输入和输出以及访问易失性内存。然而,这种优化导致了可观察行为的改变:拷贝构造函数和匹配的析构函数的预期输出都不见了。事实上,这是一个例外,违背了通常的规则:即使这些函数具有包括可观察行为在内的副作用,编译器也允许消除对拷贝或移动构造函数以及相应析构函数的调用。这个例外并不局限于 RVO。这意味着,一般来说,你不能指望拷贝和移动构造函数会被调用,只因为你写了一些看起来像是在进行拷贝的代码。这就是所谓的拷贝省略(或移动省略,对于移动构造函数)。

其次,要记住(再次)这是一种优化。在进行优化之前,代码必须能够编译。如果你的对象没有任何拷贝或移动构造函数,这段代码将无法编译,我们将永远无法进行将删除所有这些构造函数调用的优化步骤。如果我们在示例中删除所有拷贝和移动构造函数,这一点很容易看出:

class C {
  …
  C(const C& c) = delete;
  C(C&& c) = delete;
};  

编译现在会失败。确切的错误消息取决于编译器和 C++标准级别;在 C++17 中,它会看起来像这样:

图 9.4 - 使用 C++17 或 C++20 的 Clang 编译输出

图 9.4 - 使用 C++17 或 C++20 的 Clang 编译输出

有一种特殊情况,即使删除了拷贝和移动操作,我们的程序也会编译。让我们对makeC函数进行一些微小的更改:

C makeC(int i) { return C(i); }

C++11 或 C++14 中没有任何变化;然而,在 C++17 及以上版本中,这段代码可以成功编译。请注意与之前版本的细微差别:返回的对象以前是一个 l-value,它有一个名字。现在它是一个 r-value,一个没有名字的临时对象。这造成了很大的不同:虽然命名返回值优化(NRVO)仍然是一种优化,但自 C++17 以来,无名的返回值优化是强制性的,不再被视为拷贝省略。相反,标准规定首先不会请求任何拷贝或移动。

最后,你可能会想知道编译器是否必须内联函数,以便在编译函数本身时知道返回值的位置。通过简单的测试,你可以确信这并非如此:即使函数makeC在一个单独的编译单元中,RVO 仍然会发生。因此,编译器必须在调用点将结果的地址发送给函数。如果你根本不从函数中返回结果,而是将结果的引用作为额外的参数传递,你也可以自己做类似的事情。当然,该对象必须首先被构造,而编译器生成的优化不需要额外的构造函数调用。

你可能会发现有人建议不要依赖 RVO,而是强制移动返回值:

C makeC(int i) { C c(i); return std::move(c); }

有人认为,如果 RVO 没有发生,你的程序将承受复制操作的性能损失,而移动操作无论如何都很便宜。然而,这个观点是错误的。要理解为什么,请仔细看图 9.4中的错误消息:尽管ctmp是一个 l 值并且应该被复制,编译器却抱怨移动构造函数被删除。这不是编译器的错误,而是标准所要求的行为:在返回值优化可能发生的情况下,但编译器决定不这样做时,编译器必须首先尝试找到一个move构造函数来返回结果。如果找不到move构造函数,就会进行第二次查找;这一次,编译器会寻找一个复制构造函数。在这两种情况下,编译器实际上是在执行重载解析,因为可能有许多复制或move构造函数。因此,没有理由写一个显式的移动:编译器会为我们做一个。那么,有什么害处呢?害处在于使用显式移动会禁用 RVO;你要求进行移动,所以你会得到一个。虽然移动可能需要很少的工作,但 RVO 根本不需要工作,没有工作总是比一些工作更快。

如果我们删除move构造函数但不删除复制构造函数会发生什么?即使在两个构造函数都被删除的情况下编译仍然失败。这是语言的一个微妙之处:声明一个已删除的成员函数并不等同于不声明任何成员函数。如果编译器执行move构造函数的重载解析,它会找到一个,即使这个构造函数被删除了。编译失败是因为重载解析选择了一个已删除的函数作为最佳(或唯一)重载。如果你想强制使用复制构造函数(当然是为了科学),你必须根本不声明任何move构造函数。

到目前为止,你一定已经看到了意外复制对象并破坏程序性能的危险隐藏在你的代码的每一个黑暗角落。你能做些什么来避免意外复制?我们马上会有一些建议,但首先,让我们回到我们已经简要使用过的一个方法:使用指针。

使用指针来避免复制

在传递对象时避免复制对象的一种方法是传递指针。如果我们不必管理对象的生命周期,这是最容易的。如果一个函数需要访问一个对象但不需要删除它,通过引用或原始指针传递对象是最好的方式(在这种情况下,引用实际上只是一个不能为 null 的指针)。

同样,我们可以使用指针从函数返回对象,但这需要更多的注意。首先,对象必须在堆上分配。你绝对不能返回指向局部变量的指针或引用。参考以下代码:

C& makeC(int i) { C c(i); return c; } // Never do this!

其次,调用者现在负责删除对象,因此你的函数的每个调用者都必须知道对象是如何构造的(new操作符不是构造对象的唯一方式,只是最常见的一种)。这里最好的解决方案是返回一个智能指针:

std::unique_ptr<C> makeC(int i) {
  return std::make_unique<C>(i);
}

请注意,这样的工厂函数应该返回独特的指针,即使调用者可能使用共享指针来管理对象的生命周期:从独特指针移动到共享指针是简单且便宜的。

说到共享指针,它们经常用于传递由智能指针管理生命周期的对象。除非意图是传递对象的所有权,否则这又是一个不必要和低效的复制的例子。复制共享指针并不便宜。那么,如果我们有一个由共享指针管理的对象和一个需要在不获取所有权的情况下对该对象进行操作的函数,我们使用原始指针:

void do_work1(C* c);
void do_work2(const C* c);
std::shared_ptr<C> p { new C(…) };
do_work1(&*p);
do_work2(&*p);

函数do_work1()do_work2()的声明告诉我们程序员的意图:两个函数都在不删除对象的情况下操作对象。第一个函数修改对象;第二个函数不修改。这两个函数都期望在没有对象的情况下被调用,并将处理这种特殊情况(否则,参数将被按引用传递)。

同样,你可以创建原始指针的容器,只要对象的生命周期在其他地方管理。如果你希望容器管理其元素的生命周期,但又不想将对象存储在容器中,唯一指针的容器就可以胜任。

现在是时候提出一些通用的准则,帮助你避免不必要的拷贝和由此引起的低效率。

如何避免不必要的拷贝

要减少意外的、无意的拷贝,你可以做的最重要的事情也许是确保所有的数据类型都是可移动的,如果移动的成本比拷贝更低的话。如果你有容器库或其他可重用的代码,确保它也是可移动的。

下一个建议有些粗糙,但可以节省大量的调试时间:如果你有昂贵的类型需要拷贝,最好一开始就将它们设置为不可拷贝。声明拷贝和赋值操作为删除。如果类支持快速移动,提供移动操作。当然,这将阻止任何拷贝,无论是有意还是无意的。希望有意的拷贝很少,你可以实现一个特殊的成员函数,比如clone(),它将创建对象的副本。至少这样,所有的拷贝都是显式的,并且在你的代码中是可见的。如果类既不可拷贝也不可移动,你将无法将其与 STL 容器一起使用;然而,使用唯一指针的容器是一个很好的替代方案。

在向函数传递参数时,尽可能使用引用或指针。如果函数需要对参数进行拷贝,请考虑按值传递并从参数中移动。记住,这仅适用于可移动类型,并参考第一个准则。

我们关于传递函数参数的所有说法也适用于临时局部变量(毕竟,函数参数基本上就是函数范围内的临时局部变量)。除非你需要一个拷贝,否则这些应该是引用。这不适用于像整数或指针这样的内置类型:它们比间接访问更便宜。在模板代码中,你无法知道类型是大还是小,所以使用引用,并依赖于编译器优化来避免对内置类型的不必要的间接访问。

当从函数返回值时,你首选应该依赖于 RVO 和拷贝省略。只有当你发现编译器没有执行这种优化,并且在你的特定情况下这很重要时,你才应该考虑其他选择。这些替代方案包括:使用带有输出参数的函数和使用在动态分配内存中构造结果并返回拥有智能指针(如std::unique_ptr)的工厂函数。

最后,审查你的算法和实现,留意不必要的拷贝:记住,恶意的拷贝对性能的影响和无意的拷贝一样糟糕。

我们已经完成了 C++程序中效率的第一个问题,即不必要的对象拷贝。接下来的问题是糟糕的内存管理。

低效的内存管理

C++中的内存管理这个主题可能值得一本专门的书。有数十甚至数百篇论文专门讨论 STL 分配器的问题。在本章中,我们将专注于影响性能最大的几个问题。有些问题有简单的解决方案;对于其他问题,我们将描述问题并概述可能的解决方案。

在性能的背景下,你可能会遇到两种与内存相关的问题。第一个是使用过多的内存:你的程序要么耗尽内存,要么不满足内存使用要求。第二个问题是当你的程序变得受限于内存:其性能受到内存访问速度的限制。通常情况下,程序的运行时间与其内存使用量直接相关,减少内存使用也会使程序运行更快。

本节介绍的材料对于处理受限于内存的程序或频繁分配大量内存的程序的程序员来说是有帮助的。我们首先从内存分配本身的性能影响开始。

不必要的内存分配

与内存使用相关的最常见的性能问题之一是不必要的内存分配。这里是一个非常常见的问题,用类似 C++的伪代码描述:

for ( … many iterations … ) {
  T* buffer = allocate(… size …);
  do_work(buffer); // Computations use memory
  deallocate(buffer);
}

一个写得很好的程序会使用 RAII 类来管理释放,但为了清晰起见,我们希望明确地进行分配和释放。分配通常隐藏在管理自己内存的对象内部,比如 STL 容器。这样的程序大部分时间都花在内存分配和释放函数上(比如malloc()free())。

我们可以看到性能对一个非常简单的基准测试的影响:

void BM_make_str_new(benchmark::State & state) {
    const size_t NMax = state.range(0);
    for (auto _: state) {
        const size_t N = (random_number() % NMax) + 1;
        char * buf = new char[N];
        memset(buf, 0xab, N);
        delete[] buf;
    }
    state.SetItemsProcessed(state.iterations());
}

这里的工作是通过初始化一个字符串来表示,random_number()函数返回随机整数值(它可以只是rand(),但如果我们预先计算并存储随机数以避免对随机数生成器进行基准测试,那么基准测试就会更干净)。你可能还需要欺骗编译器,使其不要优化结果:如果通常的benchmark::DoNotOptimize()不够用,你可能需要插入一个带有永远不会发生的条件的打印语句(但编译器不知道)比如rand() < 0

我们从基准测试中得到的数字本身是没有意义的:我们需要将它们与某些东西进行比较。在我们的情况下,基准很容易找到:我们必须做同样的工作,但没有任何分配。这可以通过将分配和释放移出循环来实现,因为我们知道最大内存大小:

  char * buf = new char[NMax];
  for (auto _: state) {
      …}
  delete[] buf;

在这样的基准测试中,你观察到的性能差异在很大程度上取决于操作系统和系统库,但你可能会看到类似这样的情况(我们使用了最多 1KB 的随机大小的字符串):

图 9.5-分配-释放模式的性能影响

图 9.5-分配-释放模式的性能影响

应该注意,在微基准测试中,内存分配通常比在大型程序的上下文中更有效率,因为内存分配模式要复杂得多,因此频繁分配和释放的实际影响可能更大。即使在我们的小基准测试中,每次分配内存的实现速度只有分配最大可能内存量一次版本的 40%。

当然,当我们在计算过程中需要的最大内存量事先知道时,预先分配并在下一次迭代中重复使用是一个简单的解决方案。这个解决方案也适用于许多容器:对于向量或双端队列,我们可以在迭代开始之前预留内存,并利用调整容器大小不会减小其容量的特性。

当我们事先不知道最大内存大小时,解决方案只是稍微复杂一些。这种情况可以用一个只增长不缩小的缓冲区来处理。这是一个简单的缓冲区,可以增长但永远不会缩小:

class Buffer {
  size_t size_;
  std::unique_ptr<char[]> buf_;
  public:
  explicit Buffer(size_t N) : size_(N), buf_(
    new char[N]) {}
  void resize(size_t N) { 
     if (N <= size_) return;
     char* new_buf = new char[N];
     memcpy(new_buf, get(), size_);
     buf_.reset(new_buf);
     size_ = N;
  }
  char* get() { return &buf_[0]; }
};

再次强调,这段代码对于演示和探索是有用的。在一个真实的程序中,你可能会使用 STL 容器或你自己的库类,但它们都应该有增加内存容量的能力。我们可以通过简单修改我们的基准测试来比较这个仅增长缓冲区与固定大小预分配缓冲区的性能:

void BM_make_str_buf(benchmark::State& state) {
  const size_t NMax = state.range(0);
  Buffer buf(1);
  for (auto _ : state) {
     const size_t N = (random_number() % NMax) + 1;     
     buf.resize(N);
     memset(buf.get(), 0xab, N);
  }
  state.SetItemsProcessed(state.iterations());
}

再次强调,在一个真实的程序中,通过更智能的内存增长策略(略微超过请求的增长,这样你就不必经常增长内存 - 大多数 STL 容器都采用某种形式的这种策略)你可能会得到更好的结果。但是,对于我们的演示,我们希望尽可能地保持简单。在同一台机器上,基准测试的结果如下:

图 9.6 - 仅增长缓冲区的性能(与图 9.5 进行比较)

图 9.6 - 仅增长缓冲区的性能(与图 9.5 进行比较)

增长型缓冲区比固定大小缓冲区慢,但比每次分配和释放内存要快得多。再次强调,更好的增长策略会使这个缓冲区变得更快,接近固定大小缓冲区的速度。

这还不是全部:在多线程程序中,良好的内存管理的重要性更大,因为对系统内存分配器的调用不会很好地扩展,并且可能涉及全局锁。在同一台机器上使用 8 个线程运行我们的基准测试产生了以下结果:

图 9.7 - 多线程程序中分配-释放模式的性能影响

图 9.7 - 多线程程序中分配-释放模式的性能影响

在这里,频繁分配的惩罚更大(仅增长缓冲区显示了剩余分配的成本,并且真的会受益于更智能的增长策略)。

关键是:尽量减少与操作系统的交互。如果你有一个需要在每次迭代中分配和释放内存的循环,那么在循环之前分配一次。如果分配的大小相同,或者你事先知道最大分配大小,那么就分配这个大小并保持它(当然,如果你使用多个缓冲区或容器,你不应该试图把它们塞进一个单一的分配中,而是预先分配每一个)。如果你不知道最大大小,使用一个可以增长但不会缩小或释放内存直到工作完成的数据结构。

避免与操作系统交互的建议在多线程程序中尤为重要,现在我们将对并发程序中内存使用进行一些更一般的评论。

并发程序中的内存管理

操作系统提供的内存分配器是一个平衡多种需求的解决方案:在一台给定的机器上,只有一个操作系统,但有许多不同的程序,它们有自己独特的需求和内存使用模式。开发人员非常努力地使它在任何合理的用例中都不会失败;另一方面,它很少是任何用例的最佳解决方案。通常情况下,它足够好,特别是如果你遵循频繁请求内存的建议。

在并发程序中,内存分配变得更加低效。主要原因是任何内存分配器都必须维护一个相当复杂的内部数据结构来跟踪分配和释放的内存。在高性能分配器中,内存被划分为多个区域,以将相似大小的分配组合在一起。这增加了性能,但也增加了复杂性。结果是,如果多个线程同时分配和释放内存,那么这些内部数据的管理必须受到锁的保护。这是一个全局锁,适用于整个程序,如果分配器经常被调用,它可能会限制整个程序的扩展。

这个问题的最常见解决方案是使用具有线程本地缓存的分配器,比如流行的malloc()替代库 TCMalloc。这些分配器为每个线程保留一定数量的内存:当一个线程需要分配内存时,首先从线程本地内存区域中取出。这不需要锁,因为只有一个线程与该区域交互。只有当该区域为空时,分配器才必须获取锁,并从所有线程共享的内存中分配。同样,当一个线程释放内存时,它会被添加到特定于线程的区域,而无需任何锁定。

线程本地缓存并非没有问题。

首先,它们往往会使用更多的内存:如果一个线程释放了大量内存,另一个线程分配了大量内存,那么最近释放的内存对于其他线程是不可用的(它是本地的)。因此,分配更多的内存,而未使用的内存对其他线程是可用的。为了限制这种内存浪费,分配器通常不允许每个线程的区域增长超过某个预定义的限制。一旦达到限制,线程本地内存就会返回到所有线程共享的主要区域(这个操作需要一个锁)。

第二个问题是,如果每个分配都由一个线程拥有,也就是说,同一个线程在每个地址分配和释放内存,那么这些分配器就能很好地工作。如果一个线程分配了一些内存,但另一个线程必须释放它,这种跨线程的释放是困难的,因为内存必须从一个线程的本地区域转移到另一个线程的本地区域(或共享区域)。简单的基准测试显示,使用标准分配器(如malloc()或 TCMalloc)进行跨线程释放的性能至少比线程拥有的内存差一个数量级。这很可能对任何利用线程本地缓存的分配器都是如此,因此应尽量避免线程之间的内存转移。

到目前为止,我们讨论了将内存从一个线程转移到另一个线程以便释放的问题。那么简单地使用另一个线程分配的内存呢?这种内存访问的性能在很大程度上取决于硬件能力。对于一个具有少量 CPU 的简单系统,这可能不是问题。但更大的系统有多个内存银行,CPU 和内存之间的连接不对称:每个内存银行更接近一个 CPU。这被称为非一致内存架构NUMA)。NUMA 的性能影响因不重要快两倍而变化很大。有方法可以调整 NUMA 内存系统的性能,以及使程序内存管理对 NUMA 细节敏感,但要注意,你可能在调整性能以适应特定的机器:关于 NUMA 系统的性能几乎没有什么可以说的。

现在我们回到了更有效地使用内存的问题,因为这对并发和串行程序的性能都是有益的。

避免内存碎片化

一个困扰许多程序的问题是与内存分配系统的低效交互。假设程序需要分配 1 KB 的内存。这块内存是从某个较大的内存区域中划分出来的,由分配器标记为已使用,并将地址返回给调用者。随后进行更多的内存分配,所以我们 1 KB 的内存块之后的内存现在也被使用了。然后程序释放第一个分配的内存,并立即请求 2 KB 的内存。有 1 KB 的空闲块,但不足以满足这个新的请求。可能在其他地方有另一个 1 KB 的块,但只要这两个块不相邻,它们对于 2 KB 的分配就没有用处:

图 9.8 - 内存碎片化:存在 2 KB 的空闲内存,但对于单个 2 KB 的分配是无用的

图 9.8 - 内存碎片化:存在 2 KB 的空闲内存,但对于单个 2 KB 的分配是无用的

这种情况被称为malloc(),但对于快速消耗内存的程序,可能需要更极端的措施。

其中一种措施是块分配器。其思想是所有内存都以固定大小的块分配,比如 64 KB。你不应该一次从操作系统中分配这么大的单个块,而是应该分配更大的固定大小的块(比如 8 MB),然后将它们细分为更小的块(在我们的例子中是 64 KB)。处理这些请求的内存分配器是程序中的主要分配器,直接与malloc()交互。因为它只分配一个大小的块,所以它可以非常简单,我们可以专注于最有效的实现(并发程序的线程本地缓存,实时系统的低延迟等)。当然,你不希望在代码的各个地方都处理这些 64 KB 的块。这是次要分配器的工作,如下图图 9.9所示:

图 9.9 - 固定大小块分配

图 9.9 - 固定大小块分配

你可以有一个分配器进一步将 64 KB 的块细分为更小的分配。特别高效的是统一分配器(只分配一个大小的分配器):例如,如果你想为单个 64 位整数分配内存,你可以做到没有任何内存开销(相比之下,malloc()通常至少需要 16 字节的开销)。你还可以有容器分配内存在 64 KB 的块中并用它来存储元素。你不会使用向量,因为它们需要单个大的连续分配。你想要的类似数组的容器是 deque,它分配内存在固定大小的块中。当然,你也可以有节点容器。如果 STL 分配器接口满足你的需求,你可以使用 STL 容器;否则,你可能需要编写自己的容器库。

固定大小块分配的关键优势在于它不会受到碎片化的影响:从malloc()分配的所有分配都是相同大小的,因此主分配器的所有分配也是如此。每当一个内存块被返回给分配器,它都可以被重用来满足下一个内存请求。参考下图:

图 9.10 - 固定大小分配器中的内存重用

图 9.10 - 固定大小分配器中的内存重用

首先进先出的特性也是一个优势:最后的 64 KB 内存块很可能是最近使用的内存,仍然在缓存中。立即重用这个块可以改善内存引用局部性,因此更有效地利用缓存。分配器将返回给它的块管理为一个简单的空闲列表(图 9.10)。这些空闲列表可以按线程维护,以避免锁定,尽管它们可能需要定期重新平衡,以避免一个线程积累了许多空闲块,而另一个线程正在分配新的内存。

当然,将我们的 64 KB 块细分为更小尺寸的分配器仍然容易受到碎片化的影响,除非它们也是统一(固定大小)的分配器。然而,如果它必须处理一个小的内存范围(一个块)和少量不同的大小,编写自动整理分配器会更容易。

很可能整个程序都受到使用块内存分配的影响。例如,分配大量小数据结构,使得每个数据结构使用 64 KB 块的一部分并且剩下的部分未使用会变得非常昂贵。另一方面,一个数据结构本身是一组较小的数据结构(一个容器),这样它可以将许多较小的对象打包到一个块中,变得更容易编写。甚至可以编写压缩容器,用于长期保留数据,然后逐块解压缩以进行访问。

块大小本身也不是一成不变的。一些应用程序使用较小的块会更有效,因为如果块部分未使用,则浪费的内存较少。其他应用则可以从需要较少分配的较大块中受益。

应用特定分配器的文献非常丰富。例如,板块分配器是我们刚刚看到的块分配器的一般化;它们有效地管理多种分配大小。还有许多其他类型的自定义内存分配器,其中大多数可以在 C++程序中使用。使用适合特定应用的分配器通常会带来显著的性能改进,通常以严重限制程序员在数据结构实现中的自由为代价。

效率不高的另一个常见原因更微妙,也更难处理。

条件执行的优化

在不必要的计算和内存使用效率低下之后,编写未能充分利用可用计算资源大部分的低效代码的最简单方法可能是无法进行良好流水线处理的代码。我们已经在第三章CPU 架构、资源和性能影响中看到了 CPU 流水线处理的重要性。我们还了解到,最大的流水线破坏者通常是条件操作,特别是硬件分支预测器无法猜测的条件操作。

不幸的是,优化条件代码以获得更好的流水线是最困难的 C++优化之一。只有在分析器显示预测不良的分支时才应该进行优化。但是,请注意,预测不准的分支数量不必很大才能被认为是“不良的”:一个好的程序通常会有不到 0.1%的预测不准的分支。1%的错误预测率是相当大的。而且,要在不检查编译器输出(机器代码)的情况下预测源代码优化的效果也是相当困难的。

如果分析器显示有一个预测不良的条件操作,下一步是确定哪个条件被错误预测。我们已经在第三章CPU 架构、资源和性能影响中看到了一些例子。例如,这段代码:

if (a[i] || b[i] || c[i]) { … do something … }

即使整体结果是可预测的,也可能产生一个或多个预测不良的分支。这与 C++中布尔逻辑的定义有关:操作符||&&短路的:表达式从左到右进行评估,直到结果变为已知。例如,如果a[i]true,则代码不得访问数组元素b[i]c[i]。有时,这是必要的:实现的逻辑可能是这些元素不存在。但通常,布尔表达式会因无故引入不必要的分支。前面的if()语句需要 3 个条件操作。另一方面,这个语句:

if (a[i] + b[i] + c[i]) { … do something … }

相当于最后一个,如果值abc是非负的,但需要进行单个条件操作。同样,这不是您应该预先进行的优化类型,除非您有测量结果证实需要进行优化。

这是另一个例子。考虑这个函数:

void f2(bool b, unsigned long x, unsigned long& s) {
  if (b) s += x;
}

如果b的值是不可预测的,那么效率非常低。只需进行简单的更改,性能就会大大提高:

void f2(bool b, unsigned long x, unsigned long& s) {
  s += b*x;
}

这种改进可以通过对原始的有条件的实现与无分支实现进行简单的基准测试来确认:

BM_conditional   176.304M items/s
BM_branchless     498.89M items/s

正如你所看到的,无分支实现几乎快了 3 倍。

不要过度追求这种类型的优化是很重要的。它必须始终受到测量的驱动,有几个原因:

  • 分支预测器非常复杂,我们对它们能够处理和不能处理的直觉几乎总是错误的。

  • 编译器优化通常会显著改变代码,因此,即使我们对分支的存在有期望,没有测量或检查机器代码,我们的期望也可能是错误的。

  • 即使分支被错误预测,性能影响也会有所不同,所以没有测量是不可能确定的。

例如,手动优化这种非常常见的代码几乎从来都不是有用的:

int f(int x) { return (x > 0) ? x : 0; }

它看起来像是有条件的代码,如果x的符号是随机的,那么预测是不可能的。然而,很可能分析器不会显示出大量的错误预测分支。原因是大多数编译器不会使用条件跳转来实现这一行。在 x86 上,一些编译器会使用 CMOVE 指令,它执行条件移动:根据条件,它将两个源寄存器中的一个值移动到目的地。这个指令的条件性质是良性的:记住有条件的代码的问题在于 CPU 无法提前知道下一条指令要执行什么。有了条件移动的实现,指令序列是完全线性的,它们的顺序是预先确定的,所以没有什么需要猜测的。

另一个常见的例子,不太可能从无分支优化中受益的是有条件的函数调用:

if (condition) f1(… args …) else f2(… args …);

可以使用函数指针数组实现无分支。

using func_ptr = int(*)(… params …);
static const func_ptr f[2] = { &f1, &f2 };
(*f[condition])(… args …);

如果函数最初是内联的,用间接函数调用替换它们会导致性能下降。如果它们不是,这种改变可能几乎没有任何作用:跳转到另一个在编译期间不知道地址的函数,其效果与错误预测的分支非常相似,因此这段代码无论如何都会导致 CPU 刷新流水线。

最重要的是,优化分支预测是一个非常高级的步骤。结果可能是显著的改进,也可能是显著的失败(或者只是浪费时间),因此在每一步都要受到性能测量的指导是很重要的。

我们现在已经学到了很多关于 C++程序中许多潜在的低效性以及改进它们的方法。我们总结一些优化代码的整体指导方针。

总结

在本章中,我们已经涵盖了 C++效率的两个大领域中的第一个:避免低效的语言构造,这归结为不做不必要的工作。我们学习过的许多优化技术与我们早期学习的材料相契合,比如访问内存的效率以及在并发程序中避免虚假共享。

每个程序员面临的一个大困境是应该投入多少工作来编写高效的代码,以及什么应该留给增量优化。让我们首先说,高性能始于设计阶段:设计架构和接口,不锁定低性能和低效实现是开发高性能软件中最重要的工作。

除此之外,应该区分过早优化不必要的性能下降。为了避免别名问题而创建临时变量是过早的,除非你有性能测量数据表明你正在优化的函数对整体执行时间有很大贡献(或者除非它提高了可读性,这是另一回事)。直到分析器告诉你要改变为止,通过值传递大向量只会使你的代码变慢而没有理由,因此应该从一开始就避免。

两者之间的界限并不总是清晰的,因此你必须权衡几个因素。你必须考虑改变对程序的影响:它是否使代码更难阅读,更复杂,或者更难测试?通常情况下,你不想冒着为了性能而增加更多 bug 的风险,除非测量告诉你你必须这样做。另一方面,有时更可读或更直接的代码也是更高效的代码,那么优化就不能被认为是过早的。

C++效率的第二个主要领域与帮助编译器生成更高效的代码有关。我们将在下一章中介绍这个问题。

问题

  1. 什么时候通过值传递甚至大对象是可以接受的?

  2. 在使用资源拥有智能指针时,我们应该如何调用操作对象的函数?

  3. 什么是返回值优化,它在哪里使用?

  4. 为什么低效的内存管理不仅影响内存消耗,还影响运行时间?

  5. 什么是 A-B-A 问题?

第十章:C++中的编译器优化

在上一章中,我们已经了解了 C++程序中效率低下的主要原因。消除这些低效性的责任大部分落在程序员身上。然而,编译器也可以通过许多方式使您的程序运行更快。这就是我们现在要探讨的内容。

本章将涵盖编译器优化的非常重要的问题,以及程序员如何帮助编译器生成更高效的代码。

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

  • 编译器优化代码的方法

  • 编译器优化的限制

  • 如何从编译器获得最佳优化

技术要求

同样,您将需要一个 C++编译器和一个微基准测试工具,比如我们在上一章中使用的Google Benchmark库(在github.com/google/benchmark找到)。本章附带的代码可以在github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter10找到。

您还需要一种方法来检查编译器生成的汇编代码。许多开发环境都有显示汇编代码的选项,GCC 和 Clang 可以写出汇编而不是目标代码,调试器和其他工具可以从目标代码生成汇编(反汇编)。您可以根据个人偏好选择使用哪种工具。

编译器优化代码

优化编译器对于实现高性能至关重要。只需尝试运行一个完全没有优化的程序,就能体会到编译器的作用:未经优化的程序(优化级别为零)的运行速度通常比启用所有优化的程序慢一个数量级。

然而,通常情况下,优化器可能需要程序员的一些帮助。这种帮助可能采取非常微妙且常常反直觉的变化形式。在我们查看一些特定的技术来改进代码优化之前,了解编译器如何看待您的程序会有所帮助。

编译器优化基础

关于优化,您必须理解的最重要的一点是,任何正确的代码都必须保持正确。正确在这里与您对正确的看法无关:程序可能存在错误并给出您认为错误的答案,但编译器必须保留这个答案。唯一的例外是一个程序是不明确的或者调用了未定义的行为:如果程序在标准的眼中是不正确的,编译器可以随意做任何事情。我们将在下一章中探讨这一点的影响。目前,我们将假设程序是明确定义的,并且仅使用有效的 C++。当然,编译器在进行更改时受到限制,要求答案在任何输入组合下都不得改变。后者非常重要:您可能知道某个输入值始终为正,或者某个字符串永远不会超过 16 个字符长,但编译器不知道(除非您找到一种告诉它的方法)。只有在可以证明此转换会导致完全等效的程序时,编译器才能进行优化转换:一个对于任何输入都产生相同输出的程序。实际上,编译器在放弃之前能够管理多复杂的证明也是受限的。

理解重要的不是你知道什么,而是你能证明什么是成功与编译器通过代码进行交互以实现更好优化的关键。基本上,本章的其余部分展示了您可以使得更容易证明某些理想的优化不会改变程序结果的不同方法。

编译器在程序方面也受到限制。它必须仅使用编译时已知的信息,对任何运行时数据一无所知,并且必须假设在运行时可能出现任何合法状态。

这是一个简单的例子,用来说明这一点。首先,考虑这段代码:

std::vector<int> v;
… fill v with data … 
for (int& x : v) ++x;

我们关注的重点是最后一行,循环。如果手动展开循环,性能可能会更好:如写的那样,每次增量都有一个分支(循环终止条件)。展开循环可以减少这种开销。在一个简单的情况下,比如一个只有两个元素的向量,甚至最好完全去掉循环,只增加两个元素。然而,向量的大小是运行时信息的一个例子。编译器可能能够生成一个部分展开的循环,带有一些额外的分支来处理所有可能的向量大小,但它无法为特定大小优化代码。

与此代码形成对比:

int v[16];
… fill v with data … 
for (int& x : v) ++x;

现在编译器确切地知道循环中处理了多少个整数。它可以展开循环,甚至用操作多个数字的向量指令替换单个整数的增量(例如,x86 上的 AVX2 指令集可以一次添加 8 个整数)。

如果您知道向量始终有 16 个元素,可能并不重要。重要的是编译器是否知道这一点,并且能够确定地证明。这比你想象的要困难。例如,考虑这段代码:

constexpr size_t N = 16;
std::vector<int> v(N);
… fill v with data … 
for (int& x : v) ++x;

程序员费尽心思明确表示向量大小是编译时常量。编译器会优化循环吗?可能。这完全取决于编译器能否证明向量大小不会改变。它会如何改变?问问自己,代码中填充向量的部分可能隐藏了什么?不是你知道的,而是可以从代码本身中学到的。如果所有代码都写在两行之间,构造和增量循环,理论上编译器可以知道一切(实际上,如果这段代码片段太长,编译器会放弃,并假设任何事情都有可能,否则编译时间会爆炸)。但如果调用一个函数,而该函数可以访问向量对象,编译器无法知道该函数是否改变了向量的大小,除非该函数被内联。像fill_vector_without_resizing()这样的有用函数名称只对程序员有用。

即使没有函数调用以v作为参数,我们仍然不能确定。函数如何可能访问向量对象?如果向量v是在函数作用域中声明的局部变量,它可能无法。但如果v是一个全局变量,那么任何函数都可以访问它。同样,如果v是一个类成员变量,任何成员函数或友元函数都可以访问它。因此,如果我们调用一个非内联函数,它没有通过参数列表直接访问v,它可能仍然能够通过其他方式访问v(至于创建全局指针指向局部变量的真正邪恶的做法,我们最好不要谈)。

从程序员的角度来看,很容易高估编译器的知识,基于程序员对程序实际运行情况的了解。还要记住,解谜通常不是编译器的长处。例如,您可以在循环之前添加一个assert

constexpr size_t N = 16;
std::vector<int> v(N);
… fill v with data … 
assert(v.size() == N); // if (v.size() != N) abort();
for (int& x : v) ++x;

在最高优化级别和简单的上下文中,一些编译器会推断除非向量恰好有 16 个元素,否则执行流程不会到达循环,并且会针对这个大小进行优化。大多数不会。顺便说一句,我们假设断言已启用(NDEBUG未定义),或者您使用自己的断言。

我们已经考虑的基本例子已经包含了用于帮助编译器优化代码的关键技术元素:

  • 非内联函数会破坏大多数优化,因为编译器必须假设它看不到代码的函数可以做任何它合法允许的事情。

  • 全局和共享变量对优化非常不利。

  • 编译器更有可能优化短小简单的代码片段,而不是长而复杂的代码片段。

第一个和最后一个概念在某种程度上存在冲突。编译器中的大多数优化都限于所谓的基本代码块:这些是只有一个入口点和一个出口点的代码块。它们在程序的流程控制图中充当节点。基本代码块之所以重要是因为编译器可以看到代码块内部发生的一切,因此可以推断不会改变输出的代码转换。内联的优势在于它增加了基本代码块的大小。编译器不知道非内联函数的具体操作,因此必须做出最坏的假设。但是如果函数被内联,编译器就知道它在做什么(更重要的是,它不在做什么)。内联的缺点也在于它增加了基本代码块的大小:编译器只能分析有限量的代码,否则编译时间会变得不合理。内联对于编译器优化非常重要,我们现在将探讨其中的原因。

函数内联

内联是编译器在用函数体的副本替换函数调用时进行的。为了实现这一点,内联必须是可能的:在调用代码的编译过程中,函数的定义必须是可见的,并且在编译时必须知道被调用的函数。第一个要求在一些进行整体程序优化的编译器中有所放宽(仍然不常见)。第二个要求排除了虚函数调用和通过函数指针进行的间接调用。并非每个可以内联的函数最终都会被内联:编译器必须权衡代码膨胀与内联的好处。不同的编译器对内联有不同的启发式。C++的inline关键字只是一个建议,编译器可以忽略它。

函数调用内联的最明显好处是消除了函数调用本身的成本。在大多数情况下,这也是最不重要的好处:函数调用并不那么昂贵。主要好处在于编译器在函数调用之间可以做的优化非常有限。考虑这个简单的例子:

double f(int& i, double x) {
  double res = g(x);
  ++i;
  res += h(x);
  res += g(x);
  ++i;
  res += h(x);
  return res;
}

以下是一个有效的优化吗?

double f(int& i, double x) {
  i += 2;
  return 2*(g(x) + h(x));
}

如果你回答,那么你仍然是从程序员的角度来看待这个问题,而不是从编译器的角度来看。这种优化可能会破坏代码的方式有很多(对于您可能编写的任何合理程序来说,这些方式可能都不成立,但编译器不能做出的假设是程序员是合理的)。

  • 首先,函数g()h()可以产生输出,如果消除重复的函数调用会改变可观察的行为。

  • 其次,对g()的调用可能会锁定某个互斥量,对h()的调用可能会解锁它,这种情况下执行的顺序——调用g()来锁定,增加i,调用h()来解锁——非常重要。

  • 第三,即使使用相同的参数,g()h()的结果可能不同:例如,它们可能在内部使用随机数。

  • 最后(这种可能性程序员经常忽视),变量i是通过引用传递的,因此我们不知道调用者可能对它做了什么:它可能是一个全局变量,或者某个对象可能存储了对它的引用,因此,g()h()函数可能会对i进行操作,尽管我们看不到它被传递到这些函数中。

另一方面,如果函数g()h()被内联,编译器可以清楚地看到发生了什么,例如:

double f(int& i, double x) {
  double res = x + 1; // g(x);
  ++i;
  res += x – 1; // h(x);
  res += x + 1; // g(x)
  ++i;
  res += x – 1; // h(x);
  return res;
}

整个函数f()现在是一个基本块,编译器只有一个限制:保留返回值。这是一个有效的优化:

double f(int& i, double x) {
  i += 2;
  return 4*x;
}

内联对优化的影响可以*传递得很远。考虑 STL 容器的析构函数,比如std::vector<T>。它必须做的步骤之一是调用容器中所有对象的析构函数:

for (auto it = crbegin(); it != crend(); ++it) it->~T();

因此,析构函数的执行时间与向量的大小N成正比。除非不是:考虑一个整数向量,std::vector<int>。在这种情况下,编译器非常清楚析构函数的作用:绝对什么都不做。编译器还可以看到对crbegin()crend()的调用不会修改向量(如果您担心通过const_iterator销毁对象,请考虑const对象是如何被销毁的)。因此,整个循环可以被消除。

现在考虑使用简单聚合的向量:

struct S {
  long a;
  double x;
};
std::vector<S> v;

这一次,类型T有一个析构函数,编译器再次知道它的作用(毕竟编译器生成了它)。再一次,析构函数什么都不做,整个销毁循环被消除。对于default析构函数也是一样的:

struct S {
  long a;
  double x;
  ~S() = default;
};

编译器应该能够对空析构函数进行相同的优化,但只有在内联的情况下才能这样做:

struct S {
  long a;
  double x;
  ~S() {}     // Probably optimized away
};

另一方面,如果类声明只声明了析构函数如下:

struct S {
  long a;
  double x;
  ~S();
};

如果定义在单独的编译单元中,那么编译器必须为每个向量元素生成一个函数调用。函数仍然什么都不做,但运行循环和进行N个函数调用仍然需要时间。内联允许编译器将这段时间优化为零。

这是内联及其对优化的影响的关键:内联允许编译器看到在否则神秘的函数内部发生了什么。内联还有另一个重要的作用:它创建了内联函数体的唯一克隆,可以根据调用者给定的特定输入进行优化。在这个唯一的克隆中,可能观察到一些对优化友好的条件,这些条件对于这个函数来说通常是不成立的。再次举个例子:

bool pred(int i) { return i == 0; }
  … 
std::vector<int> v = … fill vector with data …;
auto it = std::find_if(v.begin(), v.end(), pred);

假设函数pred()的定义与对std::find_if()的调用在同一个编译单元中,那么对pred()的调用会被内联吗?答案是可能,这在很大程度上取决于对find_if()是否首先进行内联。现在,find_if()是一个模板,所以编译器总是能看到函数定义。它可能决定不内联该函数。如果find_if()没有内联,那么我们就会得到一个从特定类型生成的模板函数。在这个函数内部,第三个参数的类型是已知的:它是bool (*)(int),一个接受int并返回bool的函数指针。但是这个指针的值在编译时是未知的:同一个find_if()函数可以用许多不同的谓词调用,因此它们中的任何一个都不能被内联。只有当编译器为这个特定的调用生成find_if()的唯一克隆时,谓词函数才能被内联。编译器有时会这样做;这被称为克隆。然而,大多数情况下,内联谓词或作为参数传递的任何其他内部函数的唯一方法是首先内联外部函数。

这个特定的例子在不同的编译器上产生不同的结果:例如,GCC 只会在最高优化设置下内联find_if()pred()。其他编译器即使在那时也不会这样做。然而,还有另一种方法可以鼓励编译器内联函数调用,尽管这似乎有些反直觉,因为它会向程序添加更多的代码,并使嵌套函数调用链变得更长:

 bool pred(int i) { return i == 0; }
  … 
std::vector<int> v = … fill vector with data …;
auto it = std::find_if(v.begin(), v.end(), 
  & { return pred(i); });

这里的悖论是,我们在同一个间接函数调用周围增加了一个额外的间接层,即 lambda 表达式(顺便说一句,我们假设程序员不想直接将谓词的主体简单地复制到 lambda 中有其原因)。这次对pred()的调用实际上更容易内联,即使编译器没有内联find_if()函数。原因是这次,谓词的类型是唯一的:每个 lambda 表达式都有唯一的类型,因此对于这些特定的类型参数,find_if()模板只有一个实例化。编译器更有可能内联只调用一次的函数:毕竟,这样做不会生成任何额外的代码。但即使find_if()的调用没有被内联,在该函数内部,第三个参数只有一个可能的值,这个值在编译时已知为pred(),因此pred()的调用可以被内联。

顺便说一句,我们最终可以澄清我们在第一章中提出的问题的答案,即《性能和并发简介》:虚函数调用的成本是多少?首先,编译器通常使用函数指针表来实现虚拟调用,因此调用本身涉及额外的间接层:CPU 必须读取一个额外的指针,并与非虚拟调用相比进行一次跳转。这会在函数调用中添加几个额外的指令,使函数调用的代码大约贵两倍(具体取决于硬件和缓存状态的变化)。然而,我们通常调用函数是为了完成一些工作,因此函数调用的机制只是总函数执行时间的一部分。即使对于简单的函数,虚函数的成本很少超过非虚函数的 10-15%。

然而,在我们花费太多时间计算指令之前,我们应该质疑原始问题的有效性:如果非虚函数调用足够,也就是说,如果我们在编译时知道将调用哪个函数,那么我们为什么要首先使用虚函数呢?相反,如果我们只在运行时找出要调用的函数,那么根本不能使用非虚函数,因此它的速度是无关紧要的。按照这种逻辑,我们应该将虚函数调用与功能上等效的运行时解决方案进行比较:使用一些运行时信息来有条件地调用多个函数中的一个。使用if-elseswitch语句通常会导致较慢的执行,至少如果有两个以上的函数版本要调用的话。最有效的实现是一个函数指针表,这正是编译器用虚函数做的。

当然,原始问题实际上并非毫无意义:如果我们有一个多态类,其中有一个虚函数,但在某些情况下,我们在编译时知道实际类型是什么呢?在这种情况下,比较虚函数调用和非虚函数调用是有意义的。我们还应该提到一个有趣的编译器优化:如果编译器可以在编译时找出对象的真实类型,并因此知道将调用虚函数的哪个重写,它将把调用转换为非虚函数,这就是所谓的去虚拟化

那么,为什么这个讨论发生在一个专门讨论内联的部分呢?因为我们忽略了一个重要因素:虚函数对性能的最大影响是(除非编译器可以去虚拟化调用)它们无法被内联。一个简单的函数,比如int f() { return x; }在内联后可能只有一条甚至零条指令,但非内联版本则有常规的函数调用机制,速度慢了几个数量级。现在再加上没有内联的情况下,编译器无法知道虚函数内部发生了什么,并且必须对每个外部可访问的数据做出最坏的假设,你就能看到,在最坏的情况下,虚函数调用可能会昂贵数千倍。

内联的两个效果,暴露函数的内容和创建一个独特的、专门的函数副本,都有助于优化器,因为它们增加了编译器对代码的了解程度。正如我们已经提到的,如果你想帮助编译器更好地优化你的代码,了解编译器真正知道什么是非常重要的。

现在我们将探讨编译器所遵循的不同限制,这样你就可以培养出识别错误约束的眼光:你知道是真的,但编译器不知道。

编译器真正知道什么?

优化的最大限制可能是在代码执行期间可能发生的变化。为什么这很重要?再举一个例子:

int g(int a);
int f(const std::vector<int>& v, bool b) {
  int sum = 0;
  for (int a : v) {
    if (b) sum += g(a);
  }
  return sum;
} 

在这种情况下,只有g()的声明是可用的。编译器能够优化if()语句并消除条件的重复评估吗?在这一章节的所有意外和陷阱之后,你可能在寻找为什么不能的原因。其实没有,这是一个完全有效的优化:

int f(const std::vector<int>& v, bool b) {
  if (!b) return 0;
  int sum = 0;
  for (int a : v) {
    sum += g(a);
  }
  return sum;
} 

现在让我们稍微修改一下例子:

int g(int a);
int f(const std::vector<int>& v, const bool& b) {
  int sum = 0;
  for (int a : v) {
    if (b) sum += g(a);
  }
  return sum;
} 

为什么你会通过const引用传递bool参数?最常见的原因是模板:如果你有一个不需要复制参数的模板函数,它必须将参数声明为const T&,假设T可以是任何类型。如果T被推断为bool,那么现在你有了一个const bool&参数。这个改变可能很小,但对优化的影响是深远的。如果你认为我们之前做的优化仍然有效,那么考虑一下我们的例子在更大的上下文中。现在你可以看到一切(假设编译器仍然不能):

bool flag = false;
int g(int a) {
  flag = a == 0;
  return –a;
}
int f(const std::vector<int>& v, const bool& b) {
  int sum = 0;
  for (int a : v) {
    if (b) sum += g(a);
  }
  return sum;
} 
int main() {
  f({0, 1, 2, 3, 4}, flag);
}

请注意,通过调用g(),我们可以改变b,因为b是一个绑定到全局变量的引用,在g()内部也是可访问的。在第一次迭代中,bfalse,但调用g()会产生副作用:b变为true。如果参数是按值传递的,这是不会发生的:值在函数开始时被捕获,不会跟踪调用者的变量。但是通过引用传递,它确实发生了,循环的第二次迭代不再是死代码。在每次迭代中,条件必须被评估,优化是不可能的。我们要再次强调,程序员可能知道的和编译器可以证明的之间的差异:你可能确信你的代码中没有任何全局变量,或者你可能确切地知道函数g()做了什么。编译器无法做出这样的猜测,并且必须假设程序(或将来的某个时刻)会做出我们在前面的例子中展示的类似行为,这使得优化潜在地不安全。

同样,如果函数g()被内联并且编译器可以看到它不修改任何全局变量,这种情况就不会发生。但你不能期望你的整个代码都被内联,所以在某个时候,你必须考虑如何帮助编译器确定它自己不知道的东西。在当前的例子中,最简单的方法是引入一个临时变量(当然,在这个简单的例子中,你可以手动进行优化,但在更复杂的现实代码中,这是不切实际的)。为了使这个例子稍微更加现实,我们将记住函数f()可能来自一个模板实例化。我们不想复制一个未知类型的参数b,但我们知道它必须可以转换为bool,所以这可以是我们的临时变量。

template <typename T>
int f(const std::vector<int>& v, const T& t) {
  const bool b = bool(t);
  int sum = 0;
  for (int a: v) {
    if (b) sum += g(a);
  }
  return sum;
} 

编译器仍然必须假设函数g()可能会改变t的值。但这已经不重要了:条件使用了临时变量b,因为它在f()函数之外是不可见的,所以肯定不会被更改。当然,如果函数g()确实可以访问更改f()的第二个参数的全局变量,我们的转换就改变了程序的结果。通过创建这个临时变量,我们告诉编译器这种情况不会发生。这是编译器无法自行想出的额外信息。

这里的教训很简单,理论上是这样的,但在实践中却相当困难:如果你知道关于你的程序的一些信息,而编译器无法知道这些信息是真实的,你必须以编译器可以使用的方式来断言它。这样做之所以难,是因为我们通常不会像编译器那样思考我们的程序,而且很难放弃你确信绝对正确的隐含假设。

顺便说一句,你有没有注意到我们声明临时变量bconst?这主要是为了我们自己的利益,以防止因意外修改它而产生任何错误。但它也有助于编译器。你可能会想为什么:编译器应该能够看到没有任何东西改变b的值。与早期棘手的情况不同,这种情况很简单:编译器看到了对b的所有操作。然而,你不能确定编译器知道某些东西只是因为这些知识是可用的:分析程序需要时间,程序员只愿意等待编译器完成工作的时间有限。另一方面,语法检查是强制性的:如果我们声明变量为const并尝试更改它,程序将无法编译,我们将永远无法进行优化步骤。因此,优化器可以假设任何const变量确实不会改变。还有另一个原因可以尽可能地声明对象为const,但我们将在下一章中讨论。

所以这就是第二个教训,紧随第一个教训之后:如果你知道关于你的程序的一些信息,可以轻松地传达给编译器,那就这样做。这个建议确实违反了一个非常常见的建议:不要创建临时变量,除非它们使程序更易读 - 编译器无论如何都会摆脱它们。编译器确实可能会摆脱它们,但它确实保留(并使用)它们的存在所表达的附加信息。

另一个阻止编译器进行优化的非常常见的情况是可能的别名。这是一个初始化两个 C 风格字符串的函数的示例:

void init(char* a, char* b, size_t N) {
  for (size_t i = 0; i < N; ++i) {
    a[i] = '0';
    b[i] = '1';
  }
}

一次写一个字节的内存是相当低效的。有更好的方法来将所有字符初始化为相同的值。这个版本会快得多:

void init(char* a, char* b, size_t N) {
  std::memset(a, '0', N);
  std::memset(b, '1', N);
}

您可以手动编写此代码,但编译器永远不会为您执行此优化,了解原因很重要。当您看到此函数时,您期望它被按预期使用,即初始化两个字符数组。但是编译器必须考虑两个指针ab指向同一个数组或重叠部分的可能性。对您来说,以这种方式调用init()可能毫无意义:两个初始化将互相覆盖。然而,编译器只关心一件事:如何不改变您代码的行为,无论那是什么。

同样的问题可能会发生在任何通过引用或指针接受多个参数的函数中。例如,考虑这个函数:

void do_work(int& a, int& b, int& x) {
  if (x < 0) x = -x;
  a += x;
  b += x;
}

编译器无法进行任何优化,如果abx绑定到同一个变量,那么这些优化就是无效的。这被称为在递增a之后从内存中读取x。为什么?因为ax可能指向相同的值,编译器无法假设x保持不变。

如果您确定别名不会发生,您如何解决这个问题?在 C 中,有一个关键字restrict,它通知编译器特定指针是在当前函数范围内访问值的唯一方式:

void init(char* restrict a, char* restrict b, size_t N);

init()函数内部,编译器可以假定整个数组a只能通过这个指针访问。这也适用于标量变量。restrict关键字目前还不是 C++标准的一部分。尽管如此,许多编译器支持此功能,尽管使用不同的语法(restrict__restrict__restrict__)。对于单个值(特别是引用),创建临时变量通常可以解决问题,如下所示:

void do_work(int& a, int& b, int& x) {
  if (x < 0) x = -x;
  const int y = x;
  a += y;
  b += y;
}

编译器可能会消除临时变量(不为其分配任何内存),但现在它保证ab都增加了相同的量。编译器是否会实际执行这种优化?最简单的方法是比较汇编输出如下:

图 10.1 - x86 汇编输出在别名优化之前(左)和之后(右)

图 10.1 - x86 汇编输出在别名优化之前(左)和之后(右)

图 10.1显示了由 GCC 生成的 x86 汇编,用于增量操作(我们省略了函数调用和分支,这两种情况下是相同的)。使用别名,编译器必须从内存中进行两次读取(mov指令)。使用手动优化,只有一次读取。

这些优化有多重要?这取决于许多因素,因此在着手消除代码中的所有别名之前,您不应该进行一些测量。对代码进行分析将告诉您哪些部分是性能关键的;在那里,您必须检查所有优化机会。最终帮助编译器提供额外知识的优化通常是最容易实现的(编译器做了艰苦的工作)。

向编译器提供关于程序的难以发现的信息的建议的反面是:不要担心编译器可以轻松解决的问题。这个问题出现在不同的上下文中,但其中一个更常见的情景是使用验证其输入的函数。在您的库中,您有一个在指针上工作的交换函数:

template <typename T>
void my_swap(T* p, T* q) {
    if (p && q) {
        using std::swap;
        swap(*p, *q);
    }
}

该函数接受空指针,但不对其进行任何操作。在您自己的代码中,出于某种原因,您仍然必须检查指针,并且只有在两者都非空时才调用my_swap()(也许如果它们为空,您需要做其他事情,因此您必须检查)。忽略您可能做的所有其他工作,调用代码如下:

void f(int* p, int* q) {
    if (p && q) my_swap(p, q);
}

C++程序员花费了大量时间争论多余的检查是否会影响性能。我们应该尝试在调用站点删除检查吗?假设我们不能,我们应该创建另一个不测试其输入的my_swap()版本吗?关键观察是my_swap()函数是一个模板(和一个小函数),所以几乎肯定会被内联。编译器具有确定第二次对 null 的测试是多余的所有必要信息。它会吗?与其尝试基准测试可能的性能差异(在任何情况下都会非常小),我们将比较两个程序的汇编输出。如果编译器生成具有和不具有多余if()语句的相同机器代码,我们可以肯定没有性能差异。这是由 GCC 生成的 x86 汇编输出:

图 10.2 - 带有(左)和不带(右)多余指针测试的汇编输出

图 10.2 - 带有(左)和不带(右)多余指针测试的汇编输出

图 10.2的左侧是带有两个if()语句的程序生成的代码,一个在my_swap()内部,一个在外部。右侧是具有特殊的不测试版本my_swap()的程序的代码。您可以看到机器代码是完全相同的(如果您能阅读 x86 汇编,您还会注意到两种情况下只有两次比较,而不是四次)。

正如我们已经说过的,内联在这里起着至关重要的作用:如果my_swap()没有被内联,那么在函数f()中的第一个测试是好的,因为它避免了不必要的函数调用,并允许编译器更好地优化调用代码,以便在其中一个指针为空时更好地进行优化。现在my_swap()内部的测试是多余的,但是编译器将生成它,因为它不知道my_swap()是否在其他地方被调用,也许没有对输入的任何保证。性能差异仍然极不可能是可测量的,因为硬件对第二次测试是 100%可预测的(我们在第三章中讨论过这一点,CPU 架构、资源和性能影响)。

顺便说一句,这种情况最常见的例子可能是delete运算符:C++允许删除空指针(什么也不会发生)。然而,许多程序员仍然编写这样的代码:

if (p) delete p; 

即使在理论上,它会影响性能吗?不会:你可以查看汇编输出,并确信,无论是否有额外的检查,只有一次与 null 的比较。

现在您对编译器如何看待您的程序有了更好的理解,让我们看看如何通过另一种有用的技术来获得更好的编译器优化。

从运行时到编译时的知识提升

我们将要讨论的方法归结为一件事:通过将运行时信息转换为编译时信息,为编译器提供有关程序的更多信息。在以下示例中,我们需要处理由Shape类表示的大量几何对象。它们存储在一个容器中(如果类型是多态的,它将是指针的容器)。处理包括执行两种操作之一:我们要么收缩每个对象,要么增长它。让我们看看:

enum op_t { do_shrink, do_grow };
void process(std::vector<Shape>& v, op_t op) {
  for (Shape& s : v) {
    if (op == do_shrink) s.shrink();
    else s.grow();
  }
}

总的来说,我们有一个函数,其行为由一个或多个配置变量在运行时控制。通常,这些变量是布尔值(为了可读性,我们选择了一个enum)。我们已经看到,如果配置参数op是通过引用传递的,编译器必须在循环内保留比较,并为每个形状评估它。即使参数是按值传递的,许多编译器也不会将分支提升出循环:它需要复制循环体(一个用于收缩和一个用于增长),编译器对膨胀代码过多持谨慎态度。

这个问题应该被认真对待:一个更大的可执行文件加载时间更长,而更多的代码会增加指令缓存(i-cache)的压力(i-cache 用于缓存即将被 CPU 使用的指令,就像数据缓存缓存即将被 CPU 使用的数据一样)。然而,在某些情况下,这种优化仍然是正确的选择:通常情况下,您知道很多数据在不改变配置变量的情况下被处理。也许这些变量甚至对整个程序的运行都是常量(您加载配置一次并使用它)。

将分支移出循环对于我们简单的例子来说很容易重写,但如果代码很复杂,重构也是复杂的。如果我们愿意给予编译器帮助,我们可以从编译器那里得到一些帮助。这个想法是将运行时值转换为编译时值:

template <op_t op>
void process(std::vector<Shape>& v) {
  for (Shape& s : v) {
    if (op == do_shrink) s.shrink();
    else s.grow();
  }
}
void process(std::vector<Shape>& v, op_t op) {
  if (op == do_shrink) process<do_shrink>(v);
  else process<do_grow>(v);
}

整个(可能很大)的旧函数process()被转换为一个模板,但除此之外,没有其他改变。具体来说,我们没有将分支移出循环。然而,控制分支的条件现在是一个编译时常量(模板参数)。编译器将消除每个模板实例化中的分支和相应的死代码。在我们程序的其余部分,配置变量仍然是一个运行时值,只是很少(或根本不)改变。因此,我们仍然需要运行时测试,但它只用于决定调用哪个模板实例化。

这种方法可以泛化。想象一下,我们需要为每个形状计算一些属性,比如体积、尺寸、重量等等。这一切都是由一个单一的函数完成的,因为很多计算在不同的属性之间是共享的。但是计算我们不需要的属性会花费时间,所以我们可以实现一个像这样的函数:

void measure(const std::vector<Shape>& s,
  double* length, double* width, double* depth,
  double* volume, double* weight);

空指针是有效的,并且表示我们不需要该结果。在函数内部,我们为请求的值的特定组合编写了最佳代码:我们只进行一次常见的计算,并且不计算任何我们不需要的东西。然而,这个检查是在形状循环内部进行的,这次是一个相当复杂的条件集。如果我们需要处理大量形状以获取相同的测量集合,那么将条件提升出循环是有意义的,但即使编译器可以做到这一点,它也不太可能这样做。同样,我们可以编写一个具有许多非类型参数的模板:它们将是布尔值,比如need_lengthneed_width等等。在该模板内部,编译器将消除所有对于特定测量组合从未执行的分支,因为现在这是编译时信息。在运行时调用的函数必须根据哪些指针是非空来将调用转发到正确的模板实例化。其中一个最有效的实现是查找表:

template <bool use_length, bool use_width, …>
void measure(const std::vector<Shape>& v,
         double* length, … );
void measure(const std::vector<Shape>& v,
         double* length, … ) {
  const int key = ((length != nullptr) << 0) |
                  ((width  != nullptr) << 1) |
                  ((depth  != nullptr) << 2) |
                  ((volume != nullptr) << 3) |
                  ((weight != nullptr) << 4);
  switch (key) {
    case 0x01: measure<true , false, … >(v, length, … );
               break;
    case 0x02: measure<false, true , … >(v, length, … );
               break;
    …
    default:; // Programming error, assert
 }
}

这将生成大量的代码:每个测量的变体都是一个新的函数。这种重大转变的影响应该始终通过性能分析来验证。然而,在测量相对简单的情况下(比如,许多形状都是立方体),并且对许多(数百万)形状请求相同的测量集合时,这种改变可以带来显著的性能提升。

在使用特定编译器时,了解其功能和优化是很重要的。这种细节超出了本书的范围,而且这是易变的知识——编译器发展迅速。相反,本章为理解编译器优化奠定了基础,并为您读者提供了进一步理解的参考框架。让我们回顾一下我们学到的主要要点。

总结

在本章中,我们探讨了 C++效率的主要领域之一:帮助编译器生成更高效的代码。

本书的目标是让你了解代码、计算机和编译器之间的交互,以便你能够凭借良好的判断力和扎实的理解做出这些决定。

帮助编译器优化你的代码最简单的方法是遵循有效优化的一般经验法则,其中许多也是良好设计的规则:最小化代码不同部分之间的接口和交互,将代码组织成块、函数和模块,每个模块都有简单的逻辑和明确定义的接口边界,避免全局变量和其他隐藏的交互等。这些也是最佳设计实践并非巧合:通常,对程序员易读的代码也易于编译器分析。

更高级的优化通常需要检查编译器生成的代码。如果你注意到编译器没有进行某些优化,考虑一下是否存在某种情况下该优化是无效的:不要考虑你的程序中发生了什么,而是考虑在给定的代码片段中可能发生了什么(例如,你可能知道你从不使用全局变量,但编译器必须假设你可能会使用)。

在下一章中,我们将探讨 C++(以及软件设计一般)中一个非常微妙的领域,它可能与性能研究产生意想不到的重叠。

问题

  1. 是什么限制了编译器的优化?

  2. 为什么函数内联对编译器优化如此重要?

  3. 为什么编译器不进行显而易见的优化?

  4. 为什么内联是一种有效的优化?

第十一章:未定义行为和性能

本章有双重重点。一方面,它解释了程序员在试图从他们的代码中挤取最佳性能时经常忽视的未定义行为的危险。另一方面,它解释了如何利用未定义行为来提高性能,以及如何正确地指定和记录这种情况。总的来说,与通常的“任何事都可能发生”相比,本章提供了一种更为不寻常但更相关的理解未定义行为的方式。

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

  • 理解未定义行为及其存在的原因

  • 理解未定义行为的真相与神话

  • 哪些未定义行为是危险的,必须避免

  • 如何利用未定义行为

  • 学习未定义行为与效率之间的联系以及如何利用它

您将学会在(别人的)代码中遇到未定义行为时如何识别它,并了解未定义行为与性能的关系。本章还教会您如何通过有意允许未定义行为、记录它并在其周围设置保障措施来利用未定义行为。

技术要求

与以前一样,您将需要一个 C++编译器。在本章中,我们使用 GCC 和 Clang,但任何现代编译器都可以。本章附带的代码可以在github.com/PacktPublishing/The-Art-of-Writing-Efficient-Programs/tree/master/Chapter11找到。您还需要一种方法来检查编译器生成的汇编代码。许多开发环境都有显示汇编代码的选项,GCC 和 Clang 可以将汇编代码写出而不是目标代码,调试器和其他工具可以从目标代码生成汇编代码(反汇编);您可以根据个人喜好选择使用哪种工具。

什么是未定义行为?

comp.std.c的概念警告说,“当编译器遇到(未定义的结构)时,它可以合法地让恶魔从你的鼻子里飞出来。”在类似的情境中,还提到了发射核导弹和阉割你的猫(即使你没有猫)。本章的一个旁枝目标是揭开 UB 的神秘面纱:虽然最终目标是解释 UB 与性能之间的关系,并展示如何利用 UB,但在我们能理性地讨论这个概念之前,我们无法做到这一点。

首先,在 C++(或任何其他编程语言)的上下文中,什么是 UB?标准中有特定的地方使用了“行为未定义”或“程序不合法”的词语。标准进一步指出,如果行为未定义,标准对结果“不做要求”。相应的情况被称为 UB。例如,请参考以下代码:

int f(int k) {
  return k + 10;
}

标准规定,如果加法导致整数溢出(即,如果k大于INT_MAX-10),则上述代码的结果是未定义的。

当提到 UB 时,讨论往往会朝着两个极端之一发展。我们刚刚看到的第一个。夸大的语言可能是出于对 UB 危险的警告,但它也是对理性解释的障碍。你的鼻子对于编译器的愤怒是相当安全的,你的猫也是如此。编译器最终会从你的程序生成一些代码,你将运行这些代码。它不会给你的计算机带来任何超能力:这个程序做的任何事情,你都可以有意地完成,例如,通过在汇编语言中手动编写相同的指令序列。如果你没有办法执行导致发射核导弹的机器指令,你的编译器也无法做到这一点,无论有没有 UB(当然,如果你正在编写导弹发射控制器,那就是完全不同的游戏了)。最重要的是,当你的程序行为是未定义的时候,根据标准,编译器可以生成你意料不到的代码,但这些代码不能做任何你已经做过的事情。

虽然夸大 UB 的危险是没有帮助的,但另一方面,有一种倾向于推理UB,这也是一种不幸的做法。例如,考虑这段代码:

int k = 3;
k = k++ + k;

虽然 C++标准逐渐收紧了执行这种表达式的规则,但在 C++17 中,这个特定表达式的结果仍然是未定义的。许多程序员低估了这种情况的危险。他们说,“编译器要么首先评估 k++,要么首先评估 k + k。”为了解释为什么这是错误和危险的,我们首先必须在标准中分清一些细微之处。

C++标准有三个相关的并经常混淆的行为类别:k++ + k必须发生(这将是未指定的行为,这不是标准所说的)。标准规定整个程序是不良构造的,并且对其结果没有任何限制(但在你惊慌和担心你的鼻子之前,记住结果被限制为一些可执行代码)。

反驳常常是这样的,即使编译器在编译具有未定义行为的代码时会做些什么,它仍然必须以标准规定的方式处理代码的其余部分,所以(论点是)损害仅限于该特定行的可能结果之一。就像重视危险一样重要,理解为什么这个论点是错误的也很重要。编译器是在程序被定义良好的假设下编写的,并且在这种情况下只有在这种情况下才需要产生正确的结果。没有预设如果假设被违反会发生什么。描述这种情况的一种方式是说编译器不需要容忍未定义行为。让我们回到我们的第一个例子:

int f(int k) {
  return k + 10;
}

由于程序对于足够大的k来导致整数溢出是不明确的,编译器允许假设这永远不会发生。如果发生了呢?如果你单独编译这个函数(在一个单独的编译单元中),编译器将生成一些代码,为所有k <= INT_MAX-10产生正确的结果。如果你的编译器和链接器没有整个程序的转换,相同的代码将可能对更大的k执行,并且结果将是在这种情况下你的硬件所做的任何事情。编译器可以插入对k的检查,但它可能不会这样做(尽管有一些编译器选项,它可能会这样做)。

如果函数是较大编译单元的一部分呢?这就是事情变得有趣的地方:编译器现在知道f()函数的输入参数是受限制的。这种知识可以用于优化。例如,参考以下代码:

int g(int k) {
  if (k > INT_MAX-5) cout << "Large k" << endl;
  return f(k);
}

如果f()函数的定义对编译器可见,编译器可以推断打印永远不会发生:如果k足够大,以至于程序打印,那么整个程序就是不合法的,标准不要求它打印任何东西。如果k的值在定义行为的范围内,程序将永远不会打印任何东西。无论哪种方式,不打印任何东西都是标准允许的结果。请注意,仅因为您的编译器目前不执行此优化,并不意味着它永远不会:这种类型的优化在较新的编译器中变得更加激进。

那么我们的第二个例子呢?表达式k++ + k的结果对于任何k的值都是未定义的。编译器能做什么?再次记住:编译器不需要容忍未定义行为。这个程序能保持良好定义的唯一方式是这行代码永远不被执行。编译器可以假设这是情况,并进行推理:包含这段代码的函数从未被调用,任何必要的条件都必须成立,等等,最终可能得出整个程序永远不会被执行的结论。

如果你认为真正的编译器不会做那种事情,我有一个惊喜给你:

int i = 1;
int main() {
   cout << "Before" << endl;
   while (i) {}
   cout << "After" << endl;
}

这个程序的自然期望是打印Before并永远挂起。使用 GCC(版本 9,优化 O3)编译时,它确实如此。使用 Clang(版本 13,也是 O3)编译时,它打印Before,然后打印After,然后立即终止而不会出现任何错误(它不崩溃,只是退出)。这两种结果都是有效的,因为遇到无限循环的程序的结果是未定义的(除非满足某些条件,这里都不适用)。

上面的例子非常有教育意义,可以帮助我们理解为什么我们会有未定义行为。在下一节中,我们将揭开面纱,解释未定义行为的原因。

为什么会有未定义行为?

从上一节中产生的明显问题是,为什么标准会有未定义行为?为什么它不为每种情况指定结果?一个稍微微妙的问题是,承认 C++被用于各种硬件,具有非常不同的属性,这是为什么标准不退而使用实现定义的行为,而不是将其留在未定义状态?

上一节的最后一个例子为我们提供了一个完美的演示工具,解释了为什么存在未定义行为。说法是无限循环是未定义的;另一种说法是标准不要求进入无限循环的程序产生特定的结果(标准比这更微妙,某些形式的无限循环会导致程序挂起,但这些细节目前并不重要)。要理解为什么规则存在,考虑以下代码:

size_t n1 = 0, n2 = 0;
void f(size_t n) {
  for (size_t j = 0; j != n; j += 2) ++n1; 
  for (size_t j = 0; j != n; j += 2) ++n2;
}

这两个循环是相同的,所以我们要支付两次循环的开销(循环变量的增量和比较)。编译器显然应该通过将循环折叠在一起来进行以下优化:

void f(size_t n) {
  for (size_t j = 0; j != n; j += 2) ++n1, ++n2;
}

但是,请注意,此转换仅在第一个循环终止时才有效;否则,n2的计数根本不应该被递增。在编译期间不可能知道循环是否终止 - 这取决于n的值。如果n是奇数,则循环将永远运行(与有符号整数溢出不同,递增无符号类型size_t超过其最大值是良定义的,并且该值将回滚到零)。通常情况下,编译器无法证明特定循环最终会终止(这是一个已知的 NP 完全问题)。决定假设每个循环最终都会终止,并允许否则无效的优化。因为这些优化可能使具有无限循环的程序无效,这样的循环被视为 UB,这意味着编译器不必保留具有无限循环的程序的行为。

为了避免过分简化问题,我们必须提到,并非 C++标准中定义的所有 UB 类型背后都有类似的推理。一些 UB 是因为语言必须在不同类型的硬件上得到支持,其中一些情况今天可以被认为是过时的。由于这是一本关于性能的书,我们将重点关注存在于效率原因或可用于改进某些优化的 UB 示例。

在接下来的部分中,我们将看到更多关于编译器如何利用 UB 来实现优化的示例。

未定义行为和 C++优化

在前一节中,我们刚刚看到一个例子,通过假设程序中的每个循环最终都会终止,编译器能够优化某些循环和包含这些循环的代码。优化器使用的基本逻辑始终相同:首先,我们假设程序不会出现 UB。然后,我们推断出必须满足的条件,以使这一假设成立,并假设这些条件确实总是成立。最后,任何在这些假设下有效的优化都可以进行。优化器生成的代码在违反这些假设时会执行某些操作,但我们无法知道它将执行什么操作(除了已经提到的限制,即仍然是同一台计算机执行某些指令的情况)。

标准中记录的几乎每种 UB 情况都可以转化为可能优化的示例(特定编译器是否利用这一点是另一回事)。我们现在将看到更多示例。

正如我们已经提到的,有符号整数溢出的结果是未定义的。编译器可以假设这种情况永远不会发生,并且通过正数递增有符号整数总是会得到更大的整数。编译器实际上执行了这种优化吗?让我们来看看。比较这两个函数,f()g()

bool f(int i) { return i + 1 > i; }
bool g(int i) { return true; }

在良定义的行为范围内,这些函数是相同的。我们可以尝试对它们进行基准测试,以确定编译器是否优化了f()中的整个表达式,但是,正如我们在上一章中所看到的,有一种更可靠的方法。如果两个函数生成相同的机器代码,它们肯定是相同的。

图 11.1 - 由 GCC9 生成的 f()(左)和 g()(右)函数的 x86 汇编输出

图 11.1 - 由 GCC9 生成的 f()(左)和 g()(右)函数的 x86 汇编输出

图 11.1中,我们可以看到,打开优化后,GCC 确实为这两个函数生成了相同的代码(Clang 也是如此)。汇编中出现的函数名称是所谓的 mangled names:由于 C++允许具有不同参数列表的函数具有相同的名称,因此必须为每个这样的函数生成唯一的名称。它通过将所有参数的类型编码到实际在目标代码中使用的名称中来实现。

如果您想验证此代码确实没有任何?:运算符的痕迹,最简单的方法是将f()函数与使用无符号整数进行相同计算的函数进行比较。参考以下代码:

bool f(int i) { return i + 1 > i; }
bool h(unsigned int i) { return i + 1 > i; }

无符号整数的溢出是明确定义的,并且通常并非总是i + 1始终大于i

图 11.2 - 由 GCC9 生成的 f()(左)和 h()(右)函数的 X86 汇编输出

图 11.2 - 由 GCC9 生成的 f()(左)和 h()(右)函数的 X86 汇编输出

h()函数生成不同的代码,即使您不熟悉 X86 汇编,也可以猜到cmp指令进行比较。在左边,函数f()将常量值0x1的值加载到用于返回结果的寄存器 EAX 中。

这个例子也展示了试图推断未定义行为或将其视为实现定义的危险:如果你说程序将对整数进行某种加法,如果溢出,特定的硬件将执行它的操作,那么你将非常错误。编译器可能会生成根本没有递增指令的代码。

现在,我们终于有足够的知识来完全阐明这个谜团,这个谜团的种子从书的一开始就播下了,在第二章中,性能测量。在那一章中,我们观察到了同一函数的两个几乎相同的实现之间出乎意料的性能差异。该函数的工作是逐个字符比较两个字符串,并在第一个字符串在字典顺序上更大时返回true。这是我们最简洁的实现:

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

这个函数用于对字符串进行排序,因此基准测试测量了对特定输入字符串集进行排序所需的时间:

图 11.3 - 使用 compare1()函数进行字符串比较的排序基准

图 11.3 - 使用 compare1()函数进行字符串比较的排序基准

比较实现尽可能紧凑;在这段代码中没有多余的东西。然而,令人惊讶的结果是,这是代码的性能最差的版本之一。性能最佳的版本几乎相同:

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

唯一的区别是循环变量的类型:compare1()中是unsigned int,而compare2()中是int。由于索引永远不会是负数,这应该没有任何区别,但实际上有:

图 11.4 - 使用 compare2()函数进行字符串比较的排序基准

图 11.4 - 使用 compare2()函数进行字符串比较的排序基准

这种显著的性能差异的原因再次与未定义行为有关。要理解发生了什么,我们将不得不再次检查汇编代码。图 11.5显示了 GCC 为两个函数生成的代码(只显示了最相关的部分,即字符串比较循环):

图 11.5 - 由 GCC 生成的 compare1()(左)和 compare2()(右)函数的 X86 汇编代码

图 11.5 - 由 GCC 生成的 compare1()(左)和 compare2()(右)函数的 X86 汇编代码

代码看起来非常相似,只有一个例外:在右边(compare2())可以看到add指令,用于将循环索引递增 1(编译器通过用一个循环变量替换两个循环变量来优化代码)。在左边,没有看起来像加法或递增的东西。相反,有lea指令,它代表加载和扩展地址,但在这里用于将索引变量递增 1(进行了相同的优化;只有一个循环变量)。

到目前为止,根据你学到的一切,你应该能够猜到编译器为什么必须生成不同的代码:尽管程序员期望索引永远不会溢出,但编译器通常不能做出这种假设。请注意,两个版本都使用 32 位整数,但代码是为 64 位机器生成的。如果 32 位有符号int溢出,结果是未定义的,所以在这种情况下,编译器确实假设溢出永远不会发生。如果操作没有溢出,add指令会产生正确的结果。对于unsigned int,编译器必须考虑溢出的可能性:递增UINT_MAX应该得到 0。结果表明,x86-64 上的add指令没有这些语义。相反,它扩展结果成为 64 位整数。在 X86 上进行 32 位无符号整数算术的最佳选项是lea指令;它可以完成任务,但速度要慢得多。

这个例子演示了通过从程序是良好定义的假设和 UB 永远不会发生的假设逆向推理,编译器可以实现非常有效的优化,最终使整个排序操作的速度提高了数倍。

现在我们了解了我们的代码中发生了什么,我们可以解释代码的其他版本的行为。首先,使用 64 位整数,有符号或无符号,将给我们与 32 位有符号整数相同的快速性能:在所有情况下,编译器都将使用add(对于 64 位无符号值,它确实具有正确的溢出语义)。其次,如果使用最大索引或字符串长度,编译器将推断索引不会溢出:

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

与长度的不必要比较使得这个版本比最佳变体稍慢。避免意外遇到这个问题的最可靠方法是始终使用有符号的循环变量或使用硬件本机的无符号整数(因此,除非确实需要,避免在 64 位处理器上进行unsigned int数学运算)。

我们可以使用标准中描述的几乎任何其他未定义行为的情况来构造类似的演示(尽管不能保证特定的编译器会利用可能的优化)。这里是另一个使用指针解引用的例子:

int f(int* p) {
    ++(*p);
    return p ? *p : 0; // Optimized to: return *p
}

这是一个相当常见的情况的简化,程序员已经编写了指针检查来防止空指针,但并非在所有地方都这样做。如果输入参数是空指针,第二行(递增)就是 UB。这意味着整个程序的行为是未定义的,因此编译器可以假设它永远不会发生。对汇编代码的检查显示,的确,第三行的比较被消除了:

图 11.6 - 生成带有(左)和不带有(右)?:运算符的 f()函数的 X86 汇编

图 11.6 - 生成带有(左)和不带有(右)?:运算符的 f()函数的 X86 汇编

如果我们首先进行指针检查,情况也是一样的:

int f(int* p) {
    if (p) ++(*p);
    return *p;
}

再次,对汇编代码的检查将显示指针比较被消除了,尽管到目前为止程序的行为是良好定义的。推理是相同的:如果指针p不是空的,比较是多余的,可以省略。如果p为空,程序的行为是未定义的,这意味着编译器可以做任何它想做的事情,它想要省略比较。最终结果是,无论p是否为空,比较都可以被消除。

在上一章中,当我们研究编译器优化时,我们花了大量时间分析了哪些优化是可能的,因为编译器可以证明它们是安全的。我们将重新讨论这个问题,因为首先,这对于理解编译器优化是绝对必要的,其次,它与 UB 有关。我们刚刚看到,当编译器从特定语句(例如从return语句推断出p不为空)推断出一些信息时,这些知识不仅用于优化后续代码,还用于优化前面的代码。传播这种知识的限制来自编译器能够确定的其他内容。为了证明这一点,让我们稍微修改前面的例子:

extern void g();
int f(int* p) {
    if (p) g();
    return *p;
}

在这种情况下,编译器不会消除指针检查,这可以从生成的汇编代码中看出:

图 11.7 - 用于 f()函数的 X86 汇编代码(左)和不带指针检查的 X86 汇编代码(右)

图 11.7 - 用于 f()函数的 X86 汇编代码(左)和不带指针检查的 X86 汇编代码(右)

test指令对空(零)进行比较,然后是条件跳转 - 这就是汇编中if语句的样子。

为什么编译器没有优化掉这个检查?要回答这个问题,你必须弄清楚在什么条件下,这种优化会改变程序的良好定义行为。

使优化无效需要以下两个条件:

  • 首先,g()函数必须知道指针p是否为空。这是可能的:例如,p也可以由f()的调用者存储在全局变量中。

  • 其次,如果p为空,return语句就不应该被执行。这也是可能的:如果p为空,g()可能会抛出异常。

对于我们最后一个与 UB 密切相关的 C++优化示例,我们将看一些非常不同的东西:const关键字对优化的影响。同样,这将教会我们为什么编译器不能优化某些代码,就像成功的优化一样。我们将从我们之前看到的代码片段开始:

bool f(int x) { return x + 1 > x; }

优化编译器将会消除这个函数中的所有代码,并用return true替换它。现在我们将让函数做更多的工作:

void g(int y);
bool f(int x) {
  int y = x + 1;
  g(y);
  return y > x;
}

当然,同样的优化是可能的,因为代码可以重写如下:

void g(int y);
bool f(int x) {
  g(x + 1);
  return x + 1 > x;
}

调用g()必须进行,但函数仍然返回true:比较不能产生其他结果,否则会陷入未定义的行为。再次强调,大多数编译器都会进行这种优化。我们可以通过比较从原始代码生成的汇编和从完全手动优化的代码生成的汇编来确认这一点:

void g(int y);
bool f(int x) {
  g(x + 1);
  return true;
}

优化之所以可能是因为g()函数不会改变其参数。在同样的代码中,如果g()通过引用获取参数,那么这种优化就不再可能:

void g(int& y);
bool f(int x) {
  int y = x + 1;
  g(y);
  return y > x;
}

现在g()函数可能会改变y的值,因此每次都必须进行比较。如果函数g()的意图不是改变其参数,当然我们可以通过值传递参数(正如我们已经看到的)。另一个选择是通过const引用传递;虽然对于小类型(如整数)没有必要这样做,但模板代码通常会生成这样的函数。在这种情况下,我们的代码如下:

void g(const int& y);
bool f(int x) {
    int y = x + 1;
    g(y);
    return y > x;
}

快速检查汇编程序显示return语句没有被优化:它仍然进行比较。当然,某个特定编译器不执行某个优化并不能证明什么:没有优化器是完美的。但在这种情况下,是有原因的。尽管代码中这样写,但 C++标准并不保证g()函数不会改变其参数!以下是一个完全符合标准的实现,阐明了这个问题:

void g(const int& y) { ++const_cast<int&>(y); }
bool f(int x) {
    int y = x + 1;
    g(y);
    return y > x;
}

是的,函数允许去除const。结果是明确定义的,并且在标准中有规定(这并不意味着这是的代码,只是有效的)。然而,有一个例外:从在其创建时被声明为const的对象中去除const是未定义行为。为了说明,这是明确定义的(但不建议):

int x = 0;
const int& y = x;
const_cast<int&>(y) = 1;

这是未定义行为:

const int x = 0;
const int& y = x;
const_cast<int&>(y) = 1;

我们可以尝试利用这一点,通过将中间变量y声明为const

void g(const int& y);
bool f(int x) {
    const int y = x + 1;
    g(y);
    return y > x;
}

现在编译器可以假定该函数总是返回true:改变这一点的唯一方法是调用未定义行为,而编译器并不需要容忍未定义行为。在撰写本书时,我们并不知道有任何编译器实际上进行了这种优化。

有了这个想法,关于使用const来促进优化,可以推荐什么?

  • 如果一个值不会改变,将其声明为const。虽然正确性是主要的好处,但这确实可以实现一些优化,特别是当编译器可以通过在编译时评估表达式来传播const时。

  • 为了优化,如果值在编译时已知,声明为constexpr

  • 通过const引用传递参数对于优化几乎没有什么作用,因为编译器必须假设函数可能会去除const(如果函数是内联的,编译器知道发生了什么,但参数的声明方式就不重要了)。另一方面,这是您可以将const对象传递给函数的唯一方式,因此,尽可能声明引用为const(更重要的结果是意图的清晰度)。

  • 对于小类型,按值传递可能比按引用传递更有效(这不适用于内联函数)。这很难与模板生成的通用函数协调一致(不要假设模板总是内联的;大型模板函数通常不是)。有办法强制对特定类型进行按值传递,但这会使您的模板代码变得更加繁琐。不要从编写这样的代码开始;只有在测量表明对于特定代码片段来说,这种努力是合理的时候才这样做。

我们已经详细探讨了 C++中未定义行为如何影响 C++代码的优化。现在是时候扭转局面,学习如何利用未定义行为来优化您自己的程序。

利用未定义行为进行高效设计

在本节中,我们将讨论未定义行为,不是作为标准规定并适用于 C++,而是作为您,程序员,规定并适用于您的软件。为了达到这个目的,首先从不同的角度考虑未定义行为是有帮助的。

到目前为止,我们所见过的所有未定义行为示例可以分为两种。第一种是诸如++k + k之类的代码。这些是错误,因为这样的代码根本没有定义的行为。第二种是诸如k + 1之类的代码,其中k是有符号整数。这种代码随处可见,大多数情况下都能正常工作。它的行为是明确定义的,除了某些变量值。

换句话说,代码具有隐含的前提条件:只要这些前提条件得到满足,程序就会表现良好。请注意,在程序的更大上下文中,这些前提条件可能是隐含的,也可能不是:程序可能会验证输入或中间结果,并防范会导致未定义行为的值。无论哪种方式,程序员都与用户定义了一个合同:如果输入遵守某些限制,结果就保证是正确的;换句话说,程序的行为是明确定义的。

当违反限制时会发生什么?

有以下两种可能性:

  • 首先,程序可能会检测到输入违反了合同并处理错误。这种行为仍然是明确定义的,并且是规范的一部分。

  • 其次,程序可能无法检测到合同被违反,并像通常一样继续进行。由于合同对于保证正确结果至关重要,程序现在在未知领域操作,通常情况下无法预测会发生什么。

我们刚刚描述了 UB。

现在我们明白了 UB 只是程序在规定合同之外运行的行为,让我们想想它如何适用于我们的软件。

大多数足够复杂的程序都对其输入有前提条件,与用户有合同。有人可能会认为这些前提条件应该始终被检查并报告任何错误。然而,这可能是一个非常昂贵的要求。再次,让我们考虑一个例子。

我们想编写一个程序,扫描在纸上绘制的图像(或蚀刻在印刷电路板上),并将其转换为图形数据结构。程序的输入可能如下所示:

图 11.8 - 图形绘制是图形构建程序的输入

图 11.8 - 图形绘制是图形构建程序的输入

该程序获取图像,识别矩形,从每个矩形创建图节点,识别线条,对于每条线条找出它连接的两个矩形,并在图中创建相应的边。

假设我们有一个图像获取和分析库,可以为我们提供一组形状(矩形和线条)及其所有坐标。现在我们所要做的就是弄清楚哪些线连接哪些矩形。我们已经有了所有坐标,所以从现在开始就是纯几何。表示这个图形的最简单方法之一是作为边的表格。我们可以使用任何容器(比如说,一个向量)来存储表格,如果我们为每个节点分配一个唯一的数字 ID,那么一条边就是一对数字。我们可以使用任意数量的计算几何算法来检测线条和矩形之间的交点,并构建这个表格(以及图形本身)一条边一条边地。

听起来足够简单,我们有一个自然的数据表示,相当紧凑且易于处理。不幸的是,我们还与用户有一个隐含的合同:我们要求每条线都恰好与两个矩形相交(还有,矩形之间不相交,但一次只处理一个混乱)。

图 11.9 - 图形识别程序的无效输入

图 11.9 - 图形识别程序的无效输入

图 11.9中,我们看到了一个违反合同的输入示例:一条线连接了三个矩形,而另一条线只接触了一个。正如我们之前讨论的,我们有两个选择:我们可以检测并报告输入错误,或者我们可以忽略它们。第一种选择使我们的程序更加健壮,但带来了显著的性能损失:我们的原始程序在找到第二个连接到给定边的矩形后可能会停止寻找,并且从那时起忽略该边。这种优化的收益是相当可观的:对于一个看起来像图 11.8的图形(但规模更大),它可能将运行时间减少一半。强制执行输入验证会浪费大量时间,如果输入最终是正确的,会让用户感到沮丧,因为他们有其他方法来确保输入是有效的。不验证输入会导致 UB:如果我们有一条线连接了三个矩形,算法将在找到前两个矩形后停止(并且这个顺序可能依赖于数据,所以你真正能说的是,边将在涉及的两个节点之间创建)。

如果性能差异微不足道(或者总运行时间如此之短,使其加倍无关紧要),最佳解决方案将是明显的:验证输入。但在这种情况下以及许多其他情况下,验证很容易与找到解决方案一样昂贵。在这种情况下应该怎么办?

首先,我们必须明确用户所承担的契约。我们应该清楚地指定和记录什么构成有效输入。之后,对于性能关键的程序,最佳实践是提供最佳性能。更广泛的契约(对限制较少的那种)总是比较窄的契约更好,因此,如果有一些无效输入,我们可以轻松检测并以最小的开销处理,那就应该这样做。除此之外,我们所能做的就是记录程序行为未定义的条件,就像 C++标准中所做的那样。

我们可以做一些额外的努力:我们可以为用户提供一个输入验证工具,可以作为程序中的可选步骤或作为一个独立的软件。运行它会花费时间,但如果用户从主程序中获得奇怪的结果,他们可以检查确保输入是有效的。这比简单地描述行为何时未定义要好得多(然而,有些情况下,这种验证成本太高而不切实际)。

C++编译器开发人员是否可以为我们程序员做出同样的额外努力,并为我们提供一个可选工具来检测代码中的 UB,这不是很好吗?事实证明,开发人员也这样认为:如今许多编译器都有启用 UB sanitizer(通常称为UBSan)的选项。它的工作原理如下。让我们从一些可能导致 UB 的代码开始:

int g(int k) {
    return k + 10;
}

编写一个调用此函数的程序,参数足够大(大于INT_MAX-10),并启用 UBSan 编译。对于 Clang 或 GCC,选项是-fsanitize=undefined。以下是一个例子:

clang++ --std=c++17 –O3 –fsanitize=undefined ub.C

运行程序,你会看到类似以下的内容:

ub.C:10:20: runtime error: signed integer overflow: 
        2147483645 + 10 cannot be represented in type 'int'

就像我们的图表示例一样,UB 检测需要时间并使程序变慢,因此这是你在测试和调试中应该做的事情。将经过消毒处理的运行作为常规回归测试的一部分,并且要认真对待报告的错误:仅仅因为你的程序今天产生了正确的结果,并不意味着下一个编译器不会生成一些非常不同的代码并改变结果。

我们已经了解了 UB,为什么它有时是一个必要的恶,并且如何利用它来提高性能。在翻页之前,让我们回顾一下我们学到的内容。

总结

我们有一个专门讨论 C++中 UB 的整章,为什么呢?因为这个主题与性能密切相关。

首先,要理解当程序接收到超出规定程序行为的契约的输入时,就会发生 UB。此外,规范还表示程序不需要检测此类输入并发出诊断。这适用于 C++标准定义的 UB 以及您自己程序中的 UB。

接下来,规范(或标准)未涵盖所有可能的输入并定义结果的原因主要与性能有关:当需要可靠地产生特定结果时,引入 UB 通常会非常昂贵。对于 C++中的 UB,处理器和内存架构的多样性也导致了难以统一处理的情况。在没有可行的方法来保证特定结果的情况下,标准将结果留空。

最后,程序不需要至少检测,如果不处理无效输入的原因是,这样的检测可能也非常昂贵:有时确认输入有效比计算结果花费的时间更长。

在设计软件时,你应该牢记这些考虑因素:始终希望有一个广泛的合同,为任何或几乎任何输入定义结果。但这样做可能会给只提供典型或“正常”输入的用户带来性能开销。当用户面临更快地执行所需任务和可靠地执行用户根本不想解决的任务之间的选择时,大多数用户会选择性能。作为一种妥协,你可以为用户提供一种验证输入的方式;如果这种验证是昂贵的,它应该是可选的。

当涉及到 C++标准规定的 UB 时,情况就变了,你成了用户。重要的是要理解,如果程序包含具有 UB 的代码,整个程序就是不明确定义的,不仅仅是问题中的一行代码。这是因为编译器可以假设在运行时永远不会发生 UB,并从此进行推理,以对代码进行相应的优化。现代编译器在某种程度上都这样做,未来的编译器只会更加积极地进行推理。

最后,许多编译器开发者还提供可以在运行时检测未定义行为的验证工具 - UB 消毒剂。就像你自己程序的输入验证器一样,这些工具需要时间运行,这就是为什么消毒剂是一个可选工具。你应该在软件测试和开发过程中利用它。

我们几乎到了书的结尾;在下一章,也就是最后一章中,我们将以考虑设计软件的含义和教训的眼光回顾我们所学到的一切。

问题

  1. 什么是未定义行为?

  2. 为什么我们不能为程序可能遇到的任何情况定义结果?

  3. 如果我编写了标准标记为 UB 的代码,测试结果,并验证代码有效,我没问题,对吗?

  4. 为什么我要故意设计一个具有记录的未定义行为的程序?