本章引入一个额外的机制scope_exit/scope_fail,以处理复杂场景下的cleanup与rollback,同时,对RAII语义进行一定语义上的修正,以提供一个更加友好的异常处理的心智模型。
前面介绍了Exception safety的概念,而实际上,在复杂场景下(一个函数分配了大量非内存资源、产生了大量的非资源分配的副作用),实现Exception safety并不总是那么简单。
一个实际的异常处理例子
考虑以下问题,假设我有一个函数,它由若干个步骤组成,每个步骤都会产生副作用,且每个步骤都可能失败,并抛出异常。
void foo()
{
doA();
doB();
doC();
doD();
}
上面几行代码很干净,但是你能判断它有bug吗?
假设doB失败了,我们实际上是需要回滚doA的,因为它产生了副作用,这个副作用可能是分配了一个对象,也可能是其他的,比如创建了一台EC2、订了一张机票。是的,我们需要回滚,于是,代码变成了这样:
void foo()
{
doA();
try {
doB();
} catch (...) {
rollbackA();
throw;
}
try {
doC();
} catch (...) {
rollbackB();
rollbackA();
throw;
}
try {
doD();
} catch (...) {
rollbackC();
rollbackB();
rollbackA();
throw;
}
}
更进一步的,假如这些rollbackX也会抛出异常,怎么办?代码怎么写?当然,这个更多的是设计问题。
- 设计一:清理函数绝对不对抛出异常。
- 对于简单内存资源,这个是成立的。
- 但是,如果我需要释放一台我刚刚买的EC2,但是AWS的API也异常了呢?
- 设计二:抛出异常的话,直接打日志然后忽略吧。我们的系统会保证最终一致。
- 如果是这样的话,我还处理干什么?直接依赖最终一致就好啦。
- 比如:doA是分配EC2,doD是将分配的EC2 id写入数据库,我们的系统后台会定时repair,当发现账号下某台EC2不在数据库中,并且已经持续1小时了,那就释放它。
- 设计三:抛出异常的话,就把抛出的异常向上抛。
- 啊。。。要不看看设计四
- 设计四:抛出异常的话,先记下,依然把所有的rollback函数都执行了,然后?
- rollbackC失败了,rollbackB/rollbackA还执行吗?
- 如果尽力把所有rollback都执行了话,假设三个rollback,抛出了两个异常,我上报哪个?还是直接上报root cause(doA/B/C/D失败所抛出的异常)?
对于设计一,用C++11可以这么写:
void foo()
{
doA();
// the construction should never throw
Scoped_guard defer1 = []{
rollbackA();
};
doB();
// the construction should never throw
Scoped_guard defer2 = []{
rollbackB();
};
doC();
// the construction should never throw
Scoped_guard defer3 = []{
rollbackC();
};
doD();
// critical line
defer1.release();
defer2.release();
defer3.release();
}
其中,Scoped_guard是一个RAII对象,析构时执行某个函数。调用release的话,析构时什么都不做。
C++17对这种情况下进行了优化:
void foo()
{
doA();
scope_fail defer1([]{
rollbackA();
});
doB();
// the construction should never throw
scope_fail defer2([]{
rollbackB();
});
doC();
// the construction should never throw
scope_fail defer3([]{
rollbackC();
});
doD();
}
scope_fail会在函数异常退出时执行,函数正常退出不会执行。
对于设计二也可以使用类似的方式执行,只需要在scope_fail中手动try/catch一下,并写条日志,确保rollbackX的异常不会继续向外扩散。
看起来也不那么差嘛。唯一的问题是,我依赖RAII来处理异常流,这与RAII是用来管理资源的本质有些冲突,并且,也继承了析构函数无法抛出异常的问题。这就意味着,如果我想使用设计三,异常+RAII无法优雅地完成,只能用最开始那种一个语句一个try/catch的做法(这种做法和错误码比就没啥区别了)。
如果你想选择设计四,em...取决于在rollback异常情况下,你到底想抛出哪个异常,设计四可以化归为设计二或者设计三。
额外提一句,如果rollback会失败的话,系统肯定需要一个后台repair线程去维护系统一致性,或者报警后人工介入。如果是repair线程来处理的话,这么写可能更加清晰些:
void foo()
try {
doA();
doB();
doC();
doD();
} catch (...) {
tryRepair();
throw;
}
啥,你还想更简单?
void foo()
{
scope_fail defer(tryRepair);
doA();
doB();
doC();
doD();
}
em...用RAII来充当控制流、异常流,真的不违合吗?
最后,给下错误码的写法,大家觉得哪种更容易读、更容易写、更容易review?
int foo()
{
int r = doA();
if (r) {
goto out;
}
r = doB();
if (r) {
goto rollback1;
}
r = doC();
if (r) {
goto rollback2;
}
r = doD();
if (r) {
goto rollback3;
}
goto out;
rollback3:
rollbackC();
rollback2:
rollbackB();
rollback1:
rollbackA();
out:
return r;
}
R(esponsibility) acquisition is initialization
将RAII中的R单纯理解为Resource对实现异常安全并不够,我们需要将R理解为Responsibility,即一个职责,我们不仅仅而一个资源,而是管理一个职责。
职责即在所有路径上都必须被调用的函数。
- 每个职责由一个对象管理
- 每个对象管理一个职责
- 一旦某个职责需要每调用,立即构造对象来管理它,不要手动管理职
典型的,cleanup与rollback,都是职责。如果看到手动调用cleanup函数或者rollback函数,就要小心了。
异常处理的心智模型
- 控制流 for 业务流(根据业务执行的具体情况,可能会进不同分支)
- 异常处理 for 异常流(中止业务流)
- RAII for 强制执行的流(无论业务流怎么跑,业务流是否被异常中止,都要执行的职责)
- 清理
- 清理资源 => scope_exit or 资源对象
- 清理做了一半的任务,i.e. rollback => scope_fail
- scope_success目前未看到明确的语义,不建议使用
- 其他
- 清理
总结
- 错误处理非常非常非常复杂,非常非常非常容易隐藏bug,一个系统的、清晰的错误处理机制与设计,对降低复杂度有着至关重要的意义。
- 对错误处理策略的设计很重要:
- rollback是否允许出错?
- rollback出错后怎么办?
- rollback出错后,是上报rollback的错误,还是继续上报之前的错误?
- 是否需要后台repair?
- 是否需要人工干预?
- C++异常的确可以写出更加干净的代码,但它的学习负担、心智负担,都非常高,慎用。
- 错误码保平安。