【Linux】进程信号的详细介绍

前言
本节我们将讲解Linux信号这部分的内容,本章将介绍信号的产生,发送,信号的捕捉,屏蔽等操作,将对信号进行一些列系统的了解与学习。。。

1. 什么是信号

信号是给进程发送的,进程要具备处理信号的能力。

  • 用户输入命令,在shell下启动一个前台进程。
    用户按Ctrl +c, 这个时候键盘输入产生一个硬件中断,被os 获取,解释成信号,发送给目标前台进程。
    前台进程因为收到信号,进而引起进程退出。。
  • Ctrl-C 产生的信号只能发给前台进程。(命令后面加个&可以放到后台运行,这样shell不必等到进程结束,就可以接收新的命令)
  • Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生
    的信号
  • 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行 到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步 (Asynchronous)的

对于进程来讲,即便是信号还没有产生,我们进程已经具有识别和处理这个信号的能力了。。。
信号是进程之间事件异步通知的一种方式,属于软中断。。

1.1 信号的种类

使用kill -l命令罗列出来的内容叫做信号,我们可以看到目前Linux系统下64种不同的类型:
在这里插入图片描述

  • 没有32、33、0号信号。
  • 第一批1 ~ 31(普通信号)
  • 第二批34 ~ 64(实时信号)

**每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义 #define
SIGINT 2 **

这二者的差别是:早期有实时操作系统,我们现在使用的是分时操作系统。
基于时间片轮转,基于优先级抢占的调度算法。

1.2 信号处理常见方式概览

(sigaction函数稍后详细介绍),可选的处理动作有以下三种:

  1. 忽略此信号。(也是信号处理方式之一)
  2. 执行该信号的默认处理动作。
  3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉 (Catch)一个信号

2. 信号的产生:

有很多情况会产生信号:

  1. 系统调用接口(kill命令)
  2. 键盘产生(Ctrl + C,Ctrl + \ )

SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,现在我们来验证一 下

3.软件条件(进程停止,进程运行完退出)
举例: 管道通信,假如读端不光不读,而且还关闭了,写端一直在写,会发生什么?
答:OS会自动终止对应的写端进程,通过发送信号的方式,SIGPIPE(13)

4.硬件异常(比如除0错误)

上面所说的所有信号产生,最终都要有OS来进行执行,为什么?

OS是进程的管理者

2.1 Core Dump 问题

