8.5 信号
研究一种更高层次的软件形式的异常, 也是一种软件中断,称为Unix信号,它允许进程中断其他进程。
一个信号就是一条小消息,它通知进程系统中发生一个某种类型的事件。
Linux系统支持30多种信号。
每种信号类型对应于某种系统事件
底层的信号。
当底层发生硬件异常,信号通知 用户进程 发生了这些异常。
除以0:发送SIGILL信号。
非法存储器引用:发送SIGSEGV信号
较高层次的软件事件
键入ctrl+c:发送SIGINT信号
一个进程可以发送给另一个进程SIGKILL信号强制终止它。
子进程终止或者停止,内核会发送一个SIGCHLD信号给父进程。
8.5.1 信号术语
传送一个信号到目的进程有两个步骤。
发送信号: 内核通过更新目的进程上下文的某个状态,就说发送一个信号给目的进程。
发送信号有两个原因
内核检测到一个系统事件。比如被零除错误,或者子进程终止。
一个进程调用了kill函数。显示要求进程发送信号给目的进程。
一个进程可以发信号给它自己。
接收信号: 当目的进程 被内核强迫以某种方式对信号的发送做出反应。目的进程就接收了信号。
进程可以忽略这个信号,终止。
或者通过一个称为信号处理程序(signal handler)的用户层函数捕获这个信号。
一个只发出而没有被接收的信号叫做待处理信号(pending signal)
一种类型至多只有一个待处理信号。
如果一个进程有一个类型为k的待处理信号。
那么接下来发送到这个进程类型为k的信号都会被简单的丢弃。
一个进程可以有选择性地阻塞接收某种信号
它任然可以被发送。但是产生的待处理信号不会被接收。
一个待处理信号最多被接收一次。内核为每个进程在pending位向量维护着待处理信号的集合,而在blocked位向量维护着被阻塞的信号集合。只要传送一个类型为k的信号,内核就会设置pending中的第k位,而只要接收了一个类型为k的信号,内核就会清除pending中的第k位。
8.5.2 发送信号
Unix系统 提供大量向进程发送信号的机制。所有这些机制都是基于进程组(process group)。
进程组
每个进程都属于一个进程组。
由一个正整数进程组ID来标示
getpgrp()函数返回当前进程的进程组ID:
#include<unistd.h>
pid_t getpgrp(void);
1
2
默认,一个子进程和它的父进程同属于一个进程组
一个进程可以通过setpgid()来改变自己或者其他进程的进程组。
#include<unistd.h>
int setpgid(pid_t pid,pid_t pgid);
如果pid是0 ,那么使用当前进程的pid。
如果pgid是0,那么使用指定的pid作为pgid(即pgid=pid)。
例如:进程15213调用setpgid(0,0)
那么进程15213会 创建/加入进程组15213.
1
2
3
4
5
6
7
用/bin/kill 程序发送信号
/bin/kill可以向另外的进程发送任意的信号。
比如
unix>/bin/kill -9 15213
1
发送信号9(SIGKILL)给进程15213。
一个为负的PID会导致信号被发送到进程组PID中的每个进程。
unix>/bin/kill -9 -15213
1
发送信号9(SIGKILL)给进程组15213中的每个进程。
用/bin/kill的原因是,有些Unix shell 有自己的kill命令
从键盘发送信号
作业(job) :对一个命令行求值而创建的进程。
在任何时候至多只有一个前台作业和0个或多个后台作业
前台作业就是需要等待的
后台作业就是不需要等待的
键入unix>ls|sort
创建一个两个进程组成的前台作业。
两个进程通过Unix管道链接。
shell为每个作业创建了一个独立的进程组。
进程组ID取自作业中父进程中的一个。
在键盘输入ctrl-c 会发送一个SIGINT信号到外壳。外壳捕获该信号。然后发送SIGINT信号到这个前台进程组的每个进程。在默认情况下,结果是终止前台作业
类似,输入ctrl-z会发送一个SIGTSTP信号到外壳,外壳捕获这个信号,并发送SIGTSTP信号给前台进程组的每个进程,在默认情况,结果是停止(挂起)前台作业(还是僵死的)
用kill函数发送信号
进程通过调用kill函数发送信号给其他进程,类似于bin/kill
int kill(pid_t pid, int sig);
1
pid>0,发送信号sig给进程pid
pid<0,发送信号sig给进程组abs(pid)
事例:kill(pid,SIGKILL)
用alarm函数发送信号
进程可以通过调用alarm函数向它自己SIGALRM信号。
#include<unistd.h>
unsigned int alarm(unsigned int secs);
返回:前一次闹钟剩余的秒数。
1
2
3
4
5
alarm函数安排内核在secs秒内发送一个SIGALRM信号给调用进程
如果secs=0 那么不会调度闹钟,当然不会发送SIGALRM信号。
在任何情况,对alarm的调用会取消待处理(pending)的闹钟,并且会返回被取消的闹钟还剩余多少秒结束。如果没有pending的话,返回0
一个例子:
输出
unix> ./alarm
BEEP
BEEP
BEEP
BEEP
BEEP
BOOM!
//handler是一个自己定义的信号处理程序,通过signal函数捆绑。
1
2
3
4
5
6
7
8
8.5.3 接收信号
信号的处理时机是在从内核态切换到用户态时,会执行do_signal()函数来处理信号
当内核从一个异常处理程序返回,准备将控制传递给进程p时,它会检查进程p的未被阻塞的待处理信号的集合(pening&~blocked)。
如果这个集合为空,内核将控制传递到p的逻辑控制流的下一条指令。
如果非空,内核选择集合中某个信号k(通常是最小的k),并且强制p接收k。收到这个信号会触发进程某些行为。一旦进程完成行为,传递到p的逻辑控制流的下一条指令。
每个信号类型都有一个预定义的默认类型,以下几种.
进程终止
进程终止并转储存器(dump core)
进程停止直到被SIGCONT信号重启
进程忽略该信号
进程可以通过使用signal函数修改和信号相关联的默认行为。
SIGSTOP,SIGKILL是不能被修改的例外。
#include<signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum,sighandler_t handler);
1
2
3
4
signal函数通过下列三种方式之一改变和信号signum相关联的行为。
如果handler是SIG_IGN,那么忽略类型为signum的信号
如果handler是SIG_DFL,那么类型为signum的信号恢复为默认行为。
否则,handler就是用户定义的函数地址,这个函数称为信号处理程序
只要进程接收到一个类型为signum的信号,就会调用handler。
设置信号处理程序:把函数传递给signal改变信号的默认行为。
调用信号处理程序,叫捕获信号
执行信号处理程序,叫处理信号
当处理程序执行它的return语句后,控制通常传递回控制流中进程被信号接收中断位置处的指令。
信号处理程序是计算机并发的又一个示例。信号处理程序的执行中断,类似于底层异常处理程序中断当前应用程序的控制流的方式。因为信号处理程序的逻辑控制流与主函数的逻辑控制流重叠,信号处理程序和主函数并发地运行。
自我思考:信号是一种异常/中断,当接收到信号的时候,会停下当前进程所做的事,立马去执行信号处理程序。并不是多线程/并行,但确是并发的。从下面这张图,可见一斑。
8.5.4 信号处理问题
当一个程序要捕获多个信号时,一些细微的问题就产生了。
待处理信号被阻塞
Unix 信号处理程序通常会阻塞 当前处理程序正在处理 的类型的待处理信号。
待处理信号(被抛弃了)不会排队等待
当有两个同类型信号都是待处理信号时,有一个会被抛弃。
关键思想:存在一个待处理的信号k仅仅表明至少一个一个信号k到达过。
系统调用可以被中断(在某些unix系统会出现)
像read,wait和accept这样的系统调用潜在的阻塞一段较长的时间,称为慢速系统调用。
当处理程序捕获一个信号,被中断的慢速系统调用在信号处理程序返回后将不在继续,而是立即返回给用户一个错误条件,并将errno设置为EINTR。
用一个后台回收僵死子进程的程序,前台读入做例子
1.初始简单利用接收SIGCHLD信号回收,一次调用只回收一个。
在调用的过程中,又有信号发送过来,但是被阻塞了。之后又被直接抛弃。
如果不处理被阻塞和不会排队等待的问题。会有信号被抛弃。
重要教训:不可以用信号对其他进程中发送的事件计数
handle1-code
2.一次调用尽可能的多回收,保证在回收过程中,没有遗漏的信号。
handle2-code
3.还存在一个问题,在前台中,某些unix系统(Solaris系统)的read被中断后不会自动重启,需要手动重启,Linux一般会自动重启。
之前 read模块 code
现在改为如果是errno==EINTR手动重启。
或者使用Signal包装函数标准。8.5.5会提到。
8.5.5 可移植的信号处理
不同系统之间,信号处理语义的差异(比如一个被中断的慢速系统调用是重启,还是永久放弃)是Unix信号系统的一个缺陷。
为了处理这个问题,Posix标准定义了sigaction函数,它允许与Linux和Solaris这样与Posix兼容的系统上的用户,明确指明他们想要的信号处理语义。
#include<signal.h>
int sigaction(int signum,stuct sigaction *act,struct sigaction *oldcat);
//若成功则为1,出错则为-1。
1
2
3
4
sigaction函数应用不广泛,它要求用户设置多个结构条目。
一个更简洁的方式,是定义一个包装函数,称为Signal,它调用sigaction。
它的调用方式与signal函数的调用方式一样。
Signal包装函数设置了一个信号处理程序,其信号处理语义如下(设置标准):
只有这个处理程序当前正在处理的那种类型被阻塞。
和所有信号实现一样,信号不会排队等待。
只要可能,被中断的系统调用会自动重启
一旦设置了信号处理程序,它就会一直保持,直到Signal带着handler参数为SIG_IGN或者SIG_DFL被调用。
在某些比较老的Unix系统,信号处理程序被使用一次后,又回到默认行为。
8.5.6 显示地阻塞和取消阻塞信号
通过sigprocmask函数来操作。
#include<signal.h>
int sigprocmask(int how,const sigset_t *set,sigset_t *oldset);
1
2
3
sigprocmask函数改变当前已阻塞信号的集合(8.5.1节描述的blocked位向量)。
具体行为依赖how值
SIG_BLOCK:添加set中的信号到blocked中。
SIG_UNBLOCK: 从blocked删除set中的信号。
SIG_SETMASK: blocked=set。
如果oldset非空,block位向量以前的值会保存到oldset中。
还有以下函数操作set集合
#include<signal.h>
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。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
8.5.7 同步流以避免讨厌的并发错误
如何编写读写相同存储位置的并发流程序的问题,困扰着数代计算机科学家。
流可能交错 的数量是与指令数 量呈指数关系
有些交错会产生正确结果,有些可能不会。
所谓同步流就是。以某种方式同步并发流,从而得到 最大的可行交错的集合 ,每个交错集合都能得到正确的结果。
并发编程是一个很深奥,很重要的问题。在第12章详细讨论。
现在我们只考虑一个并发相关的智力挑战。
code
如果发生以下情况,会出现同步错误。
父进程执行fork函数,内核调度新创建的子进程运行,而不是父进程。
在父进程再次运行前,子进程已经终止,变成僵死进程,需要内核一个SIGCHLD信号给父进程
父进程处理信号,调用deletejob.
调用addjob。
显然deletejob必须在addjob之后,不然添加进去的job永久存在。这就是同步错误。
这是一个称为竞争(race)的经典同步错误的示例。
main中的addjob和处理程序中调用deletejob之间存在竞争。
必须addjob赢得进展,结果才是正确的,否则就是错误的。但是addjob不一定能赢,所以有可能错误。即为同步错误。
因为内核的调度问题,这种错误十分难以被发现。难以调试。
Q:如何消除竞争?
A:可以在fork之前,阻塞SIGCHLD信号,在调用addjob后取消阻塞。
注意,子进程继承了阻塞,我们要小心地接触子进程中的阻塞。
消除竞争的原则就是,让该赢得竞争的对象在任何情况下都能赢。
一个暴露你的代码中竞争的简便技巧
制造一个fork的包装函数Fork,通过随机+休眠,在fork的那一瞬间,让子进程,父进程都有50%机会先运行
8.6 非本地跳转
C语言提供一种用户级异常控制流形式,称为非本地跳转(nonlocal jump)。
它将控制直接从一个函数转移到另一个当前正在执行的函数。不需要经过正常的调用-返回序列。
非本地跳转是通过setjmp和longjmp函数来提供。
#include<setjmp.h>
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env,int savesigs);//信号处理程序使用
//参数savesigs若为非0则代表搁置的信号集合也会一块保存
1
2
3
4
5
setjmp函数在env缓冲区保存当前调用环境,以供后面longjmp使用,并返回0
调用环境包括程序计数器,栈指针,通用目的寄存器。
#include
8.7 操作进程的工具
STRACE(痕迹):打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。
用-static编译,能得到一个更干净,不带有大量共享库相关的输出的轨迹。
PS(Processes Status): 列出当前系统的进程(包括僵死进程)
TOP(因为我们关注峰值的几个程序,所以叫TOP):打印当前进程使用的信息。
PMAP(rePort Memory map of A Process): 查看进程的内存映像信息
/proc:一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构。
用户程序可以读取这些内容。
比如,输入"cat /proc/loadavg,观察Linux系统上当前的平均负载。
8.8 小结
异常控制流(ECF)发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制。
在硬件层,异常是处理器中的事件出发的控制流中的突变。控制流传递给一个异常处理程序,该处理程序进行一些处理,然后返回控制被中断的控制流。
有四种不同类型的异常:中断,故障,终止和陷阱。
定时器芯片或磁盘控制器,设置了处理器芯片上的中断引脚时,中断会异步发生。返回到Inext
一条指令的执行可能导致故障和终止同时出现。
故障可能返回调用指令。
终止不将控制返回。
陷阱用于系统调用。结束后,返回Inext
在操作系统层,内核用ECF提供进程的基本概念。进程给应用两个重要抽象:
逻辑控制流
私有地址空间
在操作系统和应用程序接口处,有子进程,和信号。
最后,C语言的非本地跳转 完成应用程序层面的异常处理。
至此,异常贯穿了从底层硬件,到抽象的软件层次。