Linux 进程间通信——信号、信号捕获与处理

本文最后更新于:2021年4月8日 晚上

概览:Linux信号。

信号

信号是 Linux 进程间通信的最古老的方式之一,是事件发生时对进程的通知机制,有时也称之为软件中断,它是在软件层次上对中断机制的一种模拟,是一种异步通信的方式。信号可以导致一个正在运行的进程被另一个正在运行的异步进程中断,转而处理某一个突发事件。

产生信号

发往进程的诸多信号,通常都是源于内核,可以引发内核为进程产生信号的各类事件:

  • 对于前台进程,用户可以通过输入特殊的终端字符来给它发送信号。比如输入Ctrl+C通常会给进程发送一个中断信号。
  • 硬件发生异常,即硬件检测到一个错误条件并通知内核,随即再由内核发送相应信号给相关进程。比如执行一条异常的机器语言指令,诸如被 0 除,或者引用了无法访问的内存区域。
  • 系统状态变化,比如 alarm 定时器到期将引起 SIGALRM 信号,进程执行的 CPU 时间超限,或者该进程的某个子进程退出。
  • 运行 kill 命令或调用 kill 函数。

信号的特点

  • 使用简单
  • 不能够传递大量信息
  • 满足某个特定条件时才发送
  • 优先级比较高

使用信号的主要目的

  • 让进程知道已经发生了一个特定的事情
  • 强迫进程执行它自己代码中的信号处理程序。

查看系统定义的信号列表命令:kill -l

其中前31个为常规信号,其余为实时信号。

编号 信号名称 对应事件 默认动作
2 SIGINT ctrl+c组合键,用户终端向正在运行中的由终端启动的程序发送信号 终止进程
9 SIGKILL 无条件的终止进程,该信号不能够被忽略、处理和阻塞 终止进程,可以杀死任何进程
11 SIGSEGV 指示进程进行了无效的内存访问(段错误) 终止进程并产生core文件
13 SIGPIPE Borken pipe向一个没有读端的管道写数据 终止进程
14 SIGALRM 定时器超时,超时的时间由系统调用alarm来设置 终止进程
17 SIGCHLD 子进程结束时,父进程会收到这个信号 忽略这个信号
18 SIGCONT 如果进程已经停止,则使其继续运行 继续/忽略
19 SIGSTOP 停止进程的执行,信号不能被忽略、处理和阻塞 为终止进程
  • 信号的5种默认处理动作
    • TERM 终止进程
    • IGN 当前进程忽略掉这个信号
    • CORE 终止进程,并生成一个Core文件
    • STOP 暂停当前进程
    • CONT 继续执行当前被暂停的进程
  • 信号的几种状态:产生未决递达

kill相关函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);
/*
- 功能:给任何的进程或者进程组pid, 发送任何的信号 sig
- 参数:
- pid :
> 0 : 将信号发送给指定的进程
= 0 : 将信号发送给当前的进程组
= -1 : 将信号发送给每一个有权限接收这个信号的进程
< -1 : 这个pid=某个进程组的ID取反 (-12345)
- sig : 需要发送的信号的编号或者是宏值,0表示不发送任何信号

例如:kill(getppid(), 9); 杀死父进程
kill(getpid(),9); 可以自杀
*/
int raise(int sig);
/*
- 功能:给当前进程发送信号
- 参数:
- sig : 要发送的信号
- 返回值:
- 成功 0
- 失败 非0
等价于kill(getpid(), sig);
*/

void abort(void);
/*
- 功能: 发送SIGABRT信号给当前的进程,杀死当前进程
等价于kill(getpid(), SIGABRT);
*/

尝试杀死自己。 while(1){ … ; kill(getpid(),9); }看看效果

alarm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
/*
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
- 功能:设置定时器(闹钟)。函数调用,开始倒计时,当倒计时为0的时候,
函数会给当前的进程发送一个信号:SIGALARM
- 参数:
seconds: 倒计时的时长,单位:秒。如果参数为0,定时器无效(不进行倒计时,不发信号)。
取消一个定时器,通过alarm(0)。
- 返回值:
- 之前没有定时器,返回0
- 之前有定时器,返回之前的定时器剩余的时间

- SIGALARM :默认终止当前的进程,每一个进程都有且只有唯一的一个定时器。
alarm(10); -> 返回0
过了1秒
alarm(5); -> 返回9
*/

