首页 > 编程语言 >【unix高级编程系列】信号

【unix高级编程系列】信号

时间:2024-08-19 20:58:15浏览次数:8  
标签:系列 int 编程 unix 信号 printf 进程 return include

引言

以前对信号的理解,仅仅停留在main函数入口注册几个异常信号(SIGPIPESIGSEGVSIGFPE)处理函数。当捕获到异常时,将进程的堆栈进行打印,方便排查、定位问题。这一类问题我认为是利用linux系统的异常信号机制,提高开发效率;后续随着工作经验的增长,linux的信号,还可以有其它用途:

  • 业务上的触发。比如可以监听SIGUSER1SIGUSER2信号,表示触发某一业务。
  • 提高软件的健壮性。比如可以监听SIGTERM信号(reboot命令内部,回向所有进程发送)。在系统重启前,做一些关键资源的备份或处理。
  • 定时器功能。比如通过监听SIGALRM信号,可以让系统在指定时间间隔,通知进程去做周期任务。

当然肯定还有其它的使用场景,等待着我去了解,拓展。本文主要介绍linux信号的相关概念,以及工作中的注意事项,常见接口的使用方式。希望能给到您帮助。

信号的概念

每一个信号都有一个名字,他们都是以SIG开头,比如:SIGABRT是夭折信号;SIGALRM是闹钟信号;

注:信号名都是被定义为正整数常量。

信号是一个异步事件。因此当内核检测到它是触发时,实际上有两个做法:

  1. 设置一个变量(如signal),应用程序周期判断该变量状态,判断触发信号类型。
  2. 内核中断当前进程的执行代码块,去执行指定操作。

很明显方案一存在时效性的问题,因为信号的发生是可能在任一时刻的。

linux 内核处理信号的方式有三种:

  1. 忽略此信号。但是SIGKILLSIGSTOP信号无法忽略,因为需要向内核和超级用户提供使进程终止的或停止的可靠方法。也就是说我们常见的SIGSEGV段错误,实际也可以让内核忽略,从而让进程不退出。但是我们往往不会这么操作,因为一旦发生类型错误,说明代码或业务已经出现异常,无法保证正确可靠的运行了。
  2. 捕捉信号。即告诉内核捕捉到该信号后,需要调用一个用户函数。这也是我们常见的做法。
  3. 执行系统默认动作。大多数信号的系统默认动作是终止该进程。可参考下表。
信号说明默认动作
SIGABRT调用abort函数使,产生此信号终止+core
SIGALRM调用alarmsetitimer函数,产生此信号终止
SIGBUS硬件故障终止+core
SIGCHLD子进程终止或停止时,会将该信号发送给父进程,期望回收子进程资源忽略
SIGCONT若当前进程处于停止状态,则进行运行。否则忽略忽略/继续
SIGEMT硬件故障终止+core
SIGFPE算数运算异常。如除以0、浮点溢出等终止+core
SIGHUP终端检测到一个连接断开,则将该信号发送给会话中所有进程忽略
SIGILL执行一条非法硬件指令终止+core
SIGINT当用户按下中断键(ctrl+c),则将该信号发送前台进程组中的所有进程终止
SIGIO一个异步I/O事件终止/忽略
SIGIOT硬件故障终止+core
SIGKILL不可被捕捉。向系统管理员提供了可以终止任一进程的可靠方法终止
SIGPIPE如果在管道的读进程已经终止时写管道、当类型为SOCK_STREAM的套接字已不再连接时,进程写该套接字都会触发该信号终止
SIGPWR用于具有不间断电源(UPS)的系统终止/忽略
SIGQUIT当用户输入Ctrl+\时,终端驱动程序产生此信号,并发送给前台进程组中的所有进程终止+core
SIGSEGV引用无效的内存地址,也就是我们常说的:内存越界终止+core
SIGSTOP不可被捕捉。这是一个作业信号,停止一个进程终止
SIGSYS无效的系统调用终止+core
SIGTERM系统默认终止信号,比如执行reboot命令,会向所有进程发送该信号。用户进程可通过捕捉该信号,在进程退出前做好清理工作终止
SIGTSTP当用户输入Ctrl+Z挂起键时,终端驱动程序产生此信号,并发送给前台进程组中的所有进程终止
SIGUSER1这是用户定义的信号终止
SIGUSER2这是用户定义的信号终止

其中core文件:复制了该进程的内存映像,方便后续调试。关于如何调试core文件,可参考linux gdb 调试专栏

修改系统对信号的处理方式

从上章节中内核对信号的处理方式有三种:忽略、捕捉、默认。我们可以通过signal函数进行设置。

#include <signal.h>
/**
 * @brief 信号注册处理函数
 * @details 
 *
 * @param [in] signo 信号值
 * @param [in] func 信号处理函数
 * @return     若成功,返回以前的信号处理配置;若出错,返回SIG_ERR
 * @note
 */
void (*signal(ing signo, void(*func)(int))) (int);

#define SIG_ERR (void (*)()) (-1) // 一般用于判断signal 接口是否成功
#define SIG_DFL (void (*)()) (0)  // 提示内核按照默认动作处理该信号
#define SIG_IGN (void (*)()) (1)  // 提示内核忽略该信号

注:我们常常仅关注signal返回值是否是SIG_ERR。但我觉得这是不充分的。

比如存在这样的场景:

你作为软件SDK的提供者,并且在内部捕捉了部分信号,方便用于进行调试或业务开发。但是SDK集成方,也注册了相关信号处理。那么就会出现竞争情况,导致意料之外的情况发生。

如下:

#include <signal.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>

void handlerAlarm(int signo)
{
    printf("signo = %d\n", signo);
    return;
}

void handlersdkAlarm(int signo)
{
    printf("signo = %d\n", signo);
    exit(-1);
    return;
}

int init_sdk()
{
    if (signal(SIGALRM, handlersdkAlarm) == SIG_ERR)
    {
        printf("registerSIGALRM failed\n");
        return -1;
    }
}

int main()
{
    if (signal(SIGALRM, handlerAlarm) == SIG_ERR)
    {
        printf("registerSIGALRM failed\n");
        return -1;
    }
    alarm(5);

    /** 第三方SDK */
    init_sdk();

    while (1)
    {
        sleep(60);
    }

    return 0;
}

分析:原本进程针对SIGALRM信号的处理,仅是日志打印记录。但是SDK提供方则任务这是一个异常,退出进程。这很明显就修改了进程的本意。

我的建议按照以下流程:

  1. 判断signal返回值是否是系统默认,,若是系统默认则说明没有应用对该信号捕获。
  2. 若不为系统默认处理,则发出警告或恢复。
    逻辑大致如下:
    void* pftmp = signal(SIGALRM, handlerAlarm);
    if (pftmp == SIG_ERR)
    {
        printf("registerSIGALRM failed\n");
        return -1;
    }
    if(pftmp != SIG_DFL)
    {
        printf(" SIGALRM have register\n");
        abort(0);
    }

这是通过技术手段避免对唯一资源的竞争使用判断,最简单的方式,则是提前与集成方沟通约束。类似的还有进程的标准输入输出也存在类似竞争问题。

程序启动后,信号的处理方式。

在之前的进程控制章节中,我们知道进程创建的方式有两种:

  • fork函数族。

当一个进程调用fork时,其子进程集成父进程的信号处理方式;

  • exec函数族。

exec函数将原先设置为要捕捉的信号都更改为默认状态。

其原因是:exec启动一个程序,其原理是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。因此之前注册的信号处理函数地址,在当前不一定存在意义,因此需要恢复系统默认。而fork创建的子进程会将父进程的代码段都复制,因此,可以继承父进程的信号处理方式。

中断的系统调用

linux 系统中有一个特性是:如果一个进程在执行一个低速系统调用而阻塞期间捕捉到一个信号,则该系统调用会被中断,不再继续执行。该系统调用返回错误,并将errno设置为EINIR

