每个指令执行的序列就是CPU控制流,虽然可改变程序控制流,但只适用于程序状态的改变,很难应对系统状态的改变,因此系统需要异常控制流。包括异常、进程切换、信号、非本地跳转。
异常是把控制权交给常驻系统的内核以响应某些事件的机制,包括:除零、运算溢出、页错误、IO请求完成等系统级别的事件。异常过程如图:
注意:当发生异常时,系统通过查找异常表(可以理解为函数表)中对应的异常编号确定异常处理代码。
中断也叫异步异常,由CPU外部事件引起。之所以说是异步的,因为不知道什么时候会发生,因此CPU对其响应也是被动的。
常见中断有:
同步异常由执行指令的结果导致的事件,包括三类:
类型 | 说明 | 行为 | 示例 |
---|---|---|---|
Trap | 为某事有意设置 | 返回到先前下条指令 | 系统调用、调试断点 |
Fault | 潜在可恢复错误 | 返回当前指令或终止 | 页故障(page faults) |
Abort | 不可恢复的错误 | 终止当前执行的程序 | 非法指令、硬件错误 |
在X86-64系统中,每个系统调用都有唯一的ID号,如:
编号 | 名称 | 说明 |
---|---|---|
0 | read |
读取文件 |
1 | write |
写入文件 |
2 | open |
打开文件 |
3 | close |
关闭文件 |
4 | stat |
文件信息 |
57 | fork |
创建进程 |
59 | execve |
执行程序 |
60 | _exit |
关闭进程 |
62 | kill |
发送信号 |
进程给每个程序提供CPU和内存的抽象:
进程通用结构主要包括:代码区、数据区、堆区、栈区,如下所示:
多进程就是计算机同时运行多个进程,分为两大类:
用户态可执行任何指令且访问系统任何内存位置,而用户态则必须通过系统API间接访问内核代码和数据。用户态和内核态通过中断、陷入系统调用、故障这样的异常进行切换。
上下文切换是建立在异常机制上。内核为每个进程维持寄存器、用户栈、内核栈及各种内核数据结构(如页表、进程表、已打开文件的信息的文件表),内核通过调度器调度进程,主要包括三部分:
出错时,Linux系统函数通常返回-1且设置全局变量errno表示错误原因。因此每个系统调用都应检查返回值,如fork()函数,应检查返回值:
getpid返回当前进程PID、getppid返回当前进程的父进程的 PID
进程主要包括三种状态:
状态 | 说明 |
---|---|
Running | 进程要么正运行,要么等待被执行或最终将会被内核调度执行 |
Stopped | 进程被挂起,且在进一步收到SIGCONT 信号前不会被调度执行 |
Terminated | 进程被永久停止 |
包括三种情况:
main
函数返回exit
函数父进程调用fork
创建新进程,该函数执行一次,但返回两次,子进程返回0,父进程返回子进程PID,进程创建前后保存的上下文如图:
fork会创建子进程时,并没真正拷贝,而是共享父进程的每个区域,若是只读操作,则什么也不做,后续写操作则拷贝并写入。
由于并行执行,无法预计父子进程执行顺序,因此隐藏潜在的竞争关系。如何解决?在不同的分支中插入随机延迟。
/* fork wrapper function */
pid_t fork(void) {
initialize();
int parent_delay = choose_delay();
int child_delay = choose_delay();
pid_t parent_pid = getpid();
pid_t child_pid_or_zero = real_fork();
if (child_pid_or_zero > 0) {
/* Parent */
if (verbose) {
printf("Fork. Child pid=%d, delay = %dms. Parent pid=%d, delay = %dms\n",child_pid_or_zero, child_delay,parent_pid, parent_delay);
fflush(stdout);
}
ms_sleep(parent_delay);
} else {
/* Child */
ms_sleep(child_delay);
}
return child_pid_or_zero;
}
wait
或 waitpid
回收已终止的子进程,kernel 就会回收资源。若父进程不回收,通常会被 init
进程回收(所以一般不必显式回收)孤儿进程指子进程正运行,父进程突然退出,消耗资源,若所在进程组没进程收养,就作为init
进程的子进程
信号允许进程和内核中断其他进程,提醒进程一个事件已经发生
每个信号都有name和ID,每个信号对应每个处理程序,若信号没被处理,通常要么被忽略要么中断
常用信号的编号及简介:
事件 | 名称 | ID | 默认动作 |
---|---|---|---|
用户按ctrl-c | SIGINT | 2 | 终止 |
强制中断(不能被处理) | SIGKILL | 9 | 终止 |
段冲突 | SIGSEGV | 11 | 终止且dump |
时钟信号 | SIGALRM | 14(可变) | 终止 |
子进程停止或终止 | SIGCHLD | 17(可变) | 忽略 |
如果信号已被发送但是未被接收,那么处于待处理状态,注意:任何时刻一个类型至多只有一个待处理信号,后续的被发送给进程的信号都被直接丢弃。进程也可以阻塞特定信号的接收,直到信号被解除阻塞。
目标进程以某种方式对信号的传递做出响应时,进程接收信号,可能响应的操作:
内核为每个进程的维护等待和阻塞的位集合
sigprocmask
函数设置和清除每个进程都只属于一个进程组,如图:
可以通过 kill
来发送信号给进程组或进程(包括自己)
可通过键盘让内核向每个前台进程发送 SIGINT(SIGTSTP) 信号
ctrl+c
默认终止进程ctrl+z
默认停止(挂起)进程内核控制权传给进程p,会计算进程 p 的 pnb 值:pnb = pending & ~blocked
pnb == 0
,那么就把控制交给进程 p 的下一条指令pnb
中最小的非零位 k,并强制进程 p 接收信号 kpnb
中所有的非零位进行该操作sigaction
函数改变与接收信号相关的程序
信号处理程序是独立与主程序并发运行的逻辑流,此外,信号处理程序也可被其他信号处理程序中断,控制流如下:
隐式阻塞机制:内核会阻塞与当前在处理的信号同类型的其他正等待的信号,如一个 SIGINT 信号处理器不能被另一个 SIGINT 信号中断。
显式阻塞机制:使用 sigprocmask
等函数:
sigemptyset
:创建空集
sigfillset
:把所有的信号都添加到集合中
sigaddset
:添加指定信号到集合中
sigdelset
:删除集合中的指定信号
临时阻塞信号示例:
sigset_t mask, prev_mask;
Sigemptyset(&mask); // 创建空集
Sigaddset(&mask, SIGINT); // 把 SIGINT 信号加入屏蔽列表中
// 阻塞对应信号,并保存之前的集合
Sigprocmask(SIG_BLOCK, &mask, &prev_mask);
... // 这部分代码不会被 SIGINT 中断
// 取消阻塞信号,恢复原来的状态
Sigprocmask(SIG_SETMASK, &prev_mask, NULL);
这里提供一些基本的规则帮助避免出现问题:
errno
volatile
关键字声明全局变量volatile sig_atomic_t
来声明全局标识符(flag)异步信号安全函数指:
Posix 标准指定了 117 个异步信号安全的函数(可通过 man 7 signal-safety
查看),常用的printf、sprintf、malloc、exit
都不是。
int main(int argc, char **argv)
{
int pid;
sigset_t mask_all, mask_one, prev_one;
int n = N; /* N = 5 */
Sigfillset(&mask_all);
Sigemptyset(&mask_one);
Sigaddset(&mask_one, SIGCHLD);
Signal(SIGCHLD, handler);
initjobs(); /* Initialize the job list */
while (n--) {
Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); /* Block SIGCHLD */
if ((pid = Fork()) == 0) { /* Child process */
Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
Execve("/bin/date", argv, NULL);
}
Sigprocmask(SIG_BLOCK, &mask_all, NULL); /* Parent process */
addjob(pid); /* Add the child to the job list */
Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
}
exit(0);
}
volatile sig_atomic_t pid;
void sigchld_handler(int s)
{
int olderrno = errno;
pid = Waitpid(-1, NULL, 0); /* Main is waiting for nonzero pid */
errno = olderrno;
}
void sigint_handler(int s)
{
}
int main(int argc, char **argv) {
sigset_t mask, prev;
int n = N; /* N = 10 */
Signal(SIGCHLD, sigchld_handler);
Signal(SIGINT, sigint_handler);
Sigemptyset(&mask);
Sigaddset(&mask, SIGCHLD);
//Similar to a shell waiting for a foreground job to terminate.
while (n--) {
Sigprocmask(SIG_BLOCK, &mask, &prev); /* Block SIGCHLD */
if (Fork() == 0) /* Child */
exit(0);
/* Parent */
pid = 0;
Sigprocmask(SIG_SETMASK, &prev, NULL); /* Unblock SIGCHLD */
/* Wait for SIGCHLD to be received (wasteful!) */
while (!pid)
;
/* Do some work after receiving SIGCHLD */
printf(".");
}
printf("\n");
exit(0);
}
本地跳转的限制在于不能从一个函数跳转到另一个函数中。因此C语言提供非本地跳转,使用 setjmp
或 longjmp
用于将控制权转移到任意位置,打破call/return
调用机制。
setjmp
必须在longjmp
前调用,为后续longjmp
标识返回地址,调用一次返回一次或多次,保存当前程序的寄存器上下文,注意,保存的堆栈上下文环境仅在调用 setjmp
的函数内有效,调用 setjmp
的函数返回,保存的上下文环境就失效。直接返回值为 0。
longjmp
调用一次但永不返回,将会从缓存j中恢复由 setjmp
保存的程序堆栈上下文,跳转到j中保存的地址,设置%eax
(返回值)为i,而不是setjmp
的0
/* Deeply nested function foo */
void foo(void)
{
if (error1)
longjmp(buf, 1);
bar();
}
void bar(void)
{
if (error2)
longjmp(buf,2);
}
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");
break;
default:
printf("Unknown error condition in foo\n");
}
exit(0);
}
只能跳转到已经调用但尚未完成(函数还在栈中)的函数环境
P2在跳转的时候已返回,栈帧在内存中已被清理,所以P3中的 longjmp
并不能实现期望的操作
异常控制流(ECF)发生在系统各层次,是系统提供并发的基本机制: