信号通信学习路线
前面我们已经学习了进程间匿名管道和命名管道通信方式,这两种通信方式的原理都是:用户空间中的两个进程无法直接和对方进行交流,这时候就需要借助我们的linux内核来帮助两个进程实现交流。在内核中,我们手动建立管道,然后进程通过读写管道来实现数据通信。
管道通信借助的对象是管道,管道对象需要我们来手动创建,在信号通信这里借助的工具是信号。与管道通信不同,信号对象不需要我们创建,linux内核中自带有信号对象,帮助我们实现进程之间通信。linux一共为我们提供了64种信号,可以通过之前学习linux基本命名中的kill -l 命令来查看这64种信号。
学习一种通信方式,我们首先必须先学习它的框架,只有先了解了大致通信流程,我们才知道我们应该学习什么东西。信号这块的内容大致可以分为三个部分,信号的发送,信号的接收以及信号的处理。
信号的发送
介绍了两个函数,分别是kill和raise函数,对于kill函数来说,可以发送信号给任意的进程,对于raise函数来说只能发送信号给自己。
kill函数
函数介绍
函数作用:向指定的进程(包含自己)发送指定的信号。
头文件:#include<signal.h>
函数原型:int kill( pid_t pid, int sig );
参数:
pid
:指定接收信号的进程或进程组。不同的 pid
值有不同的意义:
- 正数:表示将信号发送给指定进程 ID 的进程。
- 0:将信号发送给调用进程所属的进程组中的所有进程。
- -1:将信号发送给所有有权限接收信号的进程(除了进程 1,即
init
进程)。
sig
:要发送的信号编号。sig
参数可以使用信号的宏名称或者信号的数字编号来指定信号。使用宏名称通常更具可读性。
返回值:
- 成功时,返回
0
。 - 失败时,返回
-1
,并设置errno
以指示错误原因。
函数使用
这里我们使用kill函数简易地实现了一下终端中的kill命令。
kill
函数:在 C 程序中使用,发送信号来控制进程。kill
命令:在命令行中使用,用于管理进程。关于kill命令的详细使用可以参考之前文章:linux中的进程以及进程管理-CSDN博客
#include"sys/types.h"
#include"signal.h"
#include"unistd.h"
#include"stdio.h"
#include"stdlib.h"
int main(int argc,char*argv[]){
int sig;
int pid;
if(argc< 3){
printf("please input param\n");
return -1;
}
//将字符串转化为int类型整数
sig=atoi(argv[1]);
pid=atoi(argv[2]);
printf("sig=%d,pid=%d\n",sig,pid);
kill(pid,sig);
return 0;
}
raise函数
函数介绍
函数作用:向当前进程(自己)发送指定的信号。
头文件:#include<signal.h>
函数原型:int raise( int sig );
参数:
sig
:要发送的信号编号。sig
参数可以使用信号的宏名称或者信号的数字编号来指定信号。使用宏名称通常更具可读性。
返回值:
- 成功时,返回
0
。 - 失败时,返回
-1
,并设置errno
以指示错误原因。
借助信号发送回顾之前所学知识的例子
#include"sys/types.h"
#include"signal.h"
#include"unistd.h"
#include"stdio.h"
#include"stdlib.h"
int main() {
pid_t pid = fork();
//父进程中fork函数的返回值是生成的子进程的id,
//父进程中的pid变量保存的是子进程的id
if (pid > 0) {
sleep(8);//睡眠状态的进程,ps会显示S
if (waitpid(pid, NULL, WNOHANG) == 0) {//没有成功回收子进程
kill(pid, 9);
}
wait(NULL);
while (1);
} else if (pid == 0) {//子进程中fork函数的返回值是0,子进程中pid变量保存的是0
printf("raise function before\n");
raise(SIGTSTP);//暂停状态的进程,ps会显示T
printf("raise function after\n");
exit(0);
}
return 0;
}
进程状态变化
子进程收到SIGTSTP信号处于停止状态(T),直到后面被父进程杀死然后子进程结束。
父进程 sleep会处于睡眠态8秒钟,父进程调用waitpid(非阻塞模式)所以不会被阻塞,但是子进程处于停止状态没有结束,此时父进程waitpid回收子进程失败,所以直接发送9号信号杀死子进程,然后调用wait再次为子进程收尸。
当wait和waitpid不关心子进程状态,只想为子进程收尸
这里将wstatus
参数设置为 NULL
,表示我们不关心子进程的退出状态,只是想等待子进程终止。这样就不用传递参数来接收子进程状态了。
注意:并不是所有的参数都可以传入NULL来代替。以下是两点要求:
- 只有当函数接受指针类型参数时,才可以传递
NULL
作为参数。这是因为NULL
在 C 语言中通常定义为(void *)0
,表示一个空指针。这里的wstatus是指针类型。 - 函数必须有明确处理,参数为
NULL
的逻辑才可以,否则也会导致程序出错。
alarm函数
函数介绍
函数作用:设置一个定时器,在指定的秒数之后发送 SIGALRM
信号给调用它的进程。这在需要定时执行某些任务或设置超时限制的程序中非常有用。
头文件:#include<signal.h>
函数原型:unsigned int alarm(unsigned int seconds);
参数:
seconds
:指定定时器的时间,以秒为单位。
- 如果
seconds > 0
,在指定的秒数后会向调用进程发送SIGALRM
信号。 - 如果
seconds == 0
,则取消任何现有的alarm
定时器。
返回值:
在 Linux 中,alarm
函数只能设置一个定时器,设计得很简单。如果一个进程多次调用 alarm
,每次新的调用会覆盖之前的定时器。因此,不能通过多次调用 alarm
来实现多个不同的定时事件。
- 如果当前进程已经设置过一个定时器,则返回上一次设置的
alarm
定时器还剩余的秒数,并覆盖该定时器。 - 如果在此之前没有设置过定时器或者上一次设置的定时器已经到期结束,或者上一次定时器被取消了,总之就是当前进程没有正在运行的定时器,返回
0
。
信号的接收
三种信号接收方式的介绍
在信号接收的时候我们要做的是不能让这个进程结束,进程结束了自然便接收不到内核发来的信号了。在 Linux 中,通常有几种方式来让进程等待“捕捉”到信号:
pause()
:进程会一直阻塞,直到进程接收到一个非屏蔽的信号并且信号处理程序执行完毕。pause()
不指定等待的时间,它只是单纯等待信号。sleep()
:进程在指定的时间内睡眠,如果时间到了,sleep()
也会返回,进程恢复执行。如果在睡眠期间收到了信号,sleep()
会被中断,提前返回。-
进程进入死循环while(1)来保证进程不要结束。
pause函数和sleep函数的对比:
pause()
和sleep()
都让进程进入浅度睡眠,可以被信号中断。- 区别在于
pause()
是无限期等待信号,而sleep()
是在指定时间内睡眠等待,或者被信号中断。
我们实际中更偏向使用sleep和pause方式,因为这两个函数都会让进程处于浅睡眠状态(S),然后被操作系统挂起,不占用CPU资源。对于while来说,只是保证了进程不结束而已,进程不会休眠被挂起,会持续占用CPU的大量资源,使用简单,我们通常在编程练习中用到比较多。
阻塞,进程状态,挂起之间的关系
我们在学习了进程已经有一段时间了,这三个词也用到了很多次,是时候来详细说一下这三个名词之间的关系了。
首先是说一下阻塞和进程状态:Linux 中,许多函数在特定条件下会导致进程阻塞。阻塞后进程通常进入浅度睡眠状态(S 状态)或深度睡眠状态(D 状态),具体取决于函数的目的和等待的条件。下面列举了几个会造成进程阻塞的常见函数:
受到阻塞后进入到浅度睡眠状态,进程在这种状态下可以响应信号。
I/O 操作函数:open(),
read()
和write(),select(),
等待数据的到达或资源的可用性。进程控制函数:
wait()
和waitpid()
pause()(无限阻塞函数)和sleep()(阻塞一段时间)
当进程进入深度睡眠状态,这种状态下进程无法响应大多数信号(如
SIGINT
(中断)和SIGTERM
(终止)),直到操作完成。一些强制性或关键的信号,如SIGKILL(
强制终止信号) 和SIGSTOP(
暂停信号)
还是会影响到进程的。进程等待无法打断的内核事件如磁盘 I/O、设备驱动交互的时候会进入深度睡眠状态
在浅度睡眠状态(S 状态)或深度睡眠状态(D 状态)下,进程都会被操作系统挂起。挂起意味着进程不会使用 CPU 资源,但它会在进程表中保持,直到它可以恢复执行。
信号的处理
知识预览
前面的例子中我们主要关注的是信号的发送和接收,没有关注信号的处理,使用的都是信号默认处理方式。对于信号处理方式来说有默认处理方式和自定义的信号处理方式;对于信号处理时间来说,可以立即处理信号也可以暂时屏蔽(阻塞)信号。
下面我们将按照如下顺序,逐一介绍:
1.信号的默认处理方式
2.自定义信号处理方式(忽略信号或调用自定义函数)
3.暂时屏蔽(阻塞)信号
4.特殊的两个信号(不能被屏蔽(阻塞)、捕获、或者忽略)
信号的默认处理方式
Linux中每种信号都有一个预定义的默认行为,在进程没有特别处理信号的情况下会被执行。下面我们罗列出Linux中的部分信号的默认行为。
signal(7)
手册页提供了 Linux 中所有信号的详细描述,包括信号的功能、默认行为等。
我们可以使用命令来查询:man 7 signal
Standard:表示该信号属于的标准或来源,说明它在哪些规范或环境中可用。
Action:描述该信号的默认行为
- Term(Terminate):进程会被终止(正常退出)。
- Ign(Ignore):信号会被忽略,进程不会做出响应 。
- Core(Core dump):进程会终止,并生成核心转储文件,用于调试。
- Stop:进程会被暂停,直到接收到
SIGCONT
信号后才会恢复执行。 - Cont:继续执行暂停的进程。
Comment:描述信号的作用或触发原因。它详细解释了信号的目的,帮助理解信号的使用场景和适用条件。
查阅所有信号的Action一栏,我们可以发现大多数信号的默认行为都是杀死进程。
补充知识:当一个进程由于信号导致终止(没有信号处理程序时),整个进程终止过程等同于调用了_exit 函数,而不是 exit。exit函数和_exit函数的区别:进程退出时调用的exit函数会处理我们的注册退出函数和清理I/O缓冲区。_exit函数则不会。关于exit函数和_exit函数详细区别请看文章:进程的所有状态,进程创建(fork)及区分进程,退出进程方式区别-CSDN博客
自定义信号处理方式
signal函数介绍
函数作用:通过 signal()
函数,你可以自定义信号的处理方式,例如当收到某个信号时调用特定的处理函数,或者忽略该信号。
头文件:#include<signal.h>
函数原型:void (*signal(int signum, void (*handler)(int)))(int);
参数:
signum
:这是一个整数,代表你想要处理的信号的编号。
handler
:这是一个指向信号处理函数的指针,当接收到 signum
指定的信号时,该函数将被调用。它有三种可能的值:
SIG_DFL
:将信号的处理方式恢复为默认操作。SIG_IGN
:忽略信号。- 自定义信号处理函数:定义一个函数,当信号
signum
到达时,执行该函数。由这里的参数形式可以看出来,自定义的信号处理函数必须是:返回值为void且需要一个int类型的参数。
返回值:
- 成功时,
signal()
返回之前的信号处理函数的地址。 - 如果出错,返回
SIG_ERR
,表示注册信号处理函数失败。
函数原型解释:
void (*signal(int signum, void (*handler)(int)))(int);
- 绿色部分表示signal函数第一个参数是int类型,接收信号的编号
- 蓝色部分表示signal函数第二个参数是函数指针类型,接收自定义的信号处理函数,由于c语言中的函数名就是一个指针,所以这里将自定义函数名传递就可以。
- 红色部分表示函数的返回值类型也是一个函数指针类型,并且函数指针指向的函数也接受一个
int
参数,并返回void
。我们此时就会想signal函数的返回值类型为什么要写成函数指针类型???(了解)
这个设计是为了支持信号处理的特定需求,这个设计允许程序查询和保存信号的旧处理器。这在一些特定场景下很有用。比如:你想临时修改某个信号的处理函数,完成任务后再恢复之前的处理函数。
void (*old_handler)(int); // 设置新的信号处理函数,同时保存旧的信号处理函数,有可能后面会进行恢复 old_handler = signal(SIGINT, my_handler);
函数效果解释:
我们按照要求编写了函数void handle_sigint(int signal),在进程中调用了代码:signal(SIGINT, handle_sigint);
代码效果:
- 我们将handle_sigint函数设置为了,SIGINT信号对应的信号处理方式。
- 当收到SIGINT信号时,进程将会自动调用handle_sigint函数来处理信号,并且将信号编号作为参数传递给handle_sigint函数。
signal函数实现避免僵尸进程
为了防止子进程结束时没有被及时回收,从而避免产生僵尸进程(zombie process),我们可以借助信号来实现一种方式来自动回收子进程。
当子进程结束时,子进程进入僵尸状态,操作系统向父进程发送 SIGCHLD
信号,目的是通知父进程其子进程已经终止。在父进程中,我们可以使用 signal()
捕捉 SIGCHLD
信号,并且自定义SIGCHLD
信号处理函数,函数中调用 wait()
或 waitpid()
来回收子进程。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
// SIGCHLD 信号处理函数
void sigchld_handler(int sig) {
// 使用 waitpid 非阻塞回收所有子进程
while (waitpid(-1, NULL, WNOHANG) > 0);
}
int main() {
// 注册 SIGCHLD 信号处理器
signal(SIGCHLD, sigchld_handler);
// 创建子进程
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("Child process %d is running...\n", getpid());
sleep(2); // 模拟子进程运行
printf("Child process %d is exiting...\n", getpid());
exit(0);
} else {
// 父进程
printf("Parent process %d is waiting for child to finish...\n", getpid());
while (1) {
sleep(1); // 模拟父进程做其他事情
}
}
return 0;
}
在这个例子中,父进程通过 signal(SIGCHLD, sigchld_handler)
注册了一个 SIGCHLD
信号处理器,在接收到 SIGCHLD
信号后,父进程使用 waitpid()
非阻塞地回收所有子进程。
之前我们回收子进程的方法要么是父进程阻塞等待子进程结束,要么是父进程采取waitpid非阻塞的方式来回收子进程且后面需要定时查询回收子进程。
借助signal回收子进程好处:不用阻塞等待,也不用复杂的定时查询子进程是否结束的逻辑。适用于那种追求高效性和快速响应的程序使用,简单的程序使用wait和waitpid更能保证进程同步性。
信号处理函数的格式
当你通过 signal
或 sigaction
(下一篇文章会详细讲到)注册一个信号处理函数时,操作系统会在指定的信号到达时自动调用你注册的处理函数。
操作系统不仅会调用这个函数,还会自动将触发信号的编号(比如 SIGINT
对应的是 2
)传递给处理函数的参数 signum
,告诉你是哪一个信号触发了当前的信号处理。
void signal_handler(int signum);
-
返回类型:
- 必须是
void
,表示该函数不返回任何值。
- 必须是
-
参数:
- 接受一个
int
类型的参数signum
,这个参数表示接收到的信号编号(即信号值),它指明了是哪一个信号触发了处理函数。
- 接受一个
信号的暂时屏蔽(阻塞)
信号屏蔽:信号的屏蔽是指暂时阻止进程对一个或多个信号的处理,使得这些信号在被屏蔽期间不会中断进程的执行。被屏蔽的信号不会立即触发信号处理函数(signal handler),而是被挂起,直到信号屏蔽解除后,系统才会将这些挂起的信号交给进程处理。
为什么需要信号屏蔽???
- 在执行某些重要的计算或处理时,你可能会阻塞信号来避免它们打断当前操作,特别是在不希望被信号打断的情况下。你可以将信号设置到信号屏蔽集合中,在重要代码执行完毕后在取消信号屏蔽,并对屏蔽阶段挂起的信号进行处理。
- 通常在多线程程序中,除了专门的信号处理线程外,其他线程会将大部分信号屏蔽(阻塞),这样保证只有信号处理线程能够接收和处理信号。让我们的程序更加清晰,更好维护
sigprocmask 函数介绍
函数作用:用来阻塞或解除阻塞信号。
头文件:#include<signal.h>
函数原型:int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数:
-
how
:SIG_BLOCK
:将set
指定的信号添加到当前的屏蔽字中(即阻塞这些信号)。SIG_UNBLOCK
:从当前的屏蔽字中移除set
指定的信号(即解除阻塞这些信号)。SIG_SETMASK
:将当前的屏蔽字替换为set
指定的信号集(即设置新的信号掩码)。
-
set
:- 指向
sigset_t
类型的信号集的指针,表示要添加、移除或设置的信号集合。 - 这个参数可以是
NULL
,如果how
是SIG_SETMASK
,则set
不能是NULL
,否则不需要设置新的信号集。
- 指向
-
oldset
:- 指向
sigset_t
类型的变量的指针,用于保存调用前的信号掩码。 - 这个参数可以是
NULL
,如果不需要保存当前的信号掩码。
- 指向
返回值:
- 成功时,
signal()
返回之前的信号处理函数的地址。 - 如果出错,返回
SIG_ERR
,表示注册信号处理函数失败。
sigset_t 数据结构
sigset_t 数据结构仅用于信号集合的操作,不用于存储或处理其他类型的数据,不是一个通用的集合数据结构。
sigset_t的主要操作函数:
sigprocmask使用示例
signal1.c
#include<sys/types.h>
#include<signal.h>
#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>
#include<signal.h>
void f(int sig){
printf("catch signal :%d\n", sig);
}
int main() {
printf("mypid: %d",getpid());
signal(SIGINT, f);
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGHUP);
sigprocmask(SIG_BLOCK,&set, NULL);
int count= 20;
while(count> 0)
{
printf("count:%d\n", count);
count--;
sleep(1);
}
sigprocmask(SIG_UNBLOCK,&set, NULL);//取消阻塞的信号
pause();//阻塞程序,防止程序终止,让程序来得及接收前面阻塞的信号
return 0;
}
signal2.c
#include<sys/types.h>
#include<signal.h>
#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>
#include<signal.h>
int main(void)
{
pid_t pid;
scanf("%d",&pid);
kill(pid,SIGINT);
return 0;
}
先执行signal1.c进程,再执行signal2.c进程,然后手动将signal1输出的进程号输入给signal2进程,让进程2发送信号给进程1。
运行结果:我们会发现即使在count倒计时20未结束的过程,将信号SIGINT发送给进程1,此时进程1会收到信号,但是不会立即对进程做出响应从而执行我们的自定义信号处理函数f。因为此时这个信号被我们加入到了进程的信号屏蔽集合中了,当我们取消信号屏蔽的时候才会去处理这些信号。
具体解释:
- 当一个信号被屏蔽(阻塞)时,任何发送到该信号的尝试都不会立即触发信号处理程序,信号会被挂起。
- 对于每种类型的信号,内核只会保留一个挂起实例。因此,即使在屏蔽状态下多次发送相同类型的信号,内核只会记录该信号处于挂起状态,而不会记录该信号的多个实例。
- 当信号从屏蔽状态解除后,系统会递送该挂起的信号,并执行相应的处理程序或默认处理。
总结:信号屏蔽期间,每种信号类型只会挂起一次。如果在屏蔽阶段多次发送相同类型的信号,信号只会处理一次。
这是因为信号的设计旨在通知某个事件已经发生,而不是记录该事件的次数。如果需要精确跟踪信号的发生次数,通常会通过信号处理程序中的状态变量来进行额外的计数或处理。
特殊的SIGKILL信号和SIGSTOP信号
在Linux中,大多数信号可以被屏蔽(阻塞)、捕获、或者忽略,但有两个个信号是不能被屏蔽(阻塞)、捕获、或者忽略。所有进程收到这两个信号的时候都会立即执行,并且按照这两个信号的默认处理方式进程执行,具体如下:
-
SIGKILL
(信号编号 9):- 这个信号用于强制终止进程。
- 无法被捕获、阻塞或忽略。系统会立即终止接收到该信号的进程,且进程无法进行任何清理操作。
-
SIGSTOP
(信号编号 19):- 用于暂停进程执行。
- 也无法被捕获、阻塞或忽略。进程接收到该信号后会立即暂停,直到接收到
SIGCONT
信号恢复执行。
这些信号的设计确保了系统可以在某些关键时刻强制控制进程的状态,以避免进程通过信号处理的机制绕过终止或暂停指令。