信号

这一章将讨论一种更高层次的软件形式的异常,称为Linux信号。信号就是一条小消息,可以通知系统中发生了一个某种类型的事件,比如:

  • 内核检测到了一个系统事件,比如除零错误、执行非法指令或子进程终止,低层次的硬件异常都是由内核异常处理程序处理的,对用户进程是不可见的,但是可以通过给用户进程发送信号的形式来告知,比如除零错误就发送SIGFPE信号,执行非法指令就发送SIGILL信号,子进程终止内核就发送SIGHLD到父进程中,则此时父进程就能对该子进程调用waitpid来进行回收。
  • 内核或其他进程出现了较高层次的软件事件,比如输入组合键,或一个进程尝试终止其他进程,都是显示要求内核发送一个信号给目标进程,比如输入组合键内核会发送SIGINT信号给所有进程,进程可以发送SIGKILL信号给别的进程来进行终止。

注意:与异常机制很类似,只是异常是由硬件和软件共同实现的,而信号时完全由软件实现的,且都是由内核进行发送的。

所以信号可以是内核检测到事件来发送到目标进程,也可以是其他进程通过内核来发送信号到目标进程。如下所示是Linux系统上支持的不同类型的信号,每种信号类型都对应某种系统事件

img
  • SIGINT:当用户输入Ctrl+C时,内核会向前台作业发送SIGINT信号,该信号默认终止该作业。
  • SIGTSTP:当用户输入Ctrl+Z时,内核会向前台作业发送SIGTSTP信号,默认停止作业,可通过发送SIGCONT信号来恢复该作业。
  • SIGKILL:该信号的默认行为是用来终止进程的,无法被修改或忽略。
  • SIGSEGV:当你试图访问受保护的或非法的内存区域,就会出现段错误,内核会发送该信号给进程,默认终止该进程。
  • SIGCHLD:当子进程终止或停止时,内核会发送该信号给父进程,由此父进程可以对子进程进行回收。

传送一个信号到目的进程是由两个步骤组成的:

  • 发送信号:内核通过更新目的进程上下文中的某个状态,来表示发送了一个信号到目的进程,所以这里除了目标进程上下文中的一些位被改变了,其他没有任何变化。
  • 接收信号:当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接受了信号。比如忽略信号、终止进程,或执行用户级的信号处理程序(Signal Handler)来捕获信号。

注意:执行信号处理程序类似于执行异常处理程序,只是异常处理程序是内核级别的,而信号处理程序就只是你的C代码程序。

img

当执行完信号处理程序后,会返回到下一条指令继续执行,类似于一次中断。

我们将发送了但是还没被接收的信号称为待处理信号(Pending Signal),而进程可以选择阻塞接收某种信号,则该信号可以被发送,但是在阻塞解除前无法被目标进程处理。我们可以发现不同的信号具有不同的编码,所以内核为每个进程在** pending位向量中维护待处理信号的集合,根据信号的编号来设置对应位的值,来传送信号,当进程接收了该信号,就会将其从pending位向量中重置该位的值;也为每个进程在 blocked位向量 **中维护了被阻塞的信号集合,可以通过查看位向量对应的位来确定该信号是否被阻塞。

注意:通过位向量的形式来保存待处理信号和被阻塞信号,可以发现每种类型的信号最多只会有一个待处理信号,并且一个待处理信号只能被接受一次。

发送信号

Unix基于进程组(Process Group)的概念,提供了大量向进程发送信号的机制。

进程组由一个正整数进程组ID来标识,每个进程组包含一个或多个进程,而每个进程都只属于一个进程组,默认父进程和子进程属于同一个进程组。我们将shell为了对一条命令行进行求值而创建的进程称为作业(Job),比如输入ls / sort命令行,就会创建两个进程,分别运行ls程序和sort程序,这两个进程通过Unix管道连接到一起,由此就得到了一个作业。注意:

  • 任何时刻,最多只有一个前台作业和任意数量的后台作业。
  • shell会为每个作业创建一个独立的进程组,该进程组ID由该作业中任意一个父进程的PID决定。
img

这里提供了以下对进程组的操作,允许你可以同时给一组进程发送信号。

