异常在 c + + 中是如何(在幕后)工作的

我一直看到人们说异常是缓慢的,但我从来没有看到任何证据。因此,我不会询问是否存在异常,而是询问异常在幕后是如何工作的,这样我就可以决定何时使用它们,以及它们是否速度慢。

据我所知,异常与多次执行返回一样,只不过它在每次返回后都会检查是否需要执行另一次或停止。它如何检查何时停止返回?我猜有第二个堆栈保存异常的类型和堆栈位置,然后它返回,直到到达那里。我还猜测,这第二个堆栈唯一被触及的时间是在抛出和每次尝试/捕捉时。AFAICT 使用返回代码执行类似的行为将需要相同的时间。但这只是猜测,所以我想知道到底发生了什么。

异常到底是如何工作的?

34857 次浏览

有许多方法可以实现异常,但通常它们将依赖于来自操作系统的一些底层支持。在 Windows 上,这是结构化异常处理机制。

关于代码项目: C + + 编译器如何实现异常处理的详细信息有很好的讨论

发生异常的开销是因为编译器必须生成代码,以便在异常传播出该范围时,跟踪每个堆栈帧(或更精确地说是作用域)中必须销毁哪些对象。如果一个函数在堆栈上没有需要调用析构函数的本地变量,那么它就不应该有性能损失的 wrt 异常处理。

使用返回代码一次只能解除堆栈的一个级别,而异常处理机制如果在中间堆栈帧中没有任何操作,则可以在一个操作中向下跳回堆栈。

Matt Pietrek 在 Win32结构化异常处理上写了一篇很棒的文章。虽然本文最初写于1997年,但它至今仍然适用(当然只适用于 Windows)。

几年前,我的一个朋友写了一些关于 Visual C + + 如何处理异常的文章。

Http://www.xyzw.de/c160.html

都是好答案。

另外,想想在方法顶部将执行“ if 检查”的代码作为门进行调试,而不是允许代码引发异常,这样做是多么容易。

我的座右铭是,编写有效的代码很容易。最重要的是为下一个查看代码的人编写代码。在某些情况下,9个月后就是你,你不想诅咒自己的名字吧!

本文 研究了这个问题,并基本上发现在实践中,异常存在运行时成本,尽管如果不引发异常,成本相当低。好文章,推荐。

与猜测不同,我决定用一小段 C + + 代码和一个比较老的 Linux 安装实际查看生成的代码。

class MyException
{
public:
MyException() { }
~MyException() { }
};


void my_throwing_function(bool throwit)
{
if (throwit)
throw MyException();
}


void another_function();
void log(unsigned count);


void my_catching_function()
{
log(0);
try
{
log(1);
another_function();
log(2);
}
catch (const MyException& e)
{
log(3);
}
log(4);
}

我用 g++ -m32 -W -Wall -O3 -save-temps -c编译了它,并查看了生成的汇编文件。

    .file   "foo.cpp"
.section    .text._ZN11MyExceptionD1Ev,"axG",@progbits,_ZN11MyExceptionD1Ev,comdat
.align 2
.p2align 4,,15
.weak   _ZN11MyExceptionD1Ev
.type   _ZN11MyExceptionD1Ev, @function
_ZN11MyExceptionD1Ev:
.LFB7:
pushl   %ebp
.LCFI0:
movl    %esp, %ebp
.LCFI1:
popl    %ebp
ret
.LFE7:
.size   _ZN11MyExceptionD1Ev, .-_ZN11MyExceptionD1Ev

_ZN11MyExceptionD1EvMyException::~MyException(),因此编译器决定它需要析构函数的非内联副本。