实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(){

int second = alarm(5);
printf("second = %d \n",second);

sleep(2);

second = alarm(2);
printf("second = %d \n",second);

while(1){
}

return 0;
}

运行结果:

1
2
3
second = 0 
second = 3
Alarm clock
  • 从上述结果中可以发现,实际上在进程sleep的过程中,alarm仍然在执行。

我们程序实际的时间由三部分组成:

  1. 内核时间,系统调用就需要在内核中完成
  2. 用户时间,程序代码执行的时间
  3. 其他消耗的时间,比如IO

重点:

  • 定时器与进程的状态无关。
  • 一个进程只有一个定时器

setitimer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <sys/time.h>
int setitimer(int which, const struct itimerval *new_value,
struct itimerval *old_value);
/*
- 功能:设置定时器(闹钟)。可以替代alarm函数。精度微秒us,可以实现周期性定时
- 参数:
- which : 定时器以什么时间计时
ITIMER_REAL: 真实时间,时间到达,发送 SIGALRM 常用
ITIMER_VIRTUAL: 用户时间,时间到达,发送 SIGVTALRM
ITIMER_PROF: 以该进程在用户态和内核态下所消耗的时间来计算,时间到达,发送 SIGPROF

- new_value: 设置定时器的属性

struct itimerval { // 定时器的结构体
struct timeval it_interval; // 每个阶段的时间,间隔时间
struct timeval it_value; // 延迟多长时间执行定时器
};

struct timeval { // 时间的结构体
time_t tv_sec; // 秒数
suseconds_t tv_usec; // 微秒
};

//例如过10秒后,每个2秒定时一次

- old_value :记录上一次的定时的时间参数,一般不使用,指定NULL

- 返回值:
成功 0
失败 -1 并设置错误号
*/

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

//先运行5秒,之后每隔2秒发送一个闹钟
static int times = 0;
void mysignal(int num){
printf("times : %d -- recv sifnal : %d\n",times++,num);
}

int main(){

signal(SIGALRM,mysignal);//注册信号捕捉函数

struct itimerval neval;
neval.it_interval.tv_sec = 2;//每隔两秒定时一次
neval.it_interval.tv_usec = 0;//0us

neval.it_value.tv_sec = 5;//延迟5秒开始执行
neval.it_value.tv_usec = 0;

int ret = setitimer(ITIMER_REAL,&neval,NULL);
printf("定时器开始了\n");

if(ret == -1){
perror("settimer");
exit(0);
}

getchar();//阻塞

return 0;
}

信号捕捉-signal

  • SIGKILLSIGSTOP不能被捕捉,不能被忽略。
  • 针对接收到的某个信号,定义一些特殊的行为。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <signal.h>
typedef void (*sighandler_t)(int);//回调函数形式 void func(int x)
sighandler_t signal(int signum, sighandler_t handler);

/*
- 功能:设置某个信号的捕捉行为
- 参数:
- signum: 要捕捉的信号
- handler: 捕捉到信号要如何处理
- SIG_IGN : 忽略信号
- SIG_DFL : 使用信号默认的行为
- 回调函数 : 这个函数是内核调用,程序员只负责写,捕捉到信号后如何去处理信号。
回调函数:
- 需要程序员实现,提前准备好的,函数的类型根据实际需求,看函数指针的定义
- 不是程序员调用,而是当信号产生,由内核调用
- 函数指针是实现回调的手段,函数实现之后,将函数名放到函数指针的位置就可以了。

- 返回值:
成功,返回上一次注册的信号处理函数的地址。第一次调用返回NULL
失败,返回SIG_ERR,设置错误号
*/

另外一个处理函数

1
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

信号集

许多信号相关的系统调用都需要能表示一组不同的信号,多个信号可使用一个称之为信号集的数据结构来表示,其系统数据类型为 sigset_t。
在 PCB 中有两个非常重要的信号集。一个称之为 “阻塞信号集” ,另一个称之为“未决信号集” 。这两个信号集都是内核使用位图机制来实现的。但操作系统不允许我们直接对这两个信号集进行位操作。而需自定义另外一个集合,借助信号集操作函数来对 PCB 中的这两个信号集进行修改。

信号的 “未决” 是一种状态,指的是从信号的产生到信号被处理前的这一段时间。

信号的 “阻塞” 是一个开关动作,指的是阻止信号被处理,但不是阻止信号产生。

