C++的异常处理究竟有多慢?
我们能在各处看到“C++的异常很慢,不要用”这样的言论,不过很少有人告诉你,C++的异常到底有多慢,以及它为什么这么慢。本文会详细介绍C++在抛出以及捕获异常时,代码到底做了什么,以及我们使用C++异常到底付出了多少代价。
抛出异常
要了解异常处理到底做了什么,我们需要深入到汇编语言来看看编译器到底如何生成代码。我们以下面这一段简单的代码为例:
__attribute__((noinline))
auto foo(int value) -> int {
if (value < 0) {
throw value;
} else {
return value + 1;
}
}
同时,我们使用Compiler Explorer分析一下它生成的汇编:
foo(int): # @foo(int)
push rbx
mov ebx, edi
test edi, edi
js .LBB0_2
inc ebx
mov eax, ebx
pop rbx
ret
.LBB0_2:
mov edi, 4
call __cxa_allocate_exception@PLT
mov dword ptr [rax], ebx
mov rsi, qword ptr [rip + typeinfo for int@GOTPCREL]
mov rdi, rax
xor edx, edx
call __cxa_throw@PLT
这段代码是使用x86-64 clang 17.0.1生成的,编译选项为-O2 -std=c++20
。需要注意的是,Compiler Explorer的clang使用的是Itanium ABI,因此此处所分析的异常处理为Itanium ABI下的异常处理,Windows平台的异常处理原理并不相同。
这段代码非常简单,所以我们很容易可以分析出汇编代码与源代码的对应关系。对于不抛出异常的部分,代码的执行是这样的:
inc ebx
mov eax, ebx
pop rbx
ret
这段汇编代码所作的事情就是value += 1
,然后返回value
。可以看到,在不抛出异常的路径下并没有引入任何影响执行速度的因素。这与Itanium ABI所保证的异常处理在Happy Path上零开销所吻合。
.LBB0_2
段对应了throw value
的代码。如果你对于x86函数调用寄存器的分配方式比较熟悉,那么不难看出,throw value
实际上是调用了两次函数:
int *thrown_object = __cxa_allocate_exception(4 /* sizeof(int) */);
*thrown_object = value;
__cxa_throw(thrown_object, typeinfo_for_int);
所以,抛出异常的额外开销也就是这两次函数调用了。我们来看一下__cxa_allocate_exception
与__cxa_throw
这两个函数具体做了什么。
__cxa_allocate_exception
以LLVM libcxxabi为例:
// Allocate a __cxa_exception object, and zero-fill it.
// Reserve "thrown_size" bytes on the end for the user's exception
// object. Zero-fill the object. If memory can't be allocated, call
// std::terminate. Return a pointer to the memory to be used for the
// user's exception object.
void *__cxa_allocate_exception(size_t thrown_size) throw() {
size_t actual_size = cxa_exception_size_from_exception_thrown_size(thrown_size);
// Allocate extra space before the __cxa_exception header to ensure the
// start of the thrown object is sufficiently aligned.
size_t header_offset = get_cxa_exception_offset();
char *raw_buffer =
(char *)__aligned_malloc_with_fallback(header_offset + actual_size);
if (NULL == raw_buffer)
std::terminate();
__cxa_exception *exception_header =
static_cast<__cxa_exception *>((void *)(raw_buffer + header_offset));
::memset(exception_header, 0, actual_size);
return thrown_object_from_cxa_exception(exception_header);
}
这个函数的注释已经把它所作的事情解释得很清楚了,不过我还是解释一下这段代码所作的事情:
- 计算真正需要申请内存的大小(
header_offset + actual_size
) - 尝试进行
aligned_malloc
- 如果
aligned_malloc
失败,则std::terminate()
- 将申请的内存清零(只清零用于存放被抛出对象的部分,header部分不清零)
所以这段代码约等于进行了一次calloc
,其引入的主要开销也就是内存分配了。
__cxa_throw
还是以LLVM libcxxabi为例:
/*
After constructing the exception object with the throw argument value,
the generated code calls the __cxa_throw runtime library routine. This
routine never returns.
The __cxa_throw routine will do the following:
* Obtain the __cxa_exception header from the thrown exception object address,
which can be computed as follows:
__cxa_exception *header = ((__cxa_exception *) thrown_exception - 1);
* Save the current unexpected_handler and terminate_handler in the __cxa_exception header.
* Save the tinfo and dest arguments in the __cxa_exception header.
* Set the exception_class field in the unwind header. This is a 64-bit value
representing the ASCII string "XXXXC++\0", where "XXXX" is a
vendor-dependent string. That is, for implementations conforming to this
ABI, the low-order 4 bytes of this 64-bit value will be "C++\0".
* Increment the uncaught_exception flag.
* Call _Unwind_RaiseException in the system unwind library, Its argument is the
pointer to the thrown exception, which __cxa_throw itself received as an argument.
__Unwind_RaiseException begins the process of stack unwinding, described
in Section 2.5. In special cases, such as an inability to find a
handler, _Unwind_RaiseException may return. In that case, __cxa_throw
will call terminate, assuming that there was no handler for the
exception.
*/
void
__cxa_throw(void *thrown_object, std::type_info *tinfo, void (*dest)(void *)) {
__cxa_eh_globals *globals = __cxa_get_globals();
__cxa_exception* exception_header = cxa_exception_from_thrown_object(thrown_object);
exception_header->unexpectedHandler = std::get_unexpected();
exception_header->terminateHandler = std::get_terminate();
exception_header->exceptionType = tinfo;
exception_header->exceptionDestructor = dest;
setOurExceptionClass(&exception_header->unwindHeader);
exception_header->referenceCount = 1; // This is a newly allocated exception, no need for thread safety.
globals->uncaughtExceptions += 1; // Not atomically, since globals are thread-local
exception_header->unwindHeader.exception_cleanup = exception_cleanup_func;
_Unwind_RaiseException(&exception_header->unwindHeader);
// This only happens when there is no handler, or some unexpected unwinding
// error happens.
failed_throw(exception_header);
}
为了使代码更清晰简介,我在复制代码时去掉了Address Sanitizer与SJLJ部分的代码,去掉这部分并不影响这段代码的语义。与__cxa_allocate_exception
相同,__cxa_throw
函数的注释也写得很清楚了(PS:LLVM的注释写得真不错)。让我们来看看它干了什么:
- 从
thrown_object
中拿到__cxa_exception
。还记得吗,__cxa_allocate_exception
申请内存时包含了__cxa_exception
的header与被抛出的对象。thrown_object
指向被抛出的对象,向前偏移header的大小即可拿到__cxa_exception
对象的地址。 - 填充
__cxa_exception
的header; - 调用
_Unwind_RaiseException
; - 如果
_Unwind_RaiseException
调用成功,则不返回。否则,异常处理失败,调用std::terminate()
。
__cxa_exception
的header主要包含了各种处理函数的函数指针,以及异常对象的RTTI信息。填充header的过程基本就是几次赋值,以及一次memcpy
(setOurExceptionClass
的主要流程)。栈回溯等主要流程由_Unwind_RaiseException
开始。
栈回溯
_Unwind_RaiseException
函数是Itanium ABI定义的异常处理函数,它函数是语言无关的,其他语言也可以使用这个函数实现异常处理等功能。Itanium ABI在Base ABI中定义了一系列函数,在抛出异常这里我们只关心_Unwind_RaiseException
。
我们继续展开_Unwind_RaiseException
看看它做了什么,它是在libunwind
中实现的:
/// Called by __cxa_throw. Only returns if there is a fatal error.
_LIBUNWIND_EXPORT _Unwind_Reason_Code
_Unwind_RaiseException(_Unwind_Exception *exception_object) {
_LIBUNWIND_TRACE_API("_Unwind_RaiseException(ex_obj=%p)",
(void *)exception_object);
unw_context_t uc;
unw_cursor_t cursor;
__unw_getcontext(&uc);
// Mark that this is a non-forced unwind, so _Unwind_Resume()
// can do the right thing.
exception_object->private_1 = 0;
exception_object->private_2 = 0;
// phase 1: the search phase
_Unwind_Reason_Code phase1 = unwind_phase1(&uc, &cursor, exception_object);
if (phase1 != _URC_NO_REASON)
return phase1;
// phase 2: the clean up phase
return unwind_phase2(&uc, &cursor, exception_object);
}
正如上面的代码所示,栈回溯包含两个阶段:unwind_phase1
与unwind_phase2
。第一阶段会遍历整个栈以查找Exception Handler,除此之外什么也不会做。如果没有找到Exception Handler,则应当终止程序。不过libunwind
本身不会直接调用std::terminate()
,而是返回_URC_END_OF_STACK
并由调用者决定如何终止程序。在第二阶段才会回溯并清理栈。
这意味着,如果有异常被抛出,但没有被捕获,那么显然不会进入到第二阶段,也就是不会进行栈回溯,栈上对象的析构函数不会被执行。需要注意的是,对于未捕获异常时的栈回溯行为,C++标准规定这是由具体实现决定的。对于Itanium ABI而言,栈回溯不会被执行,但对于其他ABI而言则未必。
unwind_phase1
与unwind_phase2
的代码都比较长,这里就不贴代码了,我会简要叙述一下它们的实现过程,感兴趣的话可以在GitHub找到它们的实现。
unwind_phase1
在unwind_phase1
中,代码会不断循环执行一段代码,来查找Exception Handler,直到找到Exception Handler或者到达栈的底部。这段循环的代码步骤如下:
- 使用
__unw_step
函数,使cursor指向下一个栈帧; - 使用
__unw_get_proc_info
函数,检查当前栈帧是否有需要执行的代码;- 如果找到了要执行的代码,则检查是否要停在当前栈帧(可能找到了Exception Handler);
- 如果这段要执行的代码要求停在当前栈帧,则说明找到了Exception Handler,标记当前栈帧并返回;
- 否则继续循环。
- 如果没有找到要执行的代码,则继续循环。
- 如果找到了要执行的代码,则检查是否要停在当前栈帧(可能找到了Exception Handler);
不难想到,“可能的Exception Handler”就是C++代码中的catch
了。在检查catch
块是否与当前异常对象所匹配时,会对比RTTI信息等,所以如果回滚过程中找到了很多catch
,会对unwind_phase1
的性能有较大的影响。
unwind_phase2
unwind_phase2
与unwind_phase1
的代码非常类似,也是在循环中不断执行代码来回溯栈,只不过它所做的事情要稍微多一点:
- 使用
__unw_step_stage2
函数,使cursor指向下一个栈帧; - 使用
__unw_get_reg
获取当前栈帧的sp
寄存器(stack pointer); - 使用
__unw_get_proc_info
函数,获取当前栈帧的信息;- 如果当前栈帧有要执行的代码,则告诉它目前正在进行栈回溯;
- 如果当前栈帧是第一阶段标记的栈帧,则告诉它这是被标记的栈帧;
- 尝试执行这段代码:
- 如果这段代码执行后,通知调用者应当继续回溯(返回
_URC_CONTINUE_UNWIND
),则继续循环; - 如果这段代码执行后,通知调用者回溯到此为止,则尝试resume控制权(调用
__unw_phase2_resume
)。
- 如果这段代码执行后,通知调用者应当继续回溯(返回
很有趣的一点是,libunwind
中的函数在调用具体的异常处理函数时全部是通过函数指针实现的,这一点保证了它能够做到语言无关。具体的栈清理等代码由C++的前端编译器生成,并以函数指针的形式传递给libunwind
,这给了编译器前端很大的自由度。
捕获和处理异常
我们还是以一段简单的代码为例:
__attribute((noinline))
auto bar(int value) -> int {
try {
return foo(value);
} catch (int error) {
printf("Get error %d\n", error);
}
return -1;
}
我们使用Compiler Explorer分析一下它生成的汇编:
bar(int): # @bar(int)
push rax
call foo(int)
pop rcx
ret
mov rdi, rax
call __cxa_begin_catch@PLT
mov esi, dword ptr [rax]
lea rdi, [rip + .L.str]
xor eax, eax
call printf@PLT
call __cxa_end_catch@PLT
mov eax, -1
pop rcx
ret
.Ltmp19: # TypeInfo 1
.long .L_ZTIi.DW.stub-.Ltmp19
.L.str:
.asciz "Get error %d\n"
DW.ref.__gxx_personality_v0:
.quad __gxx_personality_v0
可以发现,在不需要执行catch
的情况下,bar
函数只有4行汇编:
bar(int): # @bar(int)
push rax
call foo(int)
pop rcx
ret
而catch
生成的代码显然被直接塞到了ret
指令的后面。catch
部分首先调用了__cxa_begin_catch
,然后开始执行printf
,最后调用__cxa_end_catch
函数后回到了正常的代码路径上。除此之外,编译器还生成了一些其他的信息:Ltmp19
段存储了RTTI信息,DW.ref.__gxx_personality_v0
段存储了一个函数指针__gxx_personality_v0
。这些东西又是什么?
Personality Routine
因为不知道怎么翻译比较好所以就保持原名了。
正如上文所述,libunwind
使用函数指针的回调函数来具体实现栈清理等功能。__gxx_personality_v0
即为libc++abi
与libsupc++
的Personality Routine(是个函数)。libunwind
中Personality Routine的声明如下:
_Unwind_Reason_Code (*__personality_routine)
(int version,
_Unwind_Action actions,
uint64 exceptionClass,
struct _Unwind_Exception *exceptionObject,
struct _Unwind_Context *context);
actions
参数用于表示要执行的操作,exceptionObject
与context
则分别表示异常对象与目前的上下文。__gxx_personality_v0
的具体实现可以在libcxxabi找到。这部分代码所作的事情基本就是根据action
选择执行相应的操作,具体的实现我懒得看了。
有了Personality Routine之后,还需要将其传递给libunwind
,在DWARF中这是通过.eh_frame
段实现的。栈帧和Personality Routine都存储在.eh_frame
段中,libunwind
自己会从.eh_frame
段中查找。
捕获异常
最后我们来讨论一下__cxa_begin_catch
与__cxa_end_catch
。__cxa_begin_catch
的代码如下:
/*
This routine can catch foreign or native exceptions. If native, the exception
can be a primary or dependent variety. This routine may remain blissfully
ignorant of whether the native exception is primary or dependent.
If the exception is native:
* Increment's the exception's handler count.
* Push the exception on the stack of currently-caught exceptions if it is not
already there (from a rethrow).
* Decrements the uncaught_exception count.
* Returns the adjusted pointer to the exception object, which is stored in
the __cxa_exception by the personality routine.
If the exception is foreign, this means it did not originate from one of throw
routines. The foreign exception does not necessarily have a __cxa_exception
header. However we can catch it here with a catch (...), or with a call
to terminate or unexpected during unwinding.
* Do not try to increment the exception's handler count, we don't know where
it is.
* Push the exception on the stack of currently-caught exceptions only if the
stack is empty. The foreign exception has no way to link to the current
top of stack. If the stack is not empty, call terminate. Even with an
empty stack, this is hacked in by pushing a pointer to an imaginary
__cxa_exception block in front of the foreign exception. It would be better
if the __cxa_eh_globals structure had a stack of _Unwind_Exception, but it
doesn't. It has a stack of __cxa_exception (which has a next* in it).
* Do not decrement the uncaught_exception count because we didn't increment it
in __cxa_throw (or one of our rethrow functions).
* If we haven't terminated, assume the exception object is just past the
_Unwind_Exception and return a pointer to that.
*/
void*
__cxa_begin_catch(void* unwind_arg) throw()
{
_Unwind_Exception* unwind_exception = static_cast<_Unwind_Exception*>(unwind_arg);
bool native_exception = __isOurExceptionClass(unwind_exception);
__cxa_eh_globals* globals = __cxa_get_globals();
// exception_header is a hackish offset from a foreign exception, but it
// works as long as we're careful not to try to access any __cxa_exception
// parts.
__cxa_exception* exception_header =
cxa_exception_from_exception_unwind_exception
(
static_cast<_Unwind_Exception*>(unwind_exception)
);
if (native_exception)
{
// Increment the handler count, removing the flag about being rethrown
exception_header->handlerCount = exception_header->handlerCount < 0 ?
-exception_header->handlerCount + 1 : exception_header->handlerCount + 1;
// place the exception on the top of the stack if it's not already
// there by a previous rethrow
if (exception_header != globals->caughtExceptions)
{
exception_header->nextException = globals->caughtExceptions;
globals->caughtExceptions = exception_header;
}
globals->uncaughtExceptions -= 1; // Not atomically, since globals are thread-local
return exception_header->adjustedPtr;
}
// Else this is a foreign exception
// If the caughtExceptions stack is not empty, terminate
if (globals->caughtExceptions != 0)
std::terminate();
// Push the foreign exception on to the stack
globals->caughtExceptions = exception_header;
return unwind_exception + 1;
}
虽然这段代码看起来很长,但其并不复杂。这段代码首先对native和foreign异常进行了判断。libcxxabi
不处理foreign异常,所以当其捕获到foreigh异常时,直接调用std::terminate()
。如果捕获到了native异常,则维护异常对象的引用计数,以及全局异常对象的状态。其中大部分的操作都是对各种计数进行加减操作。
__cxa_end_catch
所作的事情与__cxa_begin_catch
类似,也是维护异常对象的状态,这里就不再赘述了。感兴趣的话请自行阅读libcxxabi
的源码吧。
小结
简要总结一下Itanium ABI抛出和捕获异常的主要流程:
抛出异常
- 申请内存,用于存放
__cxa_exception
与异常对象; _Unwind_RaiseException
查找Exception Handler。这个过程可能需要频繁比较RTTI信息;_Unwind_RaiseException
回溯并释放栈对象。这个过程可能需要频繁查找.eh_frame
段;
捕获异常
- 交还控制权,
__cxa_begin_catch
维护异常状态; - 执行
catch
部分的代码; - 执行
__cxa_end_catch
,维护异常状态,捕获异常结束。
从中可以大致看出,抛出异常以及栈回溯会占用异常处理的大部分时间。比较耗费性能的部分有:申请和释放堆内存、比较RTTI信息、以及栈回溯。如果和返回错误码进行比较的话,考虑多层传递错误码的情况,函数返回本身也需要依次析构栈上的对象。这样考虑的话,异常的栈回溯也没有带来很大的额外时间开销。
空间开销
本文没怎么讨论异常带来的额外空间开销问题。异常带来的额外空间开销主要是.eh_frame
,其中RTTI要占很大一部分。对于比较大型的软件而言,异常和RTTI对二进制大小的影响还是很大的。
其他平台的异常处理
本文主要讨论的是Itanium ABI下的C++异常处理,但Windows等平台使用的并不是Itanium ABI。对于Setjmp/Longjmp(SJLJ)而言,每个函数都会把它自己注册到栈帧表中,当抛出异常时,runtime使用这个栈帧表进行回溯。因为SJLJ是在运行时注册栈帧的,所以当不抛出异常时,SJLJ要比DWARF更慢;当抛出和处理异常时,SJLJ要比DWARF性能更好。Windows的异常处理也和Itanium ABI不一样。在Windows上,异常由栈上的帧来描述,而且异常对象的内存使用__CxxThrowException
进行申请,而这个函数会在栈上申请内存。
后记
这篇博客是我在实验室摸鱼的时候写的,但是写到后面的时候脑子已经不听使唤了QAQ。