.globl __gxx_personality_v0
.globl _Unwind_Resume
.text
.align 2
.p2align 4,,15
.globl _Z20my_catching_functionv
.type   _Z20my_catching_functionv, @function
_Z20my_catching_functionv:
.LFB9:
pushl   %ebp
.LCFI2:
movl    %esp, %ebp
.LCFI3:
pushl   %ebx
.LCFI4:
subl    $20, %esp
.LCFI5:
movl    $0, (%esp)
.LEHB0:
call    _Z3logj
.LEHE0:
movl    $1, (%esp)
.LEHB1:
call    _Z3logj
call    _Z16another_functionv
movl    $2, (%esp)
call    _Z3logj
.LEHE1:
.L5:
movl    $4, (%esp)
.LEHB2:
call    _Z3logj
addl    $20, %esp
popl    %ebx
popl    %ebp
ret
.L12:
subl    $1, %edx
movl    %eax, %ebx
je  .L16
.L14:
movl    %ebx, (%esp)
call    _Unwind_Resume
.LEHE2:
.L16:
.L6:
movl    %eax, (%esp)
call    __cxa_begin_catch
movl    $3, (%esp)
.LEHB3:
call    _Z3logj
.LEHE3:
call    __cxa_end_catch
.p2align 4,,3
jmp .L5
.L11:
.L8:
movl    %eax, %ebx
.p2align 4,,6
call    __cxa_end_catch
.p2align 4,,6
jmp .L14
.LFE9:
.size   _Z20my_catching_functionv, .-_Z20my_catching_functionv
.section    .gcc_except_table,"a",@progbits
.align 4
.LLSDA9:
.byte   0xff
.byte   0x0
.uleb128 .LLSDATT9-.LLSDATTD9
.LLSDATTD9:
.byte   0x1
.uleb128 .LLSDACSE9-.LLSDACSB9
.LLSDACSB9:
.uleb128 .LEHB0-.LFB9
.uleb128 .LEHE0-.LEHB0
.uleb128 0x0
.uleb128 0x0
.uleb128 .LEHB1-.LFB9
.uleb128 .LEHE1-.LEHB1
.uleb128 .L12-.LFB9
.uleb128 0x1
.uleb128 .LEHB2-.LFB9
.uleb128 .LEHE2-.LEHB2
.uleb128 0x0
.uleb128 0x0
.uleb128 .LEHB3-.LFB9
.uleb128 .LEHE3-.LEHB3
.uleb128 .L11-.LFB9
.uleb128 0x0
.LLSDACSE9:
.byte   0x1
.byte   0x0
.align 4
.long   _ZTI11MyException
.LLSDATT9:

惊喜吧!在正常代码路径上根本没有额外的指令。相反,编译器生成了额外的行外修复代码块,通过函数末尾的表引用(实际上放在可执行文件的单独部分)。所有的工作都是由标准库基于这些表(_ZTI11MyExceptiontypeinfo for MyException)在幕后完成的。

好吧,这对我来说并不意外,我已经知道这个编译器是怎么做到的了。继续组装输出:

    .text
.align 2
.p2align 4,,15
.globl _Z20my_throwing_functionb
.type   _Z20my_throwing_functionb, @function
_Z20my_throwing_functionb:
.LFB8:
pushl   %ebp
.LCFI6:
movl    %esp, %ebp
.LCFI7:
subl    $24, %esp
.LCFI8:
cmpb    $0, 8(%ebp)
jne .L21
leave
ret
.L21:
movl    $1, (%esp)
call    __cxa_allocate_exception
movl    $_ZN11MyExceptionD1Ev, 8(%esp)
movl    $_ZTI11MyException, 4(%esp)
movl    %eax, (%esp)
call    __cxa_throw
.LFE8:
.size   _Z20my_throwing_functionb, .-_Z20my_throwing_functionb

这里我们看到引发异常的代码。虽然仅仅因为可能抛出异常而没有额外的开销,但实际抛出和捕获异常显然会有很多开销。大部分都隐藏在 __cxa_throw中,它必须:

  • 在异常表的帮助下遍历堆栈,直到找到该异常的处理程序。
  • 解除堆栈,直到它到达处理程序。
  • 打电话给联络人。

与简单返回一个值的成本相比较,您就会明白为什么异常只能用于异常返回。

要完成这个程序集文件的其余部分:

    .weak   _ZTI11MyException
