0%

73:异常控制流(3)

信号

我们已经认识到了操作系统怎么通过异常使得进程上下文切换,以实现异常控制流的实现。现在我们将尝试另一种实现——Linux信号,来允许进程和内核中断其他的进程。

我们可以将信号理解成一条消息,它通知进程系统中发生了某一个事件。每种信号类型都会对应于某种系统事件。然后由不同的处理程序去处理这个事件。我们可以通过man 7 signal来进一步的认识这些信号:

image.png

信号术语

传送一个信号到目的进程可以分作两个步骤:

  • 发送信号:内核通过更新目的进程的上下文中的某个状态,从而实现发送一个信号给目的进程。发送信号一般有以下两种原因:1)内核检测到了一个系统事件,2)一个进程调用了kill函数,显式的要求内核发送一个信号给目的进程
  • 接收信号:当目的进程被内核强迫对信号的发送做出反应时,我们就说它接受了信号。目的进程可以忽略这个信号,终止,或者通过执行信号处理程序的用户层来捕获这个信号。
image.png

一个发出但没有被接收的信号我们称之为待处理信号。在任何时刻一个类型只会有一个待处理信号。如果一个进程中有一个类型为k的待处理信号。接下来任何发送到这个进程的类型为k的信号都不会再排队等候,而是被丢弃。同时一个进程可以选择阻塞接受某种信号。如果一个信号被阻塞,它可以发送,但是不会再被接收,直到取消对它的阻塞。

这个实现通过内核为每个进程维护这一个信号处理集合实现。在pending向量中维护着一个待处理信号的集合,在blocked向量中维护着一个阻塞信号的集合。当一个信号类型为k的信号被发送时,目的进程会检查其pending位是否已被设置,若是则丢弃;不是则设置。然后检查其block位是否被阻塞,若是则丢弃;若不是则接受信号,并清除pending位。

发送信号

发送信号的机制,是基于进程组实现的。我们接下来进一步的理解信号的发送:

进程组

每个进程都只属于一个进程组。进程组是由一个正整数进程组ID来标识的。我们有以下函数可以认识并改变进程组:

1
2
3
#include <unistd.h>
pid_t getpgrp(void); //返回调用进程的进程组ID
int setpgid(pid_t pid,pid_t pgid); //成功则返回0,失败则返回-1

默认情况下,子进程和它的父进程是同属于一个进程组的。一个进程可以通过使用setpgid函数来改变自己或者其他进程的进程组。setpgid函数将pid(目标进程PID)进程加入到进程组pgid(目标进程组ID)。如果pid是0,就表示当前进程。如果pgid是0,就用pid的值作为新的pgid

kill程序发送信号

/bin/kill程序可以向其他程序发送任意的信号:

1
kill -<signalNumber> <pid>

也可以向指定的进程组中的所有进程发送信号:

1
kill -<signalNumber> -<pgid>

我们可以尝试一下:

image.png

此外我们也可以通过键盘发送信号

从键盘发送信号

Uinx shell 中通常使用job(作业)来表示对一条命令行求值而创建的进程。在任何时刻,只有一个前台作业和0或多个后台作业。其中每个作业的都属于一个独立的进程组。

在键盘上Ctrl+C会导致内核发送一个SIGINT信号到前台进程组中的每个进程,导致作业终止。在键盘上Ctrl+Z会导致内核发送一个SIGTSTP信号到前台进程组中的每个进程,导致作业被停止(挂起)。

用kill函数发送信号

1
2
3
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid,int sig) //成功则返回0,失败则返回-1

通过调用kill函数发送信号到其他进程。

  • 如果pid>0,那么发送信号sig给进程pid
  • pid=0,那么发送信号sig给调用进程所在进程组中的每个进程,包括自己。
  • pid<0,则发送信号sig给进程组|pid|中的每个进程

我们可以尝试编写一个程序来使用kill函数:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include "csapp.h"

int main(){
pid_t pid;
if((pid = Fork())==0){
Pause(); //将进程挂起,直到接收到一个信号
printf("never get it\n");
exit(0);
}
kill(pid,SIGKILL);
}

这样就实现了父进程击杀自己的子进程。

用alarm函数发送信号

进程可以通过调用alarm函数向自己发送SIGALRM信号

1
2
#include <unistd.h>
unsigned int alarm(unsigned int secs); //返回前一次闹钟剩余的秒数,若之前没有设置闹钟,返回0

alarm函数安排内核在secs秒之后发送一个SIGALRM信号给调用进程。如果secs==0,那么不会安排调度新的闹钟。在任何情况下,alarm的调用都会取消之前的待处理的闹钟,并返回前一个闹钟的剩余的秒数。

接收信号

