简述Linux的信号处理

简述Linux的信号处理

背景

工作上有一个需求:希望在程序crash的情况下能够回收内存中的一些数据,将其落到硬盘上,所以研究了一下Signal Handle。

什么是信号?

信号是软件中断,提供了一种处理异步事件的方法,它会中断程序正常执行,然后去执行注册的信号处理函数。例如:终端用户键入中断键,会通过信号机制停止一个程序。在Linux系统下有31种信号(新版可能会有扩展),包括我们熟悉的:SIGINT(Ctrl + C)、SIGSEGV(段错误)、SIGTERM(终止信号)等。

信号状态

  • 信号产生(generation):硬件异常(除0)、软件条件(如alarm定时器超时)、终端产生的信号或调用kill函数
  • 信号递送(delivery):进程可以处理这个信号了
  • 信号未决的(pending):在信号generation和delivery之间的时间间隔内,信号的状态是pending

可靠信号与不可靠信号

可靠信号:

  • 定义:可靠信号又称为实时信号,信号代码从SIGRTMIN到SIGRTMAX之间的信号都是可靠信号。

  • 特性:可靠信号支持排队,即如果发送了多个相同的可靠信号到同一进程,这些信号都会被接收并排队等待处理。内核会为每个接收到的可靠信号分配一个sigqueue结构,并注册在进程的未决信号链中,因此不存在信号丢失的问题。

  • 应用:可靠信号通常用于需要确保信号被准确接收和处理的场景,如实时系统、多线程程序等。

不可靠信号:

  • 定义:不可靠信号又称为非实时信号,信号代码从1到32(如SIGHUP到SIGSYS)都是不可靠信号。
  • 特性:不可靠信号不支持排队,即如果发送了多个相同的不可靠信号到同一进程,这些信号可能会被合并或丢弃,只保留一个信号等待处理。此外,不可靠信号在每次处理完之后,通常会恢复成默认处理,这可能是调用者不希望看到的。
  • 应用:不可靠信号通常用于传统的UNIX系统信号处理,如进程终止(SIGINT)、非法内存访问(SIGSEGV)等。

如何产生信号?

很多条件都可以产生信号:

  1. 当用户按某些终端键时,引发终端产生的信号,比如Ctrl + C产生的SIGINT信号
  2. 硬件异常产生信号:除数为0、无效的内存引用等,这些由硬件检测到,并通知内核。内核为该条件发生时正在运行的进程产生适当的信号,例如:SIGSEGV
  3. 进程调用kill函数可将任意信号发送给另一个进程或进程组,不过一些限制:要么发送和接收是同一个所有者,要么发送进程具备超级用户权限
  4. 用户可用kill命令将信号发送给其他进程,只是对kill函数的封装
  5. 进程调用pthread_kill函数可以向任意一个线程发送信号
  6. 当检测到某种软件条件已经发生,并应将其通知有关进程时也产生信号
  7. raise函数

信号处理?

因为产生信号的事件对进程而言是随机出现的,所以进程不能判断怎么时候信号发生了,只能通过系统调用告诉内核"此信号发生时,请执行下列操作"。在某个信号出现时,可以告诉内核按下列3中方式之一进行处理,称之为Signal Handler:

  1. 忽略此信号,不做任何处理,SIGKILL和SIGSTOP是不可忽略的
  2. 捕捉信号,注册一个signal handler函数来处理信号
  3. 执行系统默认动作,大部分系统默认动作时终止进程,有些信号还会产生core文件

捕捉信号

signal函数

signal 是一个用于设置信号处理方式的函数,它允许程序在接收到特定信号时执行自定义的处理函数,或者采用默认的处理方式,也可以选择忽略该信号。

注意事项:

  • 当信号发生后,第二次发生,信号会恢复到系统默认的处理动作上。(测试了Linux系统发现并不是这样的,所以不同的操作系统实现不一样)
  • 信号处理函数应该尽量简单快速,避免执行复杂的操作或长时间的阻塞操作,因为信号可能在任何时候中断程序的执行。
  • 信号处理可能会被其他信号中断,所以在信号处理函数中要考虑到这种情况。
  • 不同的操作系统对信号的处理可能会有所不同,所以在跨平台开发时需要注意兼容性问题。
  • 一旦设置了信号处理函数,它将在程序的整个生命周期内有效,除非再次调用 signal 函数来改变信号的处理方式。

