【Linux】进程信号(上半)

当我们想要强行终止掉前台进程的时候,只需要按下Ctrl+c即可,但是Ctrl+c是如何精准杀掉前台进程的?

一、信号概念

1.如何理解信号

假设点了一份外卖,外卖员到了楼下会给你发信息或者打电话,那么这通电话或者这个信息就是信号,也就是**用来提醒你需要去做某事的一种通知手段。**那么对照Linux系统,信号就是内核向进程发送的通知,提醒进程需要去完成某个任务。

2.信号的特点

对照外卖例子,当点完外卖,我们肯定不会一直在门口等着外卖员,而是先忙手中的事情,比如打游戏,那么这个过程就叫做异步 (信号由外卖员(内核)随时发出,但是对比同步 的阻塞等待,我们会先解决手头的事情),等外卖员真正的到了楼下打来电话,如果我们手头事情没忙完,我们也会先等手头事情完成才会去取外卖,这就是Linux中信号的延迟性/合适时机处理, 当然取外卖是默认动作,外卖也可能是买来送给女神的或者送给兄弟的,此时对于外卖就有了多种处理方式,这也是Linux中信号处理的可配置性。

综上,初步地可以了解到信号具有异步性 、延迟性 以及可配置性。

3.认识信号

聊完了信号的抽象模型(外卖),那么真正的信号长什么样呢?在bash中我们输入kill -l就可以调出全部信号

bash 复制代码
kill -l

可以看到一共有62个信号(没有32、33),前31个信号叫做普通信号,后31个信号称为实时信号,这里只谈普通信号。

1 SIGHUP(挂起信号):终端断开触发,默认终止进程,nohup可让进程忽略实现后台常驻。

2 SIGINT(中断信号):Ctrl+C手动触发,默认终止进程,支持捕获实现优雅退出。

3 SIGQUIT(退出信号):Ctrl+\触发,默认终止进程并生成core核心转储文件,用于调试。

4 SIGILL(非法指令信号):进程执行CPU无法识别指令触发,默认终止+核心转储。

5 SIGTRAP(陷阱信号):调试断点专用,gdb断点触发,默认终止+核心转储。

6 SIGABRT(异常终止信号):调用abort()函数触发,默认终止+核心转储,程序主动报致命错误。

7 SIGBUS(总线错误信号):内存访问对齐/硬件总线故障触发,默认终止+核心转储。

8 SIGFPE(浮点异常信号):浮点运算错误(除0/溢出)触发,默认终止+核心转储。

9 SIGKILL(终止信号):kill -9专用,强制终止进程,不可捕获、不可忽略。

10 SIGUSR1(用户自定义信号1):无默认触发场景,用户自定义使用,默认终止进程。

11 SIGSEGV(段错误信号):非法内存访问(越界/空指针)触发,默认终止+核心转储。

12 SIGUSR2(用户自定义信号2):无默认触发场景,用户自定义使用,默认终止进程。

13 SIGPIPE(管道破裂信号):向无读端的管道/套接字写数据触发,默认终止进程。

14 SIGALRM(闹钟信号):alarm()设置的定时到期触发,默认终止进程。

15 SIGTERM(终止信号):kill默认发送信号,默认终止进程,支持捕获实现优雅退出。

16 SIGSTKFLT(栈错误信号):协处理器栈溢出触发,默认终止进程,x86架构基本不用。

17 SIGCHLD(子进程状态变化信号):子进程退出/暂停/恢复触发,默认忽略,用于父进程回收子进程资源。

18 SIGCONT(继续信号):bg/fg/kill -CONT触发,恢复暂停进程,不可忽略。

19 SIGSTOP(暂停信号):Ctrl+Z/kill -STOP触发,强制暂停进程,不可捕获、不可忽略。

20 SIGTSTP(终端暂停信号):Ctrl+Z手动触发,默认暂停进程,支持捕获。

21 SIGTTIN(终端读信号):后台进程尝试读终端输入触发,默认暂停进程。

