Non-Profit, International

Spirit unsterblich.

C++ 异常问题的简单分析

字数统计:978 blog

  长久以来,C++ 的异常都被诟病为性能差,但是仅仅用性能来评价 C++ 的异常是只见云雾不见山,没用抓住问题本质。

  在认识 fast_io 的作者 cqwrteur 之后我逐渐意识到异常存在理发师悖论以及一些先天问题。毫无疑问,cqwrteur 和我是异常的簇拥。

  C++ 异常的所有争议,一切一切的原因是 C++ 的异常是对象。所有异常是对象的语言其实也都存在一样的问题,例如 C# 和 Java。

  异常对象存在的的首要矛盾是动态内存分配。许多人试图让异常可以保留一些信息,例如 std::filesystem::filesystem_error::path1。由于异常在抛出后会对已存在的对象进行析构,所以想要保留 path,就必须进行动态内存分配,让这个 path 储存到异常对象中,并以引用捕获这个异常,不然会存在悬垂引用或者段错误。

  上面这种抛出-捕获过程看似非常美好,但是别忘了,动态内存分配有可能抛出 bad_alloc 异常。单一线程中显然只能同时处理一个异常,所以如果同时存在两个异常,唯一的选择是直接结束程序,这打破了异常处理的逻辑。换句话说,存在悖论。

  那么“打破”悖论,GCC 采取了一种策略,应急堆。实际上就是提前申请一块看似足够大的空间,让异常对象在这个空间上构造。这样就使得其他异常“不会”抛出 bad_alloc。

  但这显然是错误的的,缓冲区溢出是大型程序最容易出现的问题,通常原因就是假设缓冲区足够大,不会溢出,但是事实往往是会溢出。

  由于异常每个线程有一个,所以这种实现需要 TLS(Thread local storage)来管理应急堆。

  此时,就引入第二个问题:内核态程序很难拥有 TLS 和自由的堆内存分配,所以操作系统内核无法使用异常。

  所以,必须要消除动态内存分配,异常必须是无状态的。例如 Herb Sutter 的提案 Throw value 就是一种无状态异常,所有异常都变成错误码的语法糖。由于不需要保存状态,所以异常对象通常有固定大小,例如一个或者两个指针大小。不同类型的异常就是不同的枚举值,也可以加入一个回调函数。这时,异常就可以通过寄存器进行传递,不需要 TLS 也不需要动态内存分配,catch 也可以实现为 switch,不再需要 RTTI。

  此外,由于栈展开和有副作用的析构,使得异常的捕获开销是不确定的,所以对于内核态的驱动而言,有一些关键节点无法使用异常。

  之前我写了 4 篇文章用于总结 C++ 的异常用途,其中的内容也对无状态异常适用。异常对于函数式或者递归式表达是必不可少的,一旦函数返回错误码,就代表其不能被嵌套调用,因为对象无法通过返回值传递。

  不过很遗憾,由于 Herbception 破坏了 ABI,所以很难进入 C++ 标准。

  最后,记得谨慎 catch 异常,不要用异常来 debug,例如不应该在正式发布的软件中抛出 out_of_range 以及 stacktrace。


若无特殊声明,本文以 CC BY-SA 3.0 许可协议 提供。