1
2
3
4
5
6
7
#include <unistd.h>
pid_t getpgrp(void); //返回所在的进程组
int setpgip(pid_t pid, pid_t pgid); //设置进程组, 将进程pid的进程组改为pgid
/*
* 如果pid大于零,就使用进程pid;如果pid等于0,就使用当前进程的PID。
* 如果pgid大于0,就将对应的进程组ID设置为pgid;如果pgid等于0,就用pid指向的进程的PID作为进程组ID
*/
  • /bin/kill向进程发送任意信号

程序/bin/kill具有以下格式

1
/bin/kill [-信号编号] id  

id>0时,表示将信号传递给PID为id的进程;当id<0时,表示将信号传递给进程组ID为|id|的所有进程。我们可以通过制定信号编号来确定要传输的信号,默认使用-15,即SIGTERM信号,为软件终止信号。

img
  • 从键盘发送信号

通过键盘上输入Ctrl+C会使得内核发送一个SIGINT信号到前台进程组中的所有进程,终止前台作业;通过输入Ctrl+Z会发送一个SIGTSTP信号到前台进程组的所有进程,停止前台作业,直到该进程收到SIGCONT信号。

img

ps命令可以查看进程的信息,STAT表示进程的状态:S表示进程处于睡眠状态,T表示进程处于停止状态,R表示进程处于运行状态,Z表示僵死进程,而+表示前台作业。

在以上代码中,我们输入Ctrl-Z,可以发现两个fork进程的状态变成了停止状态了,通过输入fg命令可以将这些被挂起的进程恢复到前台运行,再通过Ctrl+C可以停止这两个前台进程。

  • kill函数发送信号

可以在函数中调用kill函数来对目的进程发送信号

1
2
3
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

pid>0时,会将信号sig发送给进程pid;当pid=0时,会将信号sig发送给当前进程所在进程组的所有进程;当pid<0时,会将信号sig发送给进程组ID为|pid|的所有进程。

  • alarm函数发送SIGALARM信号
1
2
#include <unistd.h>
unsigned int alarm(unsigned int secs);

alarm函数时,会取消待处理的闹钟,返回待处理闹钟剩下的时间,并在secs秒后发送一个SIGALARM信号给当前进程。

接收信号

当内核把进程p从内核模式切换回用户模式时,比如从系统调用返回或完成了一次上下文切换时,会检查进程p的未被阻塞的待处理信号的集合,即pending & ~blocked,如果是空集合,则内核会将控制传递给p的逻辑流中的下一条指令,如果集合非空,则内核会选择集合中编号最小的信号k(所以我们需要根据优先级来排列信号),强制进程p采取某些行为来接收该信号,对该集合中的所有信号都重复这个操作,直到集合为空,此时内核再将控制传递回p的逻辑流中的下一条指令。

每次从内核模式切换回用户模式,将处理所有信号

img

每种信号类型具有以下一种预定的默认行为:

  • 进程终止
  • 进程终止并转储内存
  • 进程挂起直到被SIGCONT信号重启
  • 进程忽略信号

我们这里可以通过signal函数来修改信号的默认行为,但是无法修改SIGSTOPSIGKILL信号的默认行为

1
2
3
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
  • signum为信号编号,可以直接输入信号名称

  • handler为我们想要对信号signum采取的行为

    • handlerSIG_IGN,表示要进程忽略该信号
    • handlerSIG_DFL,表示要恢复该信号的默认行为
    • handler为用户自定义的信号处理程序地址,则会调用该函数来处理该信号,该函数原型为void signal_handler(int sig);。调用信号处理程序称为捕获信号,置信信号处理程序称为处理信号。当信号处理程序返回时,会将控制传递回逻辑流中的下一条指令。注意:信号处理程序可以被别的信号处理程序中断。
  • signal函数执行成功,则返回之前signal handler的值,否则返回SIG_ERR

例子:

1
2
3
4
5
6
7
8
9
10
#include <signal.h>
void handler(int sig){
if((waitpid(-1, NULL, 0)) < 0)
unix_error("waitpid error");
}
int main(){
if(signal(SIGCHLD, handler) == SIG_ERR)
unix_error("signal error");
return 0;
}

这里只要在main函数开始调用一次signal,就相当于从此以后改变了SIGCHLD信号的默认行为,让它去执行handler处理程序。当子进程终止或停止时,发送SIGCHLD信号到父进程,则父进程会调用handler函数来对该子进程进行回收。

阻塞信号

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

  • 隐式阻塞机制:内核默认阻塞当前正在处理信号类型的待处理信号。
  • 显示阻塞机制:应用程序通过sigprocmask函数来显示阻塞和解阻塞选定的信号。
