🐼浅析 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 的 54.53% 的时间开销花在了 __cxa_throw 这个函数中。
借助神器 CompilerExplorer 分析汇编: link

可以看到 throw 的时候,做了如下几件事情:
构造
ParseException(包括构造自己和基类std::exception, OFFSET FLAT:vtable这里设置了虚函数`what` 的虚指针。mov edx, OFFSET FLAT:ParseException::~ParseException(): 将ParseException的析构函数地址移动到edx。这里将析构函数地址保存下来,再向外传递的时候,只有向上回溯到 `catch` 关键字才会触发析构。mov esi, OFFSET FLAT:typeinfo for ParseException: 将ParseException类型信息的地址移动到esi`。这里应该是标准的实现,为了在运行时保存类型信息。
当然,这里都不是重点,来看看最重要的 `__cxa_throw` 函数做了什么。
g++ 默认使用libstdc++, 通过在最新的 gcc 仓库中搜索:
火焰图中 _Unwind_RaiseException 这个函数占了 _cxa_throw 的 95% 时间开销。该函数处在 libunwind 之中。
大概分析一下这个函数的流程:
Unwind_InitContext 用来初始化上下文。
unw_step不断向上查找有handler 函数的栈帧,unw_step是一个宏, 最后会调用UNW_PASTE(UNW_PREFIX, fn)这个宏,最终靠 `UNW_PASTE2` 将 UNW_PREFIX 和 fn 宏拼接在一起。UNW_PREFIX 的定义是:
#define UNW_PREFIX UNW_PASTE(UNW_PASTE(_U, UNW_TARGET), _)通过不断的粘贴字符串之后可以得到:_Ux86_64_{fn}, 这里的 fn 应该是函数名称。这里之后拼凑出来的函数,应该是被unw_get_proc_info函数使用,该函数获取当前进程栈帧的一些信息。之后获得当前函数的
pi.handler的处理回调,如果回调可用,则调用,这里判断调用之后的 reason,_URC_HANDLER_FOUND如果找到就break, 否则就返回。调用
_Unwind_Phase2,该函数占用了_Unwind_RaiseException的 40% 时间开销。
看上去他做了这几件事情:
设置 action 为
_UA_CLEANUP_PHASE当前处于清理状态。这部分看起来就是不断的向上追溯,使用
unw_step函数尝试向前执行一步栈展开。
由于笔者对 libunwind 并不是非常熟悉,所以只能根据仅有的注释来看。如果读者有建议or看法,欢迎联系笔者。根据这部分代码,大概流程即可梳理为,不断向上回溯,并且寻找注册好了的函数钩子,来做处理。
总结
三种错误方式,基本上都可以在不同的项目中见到缩影。但是 std::expected 还是比较少见的。从我们的 benchmark 和 分析可以得到结论:
当错误不发生时, 使用
exception的错误处理性能是最高的。当都有错误时,
exception的性能退化幅度较为大,std::expected和pair<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.pdf) 提出了在 Cpp 实现模式匹配。笔者认为 std::expected 将会是成为大一统的错误处理方式。这种强制判断结果无疑减少了工程师的心智负担。
引用
^ C++ cppreference std::expected link
Last updated