🐼浅析 Cpp 错误处理

背景

  • C++23 提案中, 通过了 std::expected<T, E> 来帮助处理错误,expected 的灵感主要来自于 Rust 中的 std::result::Result<T, E>, 总而言之,其基本原理为:

    template <typename T, typename E>
    struct Expected {
        enum Type {
            Ok,
            Err,
        };
    
        Type type;
        union {
            T value;
            E error;
        };
    };

    即将返回值与错误信息放在一个union中, 要求开发者手动判断并处理错误。

  • 我们知道在 C++ 中,有诸多错误处理方式, 有 异常, 错误码, 选择合适的错误处理方式对于定位问题有一定的帮助。

  • 在本文中,笔者通过重写一份算法来对比这三种的性能差异。

机器配置:

默认使用 g++ 编译,开启 O2 优化。

算法

  • IP => uint32 这个是一个很常见的需求,通过压缩 ip 即可通过 4 bytes 来存储一个 ip 地址。

  • 使用异常来处理:

  • 使用expected处理

  • 使用类似 golang 方式处理,同时返回结果和错误,通过判断错误是否为空来做处理。

benchmark

ip_strs 是全局的数据集,通过构造错误和正确的数据来模拟现实使用情况。需要说明的是,大多数情况而言,实际上用到的数据大多数时候是正确的。故笔者将同时构造全部正确数据和全错误数据以及混合数据,来对比二者的性能。

只需改几行代码:

测试 3 次:

通过笔者多次测试,发现这三种算法,在数据集不发生错误时,均表现正常。但是使用异常的方式略微胜出。笔者在此猜测,由于另外两种方式对结果都需要进行判断,但是只有exception的方式,直接使用结果,只有当错误抛出时,才处理。这就导致了他有微弱的优势。

数据 1/2 半错误测试:

可以发现的是,异常在这里表现不是很好,他的开销大约是 error 和 expected 的 3 倍左右。

分析

遇事不决,flamegraph 先行。我们通过修改代码,只对 bench_exception 进行采样。

perf 采样命令:

decode_exception 火焰图

和预想的一样, decode_exception 的 54.53% 的时间开销花在了 __cxa_throw 这个函数中。

借助神器 CompilerExplorer 分析汇编: linkarrow-up-right

函数汇编对应

可以看到 throw 的时候,做了如下几件事情:

  1. 构造ParseException(包括构造自己和基类std::exception, OFFSET FLAT:vtable这里设置了虚函数 `what` 的虚指针。

  2. mov edx, OFFSET FLAT:ParseException::~ParseException(): 将 ParseException 的析构函数地址移动到 edx。这里将析构函数地址保存下来,再向外传递的时候,只有向上回溯到 `catch` 关键字才会触发析构。

  3. mov esi, OFFSET FLAT:typeinfo for ParseException: 将 ParseException类型信息的地址移动到 esi`。这里应该是标准的实现,为了在运行时保存类型信息。

当然,这里都不是重点,来看看最重要的 `__cxa_throw` 函数做了什么。

g++ 默认使用libstdc++, 通过在最新的 gcc 仓库中搜索:

火焰图中 _Unwind_RaiseException 这个函数占了 _cxa_throw 的 95% 时间开销。该函数处在 libunwind 之中。

大概分析一下这个函数的流程:

  1. Unwind_InitContext 用来初始化上下文。

  2. unw_step不断向上查找有handler 函数的栈帧, unw_step 是一个宏, 最后会调用 UNW_PASTE(UNW_PREFIX, fn) 这个宏,最终靠 `UNW_PASTE2` 将 UNW_PREFIX 和 fn 宏拼接在一起。

    1. UNW_PREFIX 的定义是: #define UNW_PREFIX UNW_PASTE(UNW_PASTE(_U, UNW_TARGET), _) 通过不断的粘贴字符串之后可以得到: _Ux86_64_{fn}, 这里的 fn 应该是函数名称。这里之后拼凑出来的函数,应该是被 unw_get_proc_info 函数使用,该函数获取当前进程栈帧的一些信息。

    2. 之后获得当前函数的 pi.handler 的处理回调,如果回调可用,则调用,这里判断调用之后的 reason, _URC_HANDLER_FOUND 如果找到就break, 否则就返回。

    3. 调用 _Unwind_Phase2 ,该函数占用了 _Unwind_RaiseException 的 40% 时间开销。

看上去他做了这几件事情:

  • 设置 action 为 _UA_CLEANUP_PHASE 当前处于清理状态。

  • 这部分看起来就是不断的向上追溯,使用 unw_step 函数尝试向前执行一步栈展开。

由于笔者对 libunwind 并不是非常熟悉,所以只能根据仅有的注释来看。如果读者有建议or看法,欢迎联系笔者。根据这部分代码,大概流程即可梳理为,不断向上回溯,并且寻找注册好了的函数钩子,来做处理。

总结

三种错误方式,基本上都可以在不同的项目中见到缩影。但是 std::expected 还是比较少见的。从我们的 benchmark 和 分析可以得到结论:

  • 当错误不发生时, 使用 exception 的错误处理性能是最高的。

  • 当都有错误时, exception 的性能退化幅度较为大,std::expectedpair<T,E> 的性能差不多。通过对 bench_exception 的汇编进行分析,g++ 对于exception 会生成大量汇编代码,在大量使用异常的项目中,这很有可能增大二进制文件的体积。

一味的讨论性能可能并不是本文的唯一目的,从使用易用性角度来看,笔者认为 exception 由于错误抛出都在代码中,可能需要查看调用函数代码才能写出正确捕获的代码。但是如果项目统一使用异常来处理错误,那么这其实也并不是一件坏事。

std::expected<T,E> 作为 Rust 中通用的错误处理一直被人称赞,但这其实是和其他语法 [带有类型的枚举], [ 模式匹配] 相对应的。在 Cpp 中 P2688 提案(https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p2688r0.pdfarrow-up-right) 提出了在 Cpp 实现模式匹配。笔者认为 std::expected 将会是成为大一统的错误处理方式。这种强制判断结果无疑减少了工程师的心智负担。

引用

  1. ^ C++ cppreference std::expected linkarrow-up-right

Last updated