1
2
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
  • 通过how来决定如何改变阻塞的信号集合blocked

    • how=SIG_BLOCK时,blocked = blocked | set
    • how=SIG_UNBLOCK时,blocked = blocked & ~set
    • how=SETMASK时,block = set
  • 如果oldset非空,则会将原始的blocked值保存在oldset中,用于恢复原始的阻塞信号集合

这里还提供一些额外的函数来对set信号集合进行操作

1
2
3
4
5
6
#include <signal.h>
int sigemptyset(sigset_t *set); //初始化set为空集合
int sigfillset(sigset_t *set); //把每个信号都添加到set中
int sigaddset(sigset_t *set, int signum); //将signum信号添加到set中
int sigdelset(sigset_t *set, int signum); //将signum从set中删除
int sigismember(const sigset_t *set, int signum); //如果signum是set中的成员,则返回1,否则返回0

以下是一个使用例子

img

以上执行内部函数时,就不会接收到SIGINT信号,即不会被Ctrl+C终止。

通过阻塞信号来消除函数冲突,或者保证程序运行逻辑正确。

信号处理程序

我们之前已经看过了进程之间的并发了,只要两个进程的逻辑流在时间上是重叠的,则这两个进程的逻辑流是并发流。由于进程有各自独立的地址空间,所以比较不用担心一个进程受到别的进程的影响,所以并发流不会互相妨碍。

而信号也是并发的一个例子,信号处理程序是一个独立的逻辑流(不是进程),与主程序并发运行。比如我们在进程A中执行一个while循环,当该进程受到一个信号时,内核会将控制权转移给该信号的处理程序,所以该信号处理程序是并发执行的,当信号处理程序结束时,再将控制转移给主程序。由于信号处理程序与主程序在同一进程中,所以具有相同的上下文,所以会共享程序中的所有全局变量。

img

注意:信号处理程序与主程序在相同进程中并发执行。

将信号处理程序看成并发流的另一种方式是使用上下文切换图。当有个信号传递给进程A时,只是简单的设置了pending位向量对应的位,并不会有额外操作,当进程A后面执行上下文切换,到达进程B后,进程B执行若干指令后,通过上下文切换到达进程A,此时就会根据pending位向量记录的未处理信号集合来依次调用对应的信号处理程序,过后再将其传递到下一条指令的地址。所以信号处理程序和其他程序都处于相同的进程中。

img

信号发送的对象是进程,因为信号处理程序执行在相同的进程中,所以当该进程接收到信号时,信号处理程序是可以被别的信号处理程序中断的,构成以下多层嵌套的信号处理程序,由于这些信号处理程序和主程序处于相同的进程中,所以共享相同的全局变量,这就使得全局变量的状态较难控制。

img

安全的信号处理

信号处理的一个难点在于:处理程序与主程序在同一进程中是并发运行的,它们共享同样的全局变量,可能会与主程序和其他处理程序相互干扰。这里推荐一系列措施来进行安全的信号处理:

  • G0:处理程序要尽可能简单。

    • 当处理程序尽可能简单时,就能避免很多错误。推荐做法:处理程序修改全局标志指示出现的信号,然后直接返回,主程序会周期性检查并重置这个全局标志。
  • G1:在处理程序中调用异步信号安全的函数。

    • 异步信号安全的函数能被处理程序安全地调用,因为它是可重入的(比如所有变量都是保存在栈上的局部变量),或不能被信号处理程序中断的。Linux中保证安全的系统级函数如下所示,注意:printfsprintfmallocexit是不安全的,而write是安全的。
  • G2:保存和恢复errno

    • 全局变量errno在系统级函数出现错误时才会被赋值,许多Linux异步信号安全的函数都会在出错时返回并设置errno,当处理程序要返回时,最好提前将errno保存为局部变量,再在返回时重置errno,使得主程序可以使用原本的errno
  • G3:阻塞所有的信号,保护对共享全局数据结构的访问

    • 对于数据结构的访问(读取或写入),可能需要若干条指令,当主程序在访问全局数据结构中途被中断,进入处理程序时,如果处理程序也访问当前数据结构,可能会发现该数据结构的状态是不一致的。所以对全局数据结构进行访问时,要阻塞所有的信号(无论在主程序还是信号处理程序中)。
  • G4:volatile声明在主程序和信号处理程序共享的全局变量

    • 比如G0说的使用全局变量来保存标志,处理程序更新标志,主程序周期性读取该标志,编译器可能会感觉主程序中该标注没有变化过,所以直接将其值缓存在寄存器中,则主程序就无法读取到处理程序的更新值。所以我们需要使用volatile来声明该标志,使得编译器不会缓存该变量,使得主程序每次都从内存中读取该标志。
  • G5:sig_atomic_t声明那些仅进行读写操作,不会进行增量或更新操作的变量

    • 通过使用C提供的整型数据类型sig_atomic_t来声明变量,使得对它的读写都是原子的,不会被中断,所以就不需要暂时阻塞信号了。大多数系统中,sig_atomic_tint类型。注意:对原子性的保证只适用于单个读和写,不适用于flag++flag+=1这类操作。