信号的阻塞就是让系统暂时保留信号留待以后发送。由于另外有办法让系统忽略信号,所以一般情况下信号的阻塞只是暂时的只是为了防止信号打断敏感的操作

阻塞信号集和未决信号集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1.用户通过键盘  Ctrl + C, 产生2号信号SIGINT (信号被创建)

2.信号产生但是没有被处理 (未决)
- 在内核中将所有的没有被处理的信号存储在一个集合中 (未决信号集)
- SIGINT信号状态被存储在第二个标志位上
- 这个标志位的值为0, 说明信号不是未决状态
- 这个标志位的值为1, 说明信号处于未决状态

3.这个未决状态的信号,需要被处理,处理之前需要和另一个信号集(阻塞信号集),进行比较
- 这个标志位的值为1, 说明信号处于要被阻塞
- 阻塞信号集默认不阻塞任何的信号
- 如果想要阻塞某些信号需要用户调用系统的API

4.在处理的时候和阻塞信号集中的标志位进行查询,看是不是对该信号设置阻塞了
- 如果没有阻塞,这个信号就被处理
- 如果阻塞了,这个信号就继续处于未决状态,直到阻塞解除,这个信号就被处理

自定义信号机 - 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
以下信号集相关的函数都是对自定义的信号集进行操作。

int sigemptyset(sigset_t *set);
/*
- 功能:清空信号集中的数据,将信号集中的所有的标志位置为0
- 参数:set,传出参数,需要操作的信号集
- 返回值:成功返回0, 失败返回-1
*/
int sigfillset(sigset_t *set);
/*
- 功能:将信号集中的所有的标志位置为1
- 参数:set,传出参数,需要操作的信号集
- 返回值:成功返回0, 失败返回-1
*/
int sigaddset(sigset_t *set, int signum);
/*
- 功能:设置信号集中的某一个信号对应的标志位为1,表示阻塞这个信号
- 参数:
- set:传出参数,需要操作的信号集
- signum:需要设置阻塞的那个信号
- 返回值:成功返回0, 失败返回-1
*/
int sigdelset(sigset_t *set, int signum);
/*
- 功能:设置信号集中的某一个信号对应的标志位为0,表示不阻塞这个信号
- 参数:
- set:传出参数,需要操作的信号集
- signum:需要设置不阻塞的那个信号
- 返回值:成功返回0, 失败返回-1
*/

int sigismember(const sigset_t *set, int signum);
/*
- 功能:判断某个信号是否阻塞
- 参数:
- set:需要操作的信号集
- signum:需要判断的那个信号
- 返回值:
1 : signum被阻塞
0 : signum不阻塞
-1 : 失败
*/
  • 注意:修改的是自定义信号集!

修改pcb的阻塞集合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
/*
- 功能:将自定义信号集中的数据设置到内核中(设置阻塞,解除阻塞,替换)
- 参数:
- how : 如何对内核阻塞信号集进行处理
SIG_BLOCK: 将用户设置的阻塞信号集添加到内核中,内核中原来的数据不变
假设内核中默认的阻塞信号集是mask, mask | set
SIG_UNBLOCK: 根据用户设置的数据,对内核中的数据进行解除阻塞
mask &= ~set
SIG_SETMASK:覆盖内核中原来的值

- set :已经初始化好的用户自定义的信号集
- oldset : 保存设置之前的内核中的阻塞信号集的状态,可以是 NULL
- 返回值:
成功:0
失败:-1
设置错误号:EFAULT、EINVAL
*/
int sigpending(sigset_t *set);
/*
- 功能:获取内核中的未决信号集
- 参数:set,传出参数,保存的是内核中的未决信号集中的信息。
*/

案例:阻塞2、3号位,输出未决信号集合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>

int main(){

//设置2、3信号阻塞
sigset_t set;
sigemptyset(&set);

//将2,3信号添加到信号机
sigaddset(&set,SIGINT);
sigaddset(&set,SIGQUIT);

//修改内核中的阻塞信号集、
sigprocmask(SIG_BLOCK,&set,NULL);

while(1){
//获取当前的未决信号集
sigset_t pendingset;
sigemptyset(&pendingset);
sigpending(&pendingset);

//遍历输出
for(int i=1;i<32;i++){
if(sigismember(&pendingset,i) == 1){
printf("1");
}else if(sigismember(&pendingset,i) == 0){
printf("0");
}else{
perror("sigismember");
exit(0);
}
}
printf("\n");
sleep(1);
}

return 0;
}

