在我几年前开始写《C++编码规范与指导》一文时,就已经规划着要加入这样一篇讨论 C++ 异常机制的文章了。没想到时隔几年以后才有机会把这个尾巴补完 :-)。

还是那句开场白:“在恰当的场合使用恰当的特性” 对每个称职的 C++ 程序员来说都是一个基本标准。想要做到这点,就必须要了解语言中每个特性的实现方式及其时空开销。异常处理由于涉及大量底层内容,向来是 C++ 各种高级机制中较难理解和透彻掌握的部分。本文将在尽量少引入底层细节的前提下,讨论 C++ 中这一崭新特性,并分析其实现开销。

 

关于线程
[转] C++异常机制的实现方式和开销分析

进程和线程的概念相信各位看官早已耳熟能详。在这里,我只想带大家回忆几点重要概念:
  1. 一个进程中可以同时包含多个线程。
     
  2. 我们通常认为线程是操作系统可识别的最小并发执行和调度单位(不要跟俺说还有 Green Thread 或者 Fiber,OS Kernel 不认识也不参与这些物件的调度)。
     
  3. 同一进程中的多个线程共享代码段(代码和常量)、数据段(静态和全局变量)和扩展段(堆存储),但是每个线程有自己的栈段。栈段又叫运行时栈,用来存放所有局部变量和临时变量(参数、返回值、临时构造的变量等)。这一条对下文中的某些概念来说是非常重要的 。但是请注意,这里提到的各个“段”都是逻辑上的说法,在物理上某些硬件架构或者操作系统可能不使用段式存储。不过没关系,编译器会保证这些逻辑概念和假设的前提条件对每个 C/C++ 程序员来说始终是成立的。
     
  4. 由于共享了除栈以外的所有内存地址段,线程不可以有自己的“静态”或“全局”变量,为了弥补这一缺憾,操作系统通常会提供一种称为 TLS(Thread Local Storage,即:“线程本地存储”)的机制。通过该机制可以实现类似的功能。TLS 通常是线程控制块(TCB)中的某个指针所指向的一个指针数组,数组中的每个元素称为一个槽(Slot),每个槽中的指针由使用者定义,可以指向任意位置(但通常是指向堆存储中的某个偏移)。

 

函数的调用和返回
[转] C++异常机制的实现方式和开销分析

接着我们来回顾下一个预备知识:编译器如何实现函数的调用和返回。一般来说,编译器会为当前调用栈里的每个函数建立一个栈框架(Stack Frame)。“栈框架”担负着以下重要任务:
  1. 传递参数:通常,函数的调用参数总是在这个函数栈框架的最顶端。
  2. 传递返回地址:告诉被调用者的 return 语句应该 return 到哪里去,通常指向该函数调用的下一条语句(代码段中的偏移)。
  3. 存放调用者的当前栈指针:便于清理被调用者的所有局部变量、并恢复调用者的现场。
  4. 存放当前函数内的所有局部变量:记得吗?刚才说过所有局部和临时变量都是存储在栈上的。

最后再复习一点:栈是一种“后进先出”(LIFO)的数据结构,不过实际上大部分栈的实现都支持随机访问。

下面我们来看个具体例子:

假设有 FuncA、FuncB 和 FuncC 三个函数,每个函数均接收两个整形值作为其参数。在某线程上的某一时间段内,FuncA 调用了 FuncB,而 FuncB 又调用了 FuncC。则,它们的栈框架看起来应该像这样:

[转] C++异常机制的实现方式和开销分析
图1 函数调用栈框架示例

正如上图所示的那样,随着函数被逐级调用,编译器会为每一个函数建立自己的栈框架,栈空间逐渐消耗。随着函数的逐级返回,该函数的栈框架也将被逐级销毁,栈空间得以逐步释放。顺便说一句,递归函数的嵌套调用深度通常也是取决于运行时栈空间的剩余尺寸。

