【Linux】进程信号

目录

信号的概念

生活中的信号

技术角度的信号

信号是如何产生的?

core dump

1. kill 命令

2. 键盘产生信号

3. kill 系统调用

4. 软件条件

5. 异常

信号如何保存?

相关概念

sigset_t

信号集操作函数

sigprocmask

sigpending

signal

sigaction

信号的处理

其他概念

可重入函数

SIGCHLD


信号的概念

生活中的信号

  • 点外卖的场景:当外卖员携带用户购买的外派至指定地点时,需要通过打电话或者发消息的方式向用户告知外卖已送达,这个过程中,打电话或者发消息就相当于发信号,用户取外卖的过程就相当于对信号进行处理。
  • 在家吃饭的场景:儿子带着耳机酣畅淋漓地打着游戏,这时妈妈喊儿子去吃饭
    • 情形一:儿子没有听到,没有回应妈妈发出的让儿子吃饭的信号。
    • 情形二:儿子听到了,但玩的是即时战略游戏,在儿子看来,游戏的进行比吃饭更加重要,于是没有对妈妈发出的吃饭信号进行处理。
    • 情形三:儿子立马关掉游戏去吃饭了。

前置知识点:

  • Linux中采用 pending 位图结构来保存信号,简单来说,若收到了 signo 号信号,那么 ( pending >> signo ) & 1 的结果就会是 1。
  • 采用 block 位图结构来表示是否阻塞该信号,若想要阻塞 signo 号信号,那么令 block = block | ( 1 << signo ),那么 signo 号信号就无法递达,进程不知道产生了信号,无法调用信号处理函数,即使 pending 对应信号的位置是 1。

在吃饭场景中:

  • 情形一可解释为妈妈发出的吃饭信号成功,pending 位图修改成功,但儿子没有收到信号,即 block 位图将信号阻塞。
  • 情形二可解释为妈妈发出的吃饭信号成功,block 位图没有阻塞信号,信号成功递达,但儿子正在处理更加重要的事件,无法对妈妈发出的信号进行处理。
  • 清醒三可解释为妈妈发出的吃饭信号成功,block 位图没有阻塞信号,信号成功递达,儿子接收信号并通过 handler 查询对应的信号处理方法,对信号进行处理。

技术角度的信号

用户输入命令,在 Shell 下启动一个前台进程。

然后用户按下 Ctrl-C,这个键盘输入产生一个硬件中断,被操作系统获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。

注意

1. Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样 Shell 不必等待进程结束就可以接受新的命令,启动新的进程。
2. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
3. 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。

使用 kill -l 查看信号列表。

34 - 64 称为实时信号,pending 位图只记录 1 ~ 31 号信号。

发送19号信号暂停进程后,可以用 jobs 查看作业列表,使用 fg <jobid> 命令来继续该进程。

使用 man -7 signal 查看信号详细列表。

信号是如何产生的?

先给出结论,无论采用何种方式写入信号,最终都是由操作系统来完成信号写入的!

core dump

首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。

为什么有 core dump?进程异常终止通常是因为有 Bug,比如非法内存访问导致段错误,事后可以用调试器检查 core 文件以查清错误原因,这叫做 Post-mortem Debug(事后调试)。

如果信号默认行为是 core,就会将进程在内存中的核心数据(与调试有关)转储到磁盘中形成core 或 core.pid 的文件。

一个进程允许产生多大的 core 文件取决于进程的 Resource Limit (这个信息保存在PCB中)。默认是不允许产生 core 文件的,原因如下:

  • 一方面因为 core 文件中可能包含用户密码等敏感信息,不安全。
  • 另一方面,在服务挂掉后,需要尽快重启服务,往往设置为自动重启,如果服务一直重启,不断生成core文件,可能会将磁盘被打满。较早版本 Linux 内核 core文件名是 core.pid,就可能出现磁盘被打满的问题,较新版本 Linux 内核 core 文件名是 core。

在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生 core 文件。 首先用 ulimit -a 命令查看 core 属性,然后使用 ulimit -c <value> 改变 Shell 进程的 Resource Limit,允许 core 文件最大为 value blocks。

怎么使用 core 文件?gdb 调试过程中,输入 core-file <core>,定位到退出时的信号位置。

在通过 wait 或 waitpid 回收资源后获取的 status 存储方式如下图。

可调用如下函数获取退出状态、终止信号及是否开启 core dump 标志。

int getSig(int status)
{
    return status & 0x007f;
}

int getCoreDump(int status)
{
    return (status >> 7) & 1;
}

int getExitCode(int status)
{
    return (status >> 8) & 0x00ff;
}

1. kill 命令

使用 kill 命令可以向指定进程发送信号。

kill [options] <pid>

