- 安全的信号处理的原则
- 正确的信号处理
- 可移植的信号处理
- 信号处理中的同步问题
- 显式的等待信号
- 非本地跳转
安全的信号处理的原则
由于信号处理程序和主程序 共享同样的变量, 所以如何与主程序通信, 处理信号又不影响主程序运行, 就很重要了. 一般有如下原则:
- 处理程序尽可能简单. 简单并不是说程序要短小, 而是程序避免增加无谓的复杂度. 比如处理程序可以最终设置一个全局标志并且返回, 将处理这个全局标志的任务交给主程序. 主程序周期性的检查然后重置这个标记.
- 在处理程序中只调用异步信号安全的函数. 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 等.