当内核把进程p从内核态切换到用户态时,它会检查进程p的未被阻塞的待处理信号的集合pending & ~blocked。如果这个集合为空,那么内核将控制传递到p的逻辑控制流中的下一条指令。如果集合使非空的,那么内核选择集合中的某个信号k(通常是最小的k),并强制p接收信号k。收到这个信号会触发某种行为。一旦进程完成这个行为,就会将控制传递回p的逻辑控制流中的下一条指令。

在上面展示信号类型的图中,也有每个信号类型相关联的默认行为。我们也可以通过signal函数修改和信号相关联的默认行为。唯一例外的是SIGSTOPSIGKILL。它们的默认行为是不能修改的。

1
2
3
4
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum,sighandler_t handler);
//如果成功则返回指向前次处理程序的指针;否则返回SIG_ERR,不设置errno

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

  • 如果handler是SIG_IGN,那么忽略这个类型的信号
  • 如果handler是SIG_DFL,那么恢复这个类型的信号的默认行为
  • 否则,handler就是用户定义的函数的地址,这个函数被称为信号处理程序。当接收到指定的信号类型时就会调用信号处理程序,我们称之为捕获信号。一个处理程序可以捕获不同的信号。

比如我们可以写一个程序用来改变SIGINT信号的默认行为(终止进程):

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include "csapp.h"

void handler(int sig){
printf("\nOVER!\n");
exit(0);
}

int main(){
Signal(SIGINT,handler);
Pause();
return 0;
}

当然信号处理程序也可以被其他信号处理程序中断。例如在下图中演示了这个过程:

image.png

阻塞和解除阻塞信号

Linux提供了两种阻塞信号的机制:

  • 隐式阻塞机制 内核默认阻塞任何当前处理程序正在处理的信号类型的待处理信号。
  • 显式阻塞机制 使用sigprocmask函数和它的辅助函数,明确阻塞/解除指定的信号。
1
2
3
4
5
6
7
8
#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); //若是则返回1,不是则返回0,出错返回-1

sigprocmask函数改变当前阻塞的信号集合(blocked位向量)。其具体的行为依赖于how值:

  • SIG_BLOCK 把set中的信号添加到blocked中 blocked = set|blocked
  • SIG_UNBLOCK 从blocked中删除set中的信号 blocked = blocked & ~set
  • SIG_SETMASKblocked = set

如果oldset非空,则将之前的blocked保存在其中。对于其他的几个辅助函数:

  • sigemptyset初始化set为空集合
  • sigfillset将每个信号都添加到set中
  • sigaddset将signum加入到set中
  • sigdelset从set中删除signum,如果signum是set的成员,返回1。不是则返回0

编写信号处理程序

这一部分太难了,我难以理解并接受。之后再来看看吧

显式地等待信号

和上面的关联度较高,涉及到竞争并发等内容,我暂时无法理解

非本地跳转

C语言提供了一种用户级的异常控制流形式,即非本地跳转(本地跳转是goto),它将控制从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列。其中非本地跳转是通过setjmplongjmp实现的。

1
2
#include <setjump.h>
int setjump(jmp_buf env); //setjmp返回0,longjmp返回非0

setjmp函数会在env缓冲区中保存当前的调用环境(相当于设置一个锚点,保存当前状态),以供后面的longjmp使用,并返回0。注意setjump由于其特殊的返回机制,不能被存储在变量之中,但是可以被switch使用。

1
2
#include <setjump.h>
int longjmp(jmp_buf env, int retval); //不返回

longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp调用的返回。然后setjmp返回,并带有非零的返回值retval

注意到,setjmp只被执行一次,但是会返回多次:一次是第一次调用setjmp时,调用环境保存在缓冲区env中。一次时为每个相应的longjmp调用。另一方面,longjmp被调用一次,但是不返回。

通过非本地条状我们可以实现从一个深层嵌套的函数调用中立即返回,从而实现对错误的分析,而不用多次退出复杂的调用栈。我们以下面的程序为例,可以感受到非本地跳转的用途:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <setjmp.h>
#include "csapp.h"
jmp_buf buf;
int error1 = 0;
int error2 = 1;
void foo(),bar();
int main(){
switch(setjmp(buf)){
case 0:
foo();
break;
case 1:
printf("Detected error1 in foo\n");
break;
case 2:
printf("Detected error2 in foo\n");
break;
default:
printf("Unknown error in foo\n");
}
exit(0);
}
void foo(){
if(error1)
longjmp(buf,1);
bar();
}
void bar(){
if(error2)
longjmp(buf,2);
}

虽然C中并没有异常的捕获函数,但是我们可以通过这种方式去实现。当遇到一个错误是,从setjmp返回,并解析它的错误类型。

同时,也要注意。longjmp允许跳过中间调用机制的过程可能回导致许多意外的后果。比如没有释放一些数据结构,导致内存泄露….

写在最后

关于异常控制流我感觉还是比较抽象的。涉及到的函数很多,尤其是信号部分,牵连到许多并发相关的内容。对于现在的我而言还是太过超前,日后再来巩固。