C++ 允许在硬件级别上管理资源,这对每种语言都是成立的,即使是那些不向开发者暴露这些细节的高级语言也是如此。但“某个地方”不一定非得是你的程序!在学习 C++ 中用于资源管理的解决方案和工具之前,先了解一下不使用此类工具所引发的问题。
手动管理每个资源时,通过显式调用来获取和释放每个资源,第一个也是最明显的危险就是容易忘记执行后者(释放操作)。例如,请看以下代码:
{
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 语句数量进行比较)。
还记得上一节开头我们说过的那段“正确”的代码吗 —— 就是我们没有忘记释放资源的那段?请考虑以下代码:
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++ 中资源管理的黄金模式。