这里顺便解释另一个术语:调用约定(calling convention)。调用约定通常指:调用者将参数压入栈中(或放入寄存器中)的顺序,以及返回时由谁(调用者还是被调用者)来清理这些参数等细节规程方面的约定。

最后再说一句,这里所展示的函数调用乃是最“经典”的方式。实际情况是:在开启了优化选项后,编译器可能不会为一个内联甚至非内联的函数生成栈框架,编译器可能使用很多优化技术消除这个构造。不过对于一个 C/C++ 程序员来说,达到这样的理解程度通常就足够了。


 

C++ 函数的调用和返回
[转] C++异常机制的实现方式和开销分析

首先澄清一点,这里说的 “C++ 函数”是指:
  1. 该函数可能会直接或间接地抛出一个异常:即该函数的定义存放在一个 C++ 编译(而不是传统 C)单元内,并且该函数没有使用“throw()”异常过滤器。
  2. 或者该函数的定义内使用了 try 块。

以上两者满足其一即可。为了能够成功地捕获异常和正确地完成栈回退(stack unwind),编译器必须要引入一些额外的数据结构和相应的处理机制。我们首先来看看引入了异常处理机制的栈框架大概是什么样子:

[转] C++异常机制的实现方式和开销分析
图2 C++函数调用栈框架示例

由图2可见,在每个 C++ 函数的栈框架中都多了一些东西。仔细观察的话,你会发现,多出来的东西正好是一个 EXP 类型的结构体。进一步分析就会发现,这是一个典型的单向链表式结构:

  • piPrev 成员指向链表的上一个节点,它主要用于在函数调用栈中逐级向上寻找匹配的 catch 块,并完成栈回退工作。

  • piHandler 成员指向完成异常捕获和栈回退所必须的数据结构(主要是两张记载着关键数据的表:“try”块表:tblTryBlocks 及“栈回退表”:tblUnwind)。

  • nStep 成员用来定位 try 块,以及在栈回退表中寻找正确的入口。

需要说明的是:编译器会为每一个“C++ 函数”定义一个 EHDL 结构,不过只会为包含了“try”块的函数定义 tblTryBlocks 成员。此外,异常处理器还会为每个线程维护一个指向当前异常处理框架的指针。该指针指向异常处理器链表的链尾,通常存放在某个 TLS 槽或能起到类似作用的地方。

最后,请再看一遍图2,并至少对其中的数据结构留下一个大体印象。我们会在后面多个小节中详细讨论它们。

注意:为了简化起见,本文中描述的数据结构内,大多省略了一些与话题无关的成员。

 

栈回退(Stack Unwind)机制
[转] C++异常机制的实现方式和开销分析

注意:如果在栈回退的过程中,由于析构函数的调用而再次引发了异常(异常中的异常),则被认为是一次 异常处理机制的严重失败。此时进程将被强行禁止。为防止出现这种情况,应在所有可能抛出异常的析构函数中使用 “std::uncaught_exception()”方法判断当前是否正在进行栈回退(即:存在一个未捕获或未完全处理完毕的异常)。如是,则应抑制 异常的再次抛出。

 

异常捕获机制
[转] C++异常机制的实现方式和开销分析

备注:实际上 tblCatchBlocks 中还有一些较为关键但被故意省略的字段。比如指明该 catch 块异常对象复制方式(传值(拷贝构造)或传址(引用或指针))的字段,以及在何处存放被复制的异常对象(相对于入口地址的偏移位置)等信息。
 
 

异常的抛出
[转] C++异常机制的实现方式和开销分析

__CxxRTThrowExp 首先接收(并保存)EXCEPTION 对象;然后从 TLS:Current ExpHdl 处找到与当前函数对应的 piHandler、nStep 等异常处理相关数据;并按照前文所述的机制完成异常捕获和栈回退。由此完成了包括“抛出”->“捕获”->“回退”等步骤的整套异常处理机制。

 

Windows 中的结构化异常处理
[转] C++异常机制的实现方式和开销分析

