Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

Gray-Ice

个人博客兼个人网站

本篇所有内容均参考CSAPP,部分内容会加上博主理解(为了防止博主的理解有问题而误导读者,博主会在所有自己理解的地方标注是博主的理解,被标注的内容请谨慎阅读)。[博主吐槽: 本来想摘抄一些知识点的,没想到整个章节全部都是知识点]
Linux信号允许进程和内核中断其他进程。

信号术语

  • 发送信号。内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。发送信号可以有如下原因: 1)内核检测到一个系统事件,比如除零错误或者子进程终止。2) 一个进程调用了kill函数,显式的要求内核发送一个信号给目的进程。一个进程可以发送信号给它自己。

  • 接收信号。 当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为*信号处理程序(signal handler)*的用户层函数捕获这个信号。[博主理解: 基本流程为: 进程正在运行中->进程接收到信号->控制传递到信号处理程序->信号处理程序运行->信号处理程序返回到下一条指令。 简单的来说就是当进程接收到一个信号时,会先执行这个信号对应的处理程序,执行完毕之后再返回到进程中下一条指令接着执行]

一个发出而没有被接收的信号叫做待处理信号(pending signal)。在任何时刻,一种类型至多只会有一个待处理信号。如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待,它们只是被简单的丢弃。一个进程可以有选择性地阻塞接收某种信号。当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。

一个待处理信号最多只能被接收一次。内核为每个进程在pending位向量中维护着待处理信号的集合,而在blocked位向量中维护着被阻塞的信号集合。只要传送了一个类型为k的信号,内核就会设置pending中的第k位,而只要接收了一个类型为k的信号,内核就会清除pending中的第k位。

发送信号

Unix系统提供了大量向进程发送信号的机制。所有这些机制都是基于*进程组(process group)*这个概念的。

进程组

每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。

1
2
3
4
5
6
7
8
9
// getpgrp函数: 返回当前进程的进程组ID。
函数原型: pid_t getpgrp(void);
所属头文件: <unistd.h>
返回值: 调用进程的进程组iD

// setpgid函数: 改变自己或者其他进程的进程组。它将进程pid的进程组改为pgid。如果pid是0,那么就使用当前进程的pid,如果pgid是0,那么就用pid指定的进程的PID作为进程组的ID。
函数原型: int setpgid(pid_t pid, pid_t pgid);
所属头文件: <unistd.h>
返回值: 若成功则为0,若错误则为-1

用/bin/kill程序发送信号

kill程序可以向另外的进程发送任意的信号,比如:

1
linux> /bin/kill -9 15213

这条命令会发送信号9(SIGKILL)给进程15213。一个为负的PID会导致信号被发送到进程组PID中的每个进程。比如:

1
linux> /bin/kill -9 -15213

这条命令发送一个SIGKILL给进程组15213中的每个进程。

有些shell可能有自己内置的kill命令,所以使用kill的时候最好指定路径。

从键盘发送信号

Unix Shell使用*作业(job)*这个抽象概念来表示为对一条命令行求值而创建的进程。在任何时刻,至多只有一个前台作业和0个或多个后台作业。

1
linux> ls | sort

会创建一个由两个进程组成的前台作业,这两个进程是通过Unix管道连接起来的: 一个进程运行ls程序,另一个运行sort程序。shell为每个作业创建一个独立的进程组。进程ID通常取自作业中父进程中的一个。

在键盘上输入Ctrl + C会导致内核发送一个SIGINT信号到前台进程组中的每个进程。默认情况下,结果是终止前台作业。类似的,输入Ctrl + Z会发送一个SIGTSTP信号到前台进程组中的每个进程。默认情况下,结果是停止(挂起)前台作业。

用kill函数发送信号

进程通过调用kill函数发送信号给其他进程(包括它们自己)。

1
2
3
4
// kill函数: 给指定进程发送信号。
函数原型: int kill(pid_t pid, int sig);
所属头文件: <signal.h>
返回值: 若成功则为0,若错误则为-1

如果pid大于零,那么kill函数发送信号号码sig给进程pid.如果pid等于零,那么kill发送信号sig给调用进程所在进程组中的每个进程,包括调用进程自己。如果pid小于0,kill发送信号sig给进程组 |pid|(pid的绝对值)中的每个进程。

用alarm函数发送信号

1
2
3
4
// alarm函数: 进程可以通过alarm函数向它自己发送SIGALRM信号。
函数原型: unsigned int alarm(unsigned int secs);
所属头文件: <unistd.h>
返回值: 前一次闹钟剩余的秒数,若以前没有设定闹钟,则为0

alarm函数安排内核在secs秒后发送一个SIGALRM信号给调用进程。如果secs是零,那么不会调度安排新的闹钟(alarm)。在任何情况下,对alarm的调用都将取消任何*待处理(pending)*闹钟,并且返回任何待处理闹钟在被发送前还剩下的秒数(如果这次对alarm的调用没有取消它的话);如果没有任何待处理的闹钟,就返回零。

接收信号

当内核把进程p从内核模式切换到用户模式时(例如: 从系统调用返回或是完成了一次上下文切换),它会检查进程p的未被阻塞的待处理信号的集合(pending &~blocked)。如果这个集合为空(通常情况下),那么内核将控制传递到p的逻辑控制流中的下一条指令。然而,如果集合是非空的,那么内核选择集合中的某个信号k(通常是最小的k),并且强制p接收信号k.收到这个信号会触发进程采取某种行为。一旦进程完成了这个行为,那么控制就传递回p的逻辑控制流中的下一条指令。每个信号都有一个预定义的默认行为,是下面的一种:

  • 进程终止。

  • 进程终止并转储内存。

  • 进程停止(挂起)直到被SIGCONT信号重启。

  • 进程忽略该信号。