低速系统调用指的是可能会使进程永远阻塞的一类系统调用。包括:

  1. 如果某些类型行文件(如读管道、终端设备和网络设备)的数据不存在,则读操作(read,readv)可能会使调用者永远不会返回。
  2. pausewait函数。
  3. 某些ioctl函数。
  4. 某些进程间通信。
  5. 如果数据不能被相同的类型文件立即接受,则写操作(writewritev)可能会使调用者永远阻塞。

当我们知道低俗系统调用可能会被信号中断,那我们在编写代码时就需要增加相关防错。如下:

    /* 阻塞读取socket 数据*/
    int rByte = read(socket,buff,1024);
    if(rByte < 0)
    {
        /* 网络链接异常,断开重新连接*/
        close(socket); 
    }

上述代码似乎没有问题:当read出错时,则认为socket异常,重新建立连接。理论上功能都可以实现,但是稍微修改一下,我觉得可能会更好些。优化后版本:

    /* 阻塞读取socket 数据*/
    int rByte = read(socket,buff,1024);
    if(rByte < 0 && (errno != EINTR))
    {
        /* 网络链接异常,断开重新连接*/
        close(socket); 
    }

思考:现在的进程基本都是多线程任务,那么信号触发时,中断的是哪一个线程呢?

经过下列代码验证(sleep也可以被信号中断):

#include <signal.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>

void handlerSignal(int signo)
{ 
    printf("pid=%ld signo = %d\n",pthread_self(),signo);
    sleep(5);
    return;
}

void* thread_function(void* arg) 
{
    printf("thread_function pid=%ld\n",pthread_self());
    /** ALRM */
    alarm(5);

    /** abort */
    abort();
    
    /** SIGSEGV */
    strcpy(NULL,"123");
    while(1)
    {
        sleep(60);
        printf("thread_function sleep have broken\n");
    }
    return NULL;
}


int main()
{
    printf("main pid=%ld\n",pthread_self());
    if (signal(SIGALRM, handlerSignal) == SIG_ERR)
    {
        printf("registerSIGALRM failed\n");
        return -1;
    }
    if (signal(SIGABRT, handlerSignal) == SIG_ERR)
    {
        printf("registerSIGALRM failed\n");
        return -1;
    }
    if (signal(SIGSEGV, handlerSignal) == SIG_ERR)
    {
        printf("registerSIGALRM failed\n");
        return -1;
    }
    pthread_t pit;
    if(pthread_create(&pit,NULL,thread_function,NULL) != 0)
    {
        printf("create thread failed\n");
        return -1;
    }

    while (1)
    {
        sleep(60);
        printf("main sleep have broken\n");
    }

    return 0;
}
  • 进程外传入的信号(kill -signo pid),默认中断主线程。
  • 进程内部创建的信号,比如SIGALRM信号,也是中断主线程;但是SIGSEGVSIGABRT等信号中断的是触发的线程。

可重入函数

可重入函数必须要满足以下条件:

  1. 不使用静态或全局数据结构
  2. 不可以调用mallocfree
  3. 不可以是标准I/O

信号处理函数中保证调用可重入函数。否则对进程的影响是不可预估的。比如:进程正在执行malloc,在其堆中分配另外的存储空间,而此时由于捕捉到信号,信号处理函数中也调用了malloc,因为malloc通常为它维护了一个链表,信号处理函数中就修改了进程的链表。导致异常。这是因为早期的libc库中的malloc实现没有采用锁机制。即使加入了锁,信号处理函数也不建议调用,因为容易产生死锁。

线程安全和信号安全

有时候我们会接触到这两个概念,有时候傻傻分不清楚,容易在编码过程中造成一些隐患。

线程安全

线程安全是指一个函数可以被多个线程并发调用而不导致数据不一致或程序崩溃。线程安全函数应该满足以下条件:

  1. 不修改全局或静态数据,或者任何修改都必须要是原子操作或锁保护。
  2. 不返回指向静态数据的指针,或者确保指针在使用期间不会被修改。
  3. 只依赖于调用时的参数,不依赖于任何外部状态。

比如:

int g_count;
int countPlus()
{
    int count = g_count++;

    return count;
}

由于countPlus内部调用了全局变量,且没有用锁保护,因此它是线程不安全函数。可通过锁保证多线程安全:

pthread_mutex_t lock;
int g_count;
int countPlus()
{
    pthread_mutex_lock(&lock);  // 加锁
    int count = g_count++;
    pthread_mutex_unlock(&lock); // 解锁
    return count;
}

信号安全

信号安全是指一个函数可以在信号处理函数中被安全地调用,而不会导致未定义的行为。信号安全函数必须满足以下条件。

  1. 不调用任何非可重入函数:大多数可重入函数也是信号安全的。
  2. 不访问或修改全局或静态数据:除非这些数据是专门为信号处理而设计的,并且不受其他线程影响。

注:如上所示的线程安全函数countPlus,就不是信号安全函数。因为在执行int count=g_count++指令时,触发信号处理函数,并且信号处理函数中调用了countPlus接口,就会造成死锁。

SIGCLD信号用途

【unix高级编程系列】进程控制中,我们介绍了子进程退出后,如果没有对其进行资源回收,则会产生僵尸进程,导致对系统资源造成影响。通常情况下,我们的做法是在父进程中调用waitpid等待子进程结束,并回收资源。这样的做法会导致父进程业务阻塞,并不是好的方式。

之后了解到子进程结束时,会向父进程发送SIGCLD信号,因此我们可以从该信号做文章。总体有两种方式:

  1. 默认忽略,子进程将不再产生僵尸进程。如:
int main() 
{
    signal(SIGCHLD, SIG_IGN);
}
  1. 在信号处理函数中回收子线程资源。如:
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>

void sigchld_handler(int sig) 
{
    int status;
    pid_t child_pid = wait(&status);
    if (child_pid > 0) {
        printf("子进程 %d 已退出,状态: %d\n", child_pid, status);
    }
}

int main() {
    signal(SIGCHLD, sigchld_handler); // 设置SIGCHLD的处理函数

    pid_t pid = fork();

    if (pid > 0) {
        // 父进程
        // ... 父进程可以继续其他工作
    } else if (pid == 0) {
        // 子进程
        printf("子进程开始执行...\n");
        sleep(1); // 模拟子进程工作
        printf("子进程结束。\n");
        exit(0); // 子进程退出
    } else {
        // fork失败
        perror("fork");
        exit(1);
    }

    return 0;
}

常用信号函数

kill和raise

killraise都是发送信号。kill函数将信号发送给指定的进程或进程组;而raise是发送线程自身;

#include <signal.h>
int kill(pid_t pid, int signo);

int raise(int signo);
    // 若成功返回0;若出错返回-1;

通过raise(signo)等价于kill(getpid(),signo)

其中kill的pid参数有以下4种情况:

  • pid>0;将该信号发送给进程ID为pid的进程;
  • pid==0;将信号发送给发送进程属于同一进程组的所有进程;
  • pid<0;将信号发送给其进程组ID等于pid绝对值;
  • pid==-1;将信号发送给发送进程有权限像它们发送信号的所有进程;

注:signo==0,常被用来判断一个进程是否仍然存在。

alarm和pause

使用alarm函数可以设置一个定时器,在将来的某个时间该定时器会超时,并产生SIGALRM信号。如果忽略或不捕捉此信号,则默认终止该进程

#include <unistd.h>
unsigned int alarm(unsigned int seconds);
    //返回值: 0 或以前设置的闹钟时间的余留秒数

注: 每个进程只能有一个闹钟时钟;

pause函数是调用线程挂起,直至捕捉到一个信号。

#include <unistd.h>
int pause(void);
    //返回值:-1,errno 被设置为EINTR

注:pause仅是阻塞调用线程,并不会阻塞整个进程。如下示例:

#include <signal.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>


void* thread_function(void* arg) 
{
    int count = 0;   
    while(1)
    {
        sleep(3);
        printf("thread_function count=%d\n",count++);
        if(count == 3)
        {
            pause();
        }
    }
    return NULL;
}


int main()
{
    pthread_t pit;
    if(pthread_create(&pit,NULL,thread_function,NULL) != 0)
    {
        printf("create thread failed\n");
        return -1;
    }

    int count = 0;   
    while (1)
    {
        sleep(3);
        printf("main count=%d\n",count++);
    }

    return 0;
}

