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);
}

这个函数的注释已经把它所作的事情解释得很清楚了,不过我还是解释一下这段代码所作的事情:

  1. 计算真正需要申请内存的大小(header_offset + actual_size
  2. 尝试进行aligned_malloc
  3. 如果aligned_malloc失败,则std::terminate()
  4. 将申请的内存清零(只清零用于存放被抛出对象的部分,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的注释写得真不错)。让我们来看看它干了什么:

  1. thrown_object中拿到__cxa_exception。还记得吗,__cxa_allocate_exception申请内存时包含了__cxa_exception的header与被抛出的对象。thrown_object指向被抛出的对象,向前偏移header的大小即可拿到__cxa_exception对象的地址。
  2. 填充__cxa_exception的header;
  3. 调用_Unwind_RaiseException
  4. 如果_Unwind_RaiseException调用成功,则不返回。否则,异常处理失败,调用std::terminate()

__cxa_exception的header主要包含了各种处理函数的函数指针,以及异常对象的RTTI信息。填充header的过程基本就是几次赋值,以及一次memcpysetOurExceptionClass的主要流程)。栈回溯等主要流程由_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_phase1unwind_phase2。第一阶段会遍历整个栈以查找Exception Handler,除此之外什么也不会做。如果没有找到Exception Handler,则应当终止程序。不过libunwind本身不会直接调用std::terminate(),而是返回_URC_END_OF_STACK并由调用者决定如何终止程序。在第二阶段才会回溯并清理栈。

这意味着,如果有异常被抛出,但没有被捕获,那么显然不会进入到第二阶段,也就是不会进行栈回溯,栈上对象的析构函数不会被执行。需要注意的是,对于未捕获异常时的栈回溯行为,C++标准规定这是由具体实现决定的。对于Itanium ABI而言,栈回溯不会被执行,但对于其他ABI而言则未必。

unwind_phase1unwind_phase2的代码都比较长,这里就不贴代码了,我会简要叙述一下它们的实现过程,感兴趣的话可以在GitHub找到它们的实现。

unwind_phase1

unwind_phase1中,代码会不断循环执行一段代码,来查找Exception Handler,直到找到Exception Handler或者到达栈的底部。这段循环的代码步骤如下:

  1. 使用__unw_step函数,使cursor指向下一个栈帧;
  2. 使用__unw_get_proc_info函数,检查当前栈帧是否有需要执行的代码;
    • 如果找到了要执行的代码,则检查是否要停在当前栈帧(可能找到了Exception Handler);
      • 如果这段要执行的代码要求停在当前栈帧,则说明找到了Exception Handler,标记当前栈帧并返回;
      • 否则继续循环。
    • 如果没有找到要执行的代码,则继续循环。

不难想到,“可能的Exception Handler”就是C++代码中的catch了。在检查catch块是否与当前异常对象所匹配时,会对比RTTI信息等,所以如果回滚过程中找到了很多catch,会对unwind_phase1的性能有较大的影响。

unwind_phase2

unwind_phase2unwind_phase1的代码非常类似,也是在循环中不断执行代码来回溯栈,只不过它所做的事情要稍微多一点:

  1. 使用__unw_step_stage2函数,使cursor指向下一个栈帧;
  2. 使用__unw_get_reg获取当前栈帧的sp寄存器(stack pointer);
  3. 使用__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++abilibsupc++的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参数用于表示要执行的操作,exceptionObjectcontext则分别表示异常对象与目前的上下文。__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抛出和捕获异常的主要流程:

抛出异常

  1. 申请内存,用于存放__cxa_exception与异常对象;
  2. _Unwind_RaiseException查找Exception Handler。这个过程可能需要频繁比较RTTI信息;
  3. _Unwind_RaiseException回溯并释放栈对象。这个过程可能需要频繁查找.eh_frame段;

捕获异常

  1. 交还控制权,__cxa_begin_catch维护异常状态;
  2. 执行catch部分的代码;
  3. 执行__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。

参考资料

posted @ 2024-02-28 17:04  icysky  阅读(33)  评论(0编辑  收藏  举报