Linux进程信号

信号:Linux系统提供的一种向指定进程发送特定事件的方式,用作识别和处理,并且信号的产生是异步的。

信号的处理(信号递达delivery):默认动作、忽略动作、自定义处理

一、信号产生

前置知识:signal是自定义捕捉指定信号的函数,可以让该信号执行指定的操作,可以看看下面代码的例子中的signal(2, handler),其中2是指信号码,handler是自己写的函数,当触发到2号信号的时候,就会执行该函数。

在Linux系统中,我们可以通过 kill -l 指令来向指定的进程发送指定的信号(法一

当然也可以通过键盘来产生信号,例如 ctrl + c 和 ctrl + \ (这两个退出进程的快捷键本质分别是对进程发送SIGINT和SIGQUIT信号)(法二

还有一个方法就是用系统调用的接口,例如kill(法三),或者使用raise( raise(sig)等价于kill(getpid(), sig) )

我们可以来做做实验,一个来创建进程,另一个来调用系统调用的接口来发送kill命令,注意想要结束的话需要ctrl + \ ,因为ctrl + c就是2号信号发送的指令

#include <iostream>  
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
  
int main(int argc, char* argv[]) 
{  
    if(argc != 3)
    {
        std::cout << "Usage : " << argv[0] << "signum pid" << std::endl;
        return 1;
    }

    pid_t pid = std::stoi(argv[2]);
    int signum = std::stoi(argv[1]);

    kill(pid, signum);

    return 0;  
}
#include <iostream>  
#include <unistd.h>
#include <signal.h>

void handler(int sig)
{
    std::cout << "get a sig:" << sig << std::endl;
}

int main()
{
    signal(2, handler);
    while(true)
    {
        std::cout << "hello bit, pid : " << getpid() << std::endl;
        sleep(1);
    }
}

系统调用:abort(异常终止进程),大家可以模仿上面的代码来signal一下6号信号

那有小伙伴就动了一下脑筋,如果我把所有的信号都捕捉了,那进程不就不能退出了?

非也,你能想到的,操作系统的设计者也想到了,并规定9号信号不允许自定义捕捉,所以还是有机会退出的,大家自行做实验尝试一下。

信号的产生是由用户来决定的,那信号是有谁来发送的?

这个人就是操作系统,因为发送信号的本质是修改进程PCB当中的信号位图(对应pending位图由0置成1),只有操作系统有资格去修改他定义的数据结构。键盘会被OS检测到,解释成对应的软件,再向PCB里修改对应的位图。


第四种产生信号的方式:软件条件

闹钟:unsigned int alarm(unsigned int seconds) 在几秒后会有一个闹钟,然后就会终止进程,一般来说,一次只能设置一个闹钟。

下面这段代码可以做下实验,结果就是1s之后进程终止,打印终止

#include <iostream>  
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>

void handler(int sig)
{
    std::cout << "get a sig:" << sig << std::endl;
    exit(1);
}

int main()
{
    int cnt = 1;
    signal(SIGALRM, handler);

    alarm(1);
    while (true)
    {
        std::cout << "cnt: " << cnt << std::endl;
        cnt++;
    }
    
    return 0;
}

大概打印6~8万次就结束,如果把cnt改成全局变量,然后在handler方法中再打印,会发现大概为5亿左右。由此可见,IO其实是很慢的过程,和CPU相比,这些外设都很慢。

如何理解闹钟?

闹钟这个东西,其实底层就是一个结构体,操作系统对闹钟的管理就可以先描述再组织。用最大堆、最小堆来进行管理,方便排序和查找哪一个闹钟超时。

闹钟的返回值有什么意义?

表示上一个闹钟的剩余时间

int main()
{
    int cnt = 1;
    signal(SIGALRM, handler);

    alarm(5);
    sleep(4);

    int n = alarm(0);  // alarm(0):取消闹钟,返回值表示上一个闹钟的剩余时间

    std::cout << "n : " << n << std::endl;
    
    return 0;
}


第五种产生信号的方式:异常

我们来看一下这段代码,分析一下程序为什么会崩溃?

因为非法访问/操作了,导致OS给进程发送信号啦,证明方式就是用signal来自定义捕捉就好了,大家可以自行试一试。

int main()
{
    while(true)
    {
        std::cout << "hello bit, pid: " << getpid() << std::endl;

        int *p = nullptr;
        *p = 1;
    }
    return 0;
}

输出错误segmentation fault,对应信号SIGSEGV 

证明:

void handler(int sig)
{
    std::cout << "get a sig:" << sig << std::endl;
}

int main()
{
    signal(SIGSEGV, handler);
    while(true)
    {
        std::cout << "hello bit, pid: " << getpid() << std::endl;

        int *p = nullptr;
        *p = 10;
    }
    return 0;
}

崩溃了为什么会退出?因为默认是终止进程

可以不退出吗?可以,捕捉了异常,一般不推荐,推荐终止进程,这会释放进程的上下文数据,包括溢出标志数据或其他异常数据

如果你尝试了上面的代码,会发现怎么一直给我们发信号呀?

因为原本默认是让程序退出的,但是我们自定义捕捉了,进程就不退出了,进程要调度和切换,就把CPU内寄存器的值做保留和恢复。但是由于进程并没有退出,导致再次恢复的时候又触发了段错误,不断地向进程发送信号。

这里可以看到每一个信号的行动大多都是term或者是core,那这两个有什么区别呢?

term其实就是朴素的异常终止

而core不仅仅是异常终止,还会帮忙形成一个debug文件,用于保存进程退出时候的镜像数据,这项技术又叫做核心转储。

在云服务器中默认是关闭核心转储的,因为有的大项目里出错过多会导致该文件过大,所以一般都关闭。

如果想让系统生成debug文件,需要使用指令 ulimit -c xxx  (数值随意定)来打开core选项,允许形成core文件。然后再次运行原来的文件,就可以在gdb中进行事后调试了。

通过下面这段代码也可以看出,在进程退出时返回的数字的第8位就是core dump标志位(为1表示core,0表示term),前7位是退出信号(用于发现异常情况),次低8位表示的是退出码(用于确认进程是否正确退出)。

int Sum(int start, int end)
{
    int sum = 0;
    for(int i = start; i <= end; ++i)
    {
        sum /= 0;
        sum += i;
    }
    return sum;
}

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        Sum(0, 100);
        exit(0);
    }

    int status = 0;

    pid_t rid = waitpid(id, &status, 0);

    if(rid == id)
    {
        printf("exit code: %d, exit signal: %d, core dump: %d\ns", (status>>8)&0xff, status&0x7f, (status>>7)&1);
    }

    return 0;
}