22 SIGTTOU(终端写信号):后台进程尝试写终端输出触发,默认暂停进程。

23 SIGURG(紧急数据信号):套接字收到紧急数据触发,默认忽略。

24 SIGXCPU(CPU超时信号):进程超出CPU使用限制触发,默认终止+核心转储。

25 SIGXFSZ(文件大小超限信号):进程写入文件超出大小限制触发,默认终止+核心转储。

26 SIGVTALRM(虚拟闹钟信号):进程虚拟运行时间定时到期触发,默认终止进程。

27 SIGPROF(性能分析信号):进程CPU使用+系统调用时间定时到期触发,默认终止进程。

28 SIGWINCH(窗口大小变化信号):终端窗口大小调整触发,默认忽略,用于程序适配窗口尺寸。

29 SIGIO(IO就绪信号):文件/套接字IO就绪触发,默认终止进程,用于异步IO。

30 SIGPWR(电源故障信号):系统电源异常触发,默认终止进程,部分系统用于电源管理。

31 SIGSYS(非法系统调用信号):进程执行无效系统调用触发,默认终止+核心转储。

(内容参考AI大模型)

4.信号状态

对于一个进程而言,收到信号可以延迟处理或者条件满足的时候立即处理,那么此时信号就有了几种不同的状态:

未决 :信号已产生并由内核记录,因进程屏蔽该信号或正处理同类型信号,暂未被进程处理的状态,信号处于未决队列中。

递达:信号从内核传递到进程,且进程成功执行该信号对应处理动作(默认 / 自定义 / 忽略)的过程,是信号处理的最终完成状态。

阻塞:进程通过信号集主动设置 "屏蔽",让内核暂不将指定信号递达,被阻塞的信号会进入未决状态,解除阻塞后才会正常递达处理(阻塞≠忽略,信号不会丢失)。

二、信号相关接口调用

1.信号的捕获

1.1信号的处理方式

前面提到进程对于一个信号可以有多种处理方式,但是内核需要有默认处理方式保证信号的存在意义,其实对于信号处理可以大致分为三类:

默认:执行内核为该信号预设的处理动作,如终止、暂停、核心转储等,是信号的基础处理逻辑,保证无自定义处理时信号仍有实际意义。

忽略:进程收到信号后不执行任何操作,信号直接被丢弃,仅标记为处理完成,无任何附加行为。

其他:进程自定义信号处理函数,收到信号后执行开发者编写的业务逻辑,实现信号的个性化处理,如优雅退出、资源释放等。

1.2信号的捕获

已知信号有多种处理方式,但是我们如何改变信号处理方式使其按照我们的预期进行处理呢?

接口认识

这里需要引入一个新的接口:signal()

通过手册查找可以看到signal接口需要传入两个参数,第一是signum,对照前面的信号表(kill -l),这里的signum也就是信号对应的编号,可以直接传入数字也可以使用编号对应的宏定义,第二个参数则是一个返回值为void,带有一个int参数的函数指针,接口执行后会将信号的默认处理改为指定函数。若传入SIG_DFL(宏,代表默认处理)或SIG_IGN(宏,代表忽略信号),则会恢复信号的默认行为或设置为忽略。

代码实践

有了以上认识,我们就可以对进程接收到的信号进行捕获并且执行自定义函数,由于前面的概念,Ctrl+c发送的是SIGINT信号,我们对其进行尝试捕捉:

cpp 复制代码
#include<signal.h>
#include<iostream>
void handler(int sig){
    std::cout<<"我是进程"<<getpid()<<",我收到了信号"<<sig<<std::endl;
}
int main(){
    signal(SIGINT,handler);
    while(true){}
}

编译成可执行程序并且运行:

可以看到此时我们进行Ctrl+c组合键强行杀掉进程的时候进程并没有被杀掉,而是执行了我们的自定义函数,此时我们可以用Ctrl+\(SIGQUIT)进行进程关闭,但是此时也衍生了一个问题:假如所有的信号都被捕获,那么进程岂不是可以肆无忌惮的运行,一直消耗系统资源?其实操作系统对此早有预防,Linux内核规定kill -9(SIGKILL)信号无法被捕获,为系统稳定性留了一张底牌,同样可以进行代码验证:

