## 异常控制流
异常控制流存在于系统的每个层级,最底层的机制称为**异常(Exception)**,用以改变控制流以响应系统事件,通常是由**硬件**和**操作系统**共同实现的。更高层次的异常控制流包括**进程切换(Process Context Switch)**、**信号(Signal)**和**非本地跳转(Nonlocal Jumps)**。进程切换是由硬件计时器和操作系统共同实现的,信号则只是操作系统层面的概念,非本地跳转就已经是在 C 运行时库中实现。
## 异常 Exception
这里的异常指的是把控制交给系统内核来响应某些事件(例如处理器状态的变化),其中内核是操作系统常驻内存的一部分,而这类事件包括除以零、数学运算溢出、页错误、I/O 请求完成或用户按下了 ctrl+c 等等系统级别的事件。
具体的过程可以用下图表示:
![img](https://wdxtub.com/images/14613541138958.jpg)
系统会通过异常表(Exception Table)来确定跳转的位置,每种事件都有对应的唯一的异常编号,发生对应异常时就会调用对应的异常处理代码
### 异步异常(中断)
异步异常(Asynchronous Exception)称之为中断(Interrupt),是由处理器外面发生的事情引起的。对于执行程序来说,这种“中断”的发生完全是异步的,因为不知道什么时候会发生。CPU对其的响应也完全是被动的,但是可以屏蔽掉[1]。这种情况下:
- 需要设置处理器的中断指针(interrupt pin)
- 处理完成后会返回之前控制流中的『下一条』指令
比较常见的中断有两种:计时器中断和 I/O 中断。计时器中断是由计时器芯片每隔几毫秒触发的,内核用计时器终端来从用户程序手上拿回控制权。I/O 中断类型比较多样,比方说键盘输入了 ctrl-c,网络中一个包接收完毕,都会触发这样的中断。
### 同步异常
同步异常(Synchronous Exception)是因为执行某条指令所导致的事件,分为陷阱(Trap)、故障(Fault)和终止(Abort)三种情况。
| 类型 | 原因 | 行为 | 示例 |
| ---- | ---------------- | ---------------- | ------------------- |
| 陷阱 | 有意的异常 | 返回到下一条指令 | 系统调用,断点 |
| 故障 | 潜在可恢复的错误 | 返回到当前指令 | 页故障(page faults) |
| 终止 | 不可恢复的错误 | 终止当前程序 | 非法指令 |
这里需要注意三种不同类型的处理方式,比方说陷阱和中断一样,会返回执行『下一条』指令;而故障会重新执行之前触发事件的指令;终止则是直接退出当前的程序。
### 系统调用示例
系统调用类似函数调用,在 x86-64 系统中,每个系统调用都有一个唯一的 ID,如
| 编号 | 名称 | 描述 |
| ---- | -------- | -------------- |
| 0 | `read` | 读取文件 |
| 1 | `write` | 写入文件 |
| 2 | `open` | 打开文件 |
| 3 | `close` | 关闭文件 |
| 4 | `stat` | 获取文件信息 |
| 57 | `fork` | 创建进程 |
| 59 | `execve` | 执行一个程序 |
| 60 | `_exit` | 关闭进程 |
| 62 | `kill` | 向进程发送信号 |
对应的示意图是:
![img](https://wdxtub.com/images/14613688255926.jpg)
### 故障示例
以 Page Fault 为例,来说明 Fault 的机制。Page Fault 发生的条件是:
- 用户写入内存位置
- 但该位置目前还不在内存中
比如:
```
int a[1000];
main()
{
a[500] = 13;
}
```
那么系统会通过 Page Fault 把对应的部分载入到内存中,然后重新执行赋值语句:
![img](https://wdxtub.com/images/14613689402121.jpg)
但是如果代码改为这样:
```
int a[1000];
main()
{
a[5000] = 13;
}
```
也就是引用非法地址的时候,整个流程就会变成:
![img](https://wdxtub.com/images/14613690660319.jpg)
具体来说会像用户进程发送 `SIGSEGV` 信号,用户进程会以 segmentation fault 的标记退出。
从上面我们就可以看到异常的具体实现是依靠在用户代码和内核代码间切换而实现的,是非常底层的机制。
## 进程
进程是程序(指令和数据)的运行实例。进程给每个应用提供了两个关键的抽象:一是**逻辑控制流**,二是**私有地址空间**。
逻辑控制流通过称为**上下文切换**(context switching)的内核机制让每个程序都感觉自己在独占处理器。
私有地址空间则是通过称为**虚拟内存**(virtual memory)的机制让每个程序都感觉自己在独占内存。这样的抽象使得具体的进程不需要操心处理器和内存的相关适宜,也保证了在不同情况下运行同样的程序能得到相同的结果。
计算机会同时运行多个进程,有前台应用,也后台任务
### 进程切换 Process Context Switch
这么多进程,具体是如何工作的呢?我们来看看下面的示意图:
![img](https://wdxtub.com/images/14613707308133.jpg)
左边是单进程的模型:内存中保存着进程所需的各种信息,因为该进程独占 CPU,所以并不需要保存寄存器值。
在右边的单核多进程模型中,虚线部分是正在执行的进程:因为可能会切换到其他进程,所以内存中需要另一块区域来保存当前的寄存器值,以便下次执行的时候进行恢复(也就是所谓的上下文切换)。整个过程中,CPU 交替执行不同的进程,虚拟内存系统会负责管理地址空间,而没有执行的进程的寄存器值会被保存在内存中。切换到另一个进程的时候,会载入已保存的对应于将要执行的进程的寄存器值。
而现代处理器一般有多个核心,所以可以真正同时执行多个进程。这些进程会共享主存以及一部分缓存,具体的调度是由内核控制的,示意图如下:
![img](https://wdxtub.com/images/14613708880333.jpg)
切换进程时,内核会负责具体的调度,如下图所示
![img](https://wdxtub.com/images/14613717282590.jpg)
### 进程控制 Process Control
**系统调用的错误处理**
在遇到错误的时候,Linux 系统级函数通常会返回 -1 并且设置 `errno` 这个全局变量来表示错误的原因。使用的时候记住两个规则:
1. 对于每个系统调用都应该检查返回值
2. 当然有一些系统调用的返回值为 void,在这里就不适用
例如,对于 `fork()` 函数,我们应该这么写:
```
if ((pid = fork()) < 0) {
fprintf(stderr, "fork error: %s\n", strerror(errno));
exit(0);
}
```
我们可以把整个 `fork()` 包装起来,就可以自带错误处理,比如
```
pid_t Fork(void)
{
pid_t pid;
if ((pid = fork()) < 0)
unix_error("Fork error");
return pid;
}
```
调用的时候直接使用 `pid = Fork();` 即可
**获取进程信息**
我们可以用下面两个函数获取进程的相关信息:
- `pid_t getpid(void)` - 返回当前进程的 PID
- `pid_t getppid(void)` - 返回当前进程的父进程的 PID
进程主要有三个主要状态:
- 运行 Running
- 正在被执行、正在等待执行或者最终将会被执行
- 停止 Stopped
- 执行被挂起,在进一步通知前不会计划执行
- 终止 Terminated
- 进程被永久停止
**终止进程**
在下面三种情况时,进程会被终止:
1. 接收到一个终止信号
2. 返回到 `main`
3. 调用了 `exit` 函数
`exit` 函数会被调用一次,但从不返回,具体的函数原型是
```
// 以 status 状态终止进程,0 表示正常结束,非零则是出现了错误
void exit(int status)
```
**创建进程**
调用 `fork` 来创造新进程。具体的函数原型为
```
// 对于子进程,返回 0
// 对于父进程,返回子进程的 PID
int fork(void)
```
子进程几乎和父进程一模一样,会有**相同且独立的虚拟地址空间**,也会得到父进程已经打开的文件描述符(file descriptor)。**不同之处就是进程 PID** 。
看一个简单的例子
```
int main()
{
pid_t pid;
int x = 1;
pid = Fork();
if (pid == 0)
{ // Child
printf("I'm the child! x = %d\n", ++x);
exit(0);
}
// Parent
printf("I'm the parent! x = %d\n", --x);
exit(0);
}
```
输出是
```
linux> ./forkdemo
I'm the parent! x = 0
I'm the child! x = 2
```
fork被当前函数调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的区域结构的原样副本;当两个进程中任一个后来进行写操作时,写时复制机制会创建新页面,从而保持了私有地址空间的概念
有以下几点需要注意:
- 调用一次,但是会有两个返回值
- 并行执行,不能预计父进程和子进程的执行顺序
- 拥有自己独立的地址空间(也就是变量都是独立的),除此之外其他都相同
- 在父进程和子进程中 `stdout` 是一样的(都会发送到标准输出)
### 进程图
进程图是一个很好的帮助我们理解进程执行的工具:
- 每个节点代表一条执行的语句
- a -> b 表示 a 在 b 前面执行
- 边可以用当前变量的值来标记
- `printf` 节点可以用输出来进行标记
- 每个图由一个入度为 0 的节点作为起始
对于进程图来说,只要满足拓扑排序,就是可能的输出。我们还是用刚才的例子来简单示意一下:
```
int main()
{
pid_t pid;
int x = 1;
pid = Fork();
if (pid == 0)
{ // Child
printf("child! x = %d\n", --x);
exit(0);
}
// Parent
printf("parent! x = %d\n", x);
exit(0);
}
```
对应的进程图为
![img](https://wdxtub.com/images/14625029984869.jpg)
### 加载
子进程载入其他的程序,就需要使用 `execve` 函数
1、子进程通过execve系统调用启动加载器,加载器**删除**子进程现有的虚拟内存段;
2、**创建**一组新的代码、数据、堆和栈段,新的栈和堆段被初始化为0;
3、通过将虚拟内存空间中的页**映射**到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件的内容;
4、最后加载器**跳转**到_start地址,最终调用main函数
### 回收子进程
即使主进程已经终止,子进程也还在消耗系统资源,我们称之为『僵尸』。为了『打僵尸』,就可以采用『收割』(Reaping) 的方法。父进程利用 `wait` 或 `waitpid` 回收已终止的子进程,然后给系统提供相关信息,kernel 就会把 zombie child process 给删除。
如果父进程不回收子进程的话,通常来说会被 `init` 进程(pid == 1)回收,所以一般不必显式回收。但是在长期运行的进程中,就需要显式回收(例如 shell 和 server)。