1 异常控制流
每个指令执行的序列就是CPU控制流,虽然可改变程序控制流,但只适用于程序状态的改变,很难应对系统状态的改变,因此系统需要异常控制流。包括异常、进程切换、信号、非本地跳转。
2 异常
异常是把控制权交给常驻系统的内核以响应某些事件的机制,包括:除零、运算溢出、页错误、IO请求完成等系统级别的事件。异常过程如图:
注意:当发生异常时,系统通过查找异常表(可以理解为函数表)中对应的异常编号确定异常处理代码。
2.1 中断
中断也叫异步异常,由CPU外部事件引起。之所以说是异步的,因为不知道什么时候会发生,因此CPU对其响应也是被动的。
常见中断有:
- 定时时器中断:每隔几毫秒CPU就会触发中断,进行内核态和用户态切换
- I/O中断:类型较多:如键盘输入ctrl-c、fd可读可写等
2.2 同步异常
同步异常由执行指令的结果导致的事件,包括三类:
类型 | 说明 | 行为 | 示例 |
---|---|---|---|
Trap | 为某事有意设置 | 返回到先前下条指令 | 系统调用、调试断点 |
Fault | 潜在可恢复错误 | 返回当前指令或终止 | 页故障(page faults) |
Abort | 不可恢复的错误 | 终止当前执行的程序 | 非法指令、硬件错误 |
2.3 系统调用
在X86-64系统中,每个系统调用都有唯一的ID号,如:
编号 | 名称 | 说明 |
---|---|---|
0 | read | 读取文件 |
1 | write | 写入文件 |
2 | open | 打开文件 |
3 | close | 关闭文件 |
4 | stat | 文件信息 |
57 | fork | 创建进程 |
59 | execve | 执行程序 |
60 | _exit | 关闭进程 |
62 | kill | 发送信号 |
3 进程
进程给每个程序提供CPU和内存的抽象:
- CPU:通过上下文切换的机制让每个程序都感觉在独占处理器
- 内存:通过虚拟内存让每个程序都感觉在独占内存
进程通用结构主要包括:代码区、数据区、堆区、栈区,如下所示:
3.1 多进程
多进程就是计算机同时运行多个进程,分为两大类:
- 并发:单核CPU交错执行多个进程,因此需保存和恢复上下文
- 并行:多核CPU并行执行多个进程,共享主要内存和某些缓存
3.2 用户态模式和内核模
用户态可执行任何指令且访问系统任何内存位置,而用户态则必须通过系统API间接访问内核代码和数据。用户态和内核态通过中断、陷入系统调用、故障这样的异常进行切换。
3.3 上下文切换
上下文切换是建立在异常机制上。内核为每个进程维持寄存器、用户栈、内核栈及各种内核数据结构(如页表、进程表、已打开文件的信息的文件表),内核通过调度器调度进程,主要包括三部分:
- 保存当前进程上下文
- 恢复被抢占进程保存的上下文
- 控制权传递给该新进程
4 进程控制
4.1 系统调用错误处理
出错时,Linux系统函数通常返回-1且设置全局变量errno表示错误原因。因此每个系统调用都应检查返回值,如fork()函数,应检查返回值:
4.2 获取进程ID
getpid返回当前进程PID、getppid返回当前进程的父进程的 PID
4.3 进程生命周期
进程主要包括三种状态:
状态 | 说明 |
---|---|
Running | 进程要么正运行,要么等待被执行或最终将会被内核调度执行 |
Stopped | 进程被挂起,且在进一步收到SIGCONT 信号前不会被调度执行 |
Terminated | 进程被永久停止 |
4.3.1 终止进程
包括三种情况:
- 接收到终止信号
- 从
main
函数返回 - 调用
exit
函数
4.3.2 创建进程
父进程调用fork
创建新进程,该函数执行一次,但返回两次,子进程返回0,父进程返回子进程PID,进程创建前后保存的上下文如图:
4.3.3 COW机制
fork会创建子进程时,并没真正拷贝,而是共享父进程的每个区域,若是只读操作,则什么也不做,后续写操作则拷贝并写入。
4.3.4 解决父子竞争
由于并行执行,无法预计父子进程执行顺序,因此隐藏潜在的竞争关系。如何解决?在不同的分支中插入随机延迟。
/* 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;
}
4.4 回收子进程
4.4.1 回收子进程
- 定义:即使进程已终止,但还未被回收的进程还在消耗系统资源称之为僵尸进程
- 回收:父进程用
wait
或waitpid
回收已终止的子进程,kernel 就会回收资源。若父进程不回收,通常会被init
进程回收(所以一般不必显式回收)
4.4.2 孤儿进程
孤儿进程指子进程正运行,父进程突然退出,消耗资源,若所在进程组没进程收养,就作为init
进程的子进程
5 signal
5.1 信号
信号允许进程和内核中断其他进程,提醒进程一个事件已经发生
每个信号都有name和ID,每个信号对应每个处理程序,若信号没被处理,通常要么被忽略要么中断
常用信号的编号及简介:
事件 | 名称 | ID | 默认动作 |
---|---|---|---|
用户按ctrl-c | SIGINT | 2 | 终止 |
强制中断(不能被处理) | SIGKILL | 9 | 终止 |
段冲突 | SIGSEGV | 11 | 终止且dump |
时钟信号 | SIGALRM | 14(可变) | 终止 |
子进程停止或终止 | SIGCHLD | 17(可变) | 忽略 |
5.2 信号概念
5.2.1 待处理和阻塞信号
如果信号已被发送但是未被接收,那么处于待处理状态,注意:任何时刻一个类型至多只有一个待处理信号,后续的被发送给进程的信号都被直接丢弃。进程也可以阻塞特定信号的接收,直到信号被解除阻塞。
5.2.2 接收信号
目标进程以某种方式对信号的传递做出响应时,进程接收信号,可能响应的操作:
- 忽略:忽略信号,不作任何响应
- 终止:终止进程,可能core dump
- 捕获:执行用户层的函数
5.2.3 待处理和阻塞位
内核为每个进程的维护等待和阻塞的位集合
- 等待信号集合:表示等待信号集合,当类型为k的信号被传递,内核将位k置为等待,当接收到类型为k的信号时,内核清除待处理的位k
- 阻塞信号集合:表示阻塞信号集合,可用
sigprocmask
函数设置和清除
5.3 进程组
每个进程都只属于一个进程组,如图:
可以通过 kill
来发送信号给进程组或进程(包括自己)
5.3.1 从键盘发送信号
可通过键盘让内核向每个前台进程发送 SIGINT(SIGTSTP) 信号
- SIGINT -
ctrl+c
默认终止进程 - SIGTSTP -
ctrl+z
默认停止(挂起)进程
5.3.2 信号传递
内核控制权传给进程p,会计算进程 p 的 pnb 值:pnb = pending & ~blocked
- 如果
pnb == 0
,那么就把控制交给进程 p 的下一条指令 - 否则
- 选择
pnb
中最小的非零位 k,并强制进程 p 接收信号 k - 接收到信号之后,进程 p 会执行对应的操作
- 对
pnb
中所有的非零位进行该操作 - 最后把控制交给进程 p 的下一条指令
- 选择
5.4 信号控制
5.4.1 注册信号处理程序
sigaction
函数改变与接收信号相关的程序
5.4.2 作为并发流的信号处理程序
信号处理程序是独立与主程序并发运行的逻辑流,此外,信号处理程序也可被其他信号处理程序中断,控制流如下:
5.4.3 阻塞和非阻塞信号
-
隐式阻塞机制:内核会阻塞与当前在处理的信号同类型的其他正等待的信号,如一个 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);
5.4.4 安全处理信号
这里提供一些基本的规则帮助避免出现问题:
- 规则 1:信号处理器越简单越好
- 规则 2:信号处理器中只调用异步且信号安全(async-signal-safe)的函数
- 规则 3:在进入和退出的时候保存和恢复
errno
- 规则 4:临时阻塞所有的信号以保证对于共享数据结构的访问
- 规则 5:用
volatile
关键字声明全局变量 - 规则 6:用
volatile sig_atomic_t
来声明全局标识符(flag)
5.4.5 异步信号安全
异步信号安全函数指:
- 所有变量都保存在帧栈中
- 不会被信号中断的函数
Posix 标准指定了 117 个异步信号安全的函数(可通过 man 7 signal-safety
查看),常用的printf、sprintf、malloc、exit
都不是。
5.4.6 信号安全代码示例
- 同步避免父子竞争:
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);
}
5.5 非本地跳转
5.5.1 非本地跳转
本地跳转的限制在于不能从一个函数跳转到另一个函数中。因此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);
}
5.5.2 非本地跳转的限制
只能跳转到已经调用但尚未完成(函数还在栈中)的函数环境
P2在跳转的时候已返回,栈帧在内存中已被清理,所以P3中的 longjmp
并不能实现期望的操作
6 操作进程的工具
- STRACE:打印进程和子进程的每个系统调用
- PS:列出当前系统中所有进程(包括僵尸进程)
- TOP:打印当前进程资源使用信息
- PMAP:进程内存映射
- /proc:虚拟文件系统,以ASCII码输出大量内核数据结构内容
7 总结
异常控制流(ECF)发生在系统各层次,是系统提供并发的基本机制:
- 硬件层:四种类型异常
- 操作系统层:内核用ECF提供进程基本概念,进程提供CPU和内存的抽象
- 系统调用接口:程序可创建子进程,等进程停止或终止,运行新程序以及捕获信号
- 应用层:可用setjmp和longjmp转规避正常调用/返回的栈规则,直接从一个函数跳转到另一个函数