【Linux系统】进程信号(上)

目录

一、认识信号

1.1、你见过哪些信号?

1.2、信号的概念

1.3、见一下信号

信号捕捉方法(系统调用):signal

二、信号的产生

2.1、键盘产生

1️⃣捕捉信号

[2️⃣ 前台进程 & 后台进程](#2️⃣ 前台进程 & 后台进程)

[📌 补充指令](#📌 补充指令)

2.2、系统命令

2.3、系统调用

2.4、软件条件

1️⃣利用alarm验证IO的效率:

2️⃣设置重复闹钟,简单模拟操作系统的运行逻辑:

2.5、硬件异常

三、信号的保存

3.1、信号相关其他概念

3.2、在内核中的示意图

1️⃣信号怎样发送给进程?

[2️⃣Core 和 Term有什么区别?](#2️⃣Core 和 Term有什么区别?)

3.3、信号集操作接口

3.3.1、sigset_t

3.3.2、sigprocmask

3.3.3、sigpending

3.4、实例:用SIGINT信号验证内核信号集


一、认识信号

1.1、你见过哪些信号?

其实在生活中就有非常多的信号,比如:红绿灯,上课铃,肚子叫,敲门声等等;在前面的学习中其实我们也已经接触过信号了,当进程死循环时我们利用 Ctrl + c终止进程;对于孤儿进程(后台进程),我们用kill -9 xxx杀掉进程,其实也是进程收到了信号,只是我们还不清楚信号的底层逻辑。

不难发现,当我们收到某个信号,我们就会记下该信号并在合适的时间点去处理。比如你突然收到下楼取快递的信号,你要么立即去拿;要么等一段时间去拿;要么直接忽略。

1.2、信号的概念

信号是发送给进程,用来通知某种事件的(异步通知)。

**同步:**一件事做完再做另一件事。

**异步:**信号随时来,进程不用等、信号随时可能打断当前进程。

1.3、见一下信号

bash 复制代码
kill -l

1 ~ 31号信号为非实时信号:每个信号系统固定含义;

34 ~ 64为实时信号:全由用户自定义使用。


信号捕捉方法(系统调用):signal

signum即几号信号(如:Ctrl + c 为2号信号),handler是一个函数指针,指向信号处理方法(所以信号处理方法可以自定义)。

📌基本结论:

  • 进程能够识别信号,内核中内置的特性
  • 对于收到的信号,内核中内置了对信号的处理方法
  • 收到信号后,如果有优先级更高的事,则不会立即处理,而是在合适的时候处理
  • 信号处理有三种方式:OS默认的方法,忽略 或者按自定义方式处理。

二、信号的产生

2.1、键盘产生

1️⃣捕捉信号

既然键盘可以产生信号,那么当我们按下 Ctrl + c,能不能捕获这个信号让我们看看呢?

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
// 自定义信号处理方法
void handler(int signum)
{
    std::cout << "我是信号: " << signum << std::endl;
}

int main()
{
    signal(SIGINT, handler); // 捕捉2号信号
    while(true)
    {
        std::cout << "我的进程pid: " << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}

进程捕捉到2号信号,即Ctrl + c,并且转而执行我们自定义的处理方法,也就无法终止进程,我们用Ctrl + \来终止。


2️⃣ 前台进程 & 后台进程

键盘产生的信号只能发送给前台进程,而且键盘作为终端,同一时刻只能绑定一个前台进程。

./test & 将进程转为后台进程(孤儿进程就是一个后台进程),那么该进程将接受不到键盘产生的信号?

我们看到,此时该进程没有执行信号处理方法,也就是没有收到信号。此时shell变为前台进程,打开终端默认shell就是前台进程,当执行ls,pwd等命令,shell把自己暂且放到后台,命令跑完又自动切回前台,等待你从键盘输入命令。

📌补充指令

  • 查看后台进程:jobs
bash 复制代码
jobs
  • 将后台进程提到前台:fg + 任务号
  • 将前台进程提到后台:Ctrl + z
  • 让后台进程恢复运行:bg + 任务号

2.2、系统命令

bash 复制代码
kill -9 pid

前面我们杀掉孤儿进程,用的就是系统命令,只要我们找到其pid,就可以用系统命令干掉进程。

2.3、系统调用

1️⃣用系统调用 kill 让目的进程接受到 sig信号:

cpp 复制代码
// ./test pid sig
int main(int argc, char **argv)
{
    pid_t id = std::stoi(argv[1]);
    int signum = std::stoi(argv[2]);
    
    int n = kill(id, signum); // 让目标进程收到signalnum信号
    if(n == 0) 
    {
        std::cout << "send " << signum << " to " << id << std::endl;
    }
    return 0;
}

2️⃣ 利用raise系统调用让进程给自己发信号:

cpp 复制代码
// 自定义信号处理方法
void handler(int signum)
{
    std::cout << "我是信号: " << signum << std::endl;
}

int main()
{
    // 捕获1 ~ 31所有信号
    for (int i = 1; i <= 31; i++)
        signal(i, handlerSig);

    for (int i = 1; i <= 31; i++)
    {
        // 9和19号信号不能被捕捉
        if (i == 9 || i == 19)
            continue;
        raise(i); // 进程向自己发送信号
    }

    int cnt = 0;
    while (true)
    {
        sleep(1);
    }
}

💡我们是不是能够捕获所有31个信号,让进程 不受限制?

并不能,设计的时候已经考虑到了,9号和 19号信号无法被捕获。

📌补充系统调用:abort( )

  • 终止进程
  • abort本质是给自己发送了一个6号信号。

2.4、软件条件

例如,在用管道通信时,如果关闭读端,那么写端进程就会收到SIGPIPE信号,然后退出。

这种在程序运行时内核检测到软件运行状态发生变化,而由内核自动发送的信号,即软件条件产生的信号(没有硬件参与,纯软件)。

这里我们介绍另一种软件条件产生的信号:SIGALRM

这是通过系统接口 alarm设定一个闹钟,当计时结束就会发送14号SIGALRM信号,该信号的默认处理动作为终止当前进程。

alarm的返回值为0或者设置新的时前一个闹钟还余下的秒数。

cpp 复制代码
// 自定义信号处理方法
void handler(int signum)
{
    std::cout << "我是信号: " << signum << std::endl;
}

int main()
{
    alarm(3); // 计时3秒
    signal(SIGALRM, handler); // 捕捉14号信号
    while(true)
    {}
    return 0;
}

1️⃣利用alarm验证IO的效率:

cpp 复制代码
long long cnt = 0;
void handler(int signum)
{
    // std::cout << "IO次数cnt = " << cnt << std::endl; // IO次数
    std::cout << "运算次数cnt = " << cnt << std::endl;  // 仅++
    exit(2);
}

int main()
{
    signal(SIGALRM, handler);
    alarm(1);
    while(true)
    {
        // std::cout << cnt++ << std::endl; // 向显示器打印
        cnt++; // 仅计数
    }
}

IO效率并不高!!!


2️⃣设置重复闹钟,简单模拟操作系统的运行逻辑:

还需要再介绍一个系统接口:pause()

他会让程序暂停,直到收到一个信号,然后执行信号的处理方法。操作系统其实就是一个死循环,一直在暂停/等待,只有在收到信号时,才转而去执行信号处理方法,完事后再次循环等待

cpp 复制代码
using func_t = std::function<void()>; // 函数包装器
std::vector<func_t> funcs;

void Sched() { std::cout << "我是进程调度" << std::endl; }
void MemManger() { std::cout << "我是周期性的内存管理,正在检查有没有内存问题" << std::endl;}

void handler(int sig)
{
    // 处理事件
    for (auto &func : funcs)
        func();

    alarm(1); // 继续计时
}

int main()
{
    // 注册方法
    funcs.push_back(Sched);
    funcs.push_back(MemManger);

    signal(SIGALRM, handler); // 等待信号
    alarm(1);
    while (true)
    {
        pause(); // 暂停等待
    }
    return 0;
}

📌结论:操作系统是由信号(事件)驱动的。

2.5、硬件异常

发生硬件异常后被硬件检测到并通知内核,内核发送信号给进程。前面遇到的除零和对野指针解引用之所以会导致程序崩溃,就是由于触发了硬件异常,进程收到信号。

1️⃣除零为什么是导致硬件异常?OS怎么知道硬件异常了?又是发送什么信号?

当CPU执行除法指令,检测到除数是0,硬件就会出异常,CPU查询中断向量表,强行跳到操作系统内核预设的异常处理入口,操作系统向进程发送 SIGFPE 信号处理。

cpp 复制代码
void handler(int signum)
{
    std::cout << "我是信号: " << signum << std::endl;
    exit(3);
}

int main()
{
    for (int i = 1; i <= 31; i++)
        signal(i, handler); 

    sleep(2);
    int a = 5;
    a /= 0;
    return 0;
}

2️⃣野指针如何导致硬件异常?

当CPU执行野指针(虚拟地址)解引用指令时,就会通过MMU(内存管理单元)进行虚拟地址到物理地址的映射,当MMU查页表发现没有对应的物理内存与野指针关联时,就会转换失败,触发硬件异常。CPU查询中断向量表,并跳转到异常处理入口,操作系统向进程发送 SIGSEGV 信号进行处理。


三、信号的保存

3.1、信号相关其他概念

递达:进程收到信号去执行信号处理方法的动作

**信号未决(Pending):**信号从产生到递达的过程,此时信号被记录在pending表;

**阻塞(Block):**信号可以被阻塞,被阻塞的信号会一直处于未决状态,直到阻塞被取消才会被递达;

***注意:***信号被阻塞和信号处理方法为忽略不同,只要阻塞信号就无法被递达,而忽略是信号递达后选择的一种处理方式

3.2、在内核中的示意图

进程在 task_struct中维护了三张表来记录信号的阻塞,未决和处理方法。

**block表:**阻塞信号集,本质是一个位图,比特位的位置即几号信号,比特位内容标识是否阻塞该信号。

**pending表:**未决信号集,本质也是位图,比特位的位置即几号信号,比特位内容表示是否收到该信号。

**handler表:**函数指针数组,下标即几号信号,指向内容即信号处理方法。SIG_DFL即默认处理方法,SIG_IGN即忽略,也可以是自定义函数。

bash 复制代码
// 查看内核对信号默认的处理方法
man 7 signal
  • Core 和 Term都是用来终止进程
  • Ign即ignore,忽略

1️⃣信号怎样发送给进程?

发送信号给进程,本质就是修改这三张表,内核会调用相关的系统接口进行修改。

2️⃣Core 和 Term有什么区别?

Core为核心转储模式,进程结束时会在当前路径下形成一个core文件,并且保存到磁盘,对于异常退出的程序,core文件中会保存进程崩溃时的完整内存镜像。

注意:在云服务器上核心转储功能被关闭,原因如下:

  • 服务器进程内存通常很大(GB 级别),一个 core 文件可能占满磁盘

  • 磁盘满了会导致服务挂掉

  • 频繁崩溃时反复写大文件,拖垮 I/O

查看:

bash 复制代码
ulimit -a

怎么在服务器上打开core dump功能:

bash 复制代码
ulimit -c size

核心转储主要是为了Debug(事后调试),当程序异常退出,我们想要查看错误:

cpp 复制代码
int main()
{
    signal(SIGFPE, SIG_DFL);
    sleep(2);
    int a = 5;
    a /= 0;
    return 0;
}

gdb -> core-file core -> 直接帮我们定位到出错行

bash 复制代码
gdb

core-file core


📌补充:还记不记得我们前面在讲程序运行结束时分为:正常终止 和异常终止!!

操作系统中用一个整数的低16个比特位(0~15)来存储进程终止的相关信息。0~6记录终止信号(即进程异常退出信息)第7位为core dump标志 以及8~15记录退出状态(即正常终止信息)

我们可以通过等待子进程获得core dump标志位的信息。

3.3、信号集操作接口

3.3.1、sigset_t

我们先介绍一下------sigset_t,这是一个数据类型,本质是描述"一组信号"的位图容器,本身不执行任何信号操作,只负责存哪些信号在集合里。阻塞信号集和未决信号集本质都是由比特位的0或1决定的,因此可以用sigset_t来描述。

信号被存储在上面的三张表中,所以对信号的操作肯定与block,pending和handler有关。

bash 复制代码
#include <signal.h>
// 信号集操作函数(操作 block / pending )

// 1. 清空信号集:把 set 里所有信号位 全部置为 0
int sigemptyset(sigset_t *set);

// 2. 填满信号集:把 set 里所有信号位 全部置为 1
int sigfillset(sigset_t *set);

// 3. 向信号集 set 中 添加 一个信号 signum,把该信号的位置为 1
int sigaddset(sigset_t *set, int signum);

// 4. 从信号集 set 中 删除 一个信号 signum,把该信号的位置为 0
int sigdelset(sigset_t *set, int signum);

// 5. 判断 信号 signum 是否在 set 中
// 返回 1 → 在集合中(有效)
// 返回 0 → 不在集合中
int sigismember(const sigset_t *set, int signum);

⚠️ 问题:通过上面的接口是否能够修改内核中的block和pending位图?

显然不能,以上接口只是对sigset_t对象进行操作,并不影响内核中的数据。


3.3.2、sigprocmask

sigprocmask 可以用来获取或修改内核中的阻塞信号集,这才是真正与内核相关的接口。

how参数:

|-----------------|-----------------------------|
| SIG_BLOCK | set 中的信号加入当前阻塞集(并集) |
| SIG_UNBLOCK | set 中的信号移出当前阻塞集 |
| SIG_SETMASK | 用 set 替换当前阻塞集(最常用) |


3.3.3、sigpending


3.4、实例:用SIGINT信号验证内核信号集

我们利用2号信号来测试:

**-**先把二号信号调用 sigprocmask屏蔽(阻塞);

**-**然后从键盘发送二号信号,通过打印 pending表的内容(比特位),应该观察到2位置处的比特位由0变为1;

**-**大概 5s后我们取消对二号信号的屏蔽,应该观察到2号信号被递达。

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

void PrintPending(sigset_t pending)
{
    printf("我的进程pid: %d, pending: ", getpid());
    // 从第31号信号开始输出,比特位为1即收到该信号,反之
    for(int sig = 31; sig >= 1; sig--)
    {
        if(sigismember(&pending, sig)) std::cout << 1;
        else std::cout << 0;
    }
    std::cout << std::endl;
}

void handler(int sig)
{
    std::cout << "####################################################" << std::endl;
    std::cout << "信号 " << sig << " 递达" << std::endl;
    // 验证一个细节问题:pending值是在信号递达前恢复还是递达之后恢复
    sigset_t pending;
    sigpending(&pending);
    PrintPending(pending);
    std::cout << "####################################################" << std::endl;
}

int main()
{
    signal(SIGINT, handler); // 捕获2号信号
    sigset_t block, old_block;

    // 对阻塞信号集初始化
    sigemptyset(&block);
    sigemptyset(&old_block);

    sigaddset(&block, SIGINT); // 对block中对应的2号信号的比特位设置为1

    // 1. 修改内核阻塞信号集,屏蔽2号信号,并输出old_block的值
    sigprocmask(SIG_SETMASK, &block, &block);

    // 2. 重复获取pending值和打印pending值的过程,方便观察
    int cnt = 0;
    while(true)
    {
        // 获取pending值
        sigset_t pending;
        sigemptyset(&pending);
        int n = sigpending(&pending);

        // 打印pending值
        PrintPending(pending);
        if(cnt == 5)
        {
            // 3. 解除对2号信号的屏蔽,此时pending应该变为全0
            std::cout << "恢复block值" << std::endl;
            sigprocmask(SIG_SETMASK, &old_block, nullptr);
        }
        sleep(1);
        cnt++;
    }
}

细节问题: pending值是在信号递达前恢复还是递达之后恢复

代码中我们已经验证!!! 在递达之前就被置位为0了。

四、总结

至此,我们对信号的产生和保存就已经全部介绍完毕了,我们已经说了进程会在合适的时候进行信号处理,处理方式我们也已经介绍了:默认,忽略和自定义,那么到底什么时候是合适的时候?下一篇博客我将为大家详细介绍!!!

相关推荐
咖喱o1 小时前
网络-堆叠
linux·运维·服务器·网络
Java面试题总结1 小时前
一文搞定 Linux Nginx 从安装、启动到 nginx.conf 全配置详解(新手也能看懂)
linux·运维·nginx
clear sky .1 小时前
【TCP】TCP数据粘包/分包问题
java·服务器·网络
计算机魔术师2 小时前
【职场观察 | 技术人处境】五一假期结束,职场两边同时加速——“简历热“和“优化潮“背后的结构性逻辑
人工智能·面试·职场和发展·cot 推理·技术人求职·ai替代逻辑
齐齐大魔王8 小时前
linux-僵死进程处理
linux·运维·服务器
wuminyu11 小时前
专家视角看Java字节码加载与存储指令机制
java·linux·c语言·jvm·c++
.小小陈.11 小时前
Linux 线程概念与控制:从底层原理到实战应用
linux·运维·jvm
网络工程小王12 小时前
【LangChain 大模型6大调用指南】调用大模型篇
linux·运维·服务器·人工智能·学习
wangbing112512 小时前
各linux版本的包管理命令
linux·运维·服务器