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

Posted

技术标签:

【中文标题】异常如何在 C++ 中工作(在幕后)【英文标题】:How do exceptions work (behind the scenes) in c++ 【发布时间】:2010-09-23 09:13:39 【问题描述】:

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

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

异常是如何真正起作用的?

【问题讨论】:

签出:***.com/questions/106586/… 还有:***.com/questions/1331220/… 【参考方案1】:

我决定用一小段 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_ 函数的作用,请参阅它们来自的原始规范:

Itanium C++ ABI

【讨论】:

总结一下。如果没有抛出异常,则不花费。抛出异常时会产生一些成本,但问题是“这个成本是否大于使用和测试错误代码一直到错误处理代码的成本”。 错误成本确实可能更高。异常代码很可能仍在磁盘上!由于从正常代码中删除了错误处理代码,因此在非错误情况下的缓存行为得到了改善。 在某些处理器上,例如 ARM,返回一个地址经过一个“bl”[branch-and-link,也称为“call”]指令的八个“额外”字节的成本相同返回到紧跟“bl”之后的地址。我想知道简单地让每个“bl”后跟“传入异常”处理程序的地址与基于表的方法相比,效率如何,以及是否有任何编译器这样做。我能看到的最大危险是不匹配的调用约定可能会导致古怪的行为。 @supercat:您正在使用异常处理代码污染您的 I-cache。毕竟,异常处理代码和表格与正常代码相差甚远是有原因的。 @CesarB:每次调用后一个指令字。似乎并不太离谱,特别是考虑到仅使用“外部”代码进行异常处理的技术通常要求代码始终保持有效的帧指针(在某些情况下可能需要 0 个额外的指令,但在其他情况下可能需要超过一)。【参考方案2】:

异常缓慢在过去是的。 在大多数现代编译器中,这不再适用。

注意:仅仅因为我们有异常并不意味着我们也不使用错误代码。当错误可以在本地处理时使用错误代码。当错误需要更多上下文来纠正使用异常时:我在这里写得更有说服力:What are the principles guiding your exception handling policy?

不使用异常时异常处理代码的成本几乎为零。

当抛出异常时,会完成一些工作。 但是您必须将此与返回错误代码的成本进行比较,并一直检查它们以回到可以处理错误的位置。编写和维护都更耗时。

还有一个适合新手的问题: 尽管 Exception 对象应该很小,但有些人在里面放了很多东西。然后你有复制异常对象的成本。解决方案有两个:

不要在异常中添加额外内容。 通过 const 引用捕获。

在我看来,我敢打赌,带有异常的相同代码要么更高效,要么至少与不带异常的代码相当(但有所有额外的代码来检查函数错误结果)。请记住,您并没有免费获得任何东西,编译器正在生成您应该首先编写的代码来检查错误代码(通常编译器比人类效率高得多)。

【讨论】:

我敢打赌,人们对使用异常犹豫不决,不是因为任何感知到的缓慢,而是因为他们不知道它们是如何实现的以及它们对您的代码做了什么。它们看起来像魔法的事实让许多接近金属的类型感到厌烦。 @speedplane:我想。但是编译器的全部意义在于我们不需要了解硬件(它提供了一个抽象层)。对于现代编译器,我怀疑你是否能找到一个了解现代 C++ 编译器各个方面的人。那么为什么理解异常与理解复杂特征 X 不同呢。 您总是需要了解硬件在做什么,这是程度问题。许多使用 C++(通过 Java 或脚本语言)的人经常这样做是为了提高性能。对他们来说,抽象层应该是相对透明的,这样你才能对金属中发生的事情有所了解。 @speedplane:那么他们应该使用 C,抽象层在设计上要薄得多。【参考方案3】:

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

关于代码项目的细节有很好的讨论:How a C++ compiler implements exception handling

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

使用返回码一次只能展开一层堆栈,而如果在中间堆栈帧中没有任何事情可做,异常处理机制可以在一次操作中向下跳回堆栈更远。

【讨论】:

“发生异常的开销是因为编译器必须生成代码来跟踪必须在每个堆栈帧(或更准确地说是作用域)中销毁哪些对象”无论如何编译器都必须这样做吗?从返回中破坏对象? 没有。给定带有返回地址和表的堆栈,编译器可以确定堆栈上的函数。从那里,哪些对象必须在堆栈上。这可以在抛出异常后完成。有点贵,但只有在实际抛出异常时才需要。 搞笑,我只是想知道“如果每个堆栈帧都跟踪其中的对象数量、它们的类型、名称,这样我的函数可以挖掘堆栈和看看它在调试期间继承了哪些范围”,并且在某种程度上,它做了类似的事情,但没有手动总是将表声明为每个范围的第一个变量。【参考方案4】:

Matt Pietrek 在Win32 Structured Exception Handling 上写了一篇出色的文章。虽然这篇文章最初写于 1997 年,但它仍然适用于今天(当然只适用于 Windows)。

【讨论】:

【参考方案5】:

This article 检查问题并基本上发现在实践中异常存在运行时成本,尽管如果不抛出异常,成本相当低。好文章,推荐。

【讨论】:

【参考方案6】:

我的一个朋友在几年前写了一点 Visual C++ 如何处理异常。

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

【讨论】:

【参考方案7】:

所有好的答案。

另外,想想调试将“if 检查”作为方法顶部的门而不是允许代码抛出异常的代码是多么容易。

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

【讨论】:

我同意,但在某些情况下,例外情况可能会简化代码。想想构造函数中的错误处理...... - 其他方式是 a) 通过引用参数返回错误代码或 b) 设置全局变量

以上是关于异常如何在 C++ 中工作(在幕后)的主要内容,如果未能解决你的问题,请参考以下文章

.push_back 如何在 C++ 中工作?

堆栈指针如何在多个进程中工作?

单类型和双类型变量如何在 Matlab 中的同一代码副本中工作,就像 C++ 中的模板一样

如何使 id3lib 在 C++ Builder 10.2 中工作?

&符号(&)符号如何在c ++中工作? [复制]

CImg 在 Debug 模式下抛出异常,在 Release 中工作正常