运行结果如下:

二、信号保存

一个信号在产生和递达之间要进行保存,这期间的状态叫做信号未决(pending)

进程可以选择阻塞(block)某个信号,阻塞之后,对应的信号一旦产生,永不递达,一直未决,直到主动解除阻塞。

注意:阻塞和未决直接没有关系,因为无论是否阻塞都会有一个未决的阶段

阻塞和忽略也没有关系,阻塞是信号要不要被递达,忽略是递达后处理的一种动作

有关信号的保存,有三张表需要了解一下,这三张表都是包含在task_struct里的三个字段

pending表:位图,比特位的位置表示信号编号,内容表示信号是否收到

block表:位图,和pending类型完全一样,比特位的位置同样表示信号编号,内容表示是否阻塞

handler表:函数指针数组,信号编号对应该数组的下标

两张位图 + 一张函数指针数组 = 让进程识别信号

信号处理过程逻辑:先看pending表是否为1(是否收到),block表是否为0(是否能递达),handler表(处理方式)

如果某个信号block表为1,则表示被阻塞,所以pending表也会置为1,而当阻塞解除时,一般会立即处理当前被解除的信号(如果被pending),pending表对应的位置也由1置成0(在递达之前)

【sigset_t】

未决和阻塞标志可以用相同的数据类型sigset_t来存储,是Linux给用户提供的一个用户级的数据类型,禁止用户修改位图。

信号集操作函数:可以用于修改位图

读取/更改进程的信号:sigprocmask

int sigprocmask(int how, const sigset_t *set, sigset_t* old_set)

how的三种方式

SIG_BLOCK:添加信号屏蔽字

SIG_UNBLOCK:解除阻塞的信号

SIG_SETMASK:mask = set,覆盖原位图

注意点:

1. 解除屏蔽,一般会立即处理当前被解除的信号(如果被pending)

set输入型参数

old_set输出型参数:保存老的信号屏蔽字

三、信号处理

信号处理其实就是一个信号被递达了

signal(2, handler); // 自定义捕捉
signal(2, SIG_IGN); // 忽略一个信号
signal(2, SIG_DFL); // 信号的默认处理动作

有了pending表,那么信号就有可能不会立即被处理,而是在合适的时候处理(从内核态返回到用户态的时候进行处理) 

【过程分析】

