0x07
最简单的控制流是一个平滑的序列,而平滑流的突变通常是由诸如跳转、调用和返回这样一些程序指令造成的。此外系统还必须能够对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕获的,而且也不一定要和程序的执行相关。如,一个硬件定时器定期产生信号、当子进程终止时,创造这些子进程的父进程必须得到通知。
现在系统通过使控制流发生突变来对这些情况做出反应。一般而言,把这些突变成为异常控制流(ECF)。
异常
异常是异常控制流的一种形式,一部分由硬件实现,一部分由操作系统实现。异常就是控制流中的突变,用来响应处理器状态中的某些变化。在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表的跳转表,进行一个间接过程调用(异常)。
异常处理
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号。如被零除、缺页、系统调用和来自外部I/O设备的信号。系统启动时,操作系统分配和初始化一张称为异常表的跳转表,使得表目k包含异常k的处理程序的地址。
在运行时,处理器检测到发生了一个事件,并且确定了相应了异常号k,随后,处理器触发异常,方法是执行间接过程调用,通过异常表的表目k,转到相应的处理程序。
异常类型类似于过程调用,但是有一些重要的不同之处:
- 过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中。然而,根据异常的类型,返回地址要么是当前指令,要么是下一条指令。
- 处理器也会把一些额外的处理器状态压入栈里,在处理程序返回时,重新开始执行被中断的程序会需要这些状态。
- 如果控制从用户程序转移到内核,所有这些项目都被压到内核栈中,而不是压到用户栈中。
- 异常处理程序运行在内核模式下,意味着它们对所有的系统资源都有完全的访问权限。
一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理完事件之后,它通过执行一条特殊的“从中断返回”指令,可选地返回到被中断的程序,该指令将适当的状态弹回到处理器的控制和数据寄存器中,如果异常中断的是一个用户程序,就将状态恢复用户模式,然后将控制返回给被中断的程序。
异常的类别
异常可以分为四类:中断、陷阱、故障和终止。
中断
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序。
剩下的异常类型是同步发生的,是执行当前指令的结果,把这类指令叫做故障指令。
陷阱和系统调用
陷阱是有意的异常,是执行一条指令的结果。陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
普通的函数运行在用户模式中,限制了函数可以执行的指令的类型,而且它们只能访问与调用函数相同的栈。系统调用运行在内核模式中,内核模式允许系统调用执行特权指令,并访问定义在内核中的栈。
故障
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它,否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。一个经典的故障示例是缺页异常。
终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误。
进程
进程的经典定义是一个执行中程序的实例。在系统上运行一个程序时,就好像程序是系统中当前运行的唯一的程序一样,程序中的代码和数据好像是系统内存中唯一的对象,这些假象都是通过进程的概念提供给我们的。
系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
逻辑控制流
使用调试器单步执行程序,会看到一系列程序计数器的值(PC),这些值唯一的对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流。在运行多个进程的系统中,处理器的一个物理控制流会被分成多个逻辑流。
并发流
逻辑流有许多不同的形式,异常处理程序、进程、信号处理程序、线程和Java进程都是逻辑流的例子。
一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地运行。
多个流并发地执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。
注意,并发流的思想与流运行得处理器核数或者计算机数无关。如果两个流在时间上重叠,那么它们就是并发的,即使它们运行在同一个处理器上,并行(parallel)流是并发流的一个真子集。如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它们为并行流,它们并行地运行,且并行地执行。
私有地址空间
进程为每个程序提供它自己的私有地址空间,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的。
用户模式和内核模式
处理器提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。
处理器通常用某个控制寄存器中的一个模式位来提供这种功能,该寄存器描述了进程当前享有的特权。
当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令。用户程序必须通过系统调用接口间接地访问内核代码和数据。
上下文切换
操作系统内核使用上下文切换的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中称为调度器的代码处理的。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。中断也可能引发上下文切换,系统有产生周期性定时器中断的机制,每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。
系统调用错误处理
当Unix系统级函数遇到错误时,通常会返回-1,并设置全局整数变量errno来表示什么出错了。但是错误验证往往使代码臃肿且难读:
if ((pid = fork()) < 0) { fprintf(stderr, "fork error: %s\n", strerror(errno)); exit(0); }
strerror返回一个文本串,描述了和某个errno值相关联的错误。通常定义如下错误报告函数来简化代码:
void unix_error(char *msg) { fprintf(stderr, "%s: %s\n", msg, strerror(errno)); exit(0); } if ((pid = fork()) < 0) unix_error("fork_error");
使用错误处理包装函数更进一步简化:
pid_t Fork(void) { pid_t pid; if ((pid = fork()) < 0) unix_error("Fork error"); return pid; } pid = Fork();
进程控制
获取进程ID
每个进程都有一个唯一的正数(非零)进程ID(PID)。getpid函数返回调用进程的PID。getppid函数返回它的父进程的PID(创建调用进程的进程)。
#include <sys/types.h> #include <unistd.h> pid_t getpid(void); pid_t getppid(void);
类型为pid_t的整数值,在Linux系统上它在types.h中被定义为int。
创建和终止进程
从程序员角度,进程总是处于一下状态之一:
- 运行。进程要么在CPU上执行,要么在等待被执行且最终会被内核调度。
- 停止。进程的执行被挂起,且不会被调度。当收到SIGSTOP、SIGTSTP、SIGTTIN或者SIGTTOU信号时,进程就会停止,并且保持停止直到它收到一个SIGCONT信号,在这个时刻,进程再次开始运行。
- 终止。进程永远地停止了。三种原因:收到一个信号,该信号的默认行为是终止进程;从主程序返回;调用exit函数。
#include <stdlib.h> void exit(int status);
exit函数以status退出状态来终止进程(另一种设置退出状态的方法是从主程序中返回一个整数值)。
#include <sys/types.h> #include <unistd.h> pid_t fork(void);
父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
fork函数只被调用一次,却会返回两次:一个是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中,fork返回子进程的PID。在子进程中,fork返回0。因为子进程的PID总是为非零,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。
int main() { pid_t pid; int x = 1; pid = Fork(); if (pid == 0) { printf("child : x = %d\n", ++x); exit(0); } printf("parent: x = %d\n", --x); exit(0); }
linux> ./fork parent: x = 0 child : x = 2
- 调用一次,返回两次。fork函数被父进程调用一次,但是返回两次,一次是返回到父进程,一次是返回到新创建的子进程。
- 并发执行。父进程和子进程是并发运行的独立进程。
- 相同但是独立的地址空间。如果能够在fork函数在父进程和子进程中返回后立即暂停这两个进程,会看到两个进程的地址空间都是相同的。每个进程有相同的用户栈、相同的本地变量值、相同的堆、相同的全局变量值,以及相同的代码。
- 共享文件。子进程继承了父进程所有的打开文件。当父进程调用fork时,stdout文件是打开的,并且指向屏幕。子进程继承了这个文件,因此它的输出也是指向屏幕的。
回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被他的父进程回收。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程。一个终止了但还未回收的进程称为僵尸进程。
如果一个父进程终止了,内核会安排init进程成为它的孤儿进程的养父。init进程的PID为1,是在系统启动时由内核创建的,它不会被终止,是所有进程的祖先。
一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止。
#include <sys/types.h> #include <sys/wait.h> pid_t waitpid(pid_t pid, int *statusp, int options);
默认情况下(当options=0时),waitpid挂起调用进程的执行,直到它的等待集合中的一个子进程终止。如果等待集合中的一个进程在刚调用的时刻就已经终止了,那么waitpid就立即返回。在这两种情况中,waitpid返回导致waitpid返回的已终止进程的PID。此时,已终止的子进程已经被回收,内核会从系统中删除掉它的所有痕迹。
-
判定等待集合的成员
等待集合的成员是由参数pid来确定的:
- 如果pid>0,那么等待集合就是一个单独的子进程,它的进程ID等于pid。
- 如果pid=-1,那么等待集合就是父进程所有的子进程组成的。
waitpid函数还支持其他类型的等待集合。
-
修改默认行为
可以通过将options设置为常量WNOHANG、WUNTRACED和WCONTINUED各种组合来修改默认行为。
- WNOHANG:如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为0)。默认的行为是挂起调用进程,直到有子进程终止。在等待子进程终止时,如果还想做些有用的工作,可使用这个选项。
- WUNTRACED:挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止。返回的PID为导致返回的已终止或被停止子进程的PID。默认的行为是只返回已终止的子进程。当想要检查已终止或被停止子进程的PID,可使用这个选项。
- WCONTINUED:挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或等待集合中一个被停止的进程收到SIGCONT信号重新开始执行。
可以用或运算把这些选项组合起来:
- WNOHANG | WUNTRACED:立即返回,如果等待集合中的子进程都没有被停止或终止,则返回值为0;如果有一个停止或终止,则返回值为该子进程的PID。
-
检查已回收子进程的退出状态
如果statusp参数非空,那么waitpid就会在status中放上关于导致返回的子进程的状态信息。
- WIFEXITED(status):如果子进程通过调用exit或者返回正常终止,就返回真。
- WEXITSTATUS(status):返回一个正常终止的子进程的退出状态,只有在WIFEXITED()返回为真时,才会定义这个状态。
- WIFSIGNALED(status):如果子进程是因为一个未被捕获的信号终止的,那么就返回真。
- WTERMSIG(status):返回导致子进程终止的信号的编号。只有在WIFSIGNALED()返回为真时,才定义这个状态。
- WIFCONTINUED(status):如果子进程收到SIGCONT信号重新启动,则返回真。
-
如果调用进程没有子进程,那么waitpid返回-1,并且设置errno为ECHILD。如果waitpid函数被一个信号中断,那么它返回-1,并设置errno为EINTR。
-
wait函数是waitpid函数的简单版本
#include <sys/types.h> #include <sys/wait.h> pid_t wait(int *statusp); 调用wait(&status)等价于调用waitpid(-1, &status, 0);
// waitpidl.c #define N 2 int main() { int status, i; pid_t pid; for (i = 0; i < N; ++i) { if ((pid = Fork()) == 0) exit(100+i); } while ((pid = waitpid(-1, &status, 0)) > 0) { if (WIFEXITED(status)) printf("child %d exit, status=%d\n", pid, WEXITSTATUS(status)); else printf("child %d terminated abnormally\n", pid); } if (errno != ECHILD) unix_error("waitpid error"); exit(0); }
当回收了所有的子进程后,再调用waitpid就返回-1,并且设置errno为ECHILD。
让进程休眠
#include <unistd.h> unsigned int sleep(unsigned int secs);
如果请求的时间到了,sleep返回0,否则返回还剩下的要休眠的秒数。因为sleep函数会被一个信号中断而过早地返回,所以后一种情况可能会用到。
#include <unistd.h> int pause(void);
让调用函数休眠,直到该进程收到一个信号。
加载并运行程序
#include <unistd.h> int execve(const char *filename, const char *argv[], const char *envp[]);
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,如找不到filename,execve才会返回到调用程序(如果成功,则不返回,如果错误,则返回-1)。
argv变量指向一个以null结尾的指针数组,每个指针都指向一个参数字符串。按照惯例,argv[0]是可执行目标文件的名字。环境变量的列表是由一个类似的数据结构表示的,envp变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如"name=value"的键值对。
execve加载了filename后,调用启动代码,启动代码设置栈,并将控制传递给新程序的主函数:
int main(int argc, char **argv, char **envp);
main函数有三个参数:argc给出argc[]数组中非空指针的数量;argv指向argv[]数组中的第一个条目;envp指向envp[]数组中的第一个条目。
Linux提供了几个函数来操作环境数组:
#include <stdlib.h> char *getenv(const char *name);
getenv函数在环境数组中搜索字符串"name=value",如果找到了,就返回一个指向value的指针,否则返回NULL。
#include <stdlib.h> int setenv(const char *name, const char *newvalue, int overwrite); void unsetenv(const char *name);
如果环境数组包含一个形如"name=oldvalue"的字符串,那么unsetenv会删除它,而setenv会用newvalue代替oldvalue,但是只有在overwrite非零时才会替换。如果name不存在,那么setenv就把"name=newvalue"添加到数组中。
利用fork和execve运行程序
如果最后一个参数是一个“&”字符,那么表示在后台执行该程序(shell不会等待它完成)。
//shellex.c #define MAXARGS 128 void eval(char *cmdline); int parseline(char *buf, char **argv); int builtin_command(char **argv); int main() { char cmdline[MAXLINE]; while (1) { printf("> "); Fgets(cmdline, MAXLINE, stdin); if (feof(stdin)) exit(0); eval(cmdline); } }
// shellex.c void eval(char *cmdline) { char *argv[MAXARGS]; char buf[MAXLINE]; int bg; /* Background job? */ pid_t pid; strcpy(buf, cmdline); bg = parseline(buf, argv); if (argv[0] == NULL) return; /* Ignore empty lines */ if (!builtin_command(argv)) { if ((pid = Fork()) == 0) { if (execve(argv[0], argv, environ) < 0) { printf("%s: Command not found.\n", argv[0]); exit(0); } } if (!bg) { int status; int status; if (waitpid(pid, &status, 0) < 0) unix_error("waitfg: waitpid error"); } else { printf("%d %s", pid, cmdline); } } return; } int builtin_command(char **argv) { if (!strcmp(argv[0], "quit")) exit(0); if (!strcmp(argv[0], "&")) return 1; return 0; } int parseline(char *buf, char **argv) { char *delim; int argc; int bg; buf[strlen(buf) - 1] = ' '; while (*buf && (*buf == ' ')) buf++; argc = 0; while ((delim = strchr(buf, ' '))) { argv[argc++] = buf; *delim = '\0'; buf = delim + 1; while (*buf && (*buf == ' ')) buf++; } argv[argc] = NULL; if (argc == 0) return 1; if ((bg = (*argv[argc - 1] == '&')) != 0) argv[--argc] = NULL; return bg; }
这个shell是有缺陷的,因为它并不回收它的后台子进程。修改这个缺陷要求使用信号。
信号
每种信号类型都对应于某种系统事件。底层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。
信号术语
传送一个信号到目的进程是由两个不同步骤组成的:
- 发送信号。内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。发送信号可以有如下两种原因:1)内核检测到一个系统事件,如除零错误或子进程终止。2)一个进程调用了kill函数,显式地要求内核发送一个信号给目的进程。一个进程可以发送信号给它自己。
- 接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序的用户层函数捕获这个信号。
一个发出而没有被接收的信号叫做待处理信号。在任何时刻,一种类型至多只会有一个待处理信号。如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待;它们只是被简单地丢弃。
一个进程可以有选择性的阻塞接收某种信号,当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。
一个待处理的信号最多只能被接收一次。
发送信号
进程发送信号的机制都是基于进程组的。
进程组
每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。getpgrp函数返回当前进程的进程组ID。
#include <unistd.h> pid_t getpgrp(void);
默认地,一个子进程和它的父进程同属于一个进程组。一个进程可以通过使用setpgid函数来改变自己或者其他进程的进程组。
#include <unistd.h> int setpgid(pid_t pid, pid_t pgid);
setpgid函数将进程pid的进程组改为pgid。如果pid是0,那么就使用当前进程的PID。如果pgid是0,那么就用pid指定的进程PID作为进程组ID。例如,如果进程15213是调用进程,那么
getpgid(0, 0);
会创建一个新的进程组,其进程组ID是15213,并把进程15213加入到这个新的进程组中。
用/bin/kill程序发送信号
/bin/kill程序可以向另外的进程发送任意的信号。如:
linux> /bin/kill -9 15213
发送信号9给进程15213。一个为负的PID会导致信号被发送到进程组PID中的每个进程。如:
linux> /bin/kill -9 -15213
发送一个SIGKILL信号给进程组15213中的每个进程。
从键盘发送信号
Unix shell使用作业这个抽象概念来表示为对一条命令行求值而创建的进程。在任何时刻,至多只有一个前台作业和0个或多个后台作业。键入:
linux> ls | sort
会创建一个由两个进程组成的前台作业,两个进程是通过Unix管道连接起来的:一个进程运行ls程序,另一个运行sort程序。shell为每个作业创建一个独立的进程组。进程组ID通常取自作业中父进程中的一个。
在键盘上输入Ctrl+C会导致内核发送一个SIGINT信号到前台进程组中的每个进程。默认情况下,结果是终止前台作业。类似地,输入Ctrl+Z会发送一个SIGTSTP信号到前台进程组中的每个进程。默认情况下,结果是停止(挂起)前台作业。
用kill函数发送信号
#include <sys/types.h> #include <signal.h> int kill(pid_t pid, int sig);
如果pid大于零,那么kill函数发送信号号码sig给进程pid。如果pid等于零,那么kill发送信号sig给调用进程所在进程组中的每个进程,包括调用进程自己。如果pid小于零,kill发送信号sig给进程组pid中的每个进程。
用alarm函数发送信号
进程可以通过调用alarm函数向它自己发送SIGALRM信号。
#include <unistd.h> unsigned int alarm(unsigned int secs);
alarm函数安排内核在secs秒后发送一个SIGALRM信号给调用进程。如果secs是零,那么不会调度安排新的闹钟。在任何情况下,对alarm的调用都将取消任何待处理的闹钟,并且返回任何待处理的闹钟在被发送前还剩下的秒数。如果没有任何待处理的闹钟,就返回零。
接收信号
当内核把进程p从内核模式切换到用户模式时,它会检查进程p的未被阻塞的待处理信号的集合。如果集合为空,那么内核将控制传递到p的逻辑控制流中的下一条指令。然而,如果集合是非空的,那么内核选择集合中的某个信号k,并且强制p接收信号k。收到这个信号会触发进程采取某种行为。一旦进程完成了这个行为,那么控制就传递回p的逻辑控制流中的下一条指令。每个信号类型都有一个预定义的默认行为,是下面中的一种:
- 进程终止。
- 进程终止并转储内存。
- 进程停止(挂起)直到被SIGCONT信号重启。
- 进程忽略该信号。
收到SIGKILL的默认行为就是终止接收进程。SIGCHLD的默认行为就是忽略这个信号。进程可以通过使用signal函数修改和信号相关联的默认行为。唯一的例外是SIGSTOP和SIGKILL,它们的默认行为是不能修改的。
#include <signal.h> typedef void (sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);
signal函数可以通过下列三种方法之一来改变和信号signum相关联的行为:
- 如果handler是SIG_IGN,那么忽略类型为signum的信号。
- 如果handler是SIG_DFL,那么类型为signum的信号行为恢复为默认行为。
- 否则,handler就是用户定义的函数的地址,这个函数被称为信号处理程序,只要进程接收到一个类型为signum的信号,就会调用这个程序。通过把处理程序的地址传递到signal函数从而改变默认行为,这叫做设置信号处理程序。调用信号处理程序被称为捕获信号。执行信号处理程序被称为处理信号。
void sigint_handler(int sig) { printf("Caught SIGINT!\n"); exit(0); } int main() { if (signal(SIGINT, sigint_handler) == SIG_ERR) { unix_error("signal error"); } pause(); return 0; }
阻塞和解除阻塞信号
Linux提供阻塞信号的隐式和显式地机制:
隐式阻塞机制:内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。直到处理程序S返回,s会变成待处理而没有被接收。
显式阻塞机制:应用程序可以使用sigprocmask函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号。
#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); int sigismember(const sigset_t *set, int signum);
sigprocmask函数改变当前阻塞的信号集合。具体的行为依赖于how的值:
- SIG_BLOCK:把set中的信号添加到blocked中。blocked = blocked | set。
- SIG_UNBLOCK:从blocked中删除set中的信号。blocked = blocked & ~set。
- SIG_SETMASK:block = set。
如果oldset非空,那么blocked位向量之前的值保存在oldset中。
sigemptyset初始化set为空集合。sigfillset函数把每个信号都添加到set中。sigaddset函数把signum添加到set,sigdelset从set中删除signum,如果signum是set的成员,那么sigismember返回1,否则返回0。
sigset_t mask, prev_mask; Sigemptyset(&mask); Sigaddset(&mask, SIGINT); Sigprocmask(SIG_BLOCK, &mask, &prev_mask); Sigprocmask(SIG_SETMASK, &prev_mask, NULL);
编写信号处理程序
处理程序与主程序并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序互相干扰;如何以及何时接收信号的规则尝尝有违人的直觉;不同的系统有不同的信号处理语义。
安全的信号处理
信号处理程序和主程序以及其他信号处理程序并发地运行(信号处理程序可以被其他信号处理程序中断)。如果处理程序和主程序并发地访问同样的全局数据结构,那么结果可能就不可预知,且经常是致命的。
-
处理程序要尽可能地简单。
-
在处理程序中只调用异步信号安全的函数。要么是可重入的,要么不能被信号处理程序中断。信号处理程序中产生输出唯一安全的方法是使用write函数。特别地,调用printf或sprintf是不安全的。
-
保存和恢复errno。许多Linux异步信号安全的函数都会在出错返回时设置errno。在处理程序中调用这样的函数可能会干扰主程序中其他依赖于errno的部分。解决方法是在进入处理程序时把errno保存在一个局部变量中,在处理程序返回前恢复它。
-
阻塞所有信号,保护对共享全局数据结构的访问。如果处理程序和主程序或其他处理程序共享一个全局数据结构,那么在访问(读或写)该数据结构时,处理程序和主程序应该暂时阻塞所有的信号。
-
用volatile声明全局变量。用volatile类型限定符来定义一个变量,告诉编译器不要缓存这个变量。限定符强迫每次在代码中引用变量时,都要从内存中读取变量的值。一般来说,和其他所有共享数据结构一样,应该暂时阻塞信号,保护每次对全局变量的访问。
-
用sig_atomic_t声明标志。C提供一种整型数据类型sig_atomic_t,对它的读和写保证会是原子的。
volatile sig_atomic_t flag; 注意,这里对原子性的保证只适用于单个的读和写,不适用于像flag++或flag = flag + 10这样的更新,因为可能需要多条指令。
正确的信号处理
信号的一个与直接不符的方面是未处理的信号是不排队的。因为pending位向量中每种类型的信号只对应有一位,所以每种类型最多只能有一个未处理的信号。不可以用信号来对其他进程中发生的事件计数。
只要有一个子进程终止或者停止,内核就会发送一个SIGCHLD信号给父进程。修改使得每次SIGCHLD处理程序被调用时,回收尽可能多的僵尸进程。
void handler(int sig) { 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 = olderrno; } int main() { int i, n; char buf[MAXBUF]; if (signal(SIGCHLD, handler) == 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); }
同步流以避免讨厌的并发错误
void handler(int sig) { int olderrno = errno; sigset_t mask_all, prev_all; pid_t pid; Sigfillet(&mask_all); while ((pid = waitpid(-1, NULL, 0)) > 0) { Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); deletejob(pid); Sigprocmask(SIG_SETMASK, &prev_all, NULL); } if (errno != ECHILD) { Sio_errno("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); } Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); addjob(pid); Sigprocmask(SIG_SETMASK, &prev_all, NULL); } exit(0); }
可能发生下面的事件序列:
- 父进程执行fork函数,内核调度新创建的子进程运行,而不是父进程。
- 在父进程能够再次运行之前,子进程就终止,并且变成一个僵尸进程,使得内核传递一个SIGCHLD信号给父进程。
- 后来,当父进程再次变成可运行但又在它执行之前,内核注意到有未处理的SIGCHLD信号,并通过在父进程中运行处理程序接收这个信号。
- 信号处理程序回收终止的子进程,并调用deletejob,这个函数什么都不做,因为父进程还没有把该子进程添加到列表中。
- 在处理程序运行完毕后,内核运行父进程,父进程从fork返回,通过盗用addjob错误地把不存在的子进程添加到作业列表中。
void handler(int sig) { int olderrno = errno; sigset_t mask_all, prev_all; pid_t pid; Sigfillset(&mask_all); while ((pid = waitpid(-1, NULL, 0)) > 0) { Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); deletejob(pid); Sigprocmask(SIG_SETMASK, &prev_all, NULL); } if (errno != ECHILD) { Sio_error("waitpid error"); } errno = olderrno; } int main(int argc, chr **argv) { int pid; sigset_t mask_all, mask_one, prev_one; Sigfillset(&mask_all); Sigemptyset(&mask_one); Sigaddset(&mask_one, SIGCHLD); Signal(SIGCHLD, handler); initjobs(); while (1) { Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); if ((pid = Fork()) == 0) { Sigprocmask(SIG_SETMASK, &prev_one, NULL); Execve("/bin/date", argv, NULL); } Sigprocmask(SIG_BLOCK, &mask_all, NULL); addjob(pid); Sigprocmask(SIG_SETMASK, &prev_one, NULL); } exit(0); }
在调用fork之前,阻塞SIGCHLD信号,然后在调用addjob之后取消阻塞这些信号,保证了在子进程被添加到作业列表中之后回收该子进程。注意,子进程继承了它们父进程的被阻塞集合,所以我们在调用execve之前,小心地解除子进程中阻塞的SIGCHLD信号。
显式地等待信号
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; Signal(SIGCHLD, sigchld_handler); Signal(SIGINT, sigint_handler); Sigemptyset(&mask); Sigaddset(&mask, SIGCHLD); while (1) { Sigprocmask(SIG_BLOCK, &mask, &prev); if (Fork() == 0) exit(0); pid = 0; Sigprocmask(SIG_SETMASK, &prev, NULL); while (!pid) ; printf("."); } exit(0); }
在接收下一条用户命令之前,它必须等待作业终止,被SIGCHLD处理程序回收。父进程设置SIGINT和SIGCHLD的处理程序,然后进入一个无限循环。它阻塞SIGCHLD信号,避免父进程和子进程之间的竞争。创建了子进程后,把pid重制为0,取消阻塞SIGCHLD,然后以循环的方式等待pid变为非零。子进程终止后,处理程序回收它,把它非零的PID赋给全局pid变量。这会终止循环,父进程继续其他工作,然后开始下一次迭代。
循环会浪费处理器资源。合适的解决方法是使用sigsuspend。
#include <signal.h> int sigsuspend(const sigset_t *mask);
sigsuspend函数暂时用mask替换当前的阻塞集合,然后挂起该进程,直到收到一个信号,其行为要么是运行一个处理程序,要么是终止该进程。如果它的行为是终止,那么该进程不从sigsuspeng返回就直接终止。如果它的行为是运行一个处理程序,那么sigsuspend从处理程序返回,恢复调用sigsyspend时原有的阻塞集合。
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; Signal(SIGCHLD, sigchld_handler); Signal(SIGINT, sigint_handler); Sigemptyset(&mask); Sigaddset(&mask, SIGCHLD); while (1) { Sigprocmask(SIG_BLOCK, &mask, &prev); if (Fork() == 0) exit(0); pid = 0; while (!pid) sigsuspend(&prev); Sigprocmask(SIT_SETMASK, &prev, NULL); printf("."); } exit(0); }
非本地跳转
非本地跳转,将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列。非本地跳转是通过setjmp和longjmp函数来提供的。
#include <setjmp.h> int setjmp(jmp_buf env); int sigsetjmp(sigjmp_buf env, int savesigs);
#include <setjmp.h> int longjmp(jmp_buf env, int retval); void siglongjmp(sigjmp_buf env, int retval);
env缓冲区中保存当前调用环境,以供后面的longjmp使用,并返回0。此返回值不能赋值给变量,但可以用在switch或条件语句的测试中。
longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp调用的返回。然后setjmp返回,并带有非零的返回值retval。
jmp_buf buf; int error1 = 0; int error2 = 1; void foo(void), bar(void); int main() { 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"); 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); }
main函数首先调用setjmp以保存当前的调用环境,然后调用函数foo,foo调用函数bar。如果foo或者bar遇到一个错误,它们立即通过依次longjmp调用从setjmp返回。setjmp的非零返回值指明了错误类型,随后可以被解码,且在代码中的某个位置进行处理。
非本地跳转的另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置。当用户在键盘上键入Ctrl+C时,这个程序用信号和非本地跳转来实现软重启。
sigjmp_buf buf; void handler(int sig) { siglongjmp(buf, 1); } int main() { if (!sigsetjmp(buf, 1)) { Signal(SIGINT, handler); Sio_puts("starting\n"); } else { Sio_puts("restarting\n"); } while (1) { Sleep(1); Sio_puts("processing...\n"); } exit(0); }
当用户键入Ctrl+C时,内核发送一个SIGINT信号给这个进程,该进程捕获这个信号。不从信号处理程序返回。为了避免竞争,必须在调用了sigsetjmp之后再设置处理程序。否则,就会有在调用sigsetjmp为siglongjmp设置调用环境之前运行处理程序的风险。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY