一剑破万法:noexcept与C++异常导致的coredump

2023-02-26 11:02:13 浏览数 (2)

作为C/C 程序员,最不想见到的就是coredump。coredump的原因有很多,今天我只谈其中的一种,那就是由于异常没有被catch导致的coredump。这是十分常见的一大的coredump原因,尤其是在大型C 在线服务中。

从一篇知乎文章讲起

先看一位知友的文章:

C 11 std::thread异常coredump导致调用堆栈丢失问题的跟踪和解决(std::teminate)

这篇文章说的时候作者遇到一次std::thread执行时coredump,但经过gdb调试后却无法一眼看到coredump的代码位置。这是因为core的原因是在回调函数中,如果不是被std::thread回调,本身C 异常导致的coredump在gdb调试时是能直观看到出问题的代码行的。

有时候coredump不可怕,但是core栈不清晰最可怕。然后作者使用了极其高深而琐细的方法,最终定位到了引发coredump的代码。不得不说作者其实很厉害,我也从中学到不少。但其实这有更简便的方法,请听我细细道来!

演示代码

借用一下这位知友后来写的demo验证代码:

代码语言:c 复制
#include <iostream>
#include <thread>
#include <vector>
void thread_func() {
    std::cout << "thread_func start ..." << std::endl;
    std::vector<int> vec;
    //vec.push_back(1);
    //vec.push_back(2);
    std::cout << vec.at(1) << std::endl;
}
int main (void) {
    std::thread th1(thread_func);
    th1.join();
    return 0;
}

该程序运行后会触发一个coredump。由于是demo代码,所以其实你一眼就能找到bug所在,但这不是重点,让我们假装不知,然后去排查。

典型的coredump堆栈

gdb打开coredump文件后,bt命令展示的堆栈信息如下:

代码语言:txt复制
Program terminated with signal 6, Aborted.
#0  0x00007fa9f0015387 in raise () from /lib64/libc.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-326.el7_9.x86_64 libgcc-4.8.5-44.el7.x86_64 libstdc  -4.8.5-44.el7.x86_64
(gdb) bt
#0  0x00007fa9f0015387 in raise () from /lib64/libc.so.6
#1  0x00007fa9f0016a78 in abort () from /lib64/libc.so.6
#2  0x00007fa9f0b41a95 in __gnu_cxx::__verbose_terminate_handler() () from /lib64/libstdc  .so.6
#3  0x00007fa9f0b3fa06 in ?? () from /lib64/libstdc  .so.6
#4  0x00007fa9f0b3fa33 in std::terminate() () from /lib64/libstdc  .so.6
#5  0x00007fa9f0b963c5 in ?? () from /lib64/libstdc  .so.6
#6  0x00007fa9f03b4ea5 in start_thread () from /lib64/libpthread.so.0
#7  0x00007fa9f00ddb0d in clone () from /lib64/libc.so.6

这是一个非常典型的coredump文件。请记住不管你在实际生产过程中是多么复杂的C 程序,只要coredump文件中有signal 6int raise()int abort()这三个关键字,基本就可以大概率确认这是一起由于异常没有被catch而导致的coredump。

在实际生产过程中采用原作者的排查方法无疑比较繁琐的,而且未必有这样的条件(因为涉及到修改libstdc 的源码,重新编译,重新连接)。其实我说的简便的方法就是C 11开始引入的noexcept关键字!

修改演示代码

来给回调函数加上noexcept声明:

代码语言:c 复制
#include <iostream>
#include <thread>
#include <vector>
void thread_func() noexcept {
    std::cout << "thread_func start ..." << std::endl;
    std::vector<int> vec;
    //vec.push_back(1);
    //vec.push_back(2);
    std::cout << vec.at(1) << std::endl;
}
int main (void) {
    std::thread th1(thread_func);
    th1.join();
    return 0;
}

重新编译执行,然后gdb调试coredump文件。这次的core堆栈如下:

代码语言:txt复制
Program terminated with signal 6, Aborted.
#0  0x00007f35b2889387 in raise () from /lib64/libc.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-326.el7_9.x86_64 libgcc-4.8.5-44.el7.x86_64 libstdc  -4.8.5-44.el7.x86_64
(gdb) bt
#0  0x00007f35b2889387 in raise () from /lib64/libc.so.6
#1  0x00007f35b288aa78 in abort () from /lib64/libc.so.6
#2  0x00007f35b33b5a95 in __gnu_cxx::__verbose_terminate_handler() () from /lib64/libstdc  .so.6
#3  0x00007f35b33b3a06 in ?? () from /lib64/libstdc  .so.6
#4  0x00007f35b33b29b9 in ?? () from /lib64/libstdc  .so.6
#5  0x00007f35b33b3624 in __gxx_personality_v0 () from /lib64/libstdc  .so.6
#6  0x00007f35b2e4c8e3 in ?? () from /lib64/libgcc_s.so.1
#7  0x00007f35b2e4cc7b in _Unwind_RaiseException () from /lib64/libgcc_s.so.1
#8  0x00007f35b33b3c46 in __cxa_throw () from /lib64/libstdc  .so.6
#9  0x00007f35b3408b17 in std::__throw_out_of_range(char const*) () from /lib64/libstdc  .so.6
#10 0x0000000000401595 in std::vector<int, std::allocator<int> >::_M_range_check (this=0x7f35b2851e60, __n=1) at /usr/include/c  /4.8.2/bits/stl_vector.h:794
#11 0x0000000000401313 in std::vector<int, std::allocator<int> >::at (this=0x7f35b2851e60, __n=1) at /usr/include/c  /4.8.2/bits/stl_vector.h:812
#12 0x0000000000400fde in thread_func () at demo.cpp:9
#13 0x000000000040262f in std::_Bind_simple<void (*())()>::_M_invoke<>(std::_Index_tuple<>) (this=0xd32040) at /usr/include/c  /4.8.2/functional:1732
#14 0x0000000000402589 in std::_Bind_simple<void (*())()>::operator()() (this=0xd32040) at /usr/include/c  /4.8.2/functional:1720
#15 0x0000000000402522 in std::thread::_Impl<std::_Bind_simple<void (*())()> >::_M_run() (this=0xd32028) at /usr/include/c  /4.8.2/thread:115
#16 0x00007f35b340a330 in ?? () from /lib64/libstdc  .so.6
#17 0x00007f35b2c28ea5 in start_thread () from /lib64/libpthread.so.0
#18 0x00007f35b2951b0d in clone () from /lib64/libc.so.6

看#11 的位置已经指出了demo.cpp的第9行,即:

代码语言:c 复制
std::cout << vec.at(1) << std::endl;

bRPC社区的案例

通过前面的解读,我们可以发现发生在回调函数中未被catch的异常所引发的coredump,在不加noexcept声明的情况下,其堆栈信息颇为隐晦。在C 在线服务中,回调函数自然必不可少,不管是多线程或者是协程的代码,都会用到回调函数。比如实现接口的代码都是被RPC框架所调用的回调函数。

前面的demo中,回调函数是极为简单的,但在实际生产环境中,业务逻辑十分复杂,因此存在大量的函数嵌套调用,稍不注意异常就会被连续抛到RPC框架的调度逻辑中,此时更难以觉察,甚至会误导业务程序员以为是RPC框架自身的bug。比如在bRPC社区中就多次出现这样的issue:

  • #2081:http_rpc_protocol.cpp中core掉了, 看起来不像是业务代码导致的
  • #1437:运行一段时间,就会core在brpc内部
  • #165:在brpc接口内部core,但是使用gdb分析时遇到问题

这些对于bRPC的误解,其实才是本文写作的初衷

#1437为例,看一下其中的core堆栈信息:

代码语言:txt复制
#0 0x00007f635e6fb597 in raise () from /lib64/libc.so.6
#1 0x00007f635e6fcdc8 in abort () from /lib64/libc.so.6
#2 0x00007f635f0029d5 in __gnu_cxx::__verbose_terminate_handler() () from /lib64/libstdc  .so.6
#3 0x00007f635f000946 in ?? () from /lib64/libstdc  .so.6
#4 0x00007f635efff909 in ?? () from /lib64/libstdc  .so.6
#5 0x00007f635f000574 in __gxx_personality_v0 () from /lib64/libstdc  .so.6
#6 0x00007f635ea99903 in ?? () from /lib64/libgcc_s.so.1
#7 0x00007f635ea99e37 in _Unwind_Resume () from /lib64/libgcc_s.so.1
#8 0x000056490472bc40 in operator() (this=, obj=) at incubator-brpc-0.9.7/src/brpc/destroyable.h:35
#9 ~unique_ptr (this=, __in_chrg=) at /usr/include/c  /4.8.2/bits/unique_ptr.h:184
#10 ~DestroyingPtr (this=, __in_chrg=) at incubator-brpc-0.9.7/src/brpc/destroyable.h:41
#11 brpc::policy::ProcessNsheadRequest (msg_base=) at incubator-brpc-0.9.7/src/brpc/policy/nshead_protocol.cpp:325
#12 0x00005649046e7eda in brpc::ProcessInputMessage (void_arg=void_arg@entry=0x5649087f8840) at incubator-brpc-0.9.7/src/brpc/input_messenger.cpp:135
#13 0x00005649046e8bf3 in operator() (this=, last_msg=0x5649087f8840) at incubator-brpc-0.9.7/src/brpc/input_messenger.cpp:141
#14 brpc::InputMessenger::OnNewMessages (m=0x7f5fa8f07040) at /usr/include/c  /4.8.2/bits/unique_ptr.h:184
#15 0x000056490479339d in brpc::Socket::ProcessEvent (arg=0x7f5fa8f07040) at incubator-brpc-0.9.7/src/brpc/socket.cpp:1017
#16 0x00005649046bcaca in bthread::TaskGroup::task_runner (skip_remained=skip_remained@entry=1) at incubator-brpc-0.9.7/src/bthread/task_group.cpp:296
#17 0x00005649046bcdcb in bthread::TaskGroup::run_main_task (this=this@entry=0x5649085aa4e0) at incubator-brpc-0.9.7/src/bthread/task_group.cpp:157
#18 0x00005649046b720e in bthread::TaskControl::worker_thread (arg=0x564907f066e0) at incubator-brpc-0.9.7/src/bthread/task_control.cpp:76
#19 0x00007f635f2b2dc5 in start_thread () from /lib64/libpthread.so.0
#20 0x00007f635e7c04dd in clone () from /lib64/libc.so.6

通过ProcessNsheadRequest()这个函数,可知这是一个nshead协议的bRPC服务,nshead是百度内部一个古老的RPC协议,bRPC也支持该协议。如果是更通用的baidu_std协议的bRPC服务,那么堆栈信息中应该是ProcessRpcRequest(),比如issue#165

直观来看,确实是core在了bthread调度的地方,run_main_task() -> task_runner() -> ProcessEvent() -> OnNewMessages()

因此导致bRPC的使用者,将矛头直指bRPC,但真相并非如此。其本质是接口service的业务实现中出现了未被catch的异常。

但具体是什么代码抛出的异常,其实仍然难以排查,如果是增量上线引入的代码,通过review本次上线的代码应该可以发现端倪。我们应该勤于在自己的业务函数中加上noexcept声明,这样更为及时地获取准确的coredump堆栈信息。但若要避免线上经常出现此类问题,则需要我们养成一个好编码习惯,请继续阅读。

C 在线服务与异常的最佳实践

以下经验不止适用于bRPC服务,其他C RPC框架的使用者也应该能从中受益。

不在服务运行时抛异常

由于C 的异常规格与Java差异较大,对于是否该使用C 的异常,C 圈子内向来争论不休。

我个人的经验是:在在线服务中,不应当在服务运行时主动throw异常。这里的服务运行中主要指的是请求处理的业务代码中。虽然异常意味着本次请求已经完全不可能继续正常处理。但若主动抛出异常,而本函数内或函数的整个调用链上都遗漏了对这种异常的catch,那么服务就会core掉。从而导致同期能够正常处理的请求也得不到处理。

当然这里说的是单进程多线程/多协程的服务,对于多进程单线程处理请求的服务而言,单进程coredump该服务仍然可以继续工作。不过这种多进程的模式在在线服务中不太流行。

另外服务运行时不throw异常还包括一些其他的背景线程。比如服务内有一个词典组件,该组件会定期热加载词典文件。加载过程在运行在一个单独的线程中的,这种线程内的函数也要避免throw异常。

当然凡事并无绝对,受限于业务场景,有些时候也存在一些workaround

服务启动时可以抛异常

对于一个在线服务而言,除了正常的运行时业务代码,还有一部分代码是在服务启动时去throw异常。所谓的服务启动时,就是该服务在正常处理外部请求之前,要先做各类初始化操作(比如各类组件的初始化),所以该阶段也可以称作初始化阶段。此时由于并不处理请求,因此可以抛出异常,直截了当地终止服务。彼时查看coredump堆栈,可以快速发现哪一处初始化失败了。

勤于给函数加上noexcept声明

