5.3. 手动资源管理的风险

C++ 允许在硬件级别上管理资源,这对每种语言都是成立的,即使是那些不向开发者暴露这些细节的高级语言也是如此。但“某个地方”不一定非得是你的程序!在学习 C++ 中用于资源管理的解决方案和工具之前,先了解一下不使用此类工具所引发的问题。

5.3.1 手动资源管理容易出错

手动管理每个资源时,通过显式调用来获取和释放每个资源,第一个也是最明显的危险就是容易忘记执行后者(释放操作)。例如,请看以下代码:

{
  object_counter* p = new object_counter;
  ... 后续很多行代码 ...
  // 我们在这里需要做些什么吗?
  // 现在已经记不清了...
}

我们现在正在泄漏一个资源(在本例中是 TrackedResource 对象)。如果在单元测试中这样做,测试将会失败:

// Example 04
TEST(Memory, Leak1) {
  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);
}

可以看到单元测试框架报告的失败测试,及其失败位置:

[ RUN      ] Memory.Leak1
04_memory.C:31: Failure
Expected equality of these values:
  0
  object_counter::count
    Which is: 1
[  FAILED  ] Memory.Leak1 (0 ms)

真实程序中,发现此类错误要困难得多。内存调试器和检测工具可以帮助查找内存泄漏,但它们要求程序实际执行到有缺陷的代码,其效果依赖于测试覆盖率。

资源泄漏也可能更加隐蔽且难以发现。考虑以下代码,我们并没有忘记释放资源:

bool process(... 一些参数 ... ) {
  object_counter* p = new object_counter;

  ... 后续很多行代码 ...

  delete p; // 啊哈,我们记得要释放了!
  return true; // 成功
}

后续维护中,发现了一个可能的失败情况,并添加了相应的测试:

bool process(... 一些参数 ... ) {
  object_counter* p = new object_counter;

  ... 后续很多行代码 ...

  if (!success) return false; // 失败,无法继续

  ... 后续很多行代码 ...

  delete p; // 还在这里释放
  return true; // 成功
}

这个改动引入了一个微妙的 bug —— 只有当中间计算失败并触发提前返回时,资源才会泄漏。如果这种失败情况足够罕见,这个错误可能逃过所有测试,即使测试流程中定期运行内存检测工具也难以发现。这个错误也特别容易发生,修改可能发生在距离对象构造和删除代码很远的地方,而当前上下文中没有线索能提示开发者需要释放资源。

这种情况下,避免资源泄漏的替代方法是释放资源。这会导致一些代码重复:

bool process(... 一些参数 ... ) {
  object_counter* p = new object_counter;

  ... 后续很多行代码 ...

  if (!success) {
    delete p;
    return false; // 失败,无法继续
  }

  ... 后续很多行代码 ...

  delete p; // 还在这里释放
  return true; // 成功
}

与代码重复一样,随之而来的是代码发散的危险。假设下一轮代码增强需要不止一个 object_counter 对象,现在以数组形式分配:

bool process(... 一些参数 ... ) {
  object_counter* p = new object_counter[10]; // 现在使用数组

  ... 后续很多行代码 ...

  if (!success) {
    delete p;
    return false; // 但标量删除
  }

  ... 后续很多行代码 ...

  delete [] p; // 匹配数组删除
  return true; // 成功
}

如果将 new 改为 new[] 来分配数组,也必须相应地将 delete 改为 delete[],并且函数末尾可能只有一个释放点。但谁会想到中间还有一个释放点呢?即使开发者没有忘记资源的存在,随着程序变得复杂,手动资源管理出错的可能性也会不成比例地增加。

而且,并非所有资源都像计数器对象那样宽容。考虑以下执行并发计算的代码,必须获取和释放互斥锁。请注意“获取”(acquire)和“释放”(release)这两个术语,这正是锁的常用说法,所以锁是一种资源(这里的资源是锁保护的数据的独占访问权):

std::mutex m1, m2, m3;
bool process_concurrently(... 一些参数 ... ) {
  m1.lock();
  m2.lock();

  ... 此部分需要同时持有两个锁 ...

  if (!success) {
    m1.unlock();
    m2.unlock();
    return false;
  } // 两个锁都已释放

  ... 更多代码 ...

  m2.unlock(); // 不再需要访问由 m2 保护的数据
               // 但仍需要持有 m1

  m3.lock();
  if (!success) {
    m1.unlock();
    return false;
  } // 此处无需解锁 m2

  ... 更多代码 ...

  m1.unlock();
  m3.unlock();
  return true;
}

这段代码既有重复,也有发散。还有一个 bug —— 能找出来吗?(提示:数一数 m3 解锁的次数,与它锁定之后的 return 语句数量进行比较)。

5.3.2 资源管理与异常安全

还记得上一节开头我们说过的那段“正确”的代码吗 —— 就是我们没有忘记释放资源的那段?请考虑以下代码:

bool process(... 一些参数 ... ) {
  object_counter* p = new object_counter;

  ... 后续很多行代码 ...

  delete p;
  return true; // 成功
}

我有一个坏消息要告诉你 —— 这段代码很可能也不正确。如果后面那许多行代码中的一行抛出了异常,那么 delete p 将永远不会被执行:

bool process(... 一些参数 ... ) {
  object_counter* p = new object_counter;

  ... 后续很多行代码 ...

  if (!success) // 无法继续
    throw process_exception();

  ... 后续很多行代码 ...

  // 如果抛出异常,这行代码将不会执行!
  delete p;
  return true;
}

看起来与提前返回的问题非常相似,但更糟糕的是 —— 异常可能由 process() 函数调用的代码抛出。甚至,异常可能添加到 process() 函数所调用的某些代码中,而 process() 函数本身却没有改动。它过去运行良好,但有一天突然就不行了。

除非改变资源管理的方式,否则唯一的解决办法就是使用 try...catch 块:

bool process(... 一些参数 ... ) {
  object_counter* p = new object_counter;
  try {
    ... 后续很多行代码 ...

    if (!success) // 无法继续
    throw process_exception();

    ... 后续很多行代码 ...
  } catch ( ... ) {
    delete p; // 异常情况下的清理
  }
  delete p; // 正常情况下的清理
}

这里明显的问题再次是代码重复,以及 try...catch 块几乎无处不在地泛滥。更糟糕的是,如果需要管理多个资源,或者甚至只是管理,比单次获取对应单次释放更复杂的东西,这种方法就无法扩展:

std::mutex m;
bool process(...  一些参数 ... ) {
  m.lock(); // 临界区从此开始
  object_counter* p = new object_counter;
  // 问题 #1: 构造函数可能抛出异常
  try {
    ... 后续很多行代码 ...
    m.unlock(); // 临界区到此结束
    ... 后续很多行代码 ...
  } catch ( ... ) {
    delete p; // OK,总是需要清理 p
    m.unlock(); // 需要这句吗?也许需要...
    throw; // 重新抛出异常,由调用者处理
  }
  delete p; // 正常情况下的清理,无需再解锁互斥量
  return true;
}

现在,无法确定 catch 块是否应该释放互斥锁 —— 这取决于异常是在正常控制流中的 unlock() 操作之前还是之后抛出的。此外,TrackedResource 的构造函数本身也可能抛出异常(不是之前那种简单的构造函数,而是可能演变成更复杂的构造函数)。这种情况会发生在 try...catch 块之外,导致互斥锁永远不会解锁。

现在我们应该清楚,对于资源管理问题,需要一个完全不同的解决方案,而不是这种零散的补丁。下一节中,将讨论在 C++ 中资源管理的黄金模式。