sigaction函数

sigaction函数的功能是检测或修改(或检查并修改)与指定信号相关联的处理动作。

c 复制代码
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
  • signum:要捕捉的信号的编号。例如,SIGINT 表示中断信号(通常由 Ctrl+C 产生),SIGTERM 表示终止信号等。
  • act:指向一个 struct sigaction 结构体的指针,该结构体包含了要设置的信号处理程序的详细信息。如果此参数为 NULL,则不会更改信号的处理程序,但可以用来获取当前信号的处理程序(通过 oldact 参数)。
  • oldact:指向一个 struct sigaction 结构体的指针,用于存储先前的信号处理程序信息。如果此参数为 NULL,则不保存旧的信号处理程序信息。
c 复制代码
struct sigaction {
    void (*sa_handler)(int);           // 信号处理函数
    void (*sa_sigaction)(int, siginfo_t *, void *); // 扩展的信号处理函数
    sigset_t sa_mask;                  // 在处理该信号时要阻塞的其他信号
    int sa_flags;                      // 控制信号处理行为的标志
    void (*sa_restorer)(void);         // 废弃字段(通常不使用)
};
  1. sa_handler:这是一个指向信号处理函数的指针。当某个信号发生时,操作系统会调用这个函数。该函数接受一个 int 类型的参数,表示信号编号(如 SIGINT, SIGTERM 等)。自定义信号处理函数用于处理信号,也可以是特殊值 SIG_DFL(执行该信号的默认处理动作)或 SIG_IGN(忽略该信号)。

  2. sa_sigaction:这是 sa_handler 的一个增强版本,适用于需要获取更详细信号信息的情况。当 sa_flags 中设置了 SA_SIGINFO 标志时,sa_sigaction 会被调用,而不是 sa_handler。它接受三个参数:信号编号、指向 siginfo_t 结构体的指针(提供关于信号的更多详细信息,如信号来源、进程 ID 等)和指向与信号相关的上下文信息的指针(如 CPU 寄存器的状态)。

  3. sa_mask:这是一个 sigset_t 类型的信号集,用于指定在处理当前信号时,应该被阻塞的其他信号。在信号处理程序运行时,sa_mask 中的信号会被暂时阻塞,以防止它们中断当前的信号处理。可以通过 sigemptyset() 清空信号集,或通过 sigaddset() 添加需要阻塞的信号。

  4. sa_flags:这是一组标志位,用于指定信号处理行为。常见的标志位包括:

    1. SA_RESTART:让被信号中断的系统调用自动重启。
    2. SA_SIGINFO:启用 sa_sigaction 处理信号,而非 sa_handler。
    3. SA_NOCLDSTOP:如果信号为 SIGCHLD,当子进程暂停时,不发送此信号。
    4. SA_RESETHAND:当调用信号处理函数时,将信号的处理函数重置为缺省值 SIG_DFL。
    5. SA_NODEFER:在调用信号处理程序时不将本信号添加到进程的信号屏蔽字中。
  5. sa_restorer:这是一个过时的字段,通常不需要设置和使用。它曾经用于指定信号处理函数返回时的清理函数,但现在已经被废弃。

示例:

c 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void signal_handler(int signum) {
    printf("Caught signal %d\n", signum);
}

int main() {
    struct sigaction act;
    // 指定处理函数
    act.sa_handler = signal_handler;
    // 清空信号掩码,表示不阻塞任何信号
    sigemptyset(&act.sa_mask);
    // 使用默认标志
    act.sa_flags = 0;
    // 注册 SIGINT 信号的处理程序
    sigaction(SIGINT, &act, NULL);
    // 无限循环,等待信号
    while (1) {
        printf("Waiting for signal...\n");
        sleep(1);
    }
    return 0;
}

阻塞信号

进程可以选用"阻塞信号递送"。如果为进程产生了一个阻塞的信号,而且对该信号的动作是系统默认动作或捕捉该信号,则为该进程将此信号保持为未决状态,直到该进程对此信号解除了阻塞,或者将对此信号的动作更改为忽略。进程可以调用sigpending函数来判断哪些信号是设置为阻塞并处于未决状态。

每个进程都有一个信号屏蔽字(signal mask),它规定了当前要阻塞递送到该进程的信号集。可以使用sigprocmask函数来检测和更改当前的信号屏蔽字。

示例程序:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <setjmp.h>