即使遵守了前面的准则,我们不主动throw异常,但未必能完全规避异常。比如在使用标准库或者某些第三方库的时候,仍然有可能抛出异常。这时就需要我们在可能抛异常的第一现场加上异常对应的catch逻辑,从而避免其继续跑到上层调用的函数中。

因此我们要勤于给函数加上noexcept声明,当然无差别的noexcept声明,确实会让代码略显冗余。确认哪些函数是必要添加的位置,从勤于变成善于,这需要一些经验。当然无差别的noexcept也并非不可取。

lambda表达式添加noexcept声明

除了普通函数、成员函数外,lambda函数也可以添加noexcept声明。比如:

代码语言:c 复制
#include <iostream>
#include <thread>
#include <vector>
int main (void) {
    std::vector<int> vec;
    std::thread th1([&]() noexcept {
        std::cout << "thread_func start ..." << std::endl;
        //vec.push_back(1);
        //vec.push_back(2);
        std::cout << vec.at(1) << std::endl;
    });
    th1.join();
    return 0;
}

兜底:service函数加上noexcept

以bRPC的echo_server为例,下面是一个提供Echo接口的服务。实现该接口的执行逻辑就是自定义类型继承proto生成的Service父类,然后覆写虚函数Echo。这时我们可以该这个Echo函数加一下noexcept声明。

虽然抛出异常的代码未必就在Echo中,而可能是Echo层层调用的千里之外的某个函数中。但加上noexcept之后,当业务代码抛出异常时,也不会让人误以为的core在RPC框架中,避免干扰排查方向。故而是一种兜底的做法。

代码语言:c 复制
class EchoServiceImpl : public EchoService {
public:
    EchoServiceImpl() {}
    virtual ~EchoServiceImpl() {}
    virtual void Echo(google::protobuf::RpcController* cntl_base,
                      const EchoRequest* request,
                      EchoResponse* response,
                      google::protobuf::Closure* done) noexcept {
             ...
        }
    }
};

是否应该使用标准库/第三方库中会抛出异常的函数?

我们需要熟悉哪些标准库的函数或者第三方库的函数会抛异常。比如STL容器中at()函数都是会做越界检查的,会抛异常。我个人强烈建议程序员自己做边界检查,避免使用at()。比如:

代码语言:c 复制
vector<int> v;
...
if (i < v.size()) {
    ...  // 使用v[i]
}

map<string, float> m;
...
auto it = m.find(key);
if (it != m.end()) {
   auto& value = it->second;
   ...  // 使用value
}

当然这样严格的使用限制虽然避免了线上coredump的风险,但是可能会导致自己也业务逻辑的bug无法被发现。比如在你预期的逻辑中,使用v[i]m[key]的时候永远不会越界。但是你在实现出现bug的时候,在某些极少数的边界情况出现了越界。这时候由于做了边界检查,导致功能上线了很长时间,而未发现有bug。这后果有时候可能更严重。

我们也可以给上面的if都补一个else去做日志打印或者报警之类的功能,但如果想更快发现bug,避免bug产生实际影响,那么我建议你在这种情况下,使用at(),并且给整个函数加上noexcept声明,从而让coredump快速定位。这也就是我前面提到的『不在服务时抛异常』的一些workaround情况。

noexcept specifier

基本介绍

前面我所提到的在函数声明中加入noexcept声明的用法,被称为noexcept specifier。其实这只是noexcept这个关键字的其中一个用法,还有另外一个用法,我们稍后会讲。先关注noexcept specifier。可以参考:https://en.cppreference.com/w/cpp/language/noexcept_spec

所谓noexcept,其实是noexcept(true)的简化,同样可以声明成noexcept(false)表示可能会抛异常。

代码语言:c 复制
void foo() noexcept(true);  // 等价于 void foo() noexcept;
void bar() noexcept(false); // 基本等价于 void bar();

自定义函数在没有加noexcept或noexcept(true)声明的时候,其默认是noexcept(false)。但对于一些特殊函数即使在没有显式添加noexcept声明时,也可能是noexcept(true)的。比如所有析构函数在C 11以后默认是noexcept的。这是语法规范的一部分。当然你也可以显式地修改它:

代码语言:c 复制
class A {
public:
    ~A() noexcept(false) {}
}

注意,本文以下内容中在未特殊指明的时候,所说的noexcept声明,都指的是noexcept(true)含义的noexcept声明。

noexcept与多态

如果在类中把某个虚函数声明成noexcept,那么在继承这个类的子类中,其同名函数必须也要声明成noexcept,否则编译直接失败。