Microsoft Windows 带有一种名为“结构化异常处理”的机制,非常著名的“内存访问违例”出错对话框就是该机制的一种体现。Windows 结构化异常处理与前文讨论的 C++ 异常处理机制有惊人的相似之处,同样使用类似的链式结构实现。对于 Windows 下的应用程序,只需使用 SetUnhandledExceptionFilter API 注册异常处理器;用 FS:[0] 替代前文所述的 TLS: Current ExpHdl 等很少的改动,即可将此两种错误处理机制合而为一。这样做的优势十分明显:
  • 由于可直接借助操作系统提供的机制,所以简化了 C++ 异常处理器的实现。
  • 使“catch (...)” 块得以捕获操作系统产生的异常(如:“内存访问违例”等等)。
  • 使操作系统的异常处理机制能够捕获所有 C++ 异常。

实际上,大多数 Windows 下的 C++ 编译器的异常机制均使用这种方式实现。

 
 

异常处理机制的开销分析
[转] C++异常机制的实现方式和开销分析

特性 时间开销 空间开销
EHDL 无运行时开销 每“C++函数”一个 EHDL 对象,其中的 tblTryBlocks[] 成员仅在函数中包含至少一个 try 块时使用。典型情况下小于 64 字节。

 

C++栈框架 极高的 O(1) 效率,每次调用时进行3次额外的整形赋值和一次 TLS 访问。 每 调用两个指针和一个整形开销。典型情况下小于 16 字节。

 

step 跟踪 极高的 O(1) 效率每次进出 try 块或对象构造/析构一次整形立即数赋值。 无(已记入 C++ 栈框架中的相应项目)。

 

异常的抛出、捕获和栈回退 异常的抛出是一次 O(1) 级操作。在单个函数中进行捕获和栈回退也均为 O(1) 操作。

但异常捕获的总体成本为 O(m),其中 m 等于当前函数调用栈中,从抛出异常的位置到达匹配 catch 块之间所经过的函数调用中,包含 try 块(即:定义了有效 tblTryBlocks[])的函数个数。

栈回退的总成本为 O(n),其中 n 等于当前函数调用栈中,从抛出异常的位置到达匹配 catch 块之间所经过的函数调用数。

在异常处理结束前,需保存异常对象及其析构函数指针和相应的 type_info 信息。

具体根据对象尺寸、编译器选项(是否开启 RTTI)及异常捕获器的参数传递方式(传值或传址)等因素有较大变化。典型情况下小于 256 字节。

 

可以看出,在没有抛出异常时,C++ 的异常处理机制是十分有效的。在有异常被抛出后,可能会依当前函数调用栈的情形进行若干次整形比较(try块表匹配)操作,但这通常不会超过几十次。对于大多数 15 年前的 CPU 来说,整形比较也只需 1 时钟周期,所以异常捕获的效率还是很高的。栈回退的效率则与 return 语句基本相当。

考虑到即使是传统的函数调用、错误处理和逐级返回机制也不是没有代价的。这些开销在绝大多数情形下仍可以接受。空间开销方面,每“C++ 函数”一个 EHDL 结构体的引入在某些极端情形下会明显增加目标文件尺寸和内存开销。但是典型情况下,它们的影响并不大,但也没有小到可以完全忽略的程度。如果正在为一个资源严格受限的环境开发应用程序,你可能需要考虑关闭异常处理和 RTTI 机制以节约存储空间。

以上讨论的是一种典型的异常机制的实现方式,各具体编译器厂商可能有自己的优化和改进方案,但总体的出入不会很大。

相关文章:

  • 2021-09-02
  • 2022-12-23
  • 2022-12-23
  • 2021-12-24
  • 2022-12-23
  • 2021-07-14
  • 2022-12-23
猜你喜欢
  • 2021-08-18
  • 2022-01-13
  • 2022-12-23
  • 2022-12-23
相关资源
相似解决方案