背景
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++ 中,有诸多错误处理方式, 有 异常
, 错误码
, 选择合适的错误处理方式对于定位问题有一定的帮助。
在本文中,笔者通过重写一份算法来对比这三种的性能差异。
机器配置:
Cpu: 7950x3D (16Core32Thread) @5.8Ghz
Memory: 32GB DDR5
Os: ArchLinux x86_64 kernel-linux-6.9.7
g++ version: 14.1.1
google_benchmark_version: 1.8.4-1
默认使用 g++ 编译,开启 O2 优化。
算法
IP => uint32 这个是一个很常见的需求,通过压缩 ip 即可通过 4 bytes 来存储一个 ip 地址。
class ParseException : public std::exception {
public:
explicit ParseException(std::string&& msg) noexcept : error_msg_(std::move(msg)) {}
explicit ParseException(const std::string& msg) noexcept
: error_msg_(msg) {}
explicit ParseException(const char *msg) noexcept : error_msg_(msg) {}
[[nodiscard]] const char *what() const noexcept override { return error_msg_.c_str(); }
std::string error_msg_;
};
uint32_t decode_ip_exception(const std::string &ip) {
// check ip size 0.0.0.0 ~ 111.111.111.111
if (ip.size() < 7 || ip.size() > 15) {
throw ParseException(
std::format("ip size {} is valid, should in (7, 15)", ip.size()));
}
size_t begin_idx = 0;
std::vector<std::string> split_str;
// split the ip
while (true) {
auto find_idx = ip.find('.', begin_idx);
if (find_idx == std::string::npos) {
auto value =
std::string(ip.c_str() + begin_idx, ip.size() - begin_idx);
if (value.empty()) {
throw ParseException(std::move(
std::format("split number {} can't empty", value)));
}
split_str.emplace_back(value);
break;
}
std::string value(ip.c_str() + begin_idx, find_idx - begin_idx);
if (value.empty()) {
throw ParseException(std::move(std::format("empty parse value is invalid")));
}
try {
split_str.emplace_back(value);
} catch (const std::exception &e) {
throw ParseException(e.what());
}
// update find index
begin_idx = find_idx + 1;
}
if (split_str.size() != 4) {
throw ParseException(std::move(
std::format("split string size {} is invalid", split_str.size())));
}
uint32_t result;
for (int i = 0; i < 4; i++) {
int v;
try {
v = std::stoi(split_str[i]);
} catch (const std::exception &e) {
throw ParseException(e.what());
}
if (v < 0 || v > 255) {
throw ParseException(std::move(
std::format("parse number: {} should in (0, 255)", v)));
}
(reinterpret_cast<uint8_t *>((&result)))[i] = v;
}
return result;
}
std::expected<uint32_t, std::string> decode_ip_expected(
const std::string& ip) noexcept {
if (ip.size() < 7 || ip.size() > 16) {
return std::unexpected(
std::format("ip size {} is valid, should in (7, 15)", ip.size()));
}
int begin_idx = 0;
std::vector<std::string> split_str;
while (true) {
auto find_idx = ip.find('.', begin_idx);
if (find_idx == std::string::npos) {
auto value =
std::string(ip.c_str() + begin_idx, ip.size() - begin_idx);
if (value.empty()) {
return std::unexpected(
std::format("split number {} can't be empty", value));
}
try {
split_str.emplace_back(value);
} catch (const std::exception& e) {
return std::unexpected(e.what());
}
break;
}
std::string value(ip.c_str() + begin_idx, find_idx - begin_idx);
if (value.empty()) {
return std::unexpected(
std::format("parse value is empty is invalid"));
}
try {
split_str.emplace_back(value);
} catch (const std::exception& e) {
return std::unexpected(e.what());
}
begin_idx = find_idx + 1;
}
if (split_str.size() != 4) {
return std::unexpected(
std::format("split string size {} is invalid", split_str.size()));
}
uint32_t result;
for (int i = 0; i < 4; i++) {
int v;
try {
v = std::stoi(split_str[i]);
} catch (const std::exception& e) {
return std::unexpected(e.what());
}
if (v < 0 || v > 255) {
return std::unexpected(
std::format("parse number: {} should in (0, 255)", v));
}
(reinterpret_cast<uint8_t*>((&result)))[i] = v;
}
return result;
}
使用类似 golang 方式处理,同时返回结果和错误,通过判断错误是否为空来做处理。
std::pair<uint32_t, std::string> decode_ip_error(
const std::string& ip) noexcept {
static constexpr auto ErrParseNumber = -1;
if (ip.size() < 7 || ip.size() > 15) {
return std::make_pair(
ErrParseNumber,
std::format("ip size {} is valid, should in (7, 15)", ip.size()));
}
size_t begin_idx = 0;
std::vector<std::string> split_str;
while (true) {
auto find_idx = ip.find('.', begin_idx);
if (find_idx == std::string::npos) {
auto value = std::string(ip.c_str() + begin_idx, ip.size() - begin_idx);
if (value.empty()) {
return std::make_pair(ErrParseNumber, std::format("split number {} can't empty", value));
}
try {
split_str.emplace_back(value);
} catch (const std::exception& e) {
return std::make_pair(ErrParseNumber, e.what());
}
break;
}
std::string value(ip.c_str() + begin_idx, find_idx - begin_idx);
if (value.empty()) {
return std::make_pair(ErrParseNumber, std::format("empty parse value is invalid"));
}
try {
split_str.emplace_back(value);
} catch (const std::exception & e) {
return std::make_pair(ErrParseNumber, e.what());
}
begin_idx = find_idx + 1;
}
if (split_str.size() != 4) {
return std::make_pair(ErrParseNumber, std::format("split string size {} is invalid", split_str.size()));
}
uint32_t result;
for (int i = 0; i < 4; i++) {
int v;
try {
v = std::stoi(split_str[i]);
} catch (const std::exception& e) {
return std::make_pair(ErrParseNumber, e.what());
}
if (v < 0 || v > 255) {
return std::make_pair(ErrParseNumber, std::format("parse number: {} should in (0, 255)", v));
}
(reinterpret_cast<uint8_t *>((&result)))[i] = v;
}
return std::make_pair(result, std::string());
}
benchmark
std::vector<std::string> ip_strs;
const static int len = 10000;
void bench_expected(benchmark::State& state) {
std::vector<uint32_t> vec_res;
std::vector<uint32_t> vec_err;
int idx = 0;
for ([[maybe_unused]] auto _ : state) {
auto res = decode_ip_expected(ip_strs[(idx++) % len]);
if (res.has_value()) {
vec_res.push_back(res.value());
} else {
vec_err.push_back({});
}
}
}
void bench_exception(benchmark::State& state) {
std::vector<uint32_t> vec_res;
std::vector<uint32_t> vec_err;
int idx = 0;
for ([[maybe_unused]] auto _ : state) {
try {
auto res = decode_ip_exception(ip_strs[(idx++) % len]);
vec_res.push_back(res);
}
catch (const ParseException& e) {
vec_err.push_back({});
} catch (const std::exception& e) {
vec_err.push_back({});
}
}
}
void bench_error(benchmark::State& state) {
std::vector<uint32_t> vec_res;
std::vector<uint32_t> vec_err;
int idx = 0;
for ([[maybe_unused]] auto _ : state) {
auto [res, err] = decode_ip_error(ip_strs[(idx++) % len]);
if (err.empty()) {
vec_res.push_back(res);
} else {
vec_err.push_back({});
}
}
}
void initialize() {
ip_strs.reserve(len);
int i = 0;
for (; i < (len / 4); i++) {
ip_strs.push_back(std::format("192.168.110.{}", (i % 256)));
}
srand((unsigned long)(time(nullptr)));
for (; i < (len / 2); i++) {
ip_strs.push_back(std::to_string(rand()));
}
for (; i < len; i++) {
ip_strs.push_back(std::format("192.168.110.{}", (i % 256)));
}
}
BENCHMARK(bench_error);
BENCHMARK(bench_exception);
BENCHMARK(bench_expected);
int main(int argc, char** argv) {
initialize();
benchmark::Initialize(&argc, argv);
benchmark::RunSpecifiedBenchmarks();
return 0;
}
ip_strs
是全局的数据集,通过构造错误和正确的数据来模拟现实使用情况。需要说明的是,大多数情况而言,实际上用到的数据大多数时候是正确的。故笔者将同时构造全部正确数据和全错误数据以及混合数据,来对比二者的性能。
只需改几行代码:
// 正常数据的初始化
void initialize() {
ip_strs.reserve(len);
int i = 0;
for (; i < (len); i++) {
ip_strs.push_back(std::format("192.168.110.{}", (i % 256)));
}
}
测试 3 次:
bench_error 92.3 ns 92.2 ns 7550984
bench_exception 89.8 ns 89.7 ns 7787362
bench_expected 90.5 ns 90.4 ns 7696559
bench_error 101 ns 100 ns 6783972
bench_exception 97.6 ns 97.4 ns 7106187
bench_expected 98.2 ns 98.0 ns 7020005
bench_error 100 ns 100 ns 6920981
bench_exception 92.7 ns 92.6 ns 7151969
bench_expected 97.8 ns 97.7 ns 7051804
通过笔者多次测试,发现这三种算法,在数据集不发生错误时,均表现正常。但是使用异常的方式略微胜出。笔者在此猜测,由于另外两种方式对结果都需要进行判断,但是只有exception
的方式,直接使用结果,只有当错误抛出时,才处理。这就导致了他有微弱的优势。
数据 1/2 半错误测试:
void initialize() {
ip_strs.reserve(len);
int i = 0;
for (; i < (len / 4); i++) {
ip_strs.push_back(std::format("192.168.110.{}", (i % 256)));
}
srand((unsigned long)(time(nullptr)));
for (; i < (len / 2); i++) {
ip_strs.push_back(std::format("{}.{}.{}.{}", rand(), rand(), rand(), rand()));
}
for (; i < len; i++) {
ip_strs.push_back(std::format("192.168.110.{}", (i % 256)));
}
}
bench_error 88.4 ns 88.3 ns 7912950
bench_exception 267 ns 267 ns 2472229
bench_expected 80.1 ns 80.0 ns 8558828
bench_error 89.1 ns 88.9 ns 7848088
bench_exception 265 ns 265 ns 2527210
bench_expected 82.4 ns 82.3 ns 8573386
bench_error 93.0 ns 92.9 ns 7517722
bench_exception 274 ns 273 ns 2533749
bench_expected 88.2 ns 88.1 ns 7982644
可以发现的是,异常在这里表现不是很好,他的开销大约是 error 和 expected 的 3 倍左右。
分析
遇事不决,flamegraph 先行。我们通过修改代码,只对 bench_exception 进行采样。
perf 采样命令:
perf record --freq=997 --call-graph dwarf -q -o ./xxx.data /home/sehep/github/error_bench/build/error
和预想的一样, 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 仓库中搜索:
// libstdc++-v3/libsupc++/eh_throw.cc:77-99
/**
* @param: obj: 异常对象指针
* @param: tinfo: 异常类型信息
* @param: dest: 异常对象析构函数指针
*/
extern "C" void
__cxxabiv1::__cxa_throw (void *obj, std::type_info *tinfo,
void (_GLIBCXX_CDTOR_CALLABI *dest) (void *))
{
PROBE2 (throw, obj, tinfo);
// 看上去了拿到了全局管理异常的一个class
__cxa_eh_globals *globals = __cxa_get_globals ();
// 引用计数 +1
globals->uncaughtExceptions += 1;
// Definitely a primary.
// 构造最先开始需要处理的异常
__cxa_refcounted_exception *header =
__cxa_init_primary_exception(obj, tinfo, dest);
//
header->referenceCount = 1;
#ifdef __USING_SJLJ_EXCEPTIONS__
_Unwind_SjLj_RaiseException (&header->exc.unwindHeader);
#else
_Unwind_RaiseException (&header->exc.unwindHeader);
#endif
// Some sort of unwinding error. Note that terminate is a handler.
__cxa_begin_catch (&header->exc.unwindHeader);
// 如果他没有被 catch 住,
std::terminate ();
}
火焰图中 _Unwind_RaiseException
这个函数占了 _cxa_throw
的 95% 时间开销。该函数处在 libunwind
之中。
// src/unwind/RaiseException.c
_Unwind_Reason_Code
_Unwind_RaiseException (struct _Unwind_Exception *exception_object)
{
uint64_t exception_class = exception_object->exception_class;
_Unwind_Personality_Fn personality;
struct _Unwind_Context context;
_Unwind_Reason_Code reason;
unw_proc_info_t pi;
unw_context_t uc;
unw_word_t ip;
int ret;
Debug (1, "(exception_object=%p)\n", exception_object);
if (_Unwind_InitContext (&context, &uc) < 0)
return _URC_FATAL_PHASE1_ERROR;
/* Phase 1 (search phase) */
while (1)
{
if ((ret = unw_step (&context.cursor)) <= 0)
{
if (ret == 0)
{
Debug (1, "no handler found\n");
return _URC_END_OF_STACK;
}
else
return _URC_FATAL_PHASE1_ERROR;
}
if (unw_get_proc_info (&context.cursor, &pi) < 0)
return _URC_FATAL_PHASE1_ERROR;
personality = (_Unwind_Personality_Fn) (uintptr_t) pi.handler;
if (personality)
{
reason = (*personality) (_U_VERSION, _UA_SEARCH_PHASE,
exception_class, exception_object,
&context);
if (reason != _URC_CONTINUE_UNWIND)
{
if (reason == _URC_HANDLER_FOUND)
break;
else
{
Debug (1, "personality returned %d\n", reason);
return _URC_FATAL_PHASE1_ERROR;
}
}
}
}
/* Exceptions are associated with IP-ranges. If a given exception
is handled at a particular IP, it will _always_ be handled at
that IP. If this weren't true, we'd have to track the tuple
(IP,SP,BSP) to uniquely identify the stack frame that's handling
the exception. */
if (unw_get_reg (&context.cursor, UNW_REG_IP, &ip) < 0)
return _URC_FATAL_PHASE1_ERROR;
exception_object->private_1 = 0; /* clear "stop" pointer */
exception_object->private_2 = ip; /* save frame marker */
Debug (1, "found handler for IP=%lx; entering cleanup phase\n", (long) ip);
/* Reset the cursor to the first frame: */
if (unw_init_local (&context.cursor, &uc) < 0)
return _URC_FATAL_PHASE1_ERROR;
return _Unwind_Phase2 (exception_object, &context);
}
大概分析一下这个函数的流程:
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% 时间开销。
static _Unwind_Reason_Code ALWAYS_INLINE
_Unwind_Phase2 (struct _Unwind_Exception *exception_object,
struct _Unwind_Context *context)
{
_Unwind_Stop_Fn stop = (_Unwind_Stop_Fn) exception_object->private_1;
uint64_t exception_class = exception_object->exception_class;
void *stop_parameter = (void *) exception_object->private_2;
_Unwind_Personality_Fn personality;
_Unwind_Reason_Code reason;
_Unwind_Action actions;
unw_proc_info_t pi;
unw_word_t ip;
int ret;
actions = _UA_CLEANUP_PHASE;
if (stop)
actions |= _UA_FOR
看上去他做了这几件事情:
设置 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