这对于框架与组件库的设计者来说是一个极好的功能,方便限制住使用方在实现子类的时候不会漏掉noexcept,从而减少后续排查coredump的麻烦。但是请注意,对于框架和组件库中一些已有的函数如果之前没有加noexcept,后续就不要再加了。因为会破坏前向兼容性。会导致使用者存量的代码无法编译通过!

因此作为框架与组件库的设计者应该在第一次释出新函数接口的时候,就考虑加上noexcept声明。

再说个题外话,当在子类中需要覆写的虚函数同时使用override和noexcept的时候,要保证noexcept在前。

代码语言:c 复制
class A {
public:
    A() {}
    ~A() {}
    virtual void echo() noexcept {}
};
class B : public A {
public:
    ~B() override {}
    void echo() noexcept override {}
};

noexcept与直接throw

通常当你给一个函数加上noexcept声明的时候,就不应该在这个函数中再显式地throw异常了。

代码语言:c 复制
void foo() noexcept {
    ...
    throw runtime_error("... error");
}

编译的时候会出现编译警告:

代码语言:shell复制
In function ‘void foo()’:
warning: throw will always call terminate() [-Wterminate]
13 | throw runtime_error("... error");

如果开启g 的-Werror-Werror=terminate编译选项,则会直接编译失败。

当然如果在noexcept(true)函数中调用了一个内部会throw异常的函数,这种情况是不会编译警告或编译失败的。

noexcept与函数的声明与定义

在函数声明与定义分离的时候,如果在声明函数的头文件中的加入了noexcept声明。那么在定义函数的源文件中也要加上noexcept。而前面我们所提到的override关键字在函数声明与定义分离的时候,只能在函数声明的时候添加!

noexcept operator

前面提到的noexcept用法都是noexcept specifier,其实它还有另外一个用法是noexcept operator,用于判定一个表达式是否是noexcept的。

代码语言:c 复制
#include <iostream>
using namespace std;
void foo() noexcept {
}

void bar() noexcept(false) {  // 或者 void bar() {
} 

int main() {
    cout<<"foo() check noexcept:" << noexcept(foo()) << endl;
    cout<<"bar() check noexcept:" << noexcept(bar()) << endl;
    return 0;
}

运行后输出:

代码语言:shell复制
foo() check noexcept:1
bar() check noexcept:0

注意,noexcept operator判断是一个表达式,不是函数。所以要使用noexcept(foo())而不是noexcept(foo)。其实这不难理解,因为foo本身可能存在重载:

代码语言:c 复制
#include <iostream>
using namespace std;
void foo() noexcept {
}

void foo(int i) {
} 

int main() {
    cout<<"foo() check noexcept:" << noexcept(foo()) << endl;
    cout<<"foo(1) check noexcept:" << noexcept(foo(1)) << endl;
    return 0;
}

运行后输出:

代码语言:shell复制
foo() check noexcept:1
foo(1) check noexcept:0

另外尽管这个例子中我是用cout来输出的,但是其实noexcept operator是在编译期间求值的,也就是说程序运行时noexcept operator是无开销的。

不信你可以这样来做一下测试:

代码语言:c 复制
#include <iostream>
using namespace std;
void foo() noexcept {
}

void bar() noexcept(false) {  // 或者 void bar() {
} 

int main() {
    static_assert(noexcept(foo()), " foo is not noexcept");
    static_assert(noexcept(bar()), " bar is not noexcept");
    return 0;
}

这个代码在编译阶段就会失败,编译输出:

代码语言:shell复制
error: static assertion failed: bar is not noexcept
11 | static_assert(noexcept(bar()), " bar is not noexcept");

noexcept operator也可以用来检测,类的成员函数,前面我们提到过类的析构函数默认都是noexcept(true)的,对于类中其他默认的函数,在不加声明的时候具体是noexcept(true)还是noexcept(false),是比较复杂的。在本文中不过度展开了,有兴趣可以阅读:

https://en.cppreference.com/w/cpp/language/noexcept

noexcept不是coredump万金油!

请注意虽然本文标题十分标题党地使用了『一剑破万法』的说法,但是这个『万法』仅仅指的是各类C 异常(Exception),对于其他原因导致的coredump,比如访问非法内存地址触发coredump,noexcept并不会有太大帮助!

所以noexcept并不是排查coredump的万金油,它只对异常导致的coredump有效。

0 人点赞