综上所述:是处理函数尽可能简单,在处理程序中调用安全函数,保存和恢复errno,保护对共享数据结构的访问,使用volatilesig_atomic_t

正确的信号处理

在信号处理中,还存在一个问题:我们这里使用pending位向量来保存未处理的信号集合,当处理程序处理信号时,就会将其从该集合中删除,但是由于是位向量形式,所以当集合中存在信号k时,就不会再接收信号k了,意味着:如果存在一个未处理的信号k,则表明至少有一个信号k到达,所以我们不能通过信号来对其他进程中发生的事件进行记数,我们要使得处理程序一次能够执行尽可能多的操作。

比如主程序通过接收SIGCHLD信号来回收子程序,不正确的处理程序是如下形式的:

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
32
33
34
35
36
37
38
void handler1(int sig) 
{
pid_t pid;

if ((pid = waitpid(-1, NULL, 0)) < 0)
unix_error("waitpid error");
printf("Handler reaped child %d\n", (int)pid);
Sleep(2);
return;
}

int main()
{
int i, n;
char buf[MAXBUF];

if (signal(SIGCHLD, handler1) == SIG_ERR)
unix_error("signal error");

/* Parent creates children */
for (i = 0; i < 3; i++) {
if (Fork() == 0) {
printf("Hello from child %d\n", (int)getpid());
Sleep(1);
exit(0);
}
}

/* Parent waits for terminal input and then processes it */
if ((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0)
unix_error("read");

printf("Parent processing input\n");
while (1)
;

exit(0);
}

此时如果众多子进程一次性发送过多的SIGCHLD信号给父进程,当父进程还在信号处理程序时,就会丢失若干个SIGCHLD信号,使得无法得到正确的回收子进程的数目,可以改成如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
void handler2(int sig) 
{
pid_t pid;

while ((pid = waitpid(-1, NULL, 0)) > 0)
printf("Handler reaped child %d\n", (int)pid);
if (errno != ECHILD)
unix_error("waitpid error");
Sleep(2);
return;
}
// main函数中handler1改为handler2,其余不变

这里我们假设接收到一个SIGCHLD信号意味着有多个信号被终止或停止,所以通过while循环来回收所有的进程,此时就不会遗漏子进程的回收。

例:判断下列程序的输出

img

这里需要注意以下几点:

  • 在23行捕获一个SIGUSR1信号,此时还没有到fork函数,所以是父进程和子进程共享的代码,所以父进程和所有子进程都要捕获这个信号。
  • 在27行父进程给子进程发送SIGUSR1信号,当子进程捕获到这个信号时,会跳转到执行handler1函数,这里对全局共享的变量的访问要阻塞信号。然后通过安全的_exit(0)终止子进程。
  • 注意:通过fork函数创建的子进程,具有和父进程相似但独立的地址空间,意味着在子进程中--counter,并不会影响父进程中的counter值。

可移植的信号处理

信号处理的另一个缺陷是:不同的系统有不同的信号处理语义,比如:

  • signal函数的语义各不相同,有的Unix系统,当处理完信号时,就会将对该信号的处理恢复为默认行为。
  • 存在一些潜在的会阻塞进程较长时间的系统调用,称为慢速系统调用,比如readwriteaccpet。在执行慢速系统调用时,如果进程接收到一个信号,可能会中断该慢速系统调用,并且当信号处理程序返回时,无法继续执行慢速系统调用,而是返回一个错误条件,并将errno设置为EINTR

这些可以通过sigaction函数来明确信号处理语义,由于该函数的复杂性,提供一个封装好的函数

img

可以类似signal函数那样使用,信号处理语义为:

  • 只有当前信号处理程序正在处理的信号类型会被阻塞
  • 只要可能,被中断你的系统调用会自动重启
  • 一旦设置了信号处理程序,就会一直保持

其他

同步流来消除并发错误

并发流可能以任何交错方式运行,所以信号发送的时机很难预测,可能会出现错误,所以需要首先对目标信号进行阻塞,先执行预定操作,然后将其解阻塞进行捕获。比如以下代码

img

如果缺少30和32行,则addjob函数和deletejob函数之间存在竞争,必须在deletejob函数之前调用addjob函数,否则在deletejob函数中通过waitpid函数释放了子进程,过后调用addjob函数就会出错(将一个不存在的子进程添加到作业列表中)。但是由于内核调度进程是不可控的,以及SIGCHLD信号的发送时机是不确定的,所以可能出现这个错误。可以如上所示,在主进程中先对SIGCHLD信号进行阻塞,在执行完addjob函数后再解阻塞,保证了先执行addjob函数再执行deletejob函数。

经验之谈:不要对代码做任何假设,比如子进程运行到这里才终止。

注意:可以通过阻塞信号来控制信号的接收时机。

显示等待信号

当我们想要主进程显示等待某个信号时,可以用以下代码

img

这里主进程会显示等待子进程被回收,这里使用了sigsuspend(&mask)函数,它等价于

1
2
3
sigprocmask(SIG_SETMASK, &mask, &prev);
pause();
sigprocmask(SIG_SETMASK, &prev, NULL);

但是它是这三条代码的原子版本,即第一行和第二行是一起调用的,则SIGCHLD信号不会出现在第一行和第二行之间,造成程序不会停止。

注意:第26行要先对SIGCHLD信号进行阻塞,防止过早发送给主进程,则pause函数就无法中断,就会使得程序不会停止。

非本地跳转

C语言提供了一种用户级异常控制流形式,称为非本地跳转(Nonlocal Jmup),它可以直接将控制从一个函数转移到另一个当前正在执行的函数,不需要经过调用-返回。

这里需要两个函数

1
2
3
#include <setjmp.h>
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int retval);