在程序执行中,ctrl + c以及ctrl + \均被阻塞,程序将会打印出未决信号,其中2、3号位为1.

案例:定时器、信号处理、以及阻塞的综合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/time.h>

void mysignal(int num){
printf("解除阻塞\n");
//收到信号之后解除两个阻塞
sigset_t set;
sigemptyset(&set);

//添加2,3
sigaddset(&set,SIGINT);
sigaddset(&set,SIGQUIT);

sigprocmask(SIG_UNBLOCK,&set,NULL);


}

int main(){

signal(SIGALRM,mysignal);//注册信号捕捉函数
//设置计时器
struct itimerval timer;
timer.it_value.tv_sec = 10;//等待时间后再开始
timer.it_value.tv_usec = 0;

timer.it_interval.tv_sec = 5;//每个定期时间
timer.it_interval.tv_usec = 0;

setitimer(ITIMER_REAL,&timer,NULL);

//设置2、3信号阻塞
sigset_t set;
sigemptyset(&set);

//将2,3信号添加到信号机
sigaddset(&set,SIGINT);
sigaddset(&set,SIGQUIT);

//修改内核中的阻塞信号集、
sigprocmask(SIG_BLOCK,&set,NULL);

while(1){
//获取当前的未决信号集
sigset_t pendingset;
sigemptyset(&pendingset);
sigpending(&pendingset);

//遍历输出
for(int i=1;i<32;i++){
if(sigismember(&pendingset,i) == 1){
printf("1");
}else if(sigismember(&pendingset,i) == 0){
printf("0");
}else{
perror("sigismember");
exit(0);
}
}
printf("\n");
sleep(1);
}

return 0;
}

当按了ctrl + c之后,会被阻塞,未决状态集打印出1,然后定时器到时之后,发送信号被捕捉,打印话语之后,解除阻塞,内核检测到信号时SIGINT,之后就结束程序。

信号捕捉——sigaction()

sigactionsignal更通用!因为signal是ANSI的标准,有多个版本,可能行为不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);
/*
- 功能:检查或者改变信号的处理。信号捕捉
- 参数:
- signum : 需要捕捉的信号的编号或者宏值(信号的名称)
- act :捕捉到信号之后的处理动作
- oldact : 上一次对信号捕捉相关的设置,一般不使用,传递NULL
- 返回值:
成功 0
失败 -1

struct sigaction {
void (*sa_handler)(int);// 函数指针,指向的函数就是信号捕捉到之后的处理函数
void (*sa_sigaction)(int, siginfo_t *, void *);// 不常用
sigset_t sa_mask;// 临时阻塞信号集,在信号捕捉函数执行过程中,临时阻塞某些信号。
int sa_flags;
// 使用哪一个信号处理对捕捉到的信号进行处理
// 这个值可以是0,表示使用sa_handler,也可以是SA_SIGINFO表示使用sa_sigaction
void (*sa_restorer)(void);// 被废弃掉了
};
*/

实例:信号捕捉与处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void myalarm(int num){
printf("收到信号 :%d \n",num);
}

int main(){

struct sigaction act;
act.sa_flags = 0;
act.sa_handler = myalarm;
sigemptyset(&act.sa_mask);//临时阻塞信号集置空

//注册信号捕捉
sigaction(SIGALRM,&act,NULL);

struct itimerval newtime;
newtime.it_interval.tv_sec = 2;
newtime.it_interval.tv_usec = 0;

newtime.it_value.tv_sec = 5;//延迟时间
newtime.it_value.tv_usec=0;

int ret = setitimer(ITIMER_REAL,&newtime,NULL);
printf("定时器开始\n");
if(ret == -1){
perror("setitimer");
exit(0);
}

while(1);
return 0;
}

内核捕捉信号的处理流程

SIGCHLD信号

SIGCHLD信号产生的条件

  • 子进程终止时
  • 子进程接收到 SIGSTOP 信号停止时
  • 子进程处在停止态,接受到SIGCONT后唤醒时

以上条件发生时,内核都会给父进程发送信号,而父进程默认时阻塞信号的。

实例:通过SIGCHLD回收子进程

如果使用wait()回收子进程的话,父进程会进入阻塞,这样效率很低,毕竟父进程也要有自己的事情去做。

可以使用信号捕捉,注册信号捕捉函数,在内部执行waitpid(NOHONG)的代码来回收子进程。