.section    .rodata._ZTI11MyException,"aG",@progbits,_ZTI11MyException,comdat
.align 4
.type   _ZTI11MyException, @object
.size   _ZTI11MyException, 8
_ZTI11MyException:
.long   _ZTVN10__cxxabiv117__class_type_infoE+8
.long   _ZTS11MyException
.weak   _ZTS11MyException
.section    .rodata._ZTS11MyException,"aG",@progbits,_ZTS11MyException,comdat
.type   _ZTS11MyException, @object
.size   _ZTS11MyException, 14
_ZTS11MyException:
.string "11MyException"

Typeinfo 数据。

    .section    .eh_frame,"a",@progbits
.Lframe1:
.long   .LECIE1-.LSCIE1
.LSCIE1:
.long   0x0
.byte   0x1
.string "zPL"
.uleb128 0x1
.sleb128 -4
.byte   0x8
.uleb128 0x6
.byte   0x0
.long   __gxx_personality_v0
.byte   0x0
.byte   0xc
.uleb128 0x4
.uleb128 0x4
.byte   0x88
.uleb128 0x1
.align 4
.LECIE1:
.LSFDE3:
.long   .LEFDE3-.LASFDE3
.LASFDE3:
.long   .LASFDE3-.Lframe1
.long   .LFB9
.long   .LFE9-.LFB9
.uleb128 0x4
.long   .LLSDA9
.byte   0x4
.long   .LCFI2-.LFB9
.byte   0xe
.uleb128 0x8
.byte   0x85
.uleb128 0x2
.byte   0x4
.long   .LCFI3-.LCFI2
.byte   0xd
.uleb128 0x5
.byte   0x4
.long   .LCFI5-.LCFI3
.byte   0x83
.uleb128 0x3
.align 4
.LEFDE3:
.LSFDE5:
.long   .LEFDE5-.LASFDE5
.LASFDE5:
.long   .LASFDE5-.Lframe1
.long   .LFB8
.long   .LFE8-.LFB8
.uleb128 0x4
.long   0x0
.byte   0x4
.long   .LCFI6-.LFB8
.byte   0xe
.uleb128 0x8
.byte   0x85
.uleb128 0x2
.byte   0x4
.long   .LCFI7-.LCFI6
.byte   0xd
.uleb128 0x5
.align 4
.LEFDE5:
.ident  "GCC: (GNU) 4.1.2 (Ubuntu 4.1.2-0ubuntu4)"
.section    .note.GNU-stack,"",@progbits

更多的异常处理表,以及分类的额外信息。

因此,至少对 Linux 上的 GCC 来说,结论是: 无论是否抛出异常,成本都是额外的空间(对于处理程序和表来说) ,再加上在抛出异常时解析表和执行处理程序的额外成本。如果使用异常代替错误代码,并且错误很少,那么可以使用 再快点,因为您不再需要测试错误的开销。

如果您需要更多的信息,特别是所有 __cxa_函数的功能,请参见它们来自的原始规范:

在过去,慢 曾经是的例外是真实的。
在大多数现代编译器中,这种情况不再存在。

注意: 仅仅因为我们有异常并不意味着我们不使用错误代码。当可以在本地处理错误时,请使用错误代码。当错误需要更多的上下文来纠正时,使用异常: 我在这里写得更有说服力: 指导异常处理策略的原则是什么?

当没有使用异常时,异常处理代码的成本几乎为零。

当抛出异常时,会完成一些工作。
但是您必须将其与返回错误代码的成本进行比较,并一路检查它们,直到找到可以处理错误的地方。编写和维护都要花费更多的时间。

对于新手来说,还有一个问题:
虽然 Exception 对象应该很小,但是有些人在它们里面放了很多东西。然后您就有了复制异常对象的成本。解决办法有两个:

  • 不要在异常中添加额外的东西。
  • 通过常量引用捕获。

在我看来,带有异常的同样的代码要么更有效,要么至少和没有异常的代码一样具有可比性(但有检查函数错误结果的所有额外代码)。请记住,你不会免费得到任何东西,编译器生成的代码,你应该在第一个地方编写检查错误代码(通常编译器是更有效的比人)。