安全的信号处理的原则
由于信号处理程序和主程序 共享同样的变量, 所以如何与主程序通信, 处理信号又不影响主程序运行, 就很重要了. 一般有如下原则:
- 处理程序尽可能简单. 简单并不是说程序要短小, 而是程序避免增加无谓的复杂度. 比如处理程序可以最终设置一个全局标志并且返回, 将处理这个全局标志的任务交给主程序. 主程序周期性的检查然后重置这个标记.
- 在处理程序中只调用异步信号安全的函数. Linux系统里有一些异步信号安全的函数, 这些函数有两个特点: 一是可重入(例如只访问局部变量), 二是不会被信号处理程序中断. 书的534页有所有的Linux 保证安全的系统函数.唯一输出安全的是系统调用函数 write, C语言中对其的包装函数 printf 和 sprintf 都是不安全的.
- 保存和恢复errno, 上边的很多安全函数都会在出错的时候设置errno, 如果不加设置, 会改变errno 的值, 由于errno是全局变量, 因此可能导致主程序出错. 解决办法是进入信号处理函数的时候保存 errno 的值, 结束的时候再设置成原来的值.
- 访问共享全局数据结构的时候, 阻塞全部信号. 否则在读写的时候如果被中断, 可能会造成一系列数据结构状态异常的结果.
- 用volatile声明全局变量. 如果像第一条说的设置一个全局标志, 但是编译器很可能认为这个变量其实没变化, 所以一直用寄存器中的数据, 不会更新. 使用volatile声明之后, 每次都会重读该变量的内存中值.
对于这个全局标记本身, 也需要在访问的时候阻塞全部信号, 以保证一致性. - 用 sig_atomic_t 声明第四条中提到的标志. 这个整型数据类型可以保证读和写是原子的. 结合第四条, 用一条语句来声明:
volatile sig_automic_t flag;
这个读和写指的是 直接设置 flag=常量, 如果是需要计算的语句比如 flag++ 或者 flag = flag + a, 都无法保证原子操作.
正确的信号处理
未处理的信号不会排队, 只有一个, 所以不能用信号来计数. 关键是要理解, 只要接收到一个信号, 就说明引起该信号的事件, 至少发生了一次, 因此应该针对这批事件进行处理, 否则便可能产生遗漏.
这个时候就可以来解决之前自己编写的bash程序的问题了: 即对于后台的进程没有进行任何跟踪(否则就变成前台进程了), 在其结束的时候也没有回收.
先来追踪一下 537 页上的程序:
#include <errno.h> #include <signal.h> #include <wait.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #define MAXBUF 128 //信号处理函数 void handler1(int sig){ //保存原来的全局变量 errno int olderrno = errno; //回收一个进程 if ((waitpid(-1, NULL, 0)) < 0) { sio_error("waitpid error"); } //调用异步安全函数 Sio_puts("Handler reaped child\n"); Sleep(1); //恢复 errno errno = olderrno; } int main(){ int i, n; char buf[MAXBUF]; //只要有一个子进程终止, 内核就会发送SIGCHLD信号给父进程, 所以给这个信号设置处理函数 if (signal(SIGCHLD, handler1) == SIG_ERR) { unix_error("signal error"); } //启动三个子进程, 每个显示自己的进程号 for (i = 0; i < 3; i++) { if(Fork()==0){ printf("Hello from child %d\n", (int) getpid()); exit(0); } } //等待输入 if ((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0) { unix_error("read"); } printf("Parent processing input\n"); //无限循环 while(1) {} exit(0); }
运行这个程序, 可以发现输出如下:
Hello from child 1508 Handler reaped child Hello from child 1509 Hello from child 1510 Handler reaped child
由于之后是无限循环, 这里可以按 Ctrl+Z 来挂起进程, 然后可以输入 ps 查看进程:
PID TTY TIME CMD 1418 pts/0 00:00:00 bash 1507 pts/0 00:00:00 singall 1510 pts/0 00:00:00 singall <defunct> 1511 pts/0 00:00:00 ps
可以看到有1508, 1509, 1510三个进程被创建(发送了Hello from), 然后显示回收了两个. 通过ps命令可以看到, 回收的是1508和1509, 1510进程被系统标记 defunct ,表示是一个结束的僵死进程.
这个问题就在于, 三个进程几乎是同一个时间结束, 在处理第一个结束信号的时候, 另外两个的信号也同时到达, 但是只有一个留在 pending 中, 所以等当前处理完之后, 再处理一次就结束了. 每一个信号处理的时候, 只回收一个进程, 结果3个进程只回收了2个.
要如何修改, 其实很简单, 之前说过, 收到信号说明此类型的事情发生了, 所以至少要处理一批事件. 所以将信号处理函数改成回收所有子进程的循环即可:
void handler2(int sig){ //保存原来的全局变量 errno int olderrno = errno; //循环回收当前的所有子进程 while((waitpid(-1, NULL, 0)) >0){ Sio_puts("Handler reaped child\n"); } //检测是不是有错误 if (errno != ECHILD) { Sio_error("waitpid error"); } Sleep(1); //恢复 errno errno = olderrno; }
改用循环之后, 只要收到信号, 就去循环一次, 这里也可以使用 WNOHANG, 如果没有进程停止就休息一下. 这里还可以让子进程运行的时间更长一点, 然后只要结束一次, 就会去回收一次.
练习 8.8 程序的输出是什么
分析一下这个程序:
#include <signal.h> #include <stdlib.h> #include <stdio.h> #include <unistd.h> //用volatile设置的变量, 很可能就是全局标记 volatile long counter = 2; //处理信号的函数 void handler1(int sig){ //设置了两个信号集, 当前的是mask, 之前的是prev_mask sigset_t mask, prev_mask; //填充所有的信号到信号集中 Sigfillset(&mask); //把信号集中的信号添加到blocked位向量中, 这么操作之后, 阻塞全部信号 Sigprocmask(SIG_BLOCK, &mask, &prev_mask); //输出减少后的counter Sio_putl(--counter); //恢复原来的blocked位向量 Sigprocmask(SIG_SETMASK, &prev_mask, NULL); //直接退出程序, _exit函数不关闭任何文件,不清除任何缓冲器、也不调用任何终止函数 _exit(0); } int main(){ //进程号变量 pid_t pid; //信号集变量 sigset_t mask, prev_mask; //打印counter计数器, 刷新输出, 此时应该是2 printf("%ld", counter); fflush(stdout); //设置信号SIGUSR1(是用户定义的信号1)的处理函数是handler1 signal(SIGUSR1, handler1); //分出来一个子进程, 子进程会无限循环. 父进程继续执行下边命令 if ((pid = Fork()) == 0) { while(1) { } } //向刚刚的子进程发送SIGUSR1信号 Kill(pid, SIGUSR1); //在发送之后, 子进程会调用信号处理程序, 信号处理程序会立刻屏蔽所有信号, 然后输出--counter = 1, 再恢复之后退出. //父进程等待所有子进程结束, 上边的子进程在处理完信号之后会退出,这里会等待 Waitpid(-1, NULL, 0); //又重复设置阻塞全部信号 Sigfillset(&mask); Sigprocmask(SIG_BLOCK, &mask, &prev_mask); //输出3, 注意这里的counter 是父进程自己的counter, 不是子进程的counter! printf("%ld", ++counter); Sigprocmask(SIG_SETMASK, &prev_mask, NULL); exit(0); } //最后的输出是213
通过分析之后, 可以发现输出的是213, 注意volatile是进程内的标志位, 父进程和子进程的volatile变量依然是独立的.
可移植的信号处理
信号处理在不同的系统上有不同的默认行为, 主要的区别在于:
- signal函数的语义不同, 有些系统在调用一次信号处理程序之后, 就会把信号处理行为恢复成默认, 因此必须再调用一次signal函数.
- 系统调用可以被中断, 前边一些安全的系统函数中的read ,write之类, 会阻塞进程一段时间. 在调用这些函数的时候, 如果发生信号, 这些函数会被中断, 在信号处理完毕的时候, 也不会再继续, 而是设置errno=EINTR,
如果想要继续, 必须手工重启这些函数.
后来Posix标准定义了 sigaction 函数, 允许设置信号的时候指定信号处理语义:
#include <signal.h> int sigaction(int signum, struct sigaction *act, struct sigaction *oldact);
这个函数需要定义一个行为的结构, 比较麻烦, 一般都是封装了一个函数使用, CSAPP中封好了这个函数. 这个函数的作用是:
- 阻塞与当前信号程序处理的相同类型的信号
- 被中断的系统调用重新启动
- 一旦设置就会一直存在, 直到被SIG_IGN或者SIG_DFL调用.
信号处理中的同步问题
信号处理程序由于也会和主程序是并发运行的, 只要并发程序, 都会涉及到同步的问题. 如果将程序看做一个一个指令的流, 这些流被操作系统其实是交错执行的, 而不是真的并发执行.
交错的执行造成的结果就是, 有些语句无论顺序和执行与否, 不会对最终结果产生影响, 而有些语句的先后执行顺序和执行与否, 直接会导致程序是否会产生正确的结果.
因此对于并发程序来说, 基本的工作是决定何时同步, 以保证并行的流可以不出错.
在一个程序中, 如果有了信号处理函数, 信号处理函数和主函数的流就会交错, 此时就可能发生错误:
#include <signal.h> #include <stdlib.h> #include <stdio.h> #include <unistd.h> void hanlder(int sig) { int olderrno = errno; sigset_t mask_all, prev_all; pid_t pid; //设置信号集为全部信号 Sigfillset(&mask_all); //不断等待子进程的结束 while ((pid = wait(-1, NULL, 0)) > 0) { //阻止全部信号 Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); //删除任务 deletejob(pid); //恢复信号 Sigprocmask(SIG_SETMASK, &prev_all, NULL); } //判断errno和恢复errno if (errno != ECHILD) { Sio_error("waitpid error"); } errno = olderrno; } int main(int argc, char **argv){ int pid; sigset_t mask_all, prev_all; //设置信号集为全部信号 Sigfillset(&mask_all); //装载信号处理函数 Signal(SIGCHLD, handler); //初始化任务列表 initjobs(); //不断开启子进程 while(1){ //子进程去执行程序 if ((pid = Fork()) == 0) { Execve("/bin/date", argv, NULL); } //父进程将子进程的pid添加进addjob Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); addjob(pid); Sigprocmask(SIG_SETMASK, &prev_all, NULL); } exit(0); }
分析一段程序有没有并发错误, 需要将自己意图让程序执行的流程, 与程序并发时可能的各种情况进行对比. 特别是分布在并发程序中, 又必须按步骤执行的代码, 特别容易出现并发错误.
上边这段程序, 意图执行的顺序是 主进程fork->子进程执行date程序->将子进程加入到任务列表->子进程停止的时候发送信号->主进程信号处理程序删除任务.
问题在于, 信号处理程序和主进程是并发执行的, 是不是addjob一定会在deletejob之前发生呢? 也就是说,主进程执行到addjob()这一行是不是一定在信号发送之前?
画出拓扑图有助于分析:
可以看到, 拓扑图中间并行的部分无法保证执行顺序, 即addjob和deletejob可能会产生冲突.
如何解决这个问题, 观察拓扑图, 要保证addjob一定在deletejob之前执行, 只要把拓扑图改成addjob一定在分支之前就可以了, 那么也就是可以在Fork()之前阻塞所有子进程信号, 直到addjob之后再解除阻塞, 这样创建子进程之后, 即使有子进程结束了, 主进程也一定会将其添加到addjob中, 之后收到信号再逐个删除已经结束的子进程.
这里还有一个问题就是, 子进程在Fork之后会继承父进程的信号阻塞情况, 因此还必须解除掉才行.修改后的程序如下:
int main(int argc, char **argv){ int pid; sigset_t mask_all, mask_one, prev_one; //设置父进程信号集为全部信号 Sigfillset(&mask_all); //设置子进程的信号集为SIGCHLD Sigemptyset(&mask_one); Sigaddset(&mask_one, SIGCHLD); //装载信号处理函数 Signal(SIGCHLD, handler); //初始化任务列表 initjobs(); //不断开启子进程 while(1){ //阻塞CHLD信号, 注意这里是阻塞父进程的信号 Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); //启动子进程执行程序, 子进程此时也处于阻塞CHLD信号的状态 if ((pid = Fork()) == 0) { //解除子进程的CHLD信号阻塞 Sigprocmask(SIG_SETMASK, &prev_one, NULL); //执行程序 Execve("/bin/date", argv, NULL); } //父进程从只阻塞CHLD到阻塞全部信号 Sigprocmask(SIG_BLOCK, &mask_all, NULL); //直到addjob执行之前, CHLD都被阻塞 addjob(pid); //解除阻塞 Sigprocmask(SIG_SETMASK, &prev_one, NULL); } exit(0); }
此时的拓扑图如下:
可以看到, 解除阻塞信号之后, deletejob才会运行, 而在解除阻塞之前, addjob运行了. 这就让addjob永远运行在deletejob之前, 因为在进入子进程前就阻塞了CHLD信号, 子进程即使运行完毕, 主进程也会先调用addjob, 再处理信号.
显式的等待信号
比如Bash, 运行了一条命令, 就会等待作业终止, 被SIGCHLD信号处理程序回收. 来看一下如何写一个这种程序:
#include <signal.h> #include <stdlib.h> #include <stdio.h> #include <unistd.h> volatile sig_atomic_t pid; void sigchld_handler(int s){ int olderrno = errno; pid = waitpid(-1, NULL, 0); errno = olderrno; } void sigint_handler(int s){ } int main(int argc, char **argv){ sigset_t mask, prev; //设置SIGCHLD和SIGINT的信号处理函数 Signal(SIGCHLD, sigchld_handler); Signal(SIGINT, sigint_handler); //将mask清空, 然后只加入SIGCHLD Sigemptyset(&mask); Sigaddset(&mask, SIGCHLD); while(1){ //像上一节那样, 先阻塞信号, 再fork新进程 Sigprocmask(SIG_BLOCK, &mask, &prev); if (Fork() == 0) { //这里是子进程的执行的代码, 可以执行各种任务, 这里简单退出了 exit(0); } //下边都是父进程的代码 pid = 0 //恢复之前的信号状态, 这样就可以调用信号处理函数了. Sigprocmask(SIG_SETMASK, &prev, NULL); //反复等待pid不为0, 信号处理函数会让pid不为0, 这样程序就可以继续下去 while (!pid) { ; } printf("child process has ended.\n"); } exit(0); }
这个程序的逻辑比较简单, 每一次开启一个新子进程的时候, 如果子进程还没有返回, 全局标志pid就是0, 此时主进程会一直循环等待, 只有子进程结束, 收到信号的时候, 信号处理程序会修改全局变量pid, 这样父进程就可以开始下一次执行任务.
在分支之前, 采用了上一节的技巧, 就是先阻塞CHLD信号, 重新设置好pid=0之后, 再取消阻塞CHLD信号.
这段代码的逻辑和同步方面是没有问题的. 程序的问题在于通过循环等待pid不为0的开销太大, 应该让主进程在等待子进程的过程中挂起就好了. 于是想到可不可以在while(!pid)中使用pause()函数.
答案是不能, 因为测试判断条件到进入pause()之间不是连续的, 如果先判断了pid不为0, 然后进入循环体, 但是还没有执行pause()之前, 就收到了信号, 之后pid就不为0了. 然后就没有任何信号了, pause()函数会一直停止下去.
换成不等待信号, 只是挂起一会的sleep可以, 但是时间无法有效的控制. 这里需要使用一个函数 sigsuspend, 定义在 signal.h 中:
#include <signal.h> int sigsuspend(const sigset_t *mask);
这个函数的参数是一个信号集mask, 函数的作用是用这个mask替换当前的阻塞集合, 然后挂起进程, 直到进程收到信号. 如果这个信号有对应的处理程序, sigsuspend 会在信号处理完毕之后恢复原来的阻塞集合. 如果信号导致程序终止, sigsupend就不返回了, 因为程序已经终止了.
实际上这个函数相当于原子操作(一次性执行完毕)如下指令:
sigprocmask(SIG_SETMASK, &mask, &prev); pause() sigprocmask(SIG_SETMASK, &prev, NULL);
在第一行和第二行之间是连续操作的, 不会允许其他函数执行, 所以就没有了竞争. 利用这个函数可以修改原来的程序如下:
int main(int argc, char **argv){
sigset_t mask, prev;
//设置SIGCHLD和SIGINT的信号处理函数
Signal(SIGCHLD, sigchld_handler);
Signal(SIGINT, sigint_handler);
//将mask清空, 然后只加入SIGCHLD
Sigemptyset(&mask);
Sigaddset(&mask, SIGCHLD);
while(1){
//像上一节那样, 先阻塞信号, 再fork新进程
Sigprocmask(SIG_BLOCK, &mask, &prev);
if (Fork() == 0) {
exit(0);
}
//下边都是父进程的代码
pid = 0;
//反复等待pid不为0, 直接利用只包含SIGCHLD信号的mask信号集
//调用 sigsuspend 已经表示只阻塞SIGCHLD信号了, 所以就不用先恢复全部信号状态, 等子进程结束之后再恢复就可以了.
while (!pid) {
sigsuspend(&mask);
}
//恢复之前的信号状态
Sigprocmask(SIG_SETMASK, &prev, NULL);
printf(".");
}
exit(0);
}
非本地跳转
语言中的try catch是如何实现的, 其实底层都是通过C语言的setjmp和longjmp函数来实现的, 这是纯粹的软件控制流, 成为非本地跳转.
如果一个函数套一个函数执行了很深了, 发现了一个错误, 得到一个错误码, 要一层一层解开调用栈, 把错误返回回去. 现在有这么一种机制, 就是在进入执行函数之前, 先通过 setjmp 保存了当前的环境. 在很深的地方发现错误的时候, 调用一个 longjmp 函数. longjmp函数会将最近调用的 setjmp 函数的环境恢复, 然后让setjmp函数返回一个值, 这个值就是错误码. 这样就实现了异常处理.
先来看setjmp函数:
#include <setjmp.h> int setjmp(jmp_buf env); int sigsetjmp(sigjmp_buf env, int savesigs);
setjmp 被第一次调用时, 会在参数env中保存当前的调用环境, 包括PC, 栈, 寄存器值等, 然后返回0. setjmp的值不能够赋给变量, 但可以通过switch来操作.
之后是 longjmp 函数:
#include <setjmp.h> void longjmp(jmp_buf env, int retval); void siglongjmp(sigjmp_buf env, int retval);
longjmp 函数的第二个参数, 不是给自己用的, 而是会让 setjmp 函数此时返回 retval 这个值. longjmp 函数执行的结果就好像是跳跃到 setjmp 最后一次执行的位置, 然后让 setjmp 函数重新有了一个返回值. 这个retval不能是0.
来看一个例子:
#include <stdio.h> #include <setjmp.h> #include <stdlib.h> #include <unistd.h> jum_buf buf; int error1 = 0; int error2 = 1; void foo(void); void bar(void); int main(){ //第一次调用的时候返回0 switch (setjmp(buf)) { case 0: foo(); break; case 1: printf("Detected an error1 condition in foo\n"); break; case 2: printf("Detected an error2 condition in foo\n"); break; default: printf("Unknown error condition in foo\n"); } exit(0); } void foo(void){ if (error1) { longjmp(buf, 1); } bar(); } void bar(void){ if (error2) { longjmp(buf, 2); } }
这段代码里, 在第一次调用 setjmp 的时候, 会返回0. 因此语句执行foo(), foo()中会调用bar(). 如果foo()中发生错误, longjmp 会跳到setjmp的地方, 同时让其返回1. 而main函数就好像从调用setjmp的时点继续向下执行, 这一次测试的结果就变成了1, 于是就显示foo()中发生了错误.
对于bar()中出错也是类似的. 这里关键要理解, longjmp 跳回去的时候, 就好像程序从setjmp又开始执行一样, 唯一不同的是setjmp这次返回了不同的值.
longjmp的缺点是可能会产生内存泄露, 比如没有回收分配的内存.
对于信号处理, 则提供了setjmp和longjmp的信号版本, 这两个版本指的是可以被信号处理程序调用的版本.
#include <setjmp.h> #include <signal.h> #include <csapp.h> sigjmp_buf buf; void handler(int sig){ //跳回到sigsetjmp的位置 siglongjmp(buf, 1); } int main(){ //在这里调用sigsetjmp来保存状态 //第一次调用, 返回0, 会设置信号处理函数, 这样保证了先设置 setjmp, 之后才可能会有 longjmp, 否则会出问题 if (!sigsetjmp(buf, 1)) { Signal(SIGINT, handler); Sio_puts("Starting ... \n"); //longjmp之后, sigsetjmp不会返回0, 就执行这个分支 } else { Sio_puts("Restarting ... \n"); } while(1){ Sleep(1); Sio_puts("Processing ... \n"); } exit(0); }
由于 sigsetjmp 和 siglongjmp 都不是安全的函数, 所以要在其内部只调用安全的函数. 像在 sigsetjmp 中调用了安全的Signal和Sio_puts, 在 siglongjmp 跳转的代码中执行了Sio_puts. sleep也是安全的.
异常控制流终于看完了, 虽然原理好理解, 但是感觉写起来由于和操作系统交互很多, 只能先了解一下理论了, 不了解Linux内核, 肯定也难以编写出来
linux下有一些工具可以用来监控和操作进程, 比如 STRACE, PS, TOP, PMAP, /proc 等.