options:

        -l                        查看信号列表

        -<signo>            发送 signo 信号给 pid 进程,signo 可以为信号编号,也可以为信号宏

2. 键盘产生信号

通过键盘输入控制命令来发送信号:如 ctrl + c 给前台进程发送 SIGINT 信号,ctrl + \ 给前台进程发送 SIGQUIT 信号。

字符输入,组合键输入。由键盘驱动和操作系统进行联合解释的。操作系统如何知道键盘正在输入数据?硬件中断的技术。

上图表示从键盘输入字符或组合键后操作系统的处理流程。

首先由用户通过硬件设备从键盘输入数据,CPU某一针脚读取到高电平后,触发硬件中断,并将中断号存放于寄存器中,操作系统处理该中断时找到中断向量表并通过中断号执行对应方法,由于是从键盘输入数据,操作系统将联合键盘驱动对键盘输入数据进行解释,如果解释为控制命令,操作系统就向进程发送对应的信号,如果解释为字符,就通过找到进程的文件描述符表,由于是输入数据,所以找到文件描述符表里 0 号下标的文件结构体并将字符放入该结构体里的缓冲区。至此,从用户的键盘输入到操作系统的处理已大致完成。

3. kill 系统调用

kill 系统调用

功能
        向指定进程发送信号

原型

       #include <sys/types.h>
       #include <signal.h>

        int kill(pid_t pid, int sig);
参数

        pid:

                > 0,发送 sig 号信号给指定进程

                == 0,发送 sig 给调用进程的进程组中的每个进程

                == -1,发送 sig 给调用进程有权限发送信号的每个进程,除了 init 进程

                < -1,则向进程组中 ID 为 -pid 的每个进程发送 sig

        sig:要发送的信号编号

返回值

        执行成功返回 0,失败返回 -1。

raise 库调用

功能       

        向调用该函数的进程发送信号

原型

        #include <signal.h>

        int raise(int sig);
参数

        sig:要发送的信号编号

返回值

        执行成功返回 0,否则返回非 0 值。

abort 库调用

功能

        向调用该函数的进程发送 6) SIGABRT 信号

原型
       #include <stdlib.h>

       void abort(void);

4. 软件条件

【Linux】进程间通信 —— 管道与 System V 版本通信方式-CSDN博客 中匿名管道使用过程中的四种情况,当读端不读了 && 关闭了pipe,写端再写就无意义了,OS会直接终止写入的进程(子进程),通过信号 13) SIGPIPE 杀掉进程。这就属于一种软件条件。

通过 alarm 发送信号 14) SIGALRM 也属于一种软件条件。

alarm 系统调用

功能

        设置一个定时发送信号的定时器

原型
        #include <unistd.h>

        unsigned int alarm(unsigned int seconds);

参数

        seconds:进程将在 seconds 后发送 14 号信号,若 seconds 为 0,则取消定时器。

返回值

        上次设置 alarm 定时器的剩余时间

5. 异常

  1. 进程执行过程中发生除零错误,发送 8) SIGFPE
  2. 进程执行过程中对野指针解引用,发送 11) SIGSEGV

信号如何保存?

相关概念

首先给出信号相关的常见概念:

  • 实际执行信号的处理动作称为信号递达(Delivery)。
  • 信号从产生到递达之间的状态,称为信号未决(Pending)。
  • 进程可以选择阻塞(Block)某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
  • 注意阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

保存信号的结构:

pending 位图,比特位的内容表示是否收到指定信号。

block 位图,比特位的内容表示是否阻塞该信号。

如果某个信号被屏蔽,则该信号永远不会递达,除非解除阻塞。

handler_t handler[31],信号处理方法表。

在内核中,这两个位图和信号处理方法表都由 task_struct 来维护。后续会根据这些结构对信号延迟处理,所以给进程发送信号这种描述不准确,应该是给进程写入信号。

注:无法通过 Block 位图将 9号、19号信号屏蔽,因为一旦屏蔽,操作系统就无法关闭这些进程了,18号信号会被做特殊处理。

信号递达的细节:

  1. 信号递达前,会把对应的 pending 位图清 0。
  2. 先清0,再递达还是先递达,再清0

task_struct作为内核数据,只有操作系统才有资格写入信号,那用户也想写入信号呢?操作系统提供系统调用。

sigset_t

每个信号只有一个 bit 的未决标志,非 0 即 1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t 称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

信号集操作函数

通过对信号集的操作来修改或获取 block 位图或 pending 位图。

#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);

sigprocmask

功能

        读取或更改进程的信号屏蔽字

原型

        #include <signal.h>
        int sigprocmask(int how, const old_kernel_sigset_t *set, old_kernel_sigset_t *oldset);

参数

        how:见下图

        set:要设置的信号集

        oldset:执行该函数前的信号集

