C++ 中的异常如何工作(在幕后)
-
08-07-2019 - |
题
我不断看到人们说异常很慢,但我从未看到任何证据。因此,我不会问它们是否是,而是会问异常在幕后如何工作,这样我就可以决定何时使用它们以及它们是否很慢。
据我所知,异常与执行一堆返回是一样的,但它还会检查何时需要停止执行返回。它如何检查何时停止?我猜测,有第二个堆栈保存异常类型和堆栈位置,然后返回,直到到达那里。我还猜测堆栈接触的唯一时间是在抛出和每次尝试/捕获时。AFAICT 使用返回代码实现类似的行为将花费相同的时间。但这都是猜测,所以我想知道。
异常是如何真正发挥作用的?
解决方案
我没有猜测,而是决定使用一小段 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
_ZN11MyExceptionD1Ev
是 MyException::~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:
惊喜!正常的代码路径上根本没有额外的指令。相反,编译器生成了额外的外线修复代码块,通过函数末尾的表引用(实际上放在可执行文件的单独部分)。所有工作都是由标准库根据这些表在幕后完成的(_ZTI11MyException
是 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"
类型信息数据。
.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 对象应该很小,但有些人在里面放了很多东西。然后你就有了复制异常对象的成本。解决方案有两个:
- 不要在异常中添加额外的内容。
- 通过 const 引用捕获。
在我看来,我敢打赌,带有异常的相同代码要么更有效,要么至少与没有异常的代码具有可比性(但具有所有额外的代码来检查函数错误结果)。请记住,您不会免费获得任何东西,编译器正在生成您应该首先编写的代码来检查错误代码(通常编译器比人类更有效率)。
有许多方法可以实现异常,但通常它们将依赖于操作系统的一些底层支持。在Windows上,这是结构化的异常处理机制。
对代码项目的详细信息进行了不错的讨论: C ++编译器如何实现异常处理
出现异常的开销是因为编译器必须生成代码以跟踪在每个堆栈帧(或更精确的范围)中必须销毁哪些对象(如果异常传播出该范围)。如果一个函数在堆栈上没有需要调用析构函数的局部变量,那么在异常处理时它不会有性能损失。
使用返回代码一次只能展开堆栈的单个级别,而异常处理机制可以在一次操作中进一步向下跳转,如果在中间堆栈帧中没有任何内容可以执行。
Matt Pietrek撰写了一篇关于 Win32结构化异常处理的精彩文章。虽然这篇文章最初是在1997年编写的,但它今天仍然适用(但当然只适用于Windows)。
这篇文章检查了这个问题,并且基本上发现在实践中有一个异常的时间成本,但如果不抛出异常,成本相当低。好文章,推荐。
我的一位朋友写了几年前Visual C ++如何处理异常。
所有好的答案。
另外,请考虑调试代码更容易,这些代码在方法顶部执行“if checks”作为门,而不是允许代码抛出异常。
我的座右铭是编写有效的代码很容易。最重要的是为下一个查看它的人编写代码。在某些情况下,这是你在9个月内,你不想诅咒你的名字!