编译输出如下:

xieyihua@xieyihua:~/test$ gcc 6.c -o 6 -lpthread
xieyihua@xieyihua:~/test$ ./6
main count=0
thread_function count=0
main count=1
thread_function count=1
main count=2
thread_function count=2
main count=3
main count=4
main count=5
^C

sigaction函数

sigaction是检查或修改与指定信号相关联的处理动作。基本已取代了signal

#include <signal.h>
struct sigaction
{
    void (*sa_handler)(int); // 信号处理函数的地址、或SIG_IGN、或SIG_DFL
    sigset_t sa_mask; //是一个信号集,用于指定在处理信号时需要被阻塞的信号
    int sa_flag;    //saflags 是一些标志,用于改变 sigaction 的行为
    void (*sa_sigaction)(int , siginfo_t *,void*); //是另一个函数指针,用于更复杂的信号处理
};
int sigaction(int signo, 
                const struct sigaction *restrict act,
                struct sigaction *restrict oact);
    //返回值:若成功,返回0;若出错,返回-1

分析:

  • act指针非空,则要修改其动作。
  • oact指针非空,返回该信号的上一个动作。

常见用法:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

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

int main() {
    struct sigaction sa;

    sa.sa_handler = &handle_sigint;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;

    sigaction(SIGINT, &sa, NULL);

    while (1) {
        printf("Hello, World!\n");
        sleep(1);
    }

    return 0;
}

sleep函数

sleep是我们工作中经常使用的函数,但是我相信它的一些注意事项很多人都不太了解。可能会造成问题;

#include <unistd.h>
unsigned int sleep(unisigned int seconds);
    //返回值:0或未休眠的秒数

sleep函数返回的场景有两种:

  1. 已经过了seconds所指定的墙上时钟时间。
  2. 调用进程捕捉到一个信号,并从信号处理程序中返回。

注:墙上时钟时间指实际的物理时间,即现实世界中的时间

其中第二点是我们常常会忽略,造成错误的。假设有如下代码:

#include <signal.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>
int g_dothing = 0;

static void sig_alarm(int signo)
{
    alarm(5);
    g_dothing = 1;

    return;
}
void* thread_function(void* arg) 
{

    if(signal(SIGALRM,sig_alarm) == SIG_ERR)
    {
        printf("signal failed\n");
        return NULL;
    }
    alarm(5);
    while(1)
    {
        if(g_dothing == 1)
        {
            /**
             * TODO: 执行业务处理
             */
            printf("do someting\n");
            g_dothing = 0;
        }
        sleep(1);
    }
    return NULL;
}

int main()
{
    pthread_t pit;
    if(pthread_create(&pit,NULL,thread_function,NULL) != 0)
    {
        printf("create thread failed\n");
        return -1;
    }

    while (1)
    {
        sleep(60);
        /**
         * TODO: 上报心跳
         */
        printf("report heartbeat\n");
    }

    return 0;
}

分析:该进程有两个业务线程:

  1. 主线程周期60秒上报心跳;
  2. 子线程周期5秒,执行相关动作;

但实际运行过程中,主线程中的sleep会被子线程中定时器唤醒,因此主线程的上报周期变成了5秒。导致与预期不符。我们应该关注sleep函数的返回值,对主线程做以下优化:

   int unsleepTime = 60;
    while (1)
    {
        unsleepTime = sleep(unsleepTime);
        if(unsleepTime == 0)
        {
            /**
             * TODO: 上报心跳
             */
            printf("report heartbeat\n");
            unsleepTime = 60;
        }
    }

总结

本文详细介绍了Linux信号的概念、处理方式、常见信号的用途以及信号处理函数的使用。希望能给您带来帮助

若我的内容对您有所帮助,还请关注我的公众号。不定期分享干活,剖析案例,也可以一起讨论分享。
我的宗旨:
踩完您工作中的所有坑并分享给您,让你的工作无bug,人生尽是坦途
在这里插入图片描述

标签:系列,int,编程,unix,信号,printf,进程,return,include
From: https://blog.csdn.net/xieyihua1994/article/details/141334666