当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump(核心转储.

  • 进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因

  • 一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,

因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件**

  • Core Dump的应用场景:主要是为了调试:

在这里插入图片描述

  • 为什么生产环境一般都是要关闭Core Dump(比如云服务器)

答:因为如果大型程序,一旦代码有问题,会自动重启程序,如果一重启就挂掉,就会产生core文件,所以一直重启就会产生大量的core文件,就很大概率将磁盘空间被打满,此时就会危及到操作系统正常工作了

3. 信号的发送(保存):

信号发送的本质:OS 向目标进程写信号,OS直接修改PCB中的指定的位图结构(即:修改pending位图),完成“发送”信号的过程

  • 进程,必须具有保存信号的相关数据结构(位图,unisgned int), pcb内部保存了信号位图字段
  • 信号位图是在task_struct----》task_struct内核数据结构—》OS
  • 所有的信号发送方式,都是修改Pending位图的过程
  • 操作系统没有提供系统调用接口来直接设置pending位图(因为这个权限是给操作系统的),但我们可以获取Sigpending位图。
  1. 信号都是由操作系统向系统写入的:

计算机要是想向一个PCB进程发信号,本质上因为操作系统是进程的管理者, 可以直接以自身的身份来对进程的PCB数据结构的位图做任意修改。

崩溃现象就是底层代码引起了硬件的问题,进而被操作系统识别,然后操作系统将硬件问题识别成信号,然后向进程发送,然后终止进程

3. 1 信号产生的异步性

a.信号的产生是随机的,进程可能正在忙自己的事情,所以,信号的后续处理,可能不是立即处理的!
b.信号如果不是被立即处理,那么信号是否需要临时被进程记录下来? 记录在Pcb结构中
c.信号在什么时候处理呢?----合适的时候
d.进程要处理信号,必须具备信号“识别”的能力(看到+处理动作)
e.凭什么进程能够“识别”信号呢?程序员!
f.一般而言,信号的产生相对于进程而言,是异步的。。

4. 信号的处理(捕捉):

信号的处理,也叫做信号的的捕捉,递达处理动作。。
处理信号的三种行为:

  • 默认动作。
  • 忽略。
  • 自定义动作。

信号被记录在 进程的PCB当中的sigset_t 位图当中
sigset_t 数据结构被包含在 Signal.h当中。

4.1 信号在内核中的表示示意图:

在这里插入图片描述

  • 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号
    产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子
    中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。

  • SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前
    不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。

  • SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。

如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?

不记录该信号产生了多少次
Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可 以依次放在一个队列里。本章不讨论实时信号

  • sigset_t数据集解释:
    sigset_t是操作系统专门针对信号所构建的用户级的数据类型。

    sigset_ t类型称之为信号集。

  • 每个信号只有一个bit的未决标志,非0即1。

  • 未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集

  • 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

4.1 信号集操作函数:

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统
实现。

#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo) %判断特定的一个信号,是否在该集合当中。
  • 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有 效信号。
  • 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系 统支持的所有信号。
  • 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号

这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含
某种 信号,若包含则返回1,不包含则返回0,出错返回-1

4.1.1 信号其他相关常见概念:
  • 实际执行信号的处理动作称为信号递达(Delivery)。
  • 信号从产生到递达之间的状态,称为信号未决(Pending)。
  • 进程可以选择阻塞 (Block)某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
  • 注意: 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
4.1.2 sigprocmask函数

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集).

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
  • 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
  • 如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改
  • 如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后
    根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值
    在这里插入图片描述
4.1.3 sigpending函数
#include <signal.h>
sigpending(sigset_t *set )
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1

在这里插入图片描述
程序运行时,每秒钟把各信号的未决状态打印一遍,由于我们阻塞了SIGINT信号,

  • 按Ctrl-C将会 使SIGINT信号处于未决状态,
  • 按Ctrl-\仍然可以终止程序,因为SIGQUIT信号没有阻塞

5 . 信号的捕捉过程:

5 . 1 内核页表和用户页表:

  • 进程处理信号,不是立即处理的:
    -是在当前进程从内核态,返回用户态的时候,进行信号检测与处理的。

每一个进程都有一个内核空间,用于内核级页表的映射:
在这里插入图片描述

内核页表是负责3G到4G之间数据的映射。 所有进程共享的,只有一份内核级页表

每个进程,都有一份用户级页表,都是不一样的。

  • 补充知识:

  • 用户态是一个受管控的状态。
    内核态是一个操作系统执行自己代码的一个状态,具有非常高的优先级。。。

  • 进程如果是用户态的 —— 只能访问用户级页表。
    进程如果是内核态的 —— 访问内核级和用户级的页表。

5 . 2 信号的捕捉过程:

在这里插入图片描述
为什么会进入内核态?

–进行系统调用,缺陷异常等时,会进入内核态。。。

  • 如果信号的处理动作是用户自定义sighandler函数,在信号递达时就调用这个函数,这称为捕捉信号。
  • 由于信号处理函数sighandler的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler
  • 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号
    SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler
    和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程

便捷记忆图
在这里插入图片描述
中间交点一定要在横线以下。

5 . 3 sigaction( )处理函数:

这个函数除了能处理普通信号,实时信号也能处理
在这里插入图片描述

  • sigaction函数可以读取和修改与指定信号相关联的处理动作。
  • 调用成功则返回0,出错则返回-1
  • 若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传 出该信号原来的处理动作。
  • act和oact指向sigaction结构体

在这里插入图片描述
在这里插入图片描述

处理信号的时候,执行自定义动作,如果在处理信号期间,又来了同样的信号,OS如何处理?

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字

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

using namespace std;

