🐼浅析 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++ 中,有诸多错误处理方式, 有 异常, 错误码, 选择合适的错误处理方式对于定位问题有一定的帮助。

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

机器配置:

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;
}
  • 使用expected处理

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 的时候,做了如下几件事情:

  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 仓库中搜索:

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

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

  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% 时间开销。

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::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.pdf) 提出了在 Cpp 实现模式匹配。笔者认为 std::expected 将会是成为大一统的错误处理方式。这种强制判断结果无疑减少了工程师的心智负担。

引用

  1. ^ C++ cppreference std::expected link

Last updated