Exception handling in depth

Exception handling in depth, part 1

在MCPD会上曾听一位Intel的朋友谈到他对C++ exception handling的看法。大致观点是:
[1] 使用exception handling会损失性能
[2] 应避免使用exception handling而用return error value的方法。不过,在模块边界处为了接住别人throw上来的exception,要使用exception handling。

当时,很多人都对他的观点表示反对。我也持反对观点:

[1] 性能问题。这位朋友拿出了VC6的数据说话,确实,用VC6编译,打开exception handling之后happy path的性能也受了很大影响(unhappy path是小概率事件,所以在绝大多数情况下其性能问题不值得讨论)。不过呢,VC6只是一种编译器的一个版本,还是比较老的版本,它的exception handling实现是基于SEH的,貌似调了几个API之后会进内核,确实比较慢。不过我们不能被VC6“一叶障目”。一般认为在Windows平台上最好的编译器是Intel C++(很奇怪这位朋友来自Intel,怎么不剖析自家的编译器,而要去剖析MS的一个古董编译器呢)。此外VC7,GCC和BCC也很不错的。

C++ exception handling的性能究竟如何,可以去看ISO/IEC TR 18015 “Technical Report on C++ performance”中的相关内容。我大致概括一下:exception-handling可以有2类实现,第1类实现是插入代码,性能跟其他error handling方式各有千秋,事实上这类的1个变种就是编译器自动地系统地给函数添加error return value并去处理这些return value,于是性能特征也一样,只不过避免了手写的麻烦和漏写引起的bug。第2类实现是查表,有一定的空间开销,但时间性能要优于其他error handling方式。在happy path上可以做到zero overhead。(与之相比,error return value在happy path上不是zero overhead的,因为哪怕在happy path上也要执行if (return value == ...)。虽然这个if语句本身占不了多少时间,但if语句是乱序执行/分支预测的大敌,error return value会带来无数的if,在现代CPU上这样带来的性能损失是非常严重的。

[2] 使用问题。我的观点和这位朋友正相反。我倾向使用exception。理由我在以前的blog( http://spaces.msn.com/members/wesleybao/Blog/cns!1p0i3yoUKRgnWt0UyAV1FMog!594.entry )里提过,用error return value的方法是妄图用1个执行维度去覆盖happy path和unhappy path这2个不同的执行维度,这就好比试图用一张平整的纸去包住一个球,必定是包得皱巴巴的,极不优雅。

软件设计的一个中心话题是耦合。我们应该尽可能追求松耦合,把不相干的东西都分开。而happy path和unhappy path这两个不相干的路径,在error return value或者其他方法中是紧耦合的,缠绕在一起,只有使用exception作为error handling机制才能把这两个路径解耦合。

举个简单的例子:a = (b+c)*(d-e)/f 这个简单的语句,其实只涵盖了happy path。事实上其中的每一步计算都可能会出错。比如除法可能会除0,加法和乘法可能会溢出,诸如此类。何况,若+,*,-,/都是重载的运算符,abcdef都不是数字而是自定义的对象,那么还可能会出现其他的错误。那么,在production code中(所谓production code,就是不允许忽略错误处理,与toy code相对),这个表达式该如何写呢?若用异常处理,很简单:

try {
a = (b+c)*(d-e)/f;
} catch (exception& e) {
//error handling here
}

若用error return value可就麻烦喽
tmp1 = b+c;
if (there is error) {
report error
} else {
tmp2 = d-e;
if (there is error) {
report error
} else {
tmp3 = tmp1*tmp2;
if (there is error) {
report error
} else {
a = tmp3/f
if (there is error) {
report error
} else {
//a is the result;
}
}
}
}

如果这个表达式更复杂一点,又会如何呢?

事实上,error return value(或者GetLastError, 或者error output parameter)都只能用于procedural programming。对于其他的paradigm,比如像LISP那样的functional programming,error return value根本无法使用。哪怕在procedural programming中,使用error return value也会导致所有代码中90%是错误传递与处理代码,只有10%是真正干活的代码。在目前发明出来的error handling机制中,恐怕只有exception handling才是真正scalable的error handling机制。

还是回到那位朋友的观点,退一步说,就算放着好好的Intel编译器和不要钱又高质量的GCC不用,偏要抱着VC6。那用就用呗,偏偏还不让用exception,给写代码的人造成了极大的麻烦并带来了一堆bug(看看上面那个error return value版本的a = (b+c)*(d-e)/f,只要不小心漏了一个if可就是一个潜在的bug)。可是既然要在边界处接住别人抛上来的异常(比如一些第3方库会抛异常),那么编译器的exception flag还必须打开,于是,性能损失丝毫没有避免,开发效率损失殆尽。岂不是所有苦头都吃到,却什么好处都没捞到?

有时候我挺纳闷的,为什么有些人那么勤快,有省事的办法不用,而偏喜欢自找麻烦呢?而且竟然还要求大家一起来自找麻烦,好像显得这样才不算偷懒似的。


我的观点是: [1] 用exception作为模块内部的error handling机制 [2] 模块之间用C API(return error value),但使用C API时要做一个type-safe的包装,并把error value转成exception。当然,等C++的ABI标准出来之后并且被编译器厂商实现之后,边界处也可以用exception了。[3]只把异常用于异常情况,正常执行路径中不出现异常处理代码。

[3]的意思需要解释一下。比如,有一个函数,bool FileExists(const char* file); 显然不管文件存不存在都是在正常执行路径上。这个函数的用途就是检查文件是否存在,换句话说,从DbC的角度,这个函数没有“文件存在”的precondition,那么若文件不存在,也显然不应该throw exception。假定这个函数内部操作需要分配内存,内存分配失败了,那么显然就应该throw exception了。处理逻辑不应该在正常执行路径中的都应该用exception来解耦合。

下次有时间再说说怎么让C++的exception机制实现Java或者C#那样的printStackTrace()方法。

Exception handling in depth, part 2

SEH与C++ exception

SEH是Windows的错误处理机制,用于处理Access Violation之类的致命错误。一般来说,如果你的程序会抛出结构化异常,那多半是程序中的bug。所以,在产品release之前,程序都不应该去接结构化异常,该crash时就crash,以便让bug尽早暴露。

而C++ exception则被设计用于程序本身的错误处理,它只能接住程序中通过throw关键字抛出来的异常。对Access Violation,Division by Zero之类的问题无能为力。

从概念上来说,SEH和C++ exception是很不相同的两码事,各自有不同的适用情形。不过,在Windows平台上,编译器通过SEH来实现C++ exception倒是很方便。

值得一提的是,VC6对异常的异步实现(/EHa)有个bug,用catch (...)在某些情况下也可以接住结构化异常。有时候debug版可以接住,release版不可以。若用/GX编译,有时候在debug/release版本中都可以。若打开了一些优化选项,可能又不可以了。若选择使用异常的同步实现(/EHs),就不会有这个问题。这个bug在VS2005中修正了,不管是选择同步还是异步实现,catch(...)只接C++异常,无论如何都不会接住结构化异常。

若实在要把SEH异常转化成C++异常(反对这样做,因为SEH和C++异常处理的完全是两码事),可以借助_set_se_translator函数。不过只有选择异常的异步实现(/EHa)才能确保正常转化。

顺带说一下,若要对VC6的异常实现的性能做评测,对同步和异步实现最好分别评测一下。

如何让C++异常也带stack trace

Java和C#的exception是带stack trace的,很好用。C++的exception没有。于是,我看到不少项目制定了严格的debug log/trace规范,要求在函数入口/出口都要记trace,一个目的就是出了问题之后可以从log中推导出stack trace。不过这样做比较麻烦,要在代码中插很多log。还不如统一用exception做error handling,然后让exception带stack trace这样来得省事。

首先来说说如何产生stack trace。一种跨平台的方法是手工在栈上存标志性字符串,甚至手工维护一个栈,并在异常类的构造/析构函数中操作这个栈。不过这样不比记log的方法省事,而且也要求程序的异常处理符合一些约定,不是好办法。省事的方法却不是跨平台的。在Windows上可以调用MiniDumpWriteDump来生成mini dump。Mini dump含完整的stack trace信息。也可以通过StackWalk/StackWalk64之类的API来获得栈信息。也可以直接访问EBP然后去栈中一层一层往上找。UNIX平台上也可以生成core dump,并且一般也会有类似StackWalk的API。若把symbol都strip掉了,那么stack trace只有地址没有名称,不直观。建议在编译时设置保留符号,这样就可以通过一些API来访问符号信息,从而获得完整的stack trace。在Windows平台上,可以选择生成pdb文件,这样符号就不保存在可执行文件里,而是在pdb文件中,不会影响可执行文件尺寸和保密性。微软提供有API来读取pdb信息。或者获得mini dump之后让调试器自己根据dump文件从pdb中去读。不仅有完整的stack trace信息,还包含了文件名和行号。

然后要解决的问题是在哪里去调用这些函数来获得stack trace。因为当异常抛出之后已经做了stack unwinding了,所以建议在异常类基类的构造函数中去做。值得指出的是,这时获得的stack trace可能会多出几层(比如调用异常类基类构造函数那层),这个是跟编译器和实现相关的,可以试验一下来获得这个数字,然后在stack walking代码中跳过多出的那几层。当然不去掉也无大碍。

还有一个记stack trace的好地方是在程序异常退出的时候。前面说过,最好不要去接SEH异常。不过在产品的Release版本中,可以调用SetUnhandledExceptionFilter来设置自己的filter,并在这个filter中写mini dump或者把stack trace/文件名/行号记到log中。

另外,在自己实现的Assert/Verify/Enforce宏里记含stack trace/文件名/行号的debug log也是个好主意 :)

当然,增加stack trace会让程序在unhappy path上耗费更多时间。不过既然已经在unhappy path上了,程序都要崩了,谁还在意它是快点崩还是慢点崩呢。




  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值