void handler(int signo)
{
    cout << "获取到一个信号,信号编号是: " << signo << endl;
    sigset_t pending;

    // 永远都会正在处理2号信号
    while (true)
    {
        cout << "." << endl;
        sigpending(&pending);
        for (int i = 1; i <= 31; i++)
        {
            if (sigismember(&pending, i))
                cout << '1';
            else
                cout << '0';
        }

        cout << endl;
        sleep(1);
    }
}

int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    // act.sa_handler = SIG_IGN;
    // act.sa_handler = SIG_DFL;

    act.sa_flags = 0;
    sigemptyset(&act.sa_mask);
    
    // 三号信号拦截
    sigaddset(&act.sa_mask, 3);

    // 对二号信号的捕捉
    sigaction(2, &act, &oact);

    // sigaction的更大意义在于,当我们在做信号处理时
    // 操作系统不允许嵌套式的递归式的处理多个信号。
 
    while (true)
    {
        cout << "main running" << endl;
        sleep(1);
    }
    
    return 0;
}

5 .4 不能被捕捉的信号

  • 如果我们对所有的信号都进行了自定义捕捉,我们是不是就写了一个不会被异常或者用户杀掉的进程??

答:不是的。在Linux系统中,SIGKILL (信号编号为9) SIGSTOP (信号编号为19或17) SIGCONT
(信号编号为18或19) 是不能被捕捉的
这些不可捕捉信号通常由操作系统或其他系统级实体发送,用于管理进程的状态和行为。在正常情况下,用户进程无法阻止或修改这些信号的执行。

  • 如果我们对所有的信号都进行了block,我们是不是就写了一个不会被异常或者用户杀掉的进程??

答:不是的,在Linux系统中,SIGKILL (信号编号为9) 等不能被设置block的

6 . volatile关键字:

保持内存的可见性,每次做检测必须从内存里拿

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

// 保持内存的可见性,每次做检测必须从内存里拿
volatile int flags = 0;

void handler(int signo)
{
    printf("更改flags: 0->1\n");
    flags = 1;
}

int main()
{
    signal(2, handler);
    while (!flags);
    printf("进程是正常退出的!\n");

    return 0;
}

如果上述代码不带上volatile,则不同编译器会有不同的结果

  • 高级别的编译器,会将这个flags值优化到寄存器里,从此往后再做while循环检测时候,只做一件事,从这个寄存器里做数据读取,所以这个寄存器里的值永远不会被修改了

volatile关键字,告诉编译器,不准对flags做任何优化,每次CPU计算的时候,拿内存中的数据,都必须在内存中拿!!
volatile和const可以同时修饰一个变量

7 . SIGCHLD信号:

  • 子进程退出的时候,自动给父进程发送SIGCHLD信号

  • 之前讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进
    程结束等待清理(也就是轮询的方式)。

    采用第一种方式,父进程阻塞了就不 能处理自己的工作了;

    采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂

方法1. 现在,可以使用自定义SIGCHLD信号的处理函数,来进行子进程的回收

  • 子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号

    的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理

    函数中调用wait清理子进程即可。

代码演示:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{
    pid_t id;
    while( (id = waitpid(-1, NULL, WNOHANG)) > 0)
    {
    printf("wait child success: %d\n", id);
    }
   printf("child is quit! %d\n", getpid());
}
int main()
{
    signal(SIGCHLD, handler);
    pid_t cid;
    if((cid = fork()) == 0)
    {//child
       printf("child : %d\n", getpid());
       sleep(3);
       exit(1);
    }
    while(1){
        printf("father proc is doing some thing!\n");
        sleep(1);
        }
   return 0;
}

方法 2. 用sigaction将SIGCHLD的处理动作置为SIG_IGN

signal(SIGCHLD, SIG_IGN);

由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:

  • 父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不
    会产生僵尸进程,也不会通知父进程。

  • 系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。

  • 此方法对于Linux可用,但不保证在其它UNIX系统上都可用。

8 . 可重入函数:

main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因
为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函
数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从
sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步
之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只 有一个节点真正插入链表中了

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构

目前,我们用的90%的函数,都是不可重入的。

尾声
看到这里,相信大家对这个Linux 有了解了。
如果你感觉这篇博客对你有帮助,不要忘了一键三连哦

  • 13
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值