相关文章

  • OpenCV-Python系列之对极几何
    点击查看代码importnumpyasnpimportcv2ascvimg1=cv.imread("data1/1.png",0)#queryimageleftimageimg2=cv.imread("data1/2.png",0)#trainimagerightimagesift=cv.SIFT_create()#sift1=cv.xfeatures2d.SIFT_create()kp1,des1=sift.dete......
  • 你是如何克服编程学习中的挫折感的?
            在编程学习的征途中,挫折感无疑是每位学习者必经的考验之一。它不仅考验着我们的技术能力,更磨砺着我们的意志与心态。面对这一系列的挑战,找到适合自己的应对策略显得尤为重要。以下是我个人在编程学习过程中如何克服挫折感、穿越Bug迷宫、保持冷静面对复杂算法......
  • 问题回答:你是如何克服编程学习中的挫折感的?
    你是如何克服编程学习中的挫折感的?编程学习之路上,挫折感就像一道道难以逾越的高墙,让许多人望而却步。然而,真正的编程高手都曾在这条路上跌倒过、迷茫过,却最终找到了突破的方法。你是如何在Bug的迷宫中找到出口的?面对复杂的算法时,你用什么方法让自己保持冷静?让我们一起分享那些......
  • SpringBoot系列:使用原生JDBC实现对数据库的增删改查
    application.ymlspring:datasource:username:rootpassword:123456url:jdbc:mysql://localhost:3306/mybatis?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8driver-class-name:com.mysql.cj.jdbc.DriverApplicationTest......
  • 克服编程学习中的挫败感,收获满满的成就感
    文章目录一、前言二、克服编程学习过程中挫折感的方法2.1设定实际可行的目标2.2保持学习的新鲜感2.3理解学习过程是波动的2.4寻找学习伙伴或社群2.5反思和调整学习方法,学会复盘2.6保持好奇心和探索精神2.7接受失败并从中学习2.8寻求专业指导2.9庆祝小成就2.10......
  • 【Java 并发编程】(四) ThreadLocal 源码解读
     介绍每个Thread对象,内部有一个ThreadLocalMapthreadLocals,这是一个哈希表,底层是一个Node[]table;当在某个线程中调用ThreadLocal的set方法时,会使用Thread.currentThread获取当前先线程的thread对象,然后将ThreadLocal对象作为key,将set方法的参数作为value......
  • 存储系列之 Linux ext2 概述
     来自:https://www.cnblogs.com/orange-CC/p/12673052.html 存储系列之Linuxext2概述引言:学习经典永不过时。 我们之前介绍过存储介质主要是磁盘,先介绍过物理的,后又介绍了虚拟的。保存在磁盘上的信息一般采用文件(file)为单位,磁盘上的文件必须是持久的,同时文件是通过操......
  • 存储系列之 从ext2到ext3、ext4 的变化与区别
     来自:https://www.cnblogs.com/orange-CC/p/12673073.html 存储系列之从ext2到ext3、ext4的变化与区别引言:ext3和ext4对ext2进行了增强,但是其核心设计并没有发生变化。所以建议先查看上上篇的《存储系列之Linuxext2概述 》,有了ext2的基础,看这篇就是soeasy了。......
  • 如何利用sockserver模块编程实现客户端并发
    前面用sock模块写的服务端和客户端,存在一个大问题,就是当运行多个客户端的时候,必须等一个客户端运行结束,另一个客户端才能实现与服务端的交流,这显然不符合现实中的需求。有没有什么办法解决这个问题呢?有人说没有,屁话。当然有,这就需要用到一个sockserver的模块,用定义类继承类的方式......
  • 玩转Wireshark抓包神器教程 ---- 系列文章
    随笔分类 -  Wireshark  《熬夜整理》保姆级系列教程-玩转Wireshark抓包神器教程(5)-Wireshark捕获设置《熬夜整理》保姆级系列教程-玩转Wireshark抓包神器教程(4)-再识Wireshark《熬夜整理》保姆级系列教程-玩转Wireshark抓包神器教程(3)-Wireshark在MacOS系统上安......