1. 在某个函数需要调用系统调用时,从用户态转入内核态,处理完系统调用后,在返回用户态的过程中会进行信号处理。

2. 信号处理时,先检测pending表是否被置1,再检测block表是否置为0,如果都满足那么就执行handler表对应的方法。如果是SIG_DFL,就终止进程,如果是SIG_IGN就返回用户态继续进行原函数。

3. 如果是自定义捕捉,需要从内核态再转化成用户态,调用完用户自定义的方法,再通过特殊的系统调用回到内核态。

注意:操作系统不能直接转过去执行用户提供的handler方法,必须使用用户的身份来执行,因为如果在该函数中用到了一些只用操作系统才能用到的方法,那就是利用了OS的高权限来做到了自己没有办法做到的事情。所以用户的函数自己负责,操作系统概不负责。

当前如果正在对 n 信号进行处理,默认 n 号信号会被自动屏蔽;对 n 号信号处理完成的时候会自动解除对 n 号的屏蔽(sigemptyset)。如果在处理某个信号时,还想对其他的信号也进行屏蔽,则可以使用 sigaddset,对指定的信号进行屏蔽。 

自定义捕捉扩展

函数sigaction(int signum, const struct sigaction* act, struct sigaction* oldact),第一个参数是对应的信号,第二个参数是输入型参数,用于传入一个包含自定义方法的结构体,第三个参数是输出型参数用于回复以前的信号处理方式。

操作系统如何正常运行?

1. 理解系统调用

系统中有一个函数指针表,用于系统调用,只要我们找到特定数组下标(系统调用号)的方法就能执行系统调用了。

我们调用系统调用,就是将系统调用号放入寄存器中,然后在系统内部执行中断,也叫陷阱或缺陷。注意区别于外部发送的中断。

2. OS是如何运行的

死循环 + 时钟中断

操作系统从开机到关机一直在运行,说明操作系统本质是一个死循环。在系统中有一个时钟,以很短的时间间隔,不断地CPU发送中断,而CPU当中对应的方法是调度,CPU在硬件的催促之下不断地进行调度(需要检测当前正在运行进程的时间片),时间片没到就什么都不做,时间片到了直接切换。操作系统其实也很被动,CPU不断被收到来自硬件的中断信号,因此不断执行调度方法。

用户态和内核态

本质是在CPU中的 code semgment 寄存器中的低两位的数字的值为0和3, 0表示内核态,3表示用户态。

OS不相信任何用户。用户无法无法直接跳转到3,4G地址空间范围,必须在特定的条件下才能跳转过去。所以代码中要跳转到3到4G时,需要在CPU中先检测对应的比特位(code semgment 寄存器中的低两位),是否属于内核态。所以要跳转时,要先将其从3转换成0才行。

可重入函数

看下下面的例子:

void handler(int signo)
{
    ...
	insert(&node2);
    ...
}

void insert(node_t* p)
{
    ...
	p->next = head;
	// 在此处有信号的捕捉处理
	head = p;
    ...
}

int main()
{
    ...
	insert(&node1);
    ...
}

信号的捕捉处理需要从内核态转换成用户态,可是这里没有系统调用的代码,怎么就进入内核态了?因为OS在不断地检测时间片,时间片到了就切换成内核态,这是操作系统自己的行为,和用户无关。

在main函数里有insert方法,而且信号捕捉的代码里也有insert方法,我们把这种现象称为某个函数被重复进入了(被重入了),如果这个过程出了问题,那么这样的函数就称为不可重入函数。

不可重入是某些函数的特点,在多线程的代码里需要尤其注意。

 一般来说,某个函数中使用了全局的变量或数据结构,都是不可重入的。如果函数中只使用了局部变量,那一般就可重入的。STL一般来说都是不可重入的,多数都在堆上开了空间。

子进程退出时,不是静悄悄地退出,而是会给父进程发送信号(SIGCHLD)

可以用以下的代码进行验证:

void notice(int signo)
{
    std::cout << "get a signal: " << signo << " pid: " << getpid() << std::endl;
}

int main()
{
    signal(SIGCHLD, notice);
    // 如果想让子进程不再僵尸,可以用sigaction设置为SIG_IGN
    pid_t id = fork();
    if(id == 0)
    {
        std::cout << "I am child process, pid : " << getpid() << std::endl;
        sleep(1);
        exit(1);
    }
    sleep(100);
    return 0;
}

感谢你能看到这里,如果觉得有帮助的话不妨点个赞!

  • 23
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 5
    评论
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值