我们首先需要定义一个jmp_buf类型的全局变量env,通过调用setjmp(env),能将当前调用环境保存到env中,包括程序计数器、栈指针和通用目的寄存器,而setjmp函数会返回0。而后我们在代码某处调用longjmp(env, retval),会从env中恢复调用环境,并跳转到最近一次初始化envsetjmp函数,让setjmp函数返回retval

接下来介绍非本地跳转的两个应用

无需解析调用栈,直接从深层嵌套函数中返回

img

main函数中,首先在12行中执行setjmp(buf)函数将当前调用环境保存到buf中,并返回0,所以就调用foo函数和bar函数,当这两个函数中出现错误,则通过longjmp(buf, retval)恢复调用环境,并跳转回第13行,然后让setjmp函数返回retval的值,由此就无需解析调用栈了。但是该方法可能存在内存泄露问题(例如,中间函数调用中分配了某些数据结构,本来预期在函数结尾处释放它们,那么这些释放代码会被跳过,因而产生内存泄漏)。

控制信号处理程序结束后的位置

在信号处理中也有对应的两个非本地跳转的函数

1
2
3
#include <setjmp.h>
int sigsetjmp(sigjmp_buf env, int savesigs);
void siglomgjmp(sigjmp_buf env, int retval);

其中sigsetjmp函数还会将待处理信号和被阻塞信号保存到env中。

img

首先,在main函数中第12行通过sigsetjmp函数将调用环境保存到buf中,并返回0,随后设置信号处理程序。当用户输入Ctrl+C时,会调用信号处理程序handler,此时会通过siglongjmp恢复调用环境,然后跳转回第12行,然后让sigsetjmp返回1,此时就避免了返回到中断的下一条指令处。

注意:signal要在sigsetjmp之内,避免还未设置sigsetjmp就接收到信号而执行siglongjmp

该程序的执行结果为

img

问题: siglongjmp函数会恢复调用环境,再返回到sigsetjmp处。而调用sigsetjmp时还未设置SIGINT信号的处理函数,那么调用环境中应该也不包含该信号处理函数吧?那么siglongjmp函数恢复调用环境后,应该就不会用handler来处理SIGINT信号了吧?