1
2
3
4
5
// signal函数: 修改和信号相关联的默认行为,SIGSTOP和SIGKILL的默认行为是不能修改的。
typedef void (*sighandler_t)(int); // sighandler_t代表了一个返回类型为void,有一个int类型参数的函数指针。
函数原型: sighandler_t signal(int, signum, sighandler_t handler);
所属头文件: <signal.h>
返回值: 若成功则为指向前次处理程序的指针,若出错则为SIG_ERR(不设置errno)

signal函数可以通过下列三种方法之一来改变和信号signum相关联的行为:

  • 如果handler是SIG_IGN,那么忽略类型为signum的信号。
  • 如果handler是SIG_DFL,那么类型为signum的信号行为恢复为默认行为。
  • 否则,handler就是用户定义的函数的地址,这个函数被称为信号处理程序,只要进程接收到一个类型为signum的信号,就会调用这个程序[博主理解: CSAPP上写的是”调用这个程序”,博主认为改为”调用这个函数更恰当”]。通过把处理程序的地址传递到signal函数从而改变默认行为,这叫做设置信号处理程序(installing the handler)。调用信号处理程序被称为捕获信号,执行信号处理程序被称为处理信号

当一个进程捕获了一个类型为k的信号时,会调用为信号k设置的处理程序,一个整数参数被设置为k.这个参数允许同一个处理函数捕获不同类型的信号。

信号处理程序可以被其他信号处理程序中断[博主注: 在信号处理程序执行完毕后,控制会返回到被中断的信号处理程序继续执行]。

阻塞和解除阻塞信号

Linux提供阻塞信号的隐式和显式的机制:

隐式阻塞机制。内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。

显式阻塞机制。应用程序可以使用sigprocmask函数和它的辅助函数,明确的阻塞和解除阻塞选定的信号。

1
2
3
4
5
6
7
8
9
#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
// 以上函数的返回值: 如果成功则为0,若出错则为-1
int sigismember(const sigset_t *set, int signum); // 返回值: 若signum是set的成员则为1,如果不是则为0,若出错则为-1

sigprocmask函数改变当前阻塞的信号集合。具体的行为依赖于how的值:

  • SIG_BLOCK: 把set中的信号添加到blocked中(blocked=blocked | set)。

  • SIG_UNBLOCK: 从blocked中删除set中的信号(blocked=blocked &~set)。

  • SIG_SETMASK: block=set。

如果oldset非空,那么blocked位向量值之前的值保存在oldset中。

未完待续。。。

编写信号处理程序

信号处理程序有几个属性使得它们很难推理分析: 1)处理程序与主程序并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序相互干扰;2) 如何以及何时接收信号的规则常常有违人的直觉;3) 不同的系统有不同的信号处理语义。

安全的信号处理

保守编写处理程序的原则:

  • G0. 处理程序要尽可能简单。避免麻烦的最好方法是保持处理程序尽可能小和简单。例如,处理程序可能只是简单的设置全局标志并立即返回,所有与接收信号相关的处理都由主程序执行,它周期性地检查(并重置)这个标志。
  • G1. 在处理程序中只调用异步信号安全的函数。所谓异步信号安全的函数(或简称安全的函数)能够被信号处理程序安全地调用,原因有二: 要么它是可重入的(例如只访问局部变量),要么它不能被信号处理程序中断。[博主注: 可以百度搜索”异步信号安全函数”看看哪些是异步信号安全函数,这里博主就不写了,太多了。]
  • G2. 保存和恢复errno。许多Linux异步信号安全的函数都会在出错返回时设置errno。在处理程序中调用这样的函数可能会干扰主程序中其他依赖于errno的部分。解决方法是在进入处理程序时把errno保存在一个局部变量中,在处理程序返回前恢复它。注意,只有在处理程序要返回时才有此必要。如果处理程序调用_exit终止该进程,那么就不需要这样做了。
  • G3. 阻塞所有的信号,保护对共享全局数据结构的访问。如果处理程序和主程序或其他处理程序共享一个全局数据结构,那么在访问(读或写)该数据结构时,你的处理程序和主程序应该暂时阻塞所有的信号。这条规则的原因是从主程序访问一个数据结构d通常需要一系列的指令,如果指令序列被访问d的处理程序中断,那么处理程序可能会发现d的状态不一致,得到不可预知的结果。在访问d时展示阻塞信号保证了处理程序不会中断该指令序列。
  • G4. 用volatile声明全局变量。考虑一个处理程序和一个main函数,它们共享一个全局变量g。处理程序更新g,main周期性地读取g。对于一个优化编译器而言,main中g的值看上去从来没有变化过,因此使用缓存在寄存器中g的副本来满足对g的每次引用是很安全的。如果这样,main函数可能永远都无法看到处理程序更新过的值。可以用volatile类型限定符来定义一个变量,告诉编译器不要缓存这个变量。volatile限定符强迫编译器每次在代码中引用g时,都要从内存中读取g的值。一般来说,和其他所有共享数据结构一样,应该暂时阻塞信号,保护每次对全局变量的访问。
  • G5. 用sig_atomic_t声明标志。在常见的处理程序设计中,处理程序会写全局标志来记录收到了信号。主程序周期性地读这个标志,响应信号,再清除该标志。对于通过这种方式来共享的标志,C提供一种整型数据类型sig_atomic_t,对它的读和写保证会是原子的(不可中断的),因为可以用一条指令来实现它们: “volatile sig_atomic_t flag;”。因为它们是不可中断的,所以可以安全地读和写sig_atomic_t变量,而不需要暂时阻塞信号。注意,这里对原子性的保证只适用于单个的读和写,不适用于像flag++或flag=flag+10这样的更新,它们可能需要多条指令。

评论



愿火焰指引你