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

131

我经常看到人们说异常很慢,但我从未见过任何证据。所以,我不会问它们是否慢,而是问异常在幕后如何工作,以便我可以决定何时使用它们以及它们是否慢。

据我所知,异常与多次返回相同,只是每次返回后还会检查是否需要再次返回或停止。当它检查何时停止返回时,它是如何做到的呢?我猜测有第二个堆栈保存异常类型和堆栈位置,然后执行返回操作直到达到该位置。我还猜测,第二个堆栈只有在抛出异常和每次尝试/捕获时才会被访问。根据我所知,如果用返回代码实现类似的行为,将需要相同的时间。但这只是我的猜测,所以我想知道真正发生了什么。

异常是如何真正工作的?


1
请查看:https://dev59.com/DHVD5IYBdhLWcg3wDXF3 - Martin York
另外:https://dev59.com/i3M_5IYBdhLWcg3wjj0p - Jonas Byström
7个回答

119

我决定不再猜测,而是通过一小段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"

类型信息数据。

    .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_函数的作用,请参阅它们来自的原始规范:


27
总的来说,如果没有抛出异常则不会产生成本。当抛出异常时会有一些成本,但问题是“这个成本是否比一直使用和测试错误代码直到错误处理代码更大”。 - Martin York
8
错误成本确实可能更高。异常代码很可能仍然存储在磁盘上!由于错误处理代码已从正常代码中移除,非错误情况下的缓存行为会得到改进。 - MSalters
2
@supercat:你这样用异常处理代码污染了I-cache。毕竟,异常处理代码和表格通常都远离正常代码,这是有原因的。 - CesarB
1
@CesarB:每次调用后跟随一个指令词。看起来并不是太过分,特别是考虑到仅使用“外部”代码进行异常处理的技术通常需要代码始终保持有效的帧指针(在某些情况下可能需要0个额外的指令,但在其他情况下可能需要多于一个)。 - supercat
这是很好的信息。然而,我想指出eh_frame需要被存储在某个地方。对于具有需求分页的PC来说,将其放在正常代码路径之外是一个胜利。但是,一些系统没有磁盘或MMU,因此异常表必须存储在RAM中。它们非常大。在这个简单的代码示例中,它们的大小与代码本身大致相同!这并不是对帖子或C++的批评!然而,我看到很多人想知道为什么异常在嵌入式系统中没有流行起来。表格大小可以缩小,以便在某些系统上不会受到限制。 - artless noise
显示剩余2条评论

14

以前异常会导致性能下降,但在现代大多数编译器中,这种情况不再成立。

注意:仅因为我们有异常并不意味着我们不使用错误代码。当错误可以在本地处理时,请使用错误代码。当错误需要更多上下文信息进行更正时,请使用异常。我在这里写得更加优美:What are the principles guiding your exception handling policy?

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

当抛出异常时,会执行一些工作。但是你必须将此与返回错误代码并在所有地方检查它们的成本进行比较,直到可以处理该错误的点。这两种方法都需要编写和维护更多时间。

此外,对于新手来说还有一个问题:
虽然异常对象应该很小,但有些人在其中放置了很多东西。然后就要复制异常对象的成本了。解决方法如下:

  • 不要在异常中放入额外的内容。
  • 通过const引用捕获异常。

我的看法是,使用异常的相同代码要么更有效率,要么至少与不使用异常的代码相当(但具有检查函数错误结果的所有额外代码)。请记住,您没有得到任何免费东西,编译器正在生成您应该在第一次编写时检查错误代码的代码(通常编译器比人类更有效率)。


2
我敢打赌,人们不愿使用异常并不是因为它们被认为会减慢程序速度,而是因为他们不知道异常的实现方式以及对代码造成的影响。它们似乎像魔法一样,这让很多接近底层的开发者感到烦恼。 - speedplane
@speedplane:我想是这样的。但编译器的整个意义在于我们不需要理解硬件(它提供了一个抽象层)。使用现代编译器,我怀疑你能找到一个人能够理解现代C++编译器的每一个方面。那么为什么理解异常与理解复杂特性X不同呢? - Martin York
你总是需要对硬件的运行有一些了解,这是一个程度问题。许多使用C++(而不是Java或脚本语言)的人通常是为了性能而这样做的。对于他们来说,抽象层应该相对透明,以便你对金属中正在发生的事情有一些了解。 - speedplane
1
@speedplane:那么他们应该使用C语言,因为它的抽象层设计更加薄。 - Martin York

12

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

Code Project上有关于细节的讨论:C++编译器如何实现异常处理

异常处理的开销是因为编译器必须生成代码来跟踪每个堆栈框架(或更精确地说是作用域)中必须被析构的对象,如果一个异常从该作用域传播出去。如果一个函数在堆栈上没有任何需要调用析构函数的本地变量,那么它在异常处理方面不应该有性能惩罚。

使用返回码只能一次取消一个级别的堆栈展开,而异常处理机制可以在一次操作中跳转到更深层次的堆栈,如果在中间堆栈帧中没有任何事情可做的话。


异常处理的开销是由于编译器必须生成代码来跟踪每个堆栈帧(或更准确地说是作用域)中必须被销毁的对象。但编译器不管怎样都必须这样做,以便从返回中销毁对象,对吗? - user34537
给定一个带有返回地址和表的堆栈,编译器可以确定哪些函数在堆栈上。从而可以确定哪些对象必须在堆栈上。这可以在异常抛出后完成。这可能有点昂贵,但只有在实际抛出异常时才需要。 - MSalters
我自己在想,“如果每个堆栈帧都跟踪其中的对象数量、类型和名称,那么我的函数就可以挖掘堆栈并查看它在调试期间继承的作用域”,这太有趣了。而且,在某种程度上,这确实做到了这一点,但不需要手动声明一个表作为每个作用域的第一个变量。 - Dmytro

6

Matt Pietrek写了一篇关于Win32结构化异常处理的优秀文章。虽然这篇文章最初是在1997年写的,但今天仍然适用(当然只适用于Windows)。


5

本文 探讨了这个问题,并基本上发现,在实践中,异常会带来运行时的成本,尽管如果未抛出异常,则成本相对较低。这是一篇好文章,值得推荐。


2

0

所有的答案都很好。

此外,考虑一下将“if检查”作为方法顶部的门而不是允许代码抛出异常,这样调试代码会更容易。

我的座右铭是编写可读性强的代码。最重要的是为下一个查看它的人编写代码。在某些情况下,这个人可能是你9个月后,你不想诅咒自己的名字!


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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接