// 信号处理函数(实际上在这个例子中不会被调用,因为SIGINT被阻塞了)
void handle_sigint(int signum) {
    printf("Caught SIGINT (signal %d), but this should not happen immediately.\n", signum);
}

// 全局变量用于设置jmpbuf,以便在需要时跳出循环
jmp_buf env;

// 另一个信号处理函数,用于设置全局变量并跳出循环(虽然在这个例子中不被直接用于SIGINT)
void handle_sigterm(int signum) {
    longjmp(env, 1);
}

int main() {
    sigset_t block_set, pending_set;
    struct sigaction act;

    // 设置SIGTERM的处理函数为handle_sigterm,以便我们可以优雅地跳出循环
    act.sa_handler = handle_sigterm;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    if (sigaction(SIGTERM, &act, NULL) == -1) {
        perror("sigaction SIGTERM");
        exit(EXIT_FAILURE);
    }

    // 初始化jmpbuf,以便在需要时可以跳出循环
    if (setjmp(env) != 0) {
        printf("Received SIGTERM, exiting gracefully.\n");
        exit(EXIT_SUCCESS);
    }

    // 将SIGINT加入到阻塞信号集中
    sigemptyset(&block_set);
    sigaddset(&block_set, SIGINT);
    if (sigprocmask(SIG_BLOCK, &block_set, NULL) == -1) {
        perror("sigprocmask SIGINT");
        exit(EXIT_FAILURE);
    }

    printf("SIGINT is now blocked. Waiting for 5 seconds...\n");
    sleep(5);

    // 检查是否有SIGINT信号在等待(在这个例子中,应该不会有,因为我们还没有解除阻塞)
    sigemptyset(&pending_set);
    if (sigpending(&pending_set) == -1) {
        perror("sigpending");
        exit(EXIT_FAILURE);
    }
    if (sigismember(&pending_set, SIGINT)) {
        printf("SIGINT is pending, but this should not happen because it's blocked.\n");
    } else {
        printf("No SIGINT is pending, as expected.\n");
    }

    // 从阻塞信号集中移除SIGINT
    if (sigprocmask(SIG_UNBLOCK, &block_set, NULL) == -1) {
        perror("sigprocmask SIGINT unblock");
        exit(EXIT_FAILURE);
    }

    printf("SIGINT is now unblocked. You can now interrupt the program with Ctrl+C.\n");

    // 无限循环,等待信号(现在SIGINT可以被捕捉到了)
    while (1) {
        printf("Waiting for signals...\n");
        sleep(1);
    }

    // 注意:由于上面的无限循环,下面的代码实际上永远不会被执行到。
    // 为了测试SIGINT的处理,你可以发送SIGTERM信号来跳出循环(例如,使用kill命令)。

    return 0;
}

中断的系统调用

某些系统调用可以被信号中断,系统返回EINTR的errno码,此时需要根据系统调用返回值再次调用系统调用;有一些系统调用支持自动重启动,但是最好不要依赖它,因为各个系统(UNIX、Linux)实现都不一样,并且也很难确定哪些系统调用实现了自动重启动。

Async-signal-safe

Signal Handler中不是所有函数都可以被调用:假设程序正在执行malloc,此时由于捕捉到信号而插入执行该信号处理函数,其中有调用了malloc,这时可能破坏堆内存的维护链表。

Single UNIX Specifications说明了哪些函数可以被信号处理函数调用,这些函数是可重入的并被成为异步信号安全的(async-signal safe)。

多线程与信号处理

参考:https://cloud.tencent.com/developer/news/1260924

关键点:

  1. 每个线程都可以处理信号,操作系统会优先将信号递送给引发信号的线程,所以类似glog的FailureWriter才可以输出crash的backtrace
  2. 每个线程都有自己的阻塞信号集,控制自己响应哪些信号或阻塞哪些信号,API是phtread_sigmask
  3. 每个线程都有自己的未决信号队列,也有共享的未决信号队列(主线程)

实战

不可靠信号,多次产生信号信号处理函数会被重复调用吗?

c 复制代码
    #include <iostream>
    #include <csignal>
    #include <cstdlib>
    #include <unistd.h>

    void HandleSIGINT(int signum) {
      std::cout << "\n捕获到SIGINT信号,程序即将退出..." << std::endl;
    }

    int main() {
     signal(SIGINT, HandleSIGINT);
      while (1) {
        sleep(1);
      }
    }

