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


109

我一直看到人们说例外情况很慢,但我从未见过任何证据。因此,我将询问异常在后台如何工作,而不是询问它们是否存在,以便我可以决定何时使用它们以及它们是否缓慢。

据我所知,异常与执行多次返回相同,不同之处在于它还会在每次返回之后检查是否需要执行另一个操作或停止操作。如何检查何时停止返回?我猜有第二个堆栈保存异常的类型和堆栈位置,然后它会返回直到到达那里。我还猜测第二个堆栈唯一碰到的是一次掷球和每次尝试/接球。AFAICT使用返回码实施类似行为将花费相同的时间。但这只是一个猜测,所以我想知道真正发生了什么。

异常如何真正起作用?



Answers:


105

不用猜测,我决定实际上是用一小段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:

惊喜!正常代码路径上根本没有多余的指令。相反,编译器生成了额外的离线修正代码块,这些代码块通过函数末尾的表进行引用(实际上位于可执行文件的单独部分中)。标准库基于这些表(_ZTI11MyExceptionis typeinfo 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_功能的用途,请参阅它们的原始说明:


23
总结一下。如果没有异常,则不计成本。引发异常时会付出一些代价,但问题是“此代价是否比使用和测试错误代码一直到返回错误处理代码还要大”。
马丁·约克

5
错误成本的确可能更高。异常代码很可能仍在磁盘上!由于错误处理代码已从常规代码中删除,因此在非错误情况下的缓存行为得以改善。
MSalters

在某些处理器(例如ARM)上,返回到“ bl”(分支与链接,也称为“调用”)指令之后的八个“额外”字节的地址,其花费与立即返回到地址之后的地址相同。 “ bl”。我不知道简单地使每个“ bl”后面加上“传入异常”处理程序的地址的效率与基于表的方法的效率相比如何,以及是否有任何编译器可以做到这一点。我看到的最大危险是调用约定不匹配会导致古怪的行为。
2012年

2
@supercat:您正在用异常处理代码污染I-cache。毕竟,有一个原因是异常处理代码和表往往与普通代码相距甚远。
CesarB

1
@CesarB:每次调用后有一个指令字。似乎并不太离谱,尤其是考虑到仅使用“外部”代码进行异常处理的技术通常要求代码始终保持有效的帧指针(在某些情况下可能需要0条额外的指令,而在另一些情况下可能需要之一)。
2012年

13

例外是缓在旧时代真的。
在大多数现代编译器中,这不再成立。

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

当不使用任何异常时,异常处理代码的成本实际上为零。

引发异常时,将完成一些工作。
但是您必须将其与返回错误代码并一路检查它们以指出可以处理错误的位置的开销进行比较。两者都花费更多的时间来编写和维护。

对于新手来说也有一个陷阱:
尽管Exception对象应该很小,但是有些人却在其中放了很多东西。然后,您需要复制异常对象。解决方案有两个方面:

  • 不要把多余的东西放在例外中。
  • 通过const引用捕获。

在我看来,我敢打赌,带有例外的同一代码将比没有例外的代码更有效率,或者至少具有可比性(但具有检查功能错误结果的所有额外代码)。请记住,您没有免费获得任何东西,编译器正在生成您应该首先编写的用于检查错误代码的代码(通常,编译器比人类更有效)。


1
我敢打赌,人们对使用异常犹豫不决,并不是因为人们察觉到了它的缓慢性,而是因为他们不知道它们是如何实现的以及对代码的处理方式。它们看起来像魔术的事实激怒了许多接近金属的类型。
快艇

@speedplane:我想。但是,编译器的全部目的是使我们不需要了解硬件(它提供了抽象层)。使用现代编译器,我怀疑您是否可以找到一个了解现代C ++编译器各个方面的人。那么,为什么理解异常与理解复杂特征X
Martin York

您始终需要对硬件的功能有所了解,这是一个程度的问题。许多使用C ++(通过Java或脚本语言)的人通常这样做是为了提高性能。对于他们来说,抽象层应该相对透明,以便您对金属中发生的事情有所了解。
speedplane

@speedplane:然后他们应该使用C,抽象层在设计上要薄得多。
马丁·约克

12

您可以通过多种方式实现异常,但是通常它们将依赖于操作系统的某些基础支持。在Windows上,这是结构化异常处理机制。

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

发生异常的开销是因为,如果异常传播到该范围之外,则编译器必须生成代码以跟踪必须在每个堆栈帧(或更确切地说是范围)中销毁哪些对象。如果函数在堆栈上没有需要调用析构函数的局部变量,则它不应因异常处理而降低性能。

使用返回码一次只能解开堆栈的单个级别,而如果在中间堆栈帧中无事可做,则异常处理机制可以在一次操作中进一步跳回堆栈。


“发生异常的开销是因为编译器必须生成代码来跟踪必须在每个堆栈帧(或更确切地说是作用域)中销毁哪些对象”,编译器是否仍必须这样做以从返回中销毁对象?

否。给定一个具有返回地址和表的堆栈,编译器可以确定堆栈中包含哪些函数。由此,必须将哪些对象放在堆栈中。这可以在引发异常之后完成。有点贵,但仅在实际引发异常时才需要。
MSalters

好笑的是,我只是想问自己“如果每个堆栈框架都跟踪其中的对象数量,它们的类型,名称,这样我的函数就可以挖掘堆栈并查看它在调试期间继承了什么范围,这不是很酷” ,从某种意义上讲,它可以执行类似的操作,但无需手动始终将表声明为每个作用域的第一个变量。
德米特里'18


5

本文研究了该问题,并从根本上发现,在实践中,异常的运行时成本很高,但是如果不抛出异常,成本会相当低。好文章,推荐。



0

所有好的答案。

另外,请考虑将执行“如果检查”的代码调试为方法顶部的门有多容易,而不是允许代码引发异常。

我的座右铭是编写有效的代码很容易。最重要的是为下一个看它的人编写代码。在某些情况下,只有9个月的时间是您,您不想骂自己的名字!


我同意,但是在某些情况下,例外可以简化代码。考虑一下构造函数中的错误处理...-其他方法是a)通过参考参数返回错误代码或b)设置全局变量
Uhli 2011年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.