如何在进程崩溃后打印堆栈并防止数据丢失

进程在运行过程中遇到逻辑错误, 比如除零, 空指针等等, 系统会触发一个软件中断.
这个中断会以信号的方式通知进程, 这些信号的默认处理方式是结束进程.
发生这种情况, 我们就认为进程崩溃了.

进程崩溃后, 我们会希望知道它是为何崩溃的, 是哪个函数, 哪行代码引起的错误.
另外, 在进程退出前, 我们还希望做一些善后处理, 比如把某些数据存入数据库, 等等.

下面, 我会介绍一些技术来达成这两个目标.

1. 在core文件中查看堆栈信息

如果进程崩溃时, 我们能看到当时的堆栈信息, 就能很快定位到错误的代码.
在 gcc 中加入 -g 选项, 可执行文件中便会包含调试信息. 进程崩溃后, 会生成一个 core 文件.
我们可以用 gdb 查看这个 core 文件, 从而知道进程崩溃时的环境.

在调试阶段, core文件能给我们带来很多便利. 但是在正式环境中, 它有很大的局限:
1. 包含调试信息的可执行文件会很大. 并且运行速度也会大幅降低.
2. 一个 core 文件常常很大, 如果进程频繁崩溃, 硬盘资源会变得很紧张.

所以, 在正式环境中运行的程序, 不会包含调试信息.
它的core文件的大小, 我们会把它设为0, 也就是不会输入core文件.
在这个前提下, 我们如何得到进程的堆栈信息呢?

2. 动态获取线程的堆栈

c 语言提供了 backtrace 函数, 通过这个函数可以动态的获取当前线程的堆栈.
要使用 backtrace 函数, 有两点要求:
1. 程序使用的是 ELF 二进制格式.
2. 程序连接时使用了 -rdynamic 选项.
-rdynamic可用来通知链接器将所有符号添加到动态符号表中, 这些信息比 -g 选项的信息要少得多.

下面是将要用到的函数说明:
#include <execinfo.h>

int backtrace(void **buffer,int size);
用于获取当前线程的调用堆栈, 获取的信息将会被存放在buffer中, 它是一个指针列表。
参数 size 用来指定buffer中可以保存多少个void* 元素。
函数返回值是实际获取的指针个数, 最大不超过size大小
注意: 某些编译器的优化选项对获取正确的调用堆栈有干扰,
另外内联函数没有堆栈框架; 删除框架指针也会导致无法正确解析堆栈内容;

扫描二维码关注公众号,回复: 5434640 查看本文章

char ** backtrace_symbols (void *const *buffer, int size)
把从backtrace函数获取的信息转化为一个字符串数组.
参数buffer应该是从backtrace函数获取的指针数组,
size是该数组中的元素个数(backtrace的返回值) ;
函数返回值是一个指向字符串数组的指针, 它的大小同buffer相同.
每个字符串包含了一个相对于buffer中对应元素的可打印信息.
它包括函数名,函数的偏移地址, 和实际的返回地址.
该函数的返回值是通过malloc函数申请的空间, 因此调用者必须使用free函数来释放指针.
注意: 如果不能为字符串获取足够的空间, 函数的返回值将会为NULL.

void backtrace_symbols_fd (void *const *buffer, int size, int fd)
与backtrace_symbols 函数具有相同的功能,
不同的是它不会给调用者返回字符串数组, 而是将结果写入文件描述符为fd的文件中,每个函数对应一行.

3. 捕捉信号

我们希望在进程崩溃时打印堆栈, 所以我们需要捕捉到相应的信号. 方法很简单.
#include <signal.h>
void (*signal(int signum,void(* handler)(int)))(int);
或者:      typedef void(*sig_t) ( int );
                sig_t signal(int signum,sig_t handler);
参数说明:
第一个参数signum指明了所要处理的信号类型,它可以是除了SIGKILL和SIGSTOP外的任何一种信号。
第二个参数handler描述了与信号关联的动作,它可以取以下三种值:
1. 一个返回值为正数的函数的地址, 也就是我们的信号处理函数.
这个函数应有如下形式的定义: int func(int sig); sig是传递给它的唯一参数。
执行了signal()调用后,进程只要接收到类型为sig的信号,不管其正在执行程序的哪一部分,就立即执行func()函数。
当func()函数执行结束后,控制权返回进程被中断的那一点继续执行。
2. SIGIGN, 忽略该信号.
3. SIGDFL, 恢复系统对信号的默认处理。
返回值: 返回先前的信号处理函数指针,如果有错误则返回SIG_ERR(-1)。
 
注意:
当一个信号的信号处理函数执行时,如果进程又接收到了该信号,该信号会自动被储存而不会中断信号处理函数的执行,
直到信号处理函数执行完毕再重新调用相应的处理函数。
如果在信号处理函数执行时进程收到了其它类型的信号,该函数的执行就会被中断。
在信号发生跳转到自定的handler处理函数执行后,系统会自动将此处理函数换回原来系统预设的处理方式,
如果要改变此操作请改用sigaction()。

4. 实例

下面我们实际编码, 看看具体如何在捕捉到信号后, 打印进程堆栈, 然后结束进程.

#include <iostream>
#include <time.h>
#include <signal.h>
#include <string.h>
#include <execinfo.h>
#include <fcntl.h>
#include <map>

using namespace std;

map<int, string> SIG_LIST;

#define SET_SIG(sig) SIG_LIST[sig] = #sig;

void SetSigList(){
    SIG_LIST.clear();
    SET_SIG(SIGILL)//非法指令
    SET_SIG(SIGBUS)//总线错误
    SET_SIG(SIGFPE)//浮点异常
    SET_SIG(SIGABRT)//来自abort函数的终止信号
    SET_SIG(SIGSEGV)//无效的存储器引用(段错误)
    SET_SIG(SIGPIPE)//向一个没有读用户的管道做写操作
    SET_SIG(SIGTERM)//软件终止信号
    SET_SIG(SIGSTKFLT)//协处理器上的栈故障
    SET_SIG(SIGXFSZ)//文件大小超出限制
    SET_SIG(SIGTRAP)//跟踪陷阱
}

string& GetSigName(int sig){
    return SIG_LIST[sig];
}

void SaveBackTrace(int sig){
    //打开文件
    time_t tSetTime;
    time(&tSetTime);
    tm* ptm = localtime(&tSetTime);
    char fname[256] = {0};
    sprintf(fname, "core.%d-%d-%d_%d_%d_%d",
        ptm->tm_year+1900, ptm->tm_mon+1, ptm->tm_mday,
        ptm->tm_hour, ptm->tm_min, ptm->tm_sec);
    FILE* f = fopen(fname, "a");
    if (f == NULL){
        exit(1);
    }
    int fd = fileno(f);

    //锁定文件
    flock fl;
    fl.l_type = F_WRLCK;
    fl.l_start = 0;
    fl.l_whence = SEEK_SET;
    fl.l_len = 0;
    fl.l_pid = getpid();
    fcntl(fd, F_SETLKW, &fl);

    //输出程序的绝对路径
    char buffer[4096];
    memset(buffer, 0, sizeof(buffer));
    int count = readlink("/proc/self/exe", buffer, sizeof(buffer));
    if(count > 0){
        buffer[count] = '\n';
        buffer[count + 1] = 0;
        fwrite(buffer, 1, count+1, f);
    }

    //输出信息的时间
    memset(buffer, 0, sizeof(buffer));
    sprintf(buffer, "Dump Time: %d-%d-%d %d:%d:%d\n",
        ptm->tm_year+1900, ptm->tm_mon+1, ptm->tm_mday,
        ptm->tm_hour, ptm->tm_min, ptm->tm_sec);
    fwrite(buffer, 1, strlen(buffer), f);

    //线程和信号
    sprintf(buffer, "Curr thread: %d, Catch signal:%s\n",
        pthread_self(), GetSigName(sig).c_str());
    fwrite(buffer, 1, strlen(buffer), f);

    //堆栈
    void* DumpArray[256];
    int    nSize =    backtrace(DumpArray, 256);
    sprintf(buffer, "backtrace rank = %d\n", nSize);
    fwrite(buffer, 1, strlen(buffer), f);
    if (nSize > 0){
        char** symbols = backtrace_symbols(DumpArray, nSize);
        if (symbols != NULL){
            for (int i=0; i<nSize; i++){
                fwrite(symbols[i], 1, strlen(symbols[i]), f);
                fwrite("\n", 1, 1, f);
            }
            free(symbols);
        }
    }

    //文件解锁后关闭, 最后终止进程
    fl.l_type = F_UNLCK;
    fcntl(fd, F_SETLK, &fl);
    fclose(f);
    exit(1);
}

void SetSigCatchFun(){
    map<int, string>::iterator it;
    for (it=SIG_LIST.begin(); it!=SIG_LIST.end(); it++){
        signal(it->first, SaveBackTrace);
    }
}

void Fun(){
    int a = 0;
    int b = 1 / a;
}

static void* ThreadFun(void* arg){
    Fun();
    return NULL;
}

int main(){
    SetSigList();
    SetSigCatchFun();

    printf("main thread id = %d\n", (pthread_t)pthread_self());
    pthread_t pid;
    if (pthread_create(&pid, NULL, ThreadFun, NULL)){
        exit(1);
    }  
    printf("fun thread id = %d\n", pid);

    for(;;){
        sleep(1);
    }
    return 0;
}

文件名为 bt.cpp
编译: g++ bt.cpp -rdynamic -I /usr/local/include -L /usr/local/lib -pthread -o bt

主线程创建了 fun 线程, fun 线程有一个除零错误, 系统抛出 SIGFPE 信号.
该信号使 fun 线程中断, 我们注册的 SaveBackTrace 函数捕获到这个信号, 打印相关信息, 然后终止进程.
在输出的core文件中, 我们可以看到简单的堆栈信息.

5. 善后处理

在上面的例子中, fun 线程被 SIGFPE 中断, 转而执行 SaveBackTrace 函数.
此时, main 线程仍然在正常运行.
如果我们把 SaveBackTrace 函数最后的 exit(1); 替换成 for(;;)sleep(1);
main 线程就可以一直正常的运行下去.
利用这个特点, 我们可以做很多其它事情.

游戏的服务器进程常常有这些线程:
网络线程, 数据库线程, 业务处理线程. 引发逻辑错误的代码常常位于业务处理线程.
而数据库线程由于功能稳定, 逻辑简单, 是十分强壮的.
那么, 如果业务处理线程有逻辑错误, 我们捕捉到信号后, 可以在信号处理函数的最后,
通知数据库线程保存游戏数据.
直到数据库线程把游戏信息全部存入数据库, 信号处理函数才返回.
这样, 服务器宕机不会导致回档, 损失被大大降低.

要实现这个机制, 要求数据库模块和业务处理模块具有低耦合度.
当然, 实际应用的时候, 还有许多细节要考虑.
比如, 业务处理线程正在处理玩家的数据, 由于发生不可预知的错误, 玩家的数据被损坏了, 这些玩家的数据就不应该被存入数据库.

猜你喜欢

转载自blog.csdn.net/Zhanganliu/article/details/88219764