5.2. C++中的资源管理

每个程序都操作并需要管理资源,最常用的资源当然是内存。因此,经常会读到关于 C++ 内存管理的内容。实际上,资源可以是东西。许多程序的存在就是为了管理真实、有形的物理资源,或更短暂(但同样有价值)的数字资源。银行账户中的资金、飞机座位、汽车零部件和组装好的汽车,甚至是一箱箱的牛奶 —— 如今,只要是需要计数和追踪的东西,总会有某个软件正在处理它。但即使是在一个纯粹进行计算的程序中,也可能存在各种复杂资源,除非该程序也摒弃了抽象,直接在原始数字层面进行操作。例如,一个物理模拟程序可能会将粒子视为资源。

所有这些资源都有一个共同点 —— 都需要记录。它们不应无故消失,程序也不应凭空制造出不存在的资源。通常,需要特定的资源实例 —— 不希望别人的购买从你的银行账户扣款;资源的具体实例很重要。因此,在评估不同资源管理方法时,最重要的考虑因素是正确性 —— 设计在多大程度上确保了资源进行了正确的管理,犯错的容易程度,以及发现此类错误的难度?因此,本章在展示资源管理的代码示例时,使用一个测试框架也就不足为奇了。

5.2.1 安装微基准测试库

我们关注的是内存分配,以及可能包含此类分配的小段代码的效率。测量小段代码性能的合适工具是微基准测试(microbenchmark)。市面上有许多微基准测试库和工具;在本书中,将使用 Google Benchmark 库。要跟随本章的示例操作,必须先下载并安装该库(请遵循 Readme.md 文件中的说明)。然后就可以编译并运行这些示例了。可以构建库中包含的示例文件,以了解如何在特定系统上构建基准测试;也可以使用本章代码仓库中的示例基准测试:

// Example 01
#include <stdlib.h>
#include "benchmark/benchmark.h"

void BM_malloc(benchmark::State& state) {
  constexpr size_t size = 1024;
  for (auto _ : state) {
    void* p = malloc(size);
    benchmark::DoNotOptimize(p);
    free(p);
  }
  state.SetItemsProcessed(state.iterations());
}

BENCHMARK(BM_malloc);
BENCHMARK_MAIN();

请注意对 DoNotOptimize() 的调用:这是一个特殊函数,本身不生成代码,但会欺骗编译器,使其认为其参数是必需的,不能优化掉。如果没有这个调用,编译器很可能会发现整个基准测试循环没有可观察到的副作用,从而将其完全优化掉。

在 Linux 机器上,编译和运行一个名为 01_benchmark.cpp 的基准测试程序的命令可能如下所示:

$CXX 01_benchmark.C -I. -I$GBENCH_DIR/include –O3 \
  --std=c++17 $GBENCH_DIR/lib/libbenchmark.a -lpthread \
  -o 01_benchmark && ./01_benchmark

\$CXX 是你的 C++ 编译器,例如 g++ 或 clang++,而 \$GBENCH_DIR 是 benchmark 库的安装目录。

前面的示例应该会输出类似这样的内容:

在这台特定的机器上,单次迭代(一次 malloc() 和 free() 调用对)耗时 6.37 纳秒,相当于每秒 1.57 亿次内存分配。

有时需要对非常短的操作进行基准测试:

void BM_increment(benchmark::State& state) {
  size_t i = 0;
  for (auto _ : state) {
    ++i;
    benchmark::DoNotOptimize(i);
  }
  state.SetItemsProcessed(state.iterations());
}

我们可能会合理地担心基准测试循环本身的开销,所以可以在循环体内执行多次。甚至可以让 C++ 预处理器为我们生成这些副本:

// Example 02
#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)

void BM_increment32(benchmark::State& state) {
  size_t i = 0;
  for (auto _ : state) {
    REPEAT(
      ++i;
      benchmark::DoNotOptimize(i);
    )
  }
  state.SetItemsProcessed(32*state.iterations());
}

现在,“单次”迭代的时间包含了32次迭代,因此使用“每秒处理项数”的值会更加方便。记得将重复次数计入处理的项目总数中:

编写快速的程序固然很好,但程序首先必须是正确的。为此,需要编写测试,也需要一个测试框架。

5.2.2 安装 Google Test

我们将测试非常小段代码的正确性。一方面,这仅仅是因为每一段代码都说明了一个特定的概念或思想。另一方面,即使在大型软件系统中,资源管理也是由小段代码构成的构建块来完成的。它们可能组合形成一个相当复杂的资源管理器,但每个代码块都执行特定的功能并且可测试。适用于这种情况的测试系统是单元测试框架。有许多这样的框架可供选择;在本书中,将使用 Google Test 单元测试框架。要跟随本章的示例操作,必须先下载并安装该框架(请遵循 README 文件中的说明)。安装完成后,就可以编译并运行这些示例了。可以构建库中包含的示例测试,以了解如何在你的特定系统上构建和链接 Google Test;也可以使用本章代码仓库中的示例:

#include <vector>
#include "gtest/gtest.h"

TEST(Memory, Vector) {
  std::vector<int> v(10);
  EXPECT_EQ(10u, v.size());
  EXPECT_LE(10u, v.capacity());
}

在 Linux 机器上,编译和运行一个名为 02_test.cpp 的测试的命令可能如下所示:

$CXX 02_test.C -I. -I$GTEST_DIR/include -g -O0 -I. \
  -Wall -Wextra -Werror -pedantic --std=c++17 \
  $GTEST_DIR/lib/libgtest.a $GTEST_DIR/lib/libgtest_main.a\
  -lpthread -lrt -lm -o -2_test && ./02_test

这里,\$CXX 是 C++ 编译器,例如 g++ 或 clang++,而 \$GTEST_DIR 是 Google Test 的安装目录。如果所有测试都通过,应该会看到如下输出:

Running main() from gtest_main.cc
[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from Memory
[ RUN      ] Memory.Vector
[       OK ] Memory.Vector (0 ms)
[----------] 1 test from Memory (0 ms total)
[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (0 ms total)
[  PASSED  ] 1 test.

编写好的测试是一门艺术。必须识别出代码中需要验证的方面,并想出观察这些方面的办法。在本章中,关注的是资源管理,来看看如何测试资源的使用和释放。

5.2.3 资源计数

像 Google Test 这样的单元测试框架,允许执行一些代码并验证结果是否符合预期。可以检查的结果包括测试程序中能够访问到的变量或表达式。然而,这个定义并不涵盖,例如当前正在使用的内存量之类的信息。如果要验证资源没有丢失,就必须对它们进行计数。

下面这个简单的测试固件中,可以使用一个特殊的资源类,而不是像 int 这样的关键字。这个类植入了监控代码,用于统计此类对象的创建总数,以及当前还存活着的对象数量:

// Example 03
struct object_counter {
  static int count;
  static int all_count;

  object_counter() { ++count; ++all_count; }
  ~object_counter() { --count; }
};

int object_counter::count = 0;
int object_counter::all_count = 0;

现在可以测试程序是否正确地管理了资源:

// Example 03
#include "gtest/gtest.h"
TEST(Memory, NewDelete) {
  object_counter::all_count = object_counter::count = 0;

  object_counter* p = new object_counter;
  EXPECT_EQ(1, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);

  delete p;
  EXPECT_EQ(0, object_counter::count);
  EXPECT_EQ(1, object_counter::all_count);
}

在 Google Test 中,每个测试都作为一个测试固件来实现。有几种类型;最简单的一种是独立的测试函数,例如这里使用的这种。运行这个简单的测试程序会告诉我们测试已通过:

[----------] 1 test from Memory
[ RUN      ] Memory.NewDelete
[       OK ] Memory.NewDelete (0 ms)
[----------] 1 test from Memory (0 ms total)
[  PASSED  ] 1 test.

预期的结果是通过使用 EXPECT_* 宏之一来验证的,测试失败都会报告。此测试验证了在创建并删除 object_counter 的实例后,没有此类对象残留,并且恰好构造了一个对象。