2019-01-01 CSAPP 第八章(二)

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位。


2019-01-01 CSAPP 第八章(二)_第1张图片

8.5.2 发送信号

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

进程组

每个进程都属于一个进程组。

由一个正整数进程组ID来标示

getpgrp()函数返回当前进程的进程组ID:

#include

pid_t getpgrp(void);

1

2

默认,一个子进程和它的父进程同属于一个进程组

一个进程可以通过setpgid()来改变自己或者其他进程的进程组。

#include

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

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


2019-01-01 CSAPP 第八章(二)_第2张图片

8.5.3 接收信号

信号的处理时机是在从内核态切换到用户态时,会执行do_signal()函数来处理信号

当内核从一个异常处理程序返回,准备将控制传递给进程p时,它会检查进程p的未被阻塞的待处理信号的集合(pening&~blocked)。

如果这个集合为空,内核将控制传递到p的逻辑控制流的下一条指令。

如果非空,内核选择集合中某个信号k(通常是最小的k),并且强制p接收k。收到这个信号会触发进程某些行为。一旦进程完成行为,传递到p的逻辑控制流的下一条指令。

每个信号类型都有一个预定义的默认类型,以下几种.

进程终止

进程终止并转储存器(dump core)

进程停止直到被SIGCONT信号重启

进程忽略该信号

进程可以通过使用signal函数修改和信号相关联的默认行为。

SIGSTOP,SIGKILL是不能被修改的例外。

#include

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语句后,控制通常传递回控制流中进程被信号接收中断位置处的指令。

信号处理程序是计算机并发的又一个示例。信号处理程序的执行中断,类似于底层异常处理程序中断当前应用程序的控制流的方式。因为信号处理程序的逻辑控制流与主函数的逻辑控制流重叠,信号处理程序和主函数并发地运行。

自我思考:信号是一种异常/中断,当接收到信号的时候,会停下当前进程所做的事,立马去执行信号处理程序。并不是多线程/并行,但确是并发的。从下面这张图,可见一斑。


2019-01-01 CSAPP 第八章(二)_第3张图片

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

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

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

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

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语言的非本地跳转 完成应用程序层面的异常处理。

至此,异常贯穿了从底层硬件,到抽象的软件层次。

你可能感兴趣的:(2019-01-01 CSAPP 第八章(二))