Linux信号机制:进程异步通信的秘密
信号
信号和信号量并没有什么关系
信号是 Linux 系统提供的一种向指定进程发生特定事件的方式,进程要对信号做识别和处理
信号的产生是异步的
信号的产生方式
- kill 命令:kill 命令可以向进程发送指定信号
kill -2 10000
就是对 10000 进程发送 2 号信号,9 号信号不能进行自定义处理,因此使用kill -9
还是会退出 - 键盘:键盘可以发送信号,
ctrl+c
发送的是 2 号信号SIGINT
,ctrl+\
发送的是 3 号信号SIGQUIT
- 系统调用:系统调用
kill
,原型:int kill(pid_t pid, int sig);
向指定的进程发送任意信号,成功返回 0,失败返回-1
signal 函数:
signal(signal,func)//接收signal信号,若收到了signal表示的信号,则执行func函数
//只需捕捉一次,后续一直有效kill系统调用
int kill(pid,signum)//向指定进程发送指定的信号raise系统调用
int raise(sig)//向自己发送指定的信号,等价于:kill(getpid(),sig)
signal本质是一个异步事件处理机制,当程序在正常执行主流程代码时,信号处理函数是处于等待状态的。一旦有对应的信号产生,操作系统会中断当前正在执行的程序指令流,转而执行信号处理函数。这个过程类似于硬件中断的处理方式
为了防止进程将所有的信号都自定义处理而导致无法被关闭,因此有一些信号无法被捕捉,如 9 号信号
- 软件条件:管道、闹钟
管道:读关闭,写进行,就会触发 SIGPIPE
闹钟:
alarm()
设置闹钟,当到达设定的事件后发送SIGALRM信号
int n=alarm(0)表示取消闹钟,返回值表示上一个闹钟的剩余时间
int n=alarm(5);表示重新设置闹钟,返回值为上一个闹钟的剩余时间
闹钟设置之后默认只触发一次
闹钟信号是alarm通知OS发送的,也是OS通知进程进行接收的
OS 管理闹钟的方式
将 alarm 先描述再组织,alarm 结构体中有一个 expired,未来的超时时间=second+now()
,将所有的闹钟放入一个最小堆,只要堆顶的元素没有超时,则全部的闹钟都没有超时
- 异常:非法访问、操作时,操作系统会给进程发送信号,使进程终止
core、term 都是异常终止进程,但 core 会生成一个类似于 debug 文件的文件,但一般生成文件一般是被关闭的,使用 ulimit -a 可以查看当前所有可用资源的限制
使用 ulimit -c 可以更改 core file 的文件大小
出现 core 异常时,操作系统会将出现异常的资源从内存中移动到磁盘上,这就是 core 文件
发送信号本质上就是修改 PCB 中信号的指定位图,这个过程是内核进行的
可以通过kill -l
查看当前所有的信号
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP6) 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
- 异常,当除 0 或者访问野指针时,导致操作系统会给进程发送信号,进而造成进程崩溃,除 0 会发送SIGFPE 信号,非法访问野指针会发送 SIGSEGV,因为这些信号默认就是终止进程,因此就会导致进程退出,可以通过自己接收信号来进行处理
-
- CPU 得知运算是正常还是异常的方式:CPU 内部有一个寄存器,用于接收运算的结果,这个寄存器里面有一个溢出标志位,当这个标志位为 0 说明没有溢出,也就不会发生异常,一旦置为 1,说明发生了溢出,进而发送信号,进程退出之后就释放了进程的上下文数据,也就将异常的数据删除了,因此推荐终止进程
- 被信号终止的进程会有一个 core dump 标志位,而正常终止的进程没有
core 和 term
这两个都是异常终止,但是 core 终止会将进程终止那个时刻的镜像数据,核心转储到磁盘上,生成一个 debug 文件,但是这个功能是默认关闭的,使用ulimit -a
查看核心转储功能是否打开,通过使用 ulimit -c 文件大小
来打开核心转储并决定核心转储的文件最大的大小,由于运行在云服务器上的服务可能终止后需要立刻开启,而开启后可能又会崩溃,又会生成 core 文件,这就可能导致硬盘空间被这些文件占满,可以使用echo "./core.%e.%p" | sudo tee /proc/sys/kernel/core_pattern
改变生成 core 文件的位置,%e 是程序名,%p 是进程号,可以选择不加,这样就只会生成一个 core 文件,也可以将kernel.core_pattern = /tmp/core.%e.%p
写入/etc/sysctl.conf
文件,然后使用sudo sysctl -p
命令令配置生效
可以使用 gdb 对这个文件进行调试,在 gdb 中输入对这个 core 文件进行调试core-file 文件名
阻塞信号
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)
- 进程可以选择阻塞 (Block )某个信号,被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
- 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作
pending 中通过位图中比特位的位置来代表信号的编号,比特位的内容来代表信号是否收到
block 中通过位图中比特位的位置代表信号的编号,比特位的内容代表信号是否阻塞
handler 是一个函数指针数组,用于存储处理信号的方法,每一个信号的编号相当于这个数组的下标,通过信号的编号来找到对应的处理方法
要处理一个信号首先要 pending 为 1,表示信号已经收到,block 为 0,表示信号未阻塞,然后通过 handler 进行处理
sigset_t
sigset_t
是 Linux 为用户提供的一个位图,但是不允许用户直接修改
每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的,因此,未决和阻塞标志可以用相同的数据类型sigset_t
来存储,sigset_t
称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态
信号集操作函数
sigprocmask
调用函数sigprocmask
可以读取或更改进程的信号屏蔽字(阻塞信号集)
函数原型int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
how
:操作类型,决定如何修改当前信号屏蔽字,可选值:
-
SIG_BLOCK
:将set
中的信号 加入 当前屏蔽字(取并集)SIG_UNBLOCK
:将set
中的信号 移除 当前屏蔽字(取差集)SIG_SETMASK
:直接用set
替换 当前屏蔽字
set
:指向新信号集的指针(若为NULL
,则不修改屏蔽字)oldset
:保存修改前的旧屏蔽字(若为NULL
,则不保存)
sigset_t
是用于表示信号集的数据类型。信号集是一个能够容纳多个信号编号的集合,主要用于信号的屏蔽(阻塞)和等待等操作
sigemptyset()
- 功能:初始化一个信号集,将其中所有信号编号清除,使其变为空集。
- 语法:
int sigemptyset(sigset_t *set);
,其中set
是指向要初始化的sigset_t
类型信号集的指针。
sigset_t myset;
if (sigemptyset(&myset) == -1) {perror("sigemptyset");return 1;
}
sigaddset()
- 功能:将一个指定的信号编号添加到已有的信号集中
- 语法:
int sigaddset(sigset_t *set, int signum);
,其中set
是指向sigset_t
类型信号集的指针,signum
是要添加的信号编号。
sigset_t myset;
if (sigemptyset(&myset) == -1) {perror("sigemptyset");return 1;
}
if (sigaddset(&myset, SIGINT) == -1) {perror("sigaddset");return 1;
}
使用 sigprocmask
对信号进行阻塞的过程:
分别创建一个block_set
和一个old_set
用于记录要操作和保存哪些信号,然后通过sigemptyset
确保集合初始化为空,接下来将要添加进阻塞队列的信号使用sigaddset
添加到block_set
中,然后就可以使用sigprocmask
将block_set
中保存的信号添加进内核的阻塞队列中了,根据参数 how 的不同做出的操作也就不同
SIG_BLOCK
:将用户集合中的信号添加到内核屏蔽字(位图置 1
)
// 用户:myset = {SIGINT}
sigprocmask(SIG_BLOCK, &block_set, &old_set);
// 内核:block_bitmap |= block
// 将原本的阻塞队列保存进old_set中
SIG_UNBLOCK
:将用户集合中的信号从内核屏蔽字移除(位图置 0
)
// 用户:myset = {SIGINT}
sigprocmask(SIG_UNBLOCK, &myset, NULL);
// 内核:block_bitmap &= ~myset
// 解除了当前的阻塞,从当前屏蔽字中移除myset中指定的信号,NULL表示不保存旧的屏蔽字
// 也可以放入一个sigset_t变量进行保存
SIG_SETMASK
:直接用用户集合覆盖内核屏蔽字
// 用户:myset = {SIGINT}
sigprocmask(SIG_SETMASK, &old_set, &block_set);
// 内核:block_bitmap = old_set
// 因为最开始的阻塞位图存放在old_set中,因此直接将其覆盖回来,然后将当前的放入block_set
// 这样就是解除了阻塞
完整的流程图:
User Space Kernel Space
+-------------------+ +---------------------+
| | | |
| sigset_t myset; | | Process Signal |
| sigaddset(...); | | Mask (Block Bitmap)|
| | | |
+--------+----------+ +----------+----------+| ^| sigprocmask(SIG_BLOCK, &myset, ...) |+--------------------------------------+
解除屏蔽,一般会立即处理当前被解除的信号
pending 位图对应的信号在递达之前就已经被清空了
对信号进行处理时,默认该信号会被屏蔽,当该信号处理完成时,会解除屏蔽
sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1
函数原型:int sigpending(sigset_t *set);
通过 sigismember 判断 pending 位图中的信号是否未决,函数原型:
int sigismember(const sigset_t *set, int signum);
进程对信号的处理方式
处理信号就是递达信号,对信号的处理有 3 种方式:
- 默认动作
- 忽略动作
- 自定义处理(信号的捕捉)
进程处理信号通常都是默认的,默认动作包括:终止自己、暂停、忽略
自定义处理:signal(2,hadler)
,默认动作:signal(2,SIGDFL)
,忽略动作signal(2,SIGIGN)
信号可能不会立即处理,而是在进程从内核态返回到用户态的时候进行处理
对信号的捕捉
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。如: 用户程序注册了SIGQUIT
信号的处理函数sighandler
当前正在执行main
函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT
递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler
函数,此时需要从内核态转换到用户态来执行,sighandler
和main
函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。sighandler
函数返回后自动执行特殊的系统调用sigreturn
再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main
函数的上下文继续执行了
地址空间
以 32 位机器为例,在进程的 task_struct 中的 mm_struct 中,有 3GB 的用户空间和 1GB 的内核空间,操作系统将自身通过内核级页表映射到 mm_struct 中,因为操作系统中有很多进程,因此所有的进程的内核空间都指向同一个内核级页表,无论进程如何切换,都能找到同一个 OS,因此进程访问 OS 也是在自己的地址空间中的,和访问库函数没有区别,用户访问内核地址空间中的数据时,需要受到约束,因此只能通过系统调用进行访问
键盘输入数据的过程
键盘向系统中输入数据的时候,会向 CPU 发送硬件中断信号,每一个硬件设备都会有自己的中断号,CPU 中的寄存器接收到中断号作为一个数组的下标,传给内存中的函数指针数组,然后通过这个中断向量表中的内容执行对应硬件的操作
中断是纯硬件实现的,而信号是硬件+软件
操作系统的运行过程
操作系统本质就是一个死循环+时钟中断,不断调度系统的任务
外部中断的目的就是为了让 CPU 中的寄存器内部生成一个中断号的数字,因此可以直接跳过外部生成中断,CPU 内部生成一个中断号的数字,这个就是 CPU 陷入,CPU 寄存器内部生成一个 0x80,然后到内存中的中断向量表找到对应的操作
用户进程不能直接进入内核空间中,因此需要特定条件下才能跳转过去,在 CPU 中有一个 code segment 寄存器,这个寄存器中保存着代码段的范围,这个寄存器的低两位保存着当前是用户态还是内核态,00 表示内核态,11 表示用户态,当需要执行内核空间中的代码时,CPU 先检查当前是用户态还是内核态,如果是用户态则转换为内核态,寄存器就保存内核空间中相应的代码段,开始执行对应的系统调用,同理,需要执行用户空间的代码时,则转换为用户态,寄存器就保存内核空间中相应的代码段,继续执行用户空间的相应代码
理解系统调用
系统中有一个 sys_call_table,当需要调用系统调用时,只需要找到对应数组的下标,这个下标叫做系统调用号,就能执行对应的系统调用了,执行系统调用时会先将系统调用的内容写入到中断向量表中,接下来CPU 内部生成 0x80 进入中断,然后进入中断向量表找到对应的操作,这样就成功执行了系统调用
sigaction
原型:int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
sigaction
函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回-1。signo
是指定信号的编号。若act
指针非空,则根据act
修改该信号的处理动作。若oact
指针非空,则通过oact
传出该信号原来的处理动作。act
和oact
指向sigaction
结构体:
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_handler
赋值为常数SIG_IGN
传给sigaction
表示忽略信号,赋值为常数SIG_DFL
表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void
,可以带一个int
参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main
函数调用,而是被系统所调用
执行 handler 函数时,会自动将正在处理的信号屏蔽,处理完成后自动解除屏蔽,这样防止了对函数的递归调用导致的系统异常
struct sigaction action,oaction;
action.sa_handler=handler;//设置默认信号处理为handler
sigemptyset(&action.sa_mask);//将掩码置空
sigaddset(&action.sa_mask,3);//对其他信号也进行屏蔽,将信号添加进掩码中
action.sa_flags=0;
sigaction(2,&action,&oaction);
可重入函数
可重入函数(Reentrant Function)是指一个函数可以被多个任务(如多个线程或进程)同时调用,或者被一个任务递归调用,并且在执行过程中不会出现错误或不一致的情况。也就是说,函数在被并发或递归调用时,其执行结果和单独调用时是一样的,不会因为调用的重叠而破坏自身的数据或其他相关资源的状态
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构
特点:
不使用静态或全局数据:可重入函数不依赖于静态或全局数据,因为这些数据在多个任务间共享,容易导致数据竞争
不修改自身代码:代码在运行期间不可变
不调用不可重入函数:如果一个函数调用了不可重入的函数,那么它自身也变成不可重入的
不使用硬件资源:在多任务环境中,如果函数依赖于特定的硬件资源(如特定的寄存器),并且这些资源在不同任务间共享,可能导致结果不可控,所以可重入函数通常不依赖这些资源
不可重入函数
被重入时出现问题的函数称为不可重入函数
不可重入函数在并发调用时可能导致数据损坏或逻辑错误,通常因为它们依赖共享状态(如静态变量、全局变量或硬件资源)
特点:
- 依赖静态/全局数据:例如使用静态缓冲区或全局计数器
- 可能修改共享资源:如文件操作、内存分配(
malloc
/free
) - 存在副作用:多次调用可能产生不确定结果
volatile 关键字
它主要用于告诉编译器,被修饰的变量可能会在程序的外部被改变,例如被硬件设备、其他线程或者操作系统等改变。编译器在编译代码时,对于普通变量可能会进行一些优化,比如将变量的值缓存在寄存器中,以提高访问速度。但是对于volatile
修饰的变量,编译器不会进行这样的优化,每次访问该变量时,都会从内存中读取它的值。
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <setjmp.h>static jmp_buf env;
static volatile int flag = 0;void handler(int signum)
{flag = 1;longjmp(env, 1);
}int main()
{if (signal(SIGINT, handler) == SIG_ERR) {perror("signal");return 1;}int ret = setjmp(env);if (ret == 0) {// 正常执行的代码while (!flag) {// 这里是一些可能会被中断的代码,比如循环等待等}} else {// 从信号处理函数跳转回来后执行的代码printf("Interrupted!\n");}return 0;
}
这段代码中,若不对 flag 使用 volatile 修饰,会导致信号量将内存中的 flag 改变,而 while 循环依然使用寄存器中 flag 的旧值,导致循环无法结束
SIGCHLD 信号
子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可
如果有部分进程永远不会退出,此时使用信号进行等待必须使用非阻塞等待,否则等待时会发生阻塞,导致后面的流程都无法执行
signal(SIGCHLD,SIG_IGN);
//对收到的信号进行忽略
//系统中也对该信号设置了忽略,但是这个相当于系统定义的一种Ignore操作
//系统的IGN和用户的IGN含义不同
//用户设置SIGIGN后,子进程退出后就不会对父进程发送信号,也就不会僵尸,但是父进程无法获取到子进程的退出信息