通过手册查询,可以看到若signal()调用出错,则返回 SIG_ERR,并且留下一个错误码:

cpp 复制代码
#include<signal.h>
#include<iostream>
void handler(int sig){
    std::cout<<"我是进程"<<getpid()<<",我收到了信号"<<sig<<std::endl;
}
int main(){
    for(int i=1;i<=31;i++){
        if(signal(i,handler)==SIG_ERR){
            perror("signal failed : ");
        }
        else std::cout<<i<<"signal success"<<std::endl;
    }
}
bash 复制代码
lsir@iZ2vc8wsdyzvsm68d9hqgbZ:~/learing/sig$ ./a.out
1signal success
2signal success
3signal success
4signal success
5signal success
6signal success
7signal success
8signal success
signal fiiled : : Invalid argument
10signal success
11signal success
12signal success
13signal success
14signal success
15signal success
16signal success
17signal success
18signal success
signal fiiled : : Invalid argument
20signal success
21signal success
22signal success
23signal success
24signal success
25signal success
26signal success
27signal success
28signal success
29signal success
30signal success
31signal success

可以看到,31个信号,绝大多数都成功修改处理,但是其中9(SIGKILL)和19(SIGSTOP)无法进行修改,显示Invalid argument(无效参数),说明传入的9和19参数不支持修改,其中信号19是进程暂停的信号,同信号9属于内核强制管控的信号,防止程序恶意利用。

补充

当我们使用键盘触发的信号(如 Ctrl+C、Ctrl+\、Ctrl+Z) 只能发送给前台进程,当我们在使用一个终端的时候,只能存在一个前台进程,而我们通过键盘触发的信号也只会发送给这个前台,结合信号知识就解释了开头的问题:为什么Ctrl+c能精准杀掉进程?因为当我们进行Ctrl+c操作的时候,**内核会向唯一的前台进程发送SIGINT信号,待进程收到信号进行默认处理的时候,进程就会自动退出。**而对于终端中的前后台进程,我们可以通过 fg、bg 命令灵活实现进程的前后台状态切换,配合 jobs 命令可查看当前终端的所有进程作业状态。

bash 复制代码
# 1. 启动前台进程,按Ctrl+Z暂停(触发SIGTSTP,状态变为Stopped)
sleep 100
^Z  # 按下Ctrl+Z,输出如下
[1]+  Stopped                 sleep 100

# 2. 用jobs查看作业(确认作业号和状态)
jobs -l
[1]+  12345 Stopped                 sleep 100  # [1]是作业号,12345是PID

# 3. bg:将暂停的作业恢复为后台运行
bg %1  # 或简写bg 1、bg(无参数,默认处理+标记的作业)
[1]+  sleep 100 &  # 输出表示作业已在后台运行

# 4. fg:将后台作业调回前台
fg %1  # 或简写fg 1、fg
sleep 100  # 此时进程回到前台,占用终端,按Ctrl+C可终止

2.信号发送

kill接口

kill在Bash和C语言层面都有对应封装,但是用法大同小异

Bash命令行解释器

在Bash我们可以直接进行kill命令向指定进程发送指定信号,格式:

kill 信号 <PID/作业号>,例如kill -9 12345就代表向pid为12345的进程发送9(SIGKILL)信号,使其强行终止。

C语言

同样通过手册查询可以看到kill接口需要进行两个参数传入:pid和sig,与Bash中的kill用法大同小异kill -9 12345->kill(12345,9)。虽然和Bash中的kill是不同层面的封装,但是其系统调用接口一致,因此可以尝试用C语言封装的kill接口自制Bash中的kill:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
// mykill -signumber pid
int main(int argc, char *argv[])
{
if(argc != 3)
{
std::cerr << "Usage: " << argv[0] << " -signumber pid" << std::endl;
return 1;
}
int number = std::stoi(argv[1]+1); 
pid_t pid = std::stoi(argv[2]);
int n = kill(pid, number);
return n;
}