普通情况——产生僵尸进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main(){

//创建子进程
pid_t pid;
for(int i=0;i<20;i++){
pid = fork();
if(pid == 0){
break;
}
}

if(pid > 0){
//父进程

while(1){
printf("parent process\n");
sleep(1);
}
}else if(pid == 0){
//子进程
printf("child process:%d\n",getpid());
}
return 0;
}

使用wait

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
void mysigchld(int num){
printf("收到信号:%d\n",num);
wait(NULL);//回收进程
}

int main(){

//创建子进程
pid_t pid;
for(int i=0;i<20;i++){
pid = fork();
if(pid == 0){
break;
}
}

if(pid > 0){
//父进程

//捕捉子进程的死亡信号,然后回收,
struct sigaction act;
act.sa_flags = 0;
act.sa_handler = mysigchld;
sigemptyset(&act.sa_mask);

sigaction(SIGCHLD,&act,NULL);

while(1){
printf("parent process\n");
sleep(1);
}
}else if(pid == 0){
//子进程
printf("child process:%d\n",getpid());
}
return 0;
}

执行之后,使用ps aux查看可以发现,其实仍然可能会有一部分进程没有被回收,变成了僵尸进程。

这其实与信号集有关,当子进程死亡之后,会产生SIGCHLD信号,此时可能会有多个进程死亡,但是信号集只能说明有信号发送到了,未决状态。有的信号就会被丢弃,从而导致有部分子进程没有被回收。

改进方式1:

1
2
3
while(1){
wait(NULL);
}

但是不建议这样做,毕竟父进程还要去做其他事情,这样会停滞在信号处理函数中。

改进方式2:

1
2
3
4
5
6
7
8
9
10
11
12
while(1){
int ret = waitpid(-1,NULL,WNOHANG);//WNOHANG非阻塞方式
if(ret > 0){
printf("child process die: %d\n",ret);
}else if(ret == 0){//说明还有子进程没有回收
break;
}else if(ret == -1){
//没有子进程需要回收
printf("all child die\n");
break;
}
}

对于方式2,大部分情况下,都可以正常运行,但是某些情况之下,仍然会产生问题,会出现段错误。

出现这种情况的原因在于,如果全部的子进程都运行完了,而此时的父进程的信号捕捉函数还没有注册成功,那么就会出现问题。

疑问:为什么会出现问题?难道是因为子进程全部死掉后,OS发现不会产生SIGCHLD信号而产生错误的吗?否则的话,只是不会触发而已,为什么会是段错误?

???

总体解决思路

在整个程序运行之前,先阻塞SIGCHLD信号,然后等父进程注册号信号之后,再打开SIGCHLD信号,从而解决问题。

必须是整个程序运行前,而非产生完子进程之后,否则可能面临同样的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
void mysigchld(int num){
printf("收到信号:%d\n",num);
while(1){
int ret = waitpid(-1,NULL,WNOHANG);//-1表示等待任意的子进程,WNOHANG非阻塞方式
if(ret > 0){
printf("child process die: %d\n",ret);
}else if(ret == 0){//说明还有子进程没有回收
break;
}else if(ret == -1){
//没有子进程需要回收
printf("all child die\n");
break;
}
}
}

int main(){

sigset_t set;
sigemptyset(&set);
sigaddset(&set,SIGCHLD);
sigprocmask(SIG_BLOCK,&set,NULL);//阻塞SIGCHLD信号

//创建子进程
pid_t pid;
for(int i=0;i<20;i++){
pid = fork();
if(pid == 0){
break;
}
}

if(pid > 0){
//父进程
//捕捉子进程的死亡信号,然后回收,
struct sigaction act;
act.sa_flags = 0;
act.sa_handler = mysigchld;
sigemptyset(&act.sa_mask);

sigaction(SIGCHLD,&act,NULL);

//注册完信号处理后,解除阻塞
sigprocmask(SIG_UNBLOCK,&set,NULL);

while(1){
printf("parent process\n");
sleep(1);
}
}else if(pid == 0){
//子进程
printf("child process:%d\n",getpid());
}
return 0;
}

补充

CSAPP内容

疑问

  • 僵尸进程产生是因为 子进程停止而父进程还没有回收他的资源,那么父进程终止后,子进程的资源就会被回收吗?
  • 信号集 父子进程共享吗?
  • 父进程回收子进程的哪一部分资源?
  • 信号处理函数,