运行结果:从运行结果来看,即使signal函数也是支持反复处理信号的,和UNIX的设计还是不一样的。

bash 复制代码
yunjingguang@walle:~/work/signal$ ./signal_test
^C
捕获到SIGINT信号,程序即将退出...
^C
捕获到SIGINT信号,程序即将退出...
^C
捕获到SIGINT信号,程序即将退出...
^C

信号屏蔽字对不可靠信号是否产生作用?

c 复制代码
    #include <iostream>
    #include <csignal>
    #include <cstdlib>
    #include <unistd.h>

    void HandleSIGINT(int signum) {
      std::cout << "\n捕获到SIGINT信号,程序即将退出..." << std::endl;
    }

    int main() {
     signal(SIGINT, HandleSIGINT);
     sigset_t block_set;
     sigemptyset(&block_set);
     sigaddset(&block_set, SIGINT);
     if (sigprocmask(SIG_BLOCK, &block_set, NULL) == -1) {
         perror("sigprocmask SIGINT");
         exit(EXIT_FAILURE);
     }

      while (1) {
        sleep(1);
      }
    }

运行结果:看起来已经将SIGINT屏蔽掉了

bash 复制代码
yunjingguang@walle:~/work/signal$ ./signal_test
^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C^C

解读一下glog的FailureWriter

注册信号处理函数,并且sa_flags是SA_SIGINFO,会进一步输出细节信息:

c 复制代码
void InstallFailureSignalHandler() {
#ifdef HAVE_SIGACTION
  // Build the sigaction struct.
  struct sigaction sig_action;
  memset(&sig_action, 0, sizeof(sig_action));
  sigemptyset(&sig_action.sa_mask);
  sig_action.sa_flags |= SA_SIGINFO;
  sig_action.sa_sigaction = &FailureSignalHandler;

  for (auto kFailureSignal : kFailureSignals) {
    CHECK_ERR(sigaction(kFailureSignal.number, &sig_action, nullptr));
  }
  kFailureSignalHandlerInstalled = true;
#elif defined(GLOG_OS_WINDOWS)
  for (size_t i = 0; i < ARRAYSIZE(kFailureSignals); ++i) {
    CHECK_NE(signal(kFailureSignals[i].number, &FailureSignalHandler), SIG_ERR);
  }
  kFailureSignalHandlerInstalled = true;
#endif  // HAVE_SIGACTION
}

// Dumps signal and stack frame information, and invokes the default
// signal handler once our job is done.
#if defined(GLOG_OS_WINDOWS)
void FailureSignalHandler(int signal_number)
#else
void FailureSignalHandler(int signal_number, siginfo_t* signal_info,
                          void* ucontext)
#endif
{
  std::call_once(signaled, &HandleSignal, signal_number
#if !defined(GLOG_OS_WINDOWS)
                 ,
                 signal_info, ucontext
#endif
  );
}

Signal Handler的Tips

  1. callback使用C语言的函数指针,保证生命周期的安全性
  2. std::once_flag,解决重入的问题
  3. sem_post是async-signal-safe的,可以在Signal Handler中调用,用于通知其他线程开始收尾
  4. 在信号处理函数中获取pthread id,获得是发生问题的线程的ID,它会中断
相关推荐
xmweisi0213 分钟前
Ansible内置模块之 group
linux·运维·ansible·rhce·rhca·红帽认证
小猪写代码19 分钟前
Ubuntu 系统默认已安装 python,此处只需添加一个超链接即可
linux·python·ubuntu
孤寂大仙v1 小时前
【Linux笔记】——Linux线程理解与分页存储的奥秘
linux·运维·笔记
有谁看见我的剑了?2 小时前
ubuntu 22.04 wifi网卡配置地址上网
linux·运维·ubuntu
码农新猿类2 小时前
Ubuntu摄像头打开失败
linux·运维·ubuntu
jstart千语2 小时前
【消息队列】RabbitMQ基本认识
java·服务器·分布式·rabbitmq
PWRJOY2 小时前
Ubuntu磁盘空间分析:du命令及常用组合
linux·运维·ubuntu
ASDyushui2 小时前
Shell 编程之正则表达式与文本处理器
linux·正则表达式
wanhengidc2 小时前
SCDN能够运用在物联网加速当中吗?
运维·服务器·网络
leona_nuaa3 小时前
p2p虚拟服务器
服务器·网络协议·p2p