【Linux详解】——进程信号

📖 前言:本期介绍进程信号。


目录

  • [🕒 1. 生活角度的信号](#🕒 1. 生活角度的信号)
  • [🕒 2. 技术角度的信号](#🕒 2. 技术角度的信号)
    • [🕘 2.1 Linux中的信号](#🕘 2.1 Linux中的信号)
    • [🕘 2.2 进程对信号的处理](#🕘 2.2 进程对信号的处理)
  • [🕒 3. 信号的产生方式](#🕒 3. 信号的产生方式)
    • [🕘 3.1 键盘产生](#🕘 3.1 键盘产生)
    • [🕘 3.2 通过系统调用](#🕘 3.2 通过系统调用)
      • [🕤 3.2.1 kill信号](#🕤 3.2.1 kill信号)
      • [🕤 3.2.2 raise信号](#🕤 3.2.2 raise信号)
      • [🕤 3.2.3 abort信号](#🕤 3.2.3 abort信号)
    • [🕘 3.3 硬件异常产生信号](#🕘 3.3 硬件异常产生信号)
      • [🕤 3.3.1 溢出错误](#🕤 3.3.1 溢出错误)
      • [🕤 3.3.2 段错误](#🕤 3.3.2 段错误)
    • [🕘 3.4 软件条件产生异常](#🕘 3.4 软件条件产生异常)
  • [🕒 4. 信号的Term终止和Core终止](#🕒 4. 信号的Term终止和Core终止)
  • [🕒 5. 信号的保存](#🕒 5. 信号的保存)
    • [🕘 5.1 PCB的具体内容](#🕘 5.1 PCB的具体内容)
    • [🕘 5.2 sigset_t](#🕘 5.2 sigset_t)
    • [🕘 5.3 sigprocmask](#🕘 5.3 sigprocmask)
    • [🕘 5.4 sigpending](#🕘 5.4 sigpending)
    • [🕘 5.5 案例](#🕘 5.5 案例)
  • [🕒 6. 信号的递达](#🕒 6. 信号的递达)
    • [🕘 6.1 用户态和内核态](#🕘 6.1 用户态和内核态)
    • [🕘 6.2 信号捕捉](#🕘 6.2 信号捕捉)
      • [🕤 6.2.1 signal、sigaction](#🕤 6.2.1 signal、sigaction)
  • [🕒 7. 可重入函数](#🕒 7. 可重入函数)
  • [🕒 8. volatile关键字](#🕒 8. volatile关键字)
  • [🕒 9. SIGCHLD信号](#🕒 9. SIGCHLD信号)

🕒 1. 生活角度的信号

你在网上买了很多件商品,在等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递,也就是你能"识别快递"。快递到来的整个过程,对你来讲是异步的,你不能准确断定快递什么时候到。

当快递送到菜鸟驿站后,你可以选择不立刻去取,等合适时间再去,并且你知道并记住了有一个快递要去取。当你时间合适,顺利拿到快递之后,就要开始处理快递了。

而处理快递一般方式有三种

  1. 执行默认动作(打开快递,使用商品)
  2. 执行自定义动作(快递是礼物,你要送给别人)
  3. 忽略快递(快递拿上来之后,做其他事情)

进程就是你,操作系统就是快递员,信号就是快递

总结说,首先你能识别信号,其次即使信号没有产生,你也有处理信号的能力,至于处理,就是在你觉得合适的时候再去,处理有三种情况,一是默认,而是忽略,三是自定义。

🕒 2. 技术角度的信号

🕘 2.1 Linux中的信号

使用kill -l查看所有信号。使用信号时,可使用信号编号或它的宏。

bash 复制代码
# 查看信号
[hins@vm-centos7 testLinux]$ kill -l
 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP
 6) SIGABRT	 7) SIGBUS	 8) SIGFPE	 9) SIGKILL	10) SIGUSR1
11) SIGSEGV	12) SIGUSR2	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
21) SIGTTIN	22) SIGTTOU	23) SIGURG	24) SIGXCPU	25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	28) SIGWINCH	29) SIGIO	30) SIGPWR
31) SIGSYS	34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX	

1、Linux中信号共有61个,没有0、32、33号信号。

2、【1,31】号信号称为普通信号 ,【34,64】号信号称为实时信号

以普通信号为例,进程task_struct结构体中存在unsigned int signal变量用以存放普通信号。(32个比特位中使用0/1存储、区分31个信号------位图结构

那么发送信号就是修改进程task_struct结构体中的信号位图。当然,有权限改动进程PCB的,也只有操作系统了。

🕘 2.2 进程对信号的处理

  1. 进程本身是程序员编写的属性和逻辑的集合;
  2. 信号可以随时产生(异步)。但是进程当前可能正在处理更为重要的事情,当信号到来时,进程不一定会马上处理这个信号;信号是进程之间事件异步通知的一种方式,属于软中断,本质也是数据。
  3. 所以进程自身必须要有对信号的保存能力;
  4. 进程在处理信号时(信号被捕捉 ),一般有三种动作:默认、自定义、忽略。

🕒 3. 信号的产生方式

🕘 3.1 键盘产生

以前,我们在使用 Ctrl+C 结束进程时,本质是向指定进程发送2号信号。

bash 复制代码
man 7 signal          # 查看详细信号手册

signal()函数:

如果想让Ctrl+C热键组合不做信号的默认动作,我们可以使用signal系统调用来自定义信号的行为。

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

void handler(int signo)
{
    std::cout << "进程捕捉到了一个信号,信号编号是: " << signo << std::endl;
    // exit(0);
}

int main()
{
    // 这里是signal函数的调用,并不是handler的调用
    /// 仅仅是设置了对2号信号的捕捉方法,并不代表该方法被调用了
    // 一般这个方法不会执行,除非收到对应的信号!
    signal(2, handler);

    while(true)
    {
        std::cout << "我是一个进程: " << getpid() << std::endl;
        sleep(1);
    }
}

运行代码,从上图可以看到,无论是使用 Ctrl+C ,还是kill -2 [pid]尝试终止进程,都无效。是因为我们改变了2号信号的处理方式,这里不会终止进程,只能通过其他信号终止。键盘可以产生信号,键盘产生的信号只能用来终止前台进程,后台进程可以使用命令 kill -9 [pid] 杀掉。9号信号不可被捕捉(自定义)。

🕘 3.2 通过系统调用

🕤 3.2.1 kill信号

kill:发送一个信号给其他进程

c 复制代码
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);	// pid:目标进程的pid。sig:几号信号
// 成功时(至少发送了一个信号) ,返回零。出现错误时,返回 -1设置errno

例子:

cpp 复制代码
// mytest.cc
#include <iostream>
#include <sys/types.h>
#include <unistd.h>

int main()
{
    while(true)
    {
        std::cout << "这是一个正在运行的进程,pid: " << getpid() << std::endl;
        sleep(1);
    }
}
cpp 复制代码
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <string>

void Usage(const std::string& proc)     // 类似手册
{
	std::cout << "\nUsige: " << proc << " Pid Signo\n" << std::endl;
}

// ./myprocess pid signo
int main(int argc, char* argv[])    // 运行main函数时,需要先进行传参
{
	if (argc != 3)  // 如果传入main函数的参数个数不为3
	{
		Usage(argv[0]);
		exit(1);
	}
	pid_t pid = atoi(argv[1]);  // 获取第一个命令行参数,作为pid
	int signo = atoi(argv[2]);  // 获取第二个命令行参数,作为signo
	int n = kill(pid, signo);   // 需要发送信号的进程/发送几号信号
	if (n == -1)                // kill()失败返回-1
	{
		perror("kill");
	}
}

🕤 3.2.2 raise信号

raise:给自己发送任意信号

c 复制代码
#include <signal.h>
int raise(int sig);	// sig:信号编号
// raise()在成功时返回0,在失败时返回非0

例子:

cpp 复制代码
int main(int argc, char* argv[])    // 运行main函数时,需要先进行传参
{
	//当计数器运行到5时,进程会因3号进程退出

    int cnt = 0;
    while (cnt <= 10)
    {
        std::cout << cnt++ << std::endl;
        sleep(1);
        if(cnt >= 5)  raise(3);
    }
    return 0;
}
bash 复制代码
[hins@vm-centos7 signal]$ ./mysignal
cnt: 0
cnt: 1
cnt: 2
cnt: 3
cnt: 4
Quit

🕤 3.2.3 abort信号

abort:给自己发送指定信号SIGABRT

c 复制代码
#include <stdlib.h>
void abort(void);  // 永远不会返回

例子:

cpp 复制代码
int main(int argc, char* argv[])   
{
    int cnt = 0;
    while (cnt <= 10)
    {
        std::cout << cnt++ << std::endl;
        sleep(1);
        if(cnt >= 5)  abort();
    }
    return 0;
}
bash 复制代码
[hins@vm-centos7 signal]$ ./mysignal
cnt: 0
cnt: 1
cnt: 2
cnt: 3
cnt: 4
Aborted

🕘 3.3 硬件异常产生信号

硬件异常指非人为调用系统接口等行为,因软件问题造成的硬件发生异常。操作系统通过获知对应硬件的状态,即可向对应进程发送指定信号。

🕤 3.3.1 溢出错误

例子:

cpp 复制代码
int main(int argc, char* argv[])   
{
	while(true)
    {
        std::cout << "运行中..." << std::endl;
        sleep(1);
        int a = 10;
		a /= 0;
    }
}
bash 复制代码
[hins@vm-centos7 signal]$ ./mysignal
运行中...
Floating point exception

当我们写一段程序,有除0操作。我们知道计算的过程是交给cpu的,那么操作系统为什么能够知道我们除0?还能给进程发送一个终止信号?

其原因在于:cpu内部有一个状态寄存器,用来衡量某次运算的结果。这个寄存器有一个溢出标记位,当标记位溢出,则表示当前计算结果们没有意义,不需要被采纳。也就是说,除0会产生一个无穷大的值,从而导致状态寄存器的标记位溢出,然后发生cpu运算异常。而操作系统是要对所有硬件管理的,所以操作系统自然而然地知道了cpu发生了异常,然后操作系统又能对软件做管理,很轻松地找到了是谁导致地异常,然后再给导致异常的进程发送一个8号信号(我们看到的错误信息就是8号信号导致的)。

我们对上面的代码进行改造如下:

cpp 复制代码
void catchSig(int signo)
{
	std::cout << "获取到一个信号,信号编号是: " << signo << std::endl;
	sleep(1);
}

int main(int argc, char* argv[])   
{
	signal(SIGFPE, catchSig);
	while(true)
    {
        std::cout << "运行中..." << std::endl;
        sleep(1);
        int a = 10;
		a /= 0;
    }
}
bash 复制代码
[hins@vm-centos7 signal]$ ./mysignal
运行中...
获取到一个信号,信号编号是: 8
获取到一个信号,信号编号是: 8
......

可以看到在循环进行我们的自定义动作catchSig,即把8号信号的终止行为改成了catchSig,而里面又没有终止相关代码,自然在死循环。

此处就产生一个问题,操作系统发现除0操作后发送8号信号,不应该只会提示1次吗?为什么会在循环提示?我们把除0操作拿出循环,再观察一下。

cpp 复制代码
void catchSig(int signo)
{
	std::cout << "获取到一个信号,信号编号是: " << signo << std::endl;
	sleep(1);
}

int main(int argc, char* argv[])   
{
	signal(SIGFPE, catchSig);
    int a = 10;
	a /= 0;
	while(true)
    {
        std::cout << "运行中..." << std::endl;
        sleep(1);
    }
}
bash 复制代码
[hins@vm-centos7 signal]$ ./mysignal
获取到一个信号,信号编号是: 8
获取到一个信号,信号编号是: 8
......

可以看到,即使只进行了一次除0操作,其依旧在循环打印。

其原因在于:当cpu发生运算异常后,操作系统给进程发送一个8号信号,进程收到信号后执行自定的行为,并没有退出该进程。我们自定义的行为执行完后, 这个进程将会继续被调度,那么cpu的寄存器是保存了当前进程的上下文的,也就是说cpu的状态寄存器还没有清空,所以操作系统一直看到cpu发出运算异常,从而一直给进程发送8号信号。

🕤 3.3.2 段错误

还有一种便是野指针。我们来看代码:

cpp 复制代码
int main()
{
    int* p = nullptr;
    *p = 100;
    return 0;
}
bash 复制代码
[hins@vm-centos7 signal]$ ./mysignal
Segmentation fault

编译运行此程序将会引发段错误,其对应的信号为11号信号。一定是操作系统向进程发送了该信号。其原因在于:用户层看到的指针(地址)是虚拟地址,虚拟地址通过页表+MMU转换到物理地址(MMU集成在cpu上,通过读取页表的内容形成物理地址),MMU会检测到越界访问而发生异常,然后操作系统立马识别到MMU发生异常,然后找到导致异常的进程,然后给该进程发送信号。

所以大部分信号的行为是导致进程终止,事实上进程可以不被终止,但是没有意义。

🕘 3.4 软件条件产生异常

在进程间通信中,通信双方的进程打开同一个管道,这个管道就是一个软件。当某个进程关闭读端时,写端就没有存在的意义了,此时操作系统便会向写端进程发送一个SIGPIPE信号。管道的某端关闭导致操作系统向进程发送信号,是一种软件条件。

还有一种软件条件便是系统调用alarm,其作用是设定闹钟(单位为秒),时间到后调用该系统调用的进程将会收到SIGALRM信号(14号信号),进而终止进程。时间到达(或者超时)导致操作系统向进程发送一个信号,也是一种软件条件。

c 复制代码
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
// 返回值为0或还剩多长时间闹钟结束

🕒 4. 信号的Term终止和Core终止

Term:正常结束;Core:异常退出,可以使用核心转储功能定位错误;Ign:内核级忽略。

核心转储:当进程出现异常的时候,我们将进程对应的时刻,在内存中的有效数据转储到磁盘中

我们通过以下指令查看各种数据的上限:

当操作系统向进程发送了Core终止的信号时,应该会产生一个文件,但由于我的测试环境在云服务器上,可以看到其配置文件大小为0,是因为云服务器默认关闭了core file选项。

如果需要设置,可以使用以下命令改变core file size的大小:

bash 复制代码
ulimit -c [字节数] 

我们以上面野指针的代码为例,进行测试,发现改变core file size的大小后,在本目录下会发现多了一个文件:

bash 复制代码
[hins@vm-centos7 signal]$ ll
total 328
-rw------- 1 hins hins 557056 Mar 19 17:27 core.18418

我们对其进行gdb调试,即可看到核心转储文件导出的错误,这种调试方式称为事后调试

不一定所有的退出信号都会被core dump 例如:9号信号。

🕒 5. 信号的保存

在开始内容之前,先介绍一些信号的专业名词:

  1. 实际执行信号的处理动作称为信号递达(Delivery)
  2. 信号从产生到递达之间的状态,称为信号未决(Pending)(就是收到信号,但没有执行信号对应的动作)
  3. 进程可以选择阻塞(Block)某个信号,阻塞的信号就是收到信号,但是一直处于未决状态。
  4. 忽略信号也是一种递达动作。
  5. 未决就是未决,阻塞就是阻塞。没有收到信号时,依然可以对没有收到的信号阻塞(收到信号后直接就是未决信号)

🕘 5.1 PCB的具体内容

操作系统描述信号也是需要用对应的数据结构的。其中,描述未决状态的信号使用一个pending位图,存放在进程的pcb中(信号对应比特位为0,表示没收到该信号;为1表示收到该信号)。由此可见,发送信号不如称为"写信号"。再次强调一点,只有操作系统才能改写进程pcb中的pending位图,也就是说发送信号的载体必须是操作系统。

pcb还有一个描述阻塞状态信号的位图,称为block位图。比特位的位置表示信号编号,内容表示信号是否被阻塞。如何确实某一信号被阻塞了,那么进程收到该信号时,永远不会递达该信号,除非在未来解除阻塞。

pcb除了拥有以上的两个位图,还有一个指向handler_t handler数组的数组指针,这个数组是一个函数指针数组,其内容指向了对应信号的递达动作。由此可见,signal捕捉信号的本质就是在数组当中设置新的函数指针。

小结:

  1. 如果一个信号没有产生,并不会妨碍它被进程阻塞(也就是不妨碍操作系统设置block位图)

  2. 进程为何能够识别信号?不仅仅是通过程序员编码完成,在pcb中操作系统已经设置了上述三种数据结构(最主要的便是数组记录了信号对应的递达动作)

  3. 普通信号(1~31号信号)只能被收到一次(位图只有0或1两种状态),也就是说就算我们向某一信号发送N次M号信号,进程只会收到一次M号信号

🕘 5.2 sigset_t

这是操作系统设置的类型,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号 的"有效"或"无效"状态。

sigset_t类型的变量不能单独使用,必须要配合特定的系统调用接口使用。

信号集操作函数

c 复制代码
#include <signal.h>
int sigemptyset(sigset_t *set); // 将信号集所有的位清0
int sigfillset(sigset_t *set);  // 初始化位图,将信号集所有的位置为1
int sigaddset (sigset_t *set, int signo); // 添加信号到信号集
int sigdelset(sigset_t *set, int signo);  // 从信号集中删除信号
int sigismember(const sigset_t *set, int signo); // 是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。

🕘 5.3 sigprocmask

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

c 复制代码
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
// 返回值:若成功则为0,若出错则为-1
// set:输入型参数
// oset:输出型参数
  • 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
  • 如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。
  • 如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。

how参数的可选值 :
SIG_BLOCK:将set中的信号添加到信号屏蔽字中
SIG_UNBLOCK:将set中的信号从信号屏蔽字中解除阻塞
SIG_SETMASK:将信号屏蔽字设置为set

🕘 5.4 sigpending

该系统调用不对pending表修改,而仅仅是获取进程的pending位图。

c 复制代码
#include <signal.h>
int sigpending(sigset_t *set); // 参数为输出型参数

🕘 5.5 案例

现在需要把2、3号信号屏蔽,并观察其位图

cpp 复制代码
#include <iostream>
#include <vector>
#include <signal.h>
#include <unistd.h>

#define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31

using namespace std;

static vector<int> sigarr = {2,3};

static void show_pending(const sigset_t &pending)
{
    for(int signo = MAX_SIGNUM; signo >= 1; signo--)
    {
        if(sigismember(&pending, signo))
        {
            cout << "1";
        }
        else cout << "0";
    }
    cout << "\n";
}

int main()
{
    // 先尝试屏蔽指定的信号
    sigset_t block, oblock, pending;
    // 初始化
    sigemptyset(&block);
    sigemptyset(&oblock);
    sigemptyset(&pending);
    // 添加要屏蔽的信号
    for(const auto &sig : sigarr) sigaddset(&block, sig);
    // 开始屏蔽,设置进内核(进程)
    sigprocmask(SIG_SETMASK, &block, &oblock);

    // 遍历打印pengding信号集
    int cnt = 10;
    while(true)
    {
        sigemptyset(&pending);  // 初始化
        sigpending(&pending);   // 获取
        show_pending(pending);  // 打印
        sleep(1);
    }
}

可以看到,在输入Ctrl+C(2号信号)时,位图变成10(从右起第2个0变成1);输入Ctrl+\(3号信号),位图变成110(从右起第3个0变成1)

如果解除信号屏蔽,那信号就要递达,我们应该能观察到由1变0的情况

cpp 复制代码
......

static void myhandler(int signo)
{
    cout << signo << " 号信号已经被递达!!" << endl;
}

int main()
{
    for(const auto &sig : sigarr) signal(sig, myhandler);

    // 先尝试屏蔽指定的信号
    sigset_t block, oblock, pending;
    // 初始化
    sigemptyset(&block);
    sigemptyset(&oblock);
    sigemptyset(&pending);
    // 添加要屏蔽的信号
    for(const auto &sig : sigarr) sigaddset(&block, sig);
    // 开始屏蔽,设置进内核(进程)
    sigprocmask(SIG_SETMASK, &block, &oblock);

    // 遍历打印pengding信号集
    int cnt = 10;
    while(true)
    {
        sigemptyset(&pending);  // 初始化
        sigpending(&pending);   // 获取
        show_pending(pending);  // 打印
        sleep(1);
        if(cnt-- == 0)
        {
            sigprocmask(SIG_SETMASK, &oblock, &block); // 一旦对特定信号进行解除屏蔽,一般OS要至少立马递达一个信号!
            cout << "恢复对信号的屏蔽,不屏蔽任何信号\n";
        }
    }
}

🕒 6. 信号的递达

信号什么时候被处理?

当进程从内核态返回到用户态的时候,进行信号检测并处理信号。

🕘 6.1 用户态和内核态

用户态:用户代码和数据被访问或者执行的时候,所处的状态。自己写的代码全部都是在用户态执行。

内核态:执行OS的代码和数据时,进程所处的状态。OS的代码的执行全部都是在内核态执行(例如系统调用)。

主要区别:权限大小,内核态权限远远大于用户态。

用户态使用的是用户级页表 ,只能访问用户数据和代码;内核态使用的是内核级页表,只能访问内核数据和代码。

需要强调一点:内核级页表只存在一张。然后要执行的操作系统的代码的时候,只需要在自己的地址空间随意跳转即可。

无论进程如何切换,都不改变更改内核级页表。

CPU内有寄存器保存了当前进程的状态。

所谓系统调用:就是进程的身份转化成为内核,然后根据内核页表找到对应函数执行。

🕘 6.2 信号捕捉

信号捕捉本质是修改handler表中的内容。

内核实现信号捕捉的过程大致是下图这样:

上图可简化抽象为:

为什么返回第一次陷入内核的下一处指令需要再次返回到内核态?其原因在于:用户态执行自定义捕捉动作的时候没有权限返回第一次陷入内核的下一处指令(即使有上下文数据),所以必须再次返回内核态。

🕤 6.2.1 signal、sigaction

sigaction这个接口与signal的动作一模一样,都是信号的捕捉。不过sigaction的信号更加复杂,并且在运行时可以屏蔽除当前信号之外的信号。

c 复制代码
#include <signal.h>
 
typedef void (*sighandler_t)(int); // 函数指针
 
sighandler_t signal(int signum, sighandler_t handler);
c 复制代码
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
// act:新的处理动作
// oldact:原来的处理动作
 
// act 结构体
struct sigaction 
{
    void     (*sa_handler)(int);
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t   sa_mask;
    int        sa_flags;
    void     (*sa_restorer)(void);
};
// 当某个信号的处理函数被调用时, 内核自动将当前信号加入进程的信号屏蔽字, 当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时, 如果这种信号再次产生, 那么它会被阻塞到当前处理结束为止(即同一个信号不能被嵌套使用)。
 
// 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号, 则用sa_mask字段说明这些需要额外屏蔽的信号, 当信号处理函数返回时自动恢复原来的信号屏蔽字。 
 
// sa_flags字段包含一些选项, 本章的代码都把sa_flags设为0, sa_sigaction是实时信号的处理函数, 本章不详细解释这两个字段

可以看到sigaction的接口是两个结构体。我们在使用sigaction时需要设置好指定的结构体对象,结构体对象当中有三个我么需要操作的参数:第一个箭头很明显,是自定义捕捉动作;第二个是一个用户层信号级,用来设置要阻塞的信号;第三个箭头指向的flags置0即可。

下面我们要完成一套动作:向进程连续发送N个2号信号,然后屏蔽3号信号,然后再发送N个3号信号,观察现象。

下面是实现的代码:

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <signal.h>
#include <unistd.h>

using namespace std;

void Count(int cnt)
{
    while(cnt)
    {
        printf("cnt: %2d\r", cnt);
        fflush(stdout);
        cnt--;
        sleep(1);
    }
    printf("\n");
}

void handler(int signo)
{
    cout << "get a signo: " << signo << "正在处理中..." << endl;
    Count(10);
}

int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    act.sa_flags = 0;
    sigemptyset(&act.sa_mask); // 当我们正在处理某一种信号的时候,我们也想顺便屏蔽其他信号,就可以添加到这个sa_mask中
    sigaddset(&act.sa_mask, 3);
    sigaction(SIGINT, &act, &oact);

    while(true) sleep(1);

    return 0;
}

可以看到,我们发送了一堆2号信号,由此可以证明:当某个信号正在处理的时候,同类型信号无法被递达,其原因在于当当前信号正在被捕捉时,系统会自动将当当前信号加入到进程的信号屏蔽字,当信号完成捕捉动作后,自动解除对该信号的屏蔽。一般一个信号被解除屏蔽的时候,自动进行递达当前屏蔽的信号(信号已经被加入pending的话)。再次重申:信号检测之后,递达信号之前,pending信号对应的比特位由1置0,所以可以接收第二次信号。这就是我们为什么能看到2号信号出现了两次的原因(最多也只能出现两次)。

正因为同类型的信号只能递达两次,当这两次递达完成之后,系统将会取消上层信号级sa_mask中设置的屏蔽信号

由此可以得出结论:在上述的案例中,如果没有对3号信号屏蔽,那么当2号信号正在处理的时候就会直接退出进程。并且需要注意,上面我们一直强调某一信号递达时将会阻塞该信号,一定是同类型信号,非同类型信号将会直接递达(如果没有手动屏蔽的话)。

🕒 7. 可重入函数

一般而言,我们认为main执行流和自定义捕捉动作是两个执行流。所以不可避免两个执行流调用相同的函数。那么就可能发生这么一种情况:假设函数func会执行malloc动作,而free的动作在main执行流存在,而自定义捕捉动作没有free动作,如果main和自定捕捉动作都执行了这个func函数,那么将会发生内存泄漏。所以这个函数被称为不可重入函数(因为一旦重复调用,就会引发错误)。

一般的,我们可以由下面一条规则判断一个函数是否是可重入函数:

当函数调用了malloc接口或者标准IO函数,这个函数就是不可重入函数

大部分函数都是不可重入函数!可重入和不可重入函数并不是一个BUG,而是特性!

🕒 8. volatile关键字

volatile这个关键字是C语言的关键字,在解释这个关键字之前先看看其他问题。

编译器是存在优化的,例如STL中发生的连续拷贝构造会优化成一个,debug和release版本的效率不同等等。那么在LInux的gcc编译器中,也存在编译器的优化级别,分别为:O0、O1、O2、O3等等,编译器默认的优化级别不会超过O2。下面以一个案例来探讨一些问题:

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

int quit = 0;
 
void handler(int signo)
{
    printf("%d 号信号,正在被捕捉!\n", signo);
    printf("quit: %d", quit);
    quit = 1;
    printf("-> %d\n", quit);
}
 
int main()
{
    signal(2, handler);
    while(!quit);
    printf("正常退出程序!\n");
    return 0;
}
bash 复制代码
[hins@vm-centos7 signal]$ gcc mysignal.c -o mysignal

可看到,发送2号信号后,myHandler执行流把全局变量quit由0置1,回到main执行流时不进入while循环,就会退出程序。这是编译器默认优化情况。

现在手动将gcc优化级别提高到O3:

bash 复制代码
[hins@vm-centos7 signal]$ gcc -O3 mysignal.c -o mysignal

可以看到quit已经由0置1了,但是main执行流中的while循环似乎仍在继续,原因如下:

当编译器没有优化时,cpu需要的数据确确实实从内存拿,所以可以看到一个很正常的现象。但是当编译器优化之后,编译器会认为main执行流中的quit仅仅用作判断,并没有改变值,所以在cpu需要quit时,就会直接从内存拿quit装入寄存器,在以后需要quit的时候直接读取寄存器的值,即使内存中的值已经发生更新。

所以,为了避免上述的错误,可以使用关键字volatile,其作用是保持内存可见性。也就是说,当全局变量quit被valitle修饰之后,编译器不会把它当作优化对象,在以后需要quit的时候,会正常的从内存里读取数据。

c 复制代码
volatile int quit = 0;

🕒 9. SIGCHLD信号

这个信号和进程等待有关系。子进程死亡时会告诉父进程自己已经是"僵尸"了,这个告诉的过程便是发送一个SIGCHLD信号(17号信号)。但是这个信号与其他信号不同,它的默认处理动作是忽略,但是是内核级别的忽略动作

如何证明子进程退出时,会向父进程发17号信号?我们以下面这段代码证明:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;
 
void myHandler(int signo)
{
    cout << "get signo:" << signo << endl;
}

int main()
{
    signal(SIGCHLD,myHandler);
    pid_t id = fork();
    if(id == 0)
    {
        cout << "I am a child..." << endl;
        sleep(1);
        exit(0);
    }
    waitpid(id,nullptr,0);
    return 0;
}
bash 复制代码
[hins@vm-centos7 signal]$ ./mysignal
I am a child...
get signo:17

上面的代码仅仅是创建了一个子进程,如果我们有多个子进程同时退出,就不能单独waitpid了,必须循环等待。那么如何知道要回收哪些子进程呢?可以将waitpid的第一个参数设为-1,它会自动地检测并回收所有子进程。同时也带出了一个新的问题:如果一批子进程只有部分退出,如何处理?很显然,必须使用非阻塞式等待,否则将会影响父进程的执行流。

我们可以对SIGCHLD信号设置一个忽略动作SIG_IGN,此动作是让操作系统去负责子进程的回收,也就是对父进程来说,子进程不会变成僵尸进程:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

int main()
{
    signal(SIGCHLD,SIG_IGN);
    pid_t id = fork();
    if(id == 0)
    {
        cout << "I am a child..." << endl;
        sleep(3);
        exit(1);
    }

    while(1);   // 让父进程不退出,否则父进程一定先比子进程退出

    return 0;
}

需要注意的是,内核级的默认动作Ign与用户层的SIG_IGN忽略动作不一样,内核级的忽略动作就是要让父进程waitpid,而用SIG_IGN是让操作系统接管子进程。


OK,以上就是本期知识点"进程信号"的知识啦~~ ,感谢友友们的阅读。后续还会继续更新,欢迎持续关注哟📌~

💫如果有错误❌,欢迎批评指正呀👀~让我们一起相互进步🚀

🎉如果觉得收获满满,可以点点赞👍支持一下哟~

❗ 转载请注明出处

作者:HinsCoder

博客链接:🔎 作者博客主页

相关推荐
pk_xz12345640 分钟前
Shell 脚本中变量和字符串的入门介绍
linux·运维·服务器
小珑也要变强43 分钟前
Linux之sed命令详解
linux·运维·服务器
海绵波波1071 小时前
Webserver(4.3)TCP通信实现
服务器·网络·tcp/ip
努力变厉害的小超超2 小时前
ArkTS中的组件基础、状态管理、样式处理、class语法以及界面渲染
笔记·鸿蒙
秃头佛爷2 小时前
Python学习大纲总结及注意事项
开发语言·python·学习
九河云3 小时前
AWS账号注册费用详解:新用户是否需要付费?
服务器·云计算·aws
Lary_Rock3 小时前
RK3576 LINUX RKNN SDK 测试
linux·运维·服务器
幺零九零零4 小时前
【计算机网络】TCP协议面试常考(一)
服务器·tcp/ip·计算机网络
dayouziei4 小时前
java的类加载机制的学习
java·学习
云飞云共享云桌面5 小时前
8位机械工程师如何共享一台图形工作站算力?
linux·服务器·网络