# 浅析 Cpp 错误处理

### 背景

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

  ```cpp
  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 地址。
* 使用异常来处理:

```cpp
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处理

```cpp
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 方式处理，同时返回结果和错误，通过判断错误是否为空来做处理。

```cpp
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

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

只需改几行代码:

```cpp
// 正常数据的初始化
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 次:

```rust
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 半错误测试:

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

```rust
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 采样命令:

```bash
 perf record --freq=997 --call-graph dwarf -q -o ./xxx.data /home/sehep/github/error_bench/build/error
```

<figure><img src="https://271127613-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FUYk64eTIve1AoPVgZeUO%2Fuploads%2Frm7kODdOj2MCys2QHQD1%2F2024-07-06_19-07-1720265848.jpg?alt=media&#x26;token=8a271994-5b95-4f8f-a698-8fe293bc3184" alt=""><figcaption><p>decode_exception 火焰图</p></figcaption></figure>

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

借助神器 `CompilerExplorer` 分析汇编: [link](https://godbolt.org/z/n3v37rj1E)

<figure><img src="https://271127613-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FUYk64eTIve1AoPVgZeUO%2Fuploads%2FlB65ypeJL3dMuZwvXbN2%2F2024-07-06_19-07-1720267144.jpg?alt=media&#x26;token=bc54e828-06e2-4eac-a85b-47245d515b08" alt=""><figcaption><p>函数汇编对应</p></figcaption></figure>

可以看到 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 仓库中搜索:

```c
// 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` 之中。

<pre class="language-cpp"><code class="lang-cpp"><strong>// src/unwind/RaiseException.c
</strong><strong>_Unwind_Reason_Code
</strong>_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 (&#x26;context, &#x26;uc) &#x3C; 0)
    return _URC_FATAL_PHASE1_ERROR;

  /* Phase 1 (search phase) */

  while (1)
    {
      if ((ret = unw_step (&#x26;context.cursor)) &#x3C;= 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 (&#x26;context.cursor, &#x26;pi) &#x3C; 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,
                                   &#x26;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 (&#x26;context.cursor, UNW_REG_IP, &#x26;ip) &#x3C; 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 (&#x26;context.cursor, &#x26;uc) &#x3C; 0)
    return _URC_FATAL_PHASE1_ERROR;

  return _Unwind_Phase2 (exception_object, &#x26;context);
}
</code></pre>

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

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

```cpp
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 将会是成为大一统的错误处理方式。这种强制判断结果无疑减少了工程师的心智负担。

### 引用

1. ^ C++ cppreference std::expected [link](https://en.cppreference.com/w/cpp/utility/expected)


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://yang-fanspersonal-organization.gitbook.io/blog/ji-shu/qian-xi-cpp-cuo-wu-chu-li.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
