【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操作相对于内部硬件在操作效率上清晰的差距。

相关推荐
开开心心就好5 小时前
发票合并打印工具,多页布局设置实时预览
linux·运维·服务器·windows·pdf·harmonyos·1024程序员节
火车叼位6 小时前
脚本伪装:让 Python 与 Node.js 像原生 Shell 命令一样运行
运维·javascript·python
css趣多多6 小时前
add组件增删改的表单处理
java·服务器·前端
予枫的编程笔记6 小时前
【Linux进阶篇】从基础到实战:grep高亮、sed流编辑、awk分析,全场景覆盖
linux·sed·grep·awk·shell编程·文本处理三剑客·管道命令
Sheep Shaun6 小时前
揭开Linux的隐藏约定:你的第一个文件描述符为什么是3?
linux·服务器·ubuntu·文件系统·缓冲区
Tfly__6 小时前
在PX4 gazebo仿真中加入Mid360(最新)
linux·人工智能·自动驾驶·ros·无人机·px4·mid360
野犬寒鸦6 小时前
从零起步学习并发编程 || 第七章:ThreadLocal深层解析及常见问题解决方案
java·服务器·开发语言·jvm·后端·学习
陈桴浮海6 小时前
【Linux&Ansible】学习笔记合集二
linux·学习·ansible
迎仔6 小时前
06-存储设备运维进阶:算力中心的存储管家
运维