返回值

        执行成功返回 0,失败返回 -1

sigpending

功能

        读取当前进程的 pending 信号集,通过 set 参数传出

原型

        #include <signal.h>
        int sigpending(sigset_t *set);

参数

        set:输出型参数,存放读取到的信号集

返回值

        执行成功返回 0,失败返回 -1

signal

功能

        修改信号处理函数

原型

        #include <signal.h>

        typedef void (*sighandler_t)(int);

        sighandler_t signal(int signum, sighandler_t handler);

参数

        signum:需要自定义处理的信号

        handler:信号处理函数

返回值

        执行成功返回原先的信号处理函数,失败返回 SIG_ERR

sigaction

功能

        修改信号处理行为,包括信号处理函数

原型

        #include <signal.h>

        int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

        struct sigaction {
                void     (*sa_handler)(int);                                 // 信号处理函数
                void     (*sa_sigaction)(int, siginfo_t *, void *);  // 实时信号的处理函数
                sigset_t   sa_mask;                                          // 需要设置的信号屏蔽字
                int        sa_flags;                                               // 一般设为 0
                void     (*sa_restorer)(void);                              // 实时信号才用得到
        };

参数

        signum:需要自定义处理的信号

        act:结构体 struct sigaction,里面包含信号处理函数、信号屏蔽字等

        oldact:输出型参数,执行该函数前的 struct sigaction

返回值

        执行成功返回 0,失败返回 -1

注意:当某个信号的处理函数被调用时,内核自动将当前信号加入信号屏蔽字,处理函数结束后,将该信号从信号屏蔽字中自动移除。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 sa_mask 字段说明,当信号处理函数返回时自动恢复原来的信号屏蔽字。

信号的处理

信号该如何处理?

  • 调用默认信号处理函数
  • 忽略
  • 自定义信号处理函数

信号什么时候被处理?进程从内核态切换为用户态时,信号会被检测并处理。

信号处理流程如下:

信号处理过程的完整过程,需要4次用户态和内核态的状态切换

为什么在信号捕捉后执行自定义处理函数,要从内核态切换回用户态?为什么要在用户态执行自定义处理函数?操作系统不想让用户访问到内部的数据结构,防止自定义处理函数里出现越权的非法操作在内核态执行。

为什么发送终止信号后,需要在合适的时机来杀掉进程?为什么不是直接释放进程的PCB并杀掉进程?进程当时可能在做很重要的事件,比如数据库读取、网络数据读取,直接杀掉进程而不是发信号来保证进程有响应时间,可能会导致未定义的错误。

操作系统如何正常运行的?信号技术本就是通过软件的方式,来模拟硬件中断,CPU就要处理中断,查询中断向量表,根据中断号,找到执行方法,然后进行进程切换和调度。操作系统其实是一个死循环,不断接收和处理外部的硬件中断。

其他概念

可重入函数

若一个函数在调用期间被不同的控制流程调用,在第一次调用未结束前再次调用该函数,这种行为是被允许的,则称该函数为可重入函数,否则就是不可重入函数。

上图表示链表的多次 insert 操作,在 insert node1后,若在更新 head 前进入信号处理函数并 insert node2,最后的结果就是丢失 node2 的信息,所以 insert 是不可重入函数。

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了 malloc 或 free,因为 malloc 也是用全局链表来管理堆的。
  • 调用了标准 I/O 库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

SIGCHLD

采用 wait 和 waitpid 函数可以清理僵尸进程,父进程可以阻塞等待子进程结束,,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。

其实,子进程在终止时会给父进程发 SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD 信号处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用 wait 清理子进程即可。

事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 signal 将 SIGCHLD 的处理动作置为SIG_IGN,这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用 sigaction 函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于 Linux 可用,但不保证在其它UNIX系统上都可用。

示例代码如下:

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>



void CleanupChild(int signo)
{
    if (signo == SIGCHLD)
    {
        while (true)
        {
            pid_t rid = waitpid(-1, nullptr, WNOHANG); // -1 : 回收任意一个子进程
            if (rid > 0)
            {
                std::cout << "wait child success: " << rid << std::endl;
            }
            else if (rid <= 0)
                break;
        }
    }
    std::cout << "wait sub process done" << std::endl;
}

int main()
{
    // signal(SIGCHLD, CleanupChild);
    signal(SIGCHLD, SIG_IGN);

    // 50个退出,50个没有
    for (int i = 0; i < 100; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            // child
            int cnt = 5;
            while (cnt--)
            {
                std::cout << "I am child process: " << getpid() << std::endl;
                sleep(1);
            }
            std::cout << "child process died" << std::endl;
            exit(0);
        }
    }

    // father
    while (true)
        sleep(1);
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

毕瞿三谲丶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值