raise接口

raise是C语言封装的一个向caller(当前进程)发送信号的一个接口,参数传入需要发送的信号编号即可,代码示例:

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

void handler(int signumber)
{
std::cout << "获取了一个信号: " << signumber << std::endl;
}
// mykill -signumber pid
int main()
{
signal(2, handler); // 先对2号信号进⾏捕捉
// 每隔1S,⾃⼰给⾃⼰发送2号信号
while(true)
{
sleep(1);
raise(2);
}
}
bash 复制代码
$ g++ raise.cc -o raise
$ ./raise
获取了⼀个信号: 2
获取了⼀个信号: 2
获取了⼀个信号: 2

abort接口


abort() 是 C 标准库函数(头文件 <stdlib.h>),核心作用是让进程立即异常终止,底层是向当前进程发送信号6(SIGABRT),并且该终止行为无法被捕获(即便执行前使用signal进行捕捉设置,会先执行你注册的 SIGABRT 自定义处理函数,执行完后再强制终止进程)。

alarm接口


调⽤ alarm 函数可以设定⼀个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发
SIGALRM 信号,该信号的默认处理动作是终⽌当前进程。
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个⽐⽅,某⼈要⼩睡⼀觉,设定闹
钟为30分钟之后响,20分钟后被⼈吵醒了,还想多睡⼀会⼉,于是重新设定闹钟为15分钟之后响,"以
前设定的闹钟时间还余下的时间"就是10分钟。如果seconds值为0,表⽰取消以前设定的闹钟,函数
的返回值仍然是以前设定的闹钟时间还余下的秒数。
利用该接口可以简单感受一下IO操作与单纯CPU计算之间的速度差距:

cpp 复制代码
//IO多
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
int count = 0;
alarm(1);
while(true)
{
std::cout << "count : "
<< count << std::endl;
count++;
}
return 0;
}
//纯计算,IO少
#include <iostream>
#include <unistd.h>
#include <signal.h>
int count = 0;
void handler(int signumber)
{
std::cout << "count : " <<
count << std::endl;
exit(0);
}
int main()
{
signal(SIGALRM, handler);
alarm(1);
while (true)
{
count++;
}
return 0;
}
bash 复制代码
//多IO
... ...
count : 107148
count : 107149
Alarm clock
//纯计算,少IO
$ g++ alarm.cc -o alarm
$ ./alarm
count : 492333713

CPU 运算属于计算机内部核心硬件的指令执行与数据处理 ,全程在主机箱内的处理器 / 内存总线上完成;而 IO(输入 / 输出)是计算机核心计算部件与外部存储设备的数据交换,属于主机与外设的通信行为。通过这个简单的实验可以清楚感知到IO操作相对于内部硬件在操作效率上清晰的差距。

相关推荐
且从容.24 分钟前
ubuntu安装flink
linux·ubuntu·flink
数字新视界2 小时前
动环监控信创适配在智能机房管理中的应用价值与未来展望
大数据·服务器·数据库·动环监控系统·动力与环境监控系统
爱和冰阔落2 小时前
【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
linux·运维·服务器·网络
程序员沉梦听雨2 小时前
WSL2的安装与使用
linux·wsl
微小的鱼2 小时前
Linux的渲染与显示系统链路
android·java·linux
lisanmengmeng3 小时前
alertmanger 标签 rules 打上分组标签
linux·运维·服务器
Android系统攻城狮3 小时前
Linux FFmpeg深度解析之av_write_frame调用流程与实战(三十三)
linux·运维·服务器·ffmpeg·音视频进阶·ffmpeg音视频
上火的金鱼妹3 小时前
k8s生产实际问题排查(网关,数据库读写等)
运维·服务器
孙克旭_3 小时前
k8s基础组件与创建资源
运维·docker·容器·kubernetes
孙克旭_4 小时前
云原生基础之Containerd容器运行时
运维·云原生