基于异常控制流的进程协作

程序是编译出来保存在磁盘上的文件,进程是运行中的程序的实例。操作系统按照程序文件的描述为进程分配了独立的虚拟内存空间,并由 CPU 执行进程的指令。
在理想情况下,CPU 按顺序执行进程的指令。这些有序的指令流叫控制流。虽然控制流在实现分支或者进行调用函数时也会进行跳转,但这种跳转是程序自身有意控制的,属于正常范畴。
不过也有无意发生的跳转:当 CPU 执行完当前指令 I 后,接下来却执行和 I 毫无逻辑关系的 X。而 X 指令,可能是当前进程中的指令,也可能是其它进程中的指令。这种无意的跳转形成的控制流叫异常控制流(Exceptional Controll FLow)。
为什么会出现异常控制流呢?

一、为什么会出现异常控制流

异常控制流虽说是异常的,但这只是从程序自身的角度而言的。从操作系统的角度来看,这却是有意而正常的。也就是说,是操作系统故意插入了指令
为什么要插入指令?当然是为了实现当前进程需要但又没有实现的功能。情况之一是进程在执行已实现的功能时发生意料之外的错误,需要操作系统来收拾烂摊子。另一种情况是程序需要的功能自己没有实现,依赖操作系统的内核提供给它。

  • 进程发生错误时需要内核收拾摊子
  • 进程需要调用内核提供的功能
  • 内核调度进程实现并发
  • 内核实现进程回调

简而言之,就是为了实现进程级别的协作:进程和内核的协作以及进程和进程的协作。

二、异常控制流的基本实现

在正常控制流中,当 CPU 遇见跳转指令时就开始跳转,执行完后就返回来。跳转地址和返回地址在指令中写得明明白白。但是在进行异常跳转时,CPU 怎么知道接下来要发生异常跳转呢?怎么知道跳转的位置和返回位置呢?这一节就讨论这个问题。

2.1 定位异常和异常处理程序

在 CPU 执行当前指令前,会根据 CPU Stat 判断当前是否发生了某种异常事件(比如缺页),并得知异常号。如果发生了异常,则利用这个异常号作为 index 去异常表中查找异常处理程序的地址。异常表在操作系统被加载时就确定了地址,并且保存在异常表基址寄存器中。

2.2 确定返回地址

在得到异常处理程序的地址后,根据异常事件的类型把返回地址压入栈中。一般来说有四种异常事件:中断(interrupt)陷阱(trap)故障(fault)终止(abort)

  • 中断事件由 IO 硬件触发,返回地址是下一条指令
  • 陷阱事件就像名字所暗示的那样,是故意设置的事件,目的是为了让程序进入异常处理流程中去,返回地址也是下一条指令
  • 故障则是出错的情况下接受到的事件,返回地址是当前指令地址。这样设计的原因是,如果异常处理程序事件成功修复故障,就能返回到当前指令重新执行一遍
  • 终止事件不会设置返回地址,如果发生了不可挽回的故障,或者退出程序,就会触发它

2.3 执行异常处理程序

设置返回地址后,再保存一些额外的 CPU 状态到内存,然后以内核模式跳转到异常处理程序处开始执行。
在处理异常时,根据情况给进程发送信号。

2.4 处理进程收到的信号

在进程从内核模式切换回用户模式时(比如刚完成异常处理),会先处理进程收到的信号,再执行进程从的下一条指令。
进程可以接受 30 种信号,每个进程有两个位向量变量分别用于表示待处理信号集合(pending)被阻塞信号集合(blocked)。位向量中的第 k 位用于表示是否有 k 信号待处理或者被阻塞。另外,由于每个类型的信号只有一个比特位来表示,因此只能记录是否存在,而不能记录存在多少个。也就是说,收到两个 k 信号也只能记录一个。
内核在选择待处理的信号时,先求出 pending & ~blocked 的值,如果不为 0,则从低位开始选择一个比特位为 1 的待处理信号 k,强制程序首先执行对信号 k 的处理行为。所有信号都有默认的处理行为,这个行为也能通过 signal 函数设置为一个回调函数:

1
2
typedef void (* sig_handler) (int);
sig_handler signal(int signum, sig_handler handler);

2.5 执行设定的返回语句

内核强制进程处理完一个信号后(或者发现没有待处理信号时),才真正开始执行进程的下一条指令,即在执行异常处理程序之前设定的返回地址的指令。
当然,在信号处理行为中,进程可能已经退出了。
退出会触发一个 Abort 异常,不会设置返回地址,并给父进程发送 SIGCHLD 信号。

2.6 以缺页处理故障为例子

举一个在程序中读取地址 0x40400000 的值的例子:

1
2
movq %rax, (0x40400000)
addq %rax, $1
  • 执行 movq %rax, (0x40400000) 指令时,需要对地址寻址
  • 假设 MMU 得知缺页,设置 CPU Stat 为故障异常,并设置异常码为处理缺页异常的函数在异常表中的 index
  • CPU 发现出现了异常事件,根据异常码找到异常事件处理函数
  • 根据异常类型把返回地址设置为当前指令地址,保存必要状态到内核栈,然后切入内核状态执行内核的缺页处理程序
  • 假设缺页处理程序发现地址非法:
    • 给进程发送信号 SIGSEGV,缺页处理程序结束
    • 准备从内核状态切换回用户状态,检查进程信号发现 SIGSEGV,强制先执行其处理行为
    • 由于没有自定义 SIGSEGV 的处理函数,采用默认行为,退出进程,进程成为僵尸进程
    • 触发 abort 异常,不设置返回地址,在异常处理程序中给父进程发送 SIGCHLD 信号
    • 父进程在从内核模式切回用户模式时,检查到 SIGCHLD 信号,如果父进程中执行了 waitpid,则回收子进程的资源
  • 假设缺页处理程序成功载入页面:
    • 缺页处理程序结束
    • 准备从内核状态切换回用户状态,检查发现进程没有待处理信号
    • 执行返回地址的指令,即 movq %rax, (0x40400000)
    • 此时没有缺页了,程序继续执行

可以发现,有两种方式使 CPU 进入异常控制流:设置 CPU 异常状态触发异常处理程序、设置进程信号触发信号处理行为。
前者可以实现“进程到内核”级别的异常控制流,而后者可以实现“进程到进程内部”级别的异常控制流。前者也可以说是“进程到任何地方”级别的异常控制流。因为进入了内核,就可以做任何事情。
接下来两节分别讨论这两种异常控制流的经典应用:进程调度和进程回调。

三、进程调度

进程调度是一个非常有深度和广度的话题,这里只是从异常控制流的角度讨论实现进程调度的大致流程,不涉及具体代码和调度算法。

3.1 进程上下文(Context)

CPU 是没有进程的概念的,CPU 运行的指令属于哪个进程(或者说 CPU 正在运行哪个进程),由运行该指令时的一系列上下文信息决定。
这些上下文信息包含哪些呢?

  • CPU 中的上下文信息 —— 通用寄存器、浮点寄存器记录当前操作的数据,程序计数器记录指令的地址,CR3 记录页表的物理地址、RSP 记录栈指针地址、RBP 记录帧指针地址
  • task_struct 的内存段信息 —— vma 记录各个分段的虚拟地址,包括用户栈和内核栈
  • task_struct 的文件描述符集合 —— 记录进程打开的所有文件
  • task_struct 的进程协作信息 —— pid,ppid,gid,信号,运行模式等等

task_struct 中记录了大量的信息,从中可以还原出一个进程的全貌,这里只说到了少数我认为调度进程必须的字段,其它的就不去一一列举了。对于每个进程来说,thread_info 的地址固定从其虚拟地址空间的最后两个虚拟页开始,其中保存着 tast_struct。而从 CR3 寄存器里面得知页表地址后,就能找到 task_struct 的地址。

3.2 进程上下文切换

刚才提到进程上下文主要记录在 CPU 寄存器和 task_struct 中,而 CR3 寄存器提供了一个从 CPU 寄存器间接找到 task_struct 的方法。因此,切换进程上下文,最重要的是切换 CPU 状态。而 CPU 的状态,被保存在内存中。
因此,每次进程上下文切换,意味着大量的内存读写操作。不仅要写出和载入当前 CPU 状态到内核内存段,而且还要通过 task_struct 读写很多数据。
另外切换进程上下文时,内核要能够从内存中访问任意进程的 CPU 状态,这说明所有进程的 CPU 状态应该是以某种数据结构保存在连续的物理页面上。

3.3 进程调度流程

以下流程其实是基于异常控制流程和 CPU 工作模式的猜测:

  • 设置 CPU Stat 为发生异常。这个异常事件,可能是内核调度器设置的,可能是硬件设置的,也可能是进程本身放置的 trap
  • 保存必要 CPU 状态。异常处理流程本身也是指令流,运行它会破坏 CPU 当前状态,因此需要先保存 CPU 状态
  • 触发异常处理程序进行调度:
    • 修改被调出进程的 task_struct
    • 选择被调入的进程
    • 修改被调入进程的 task_struct
    • 处理被调入进程的信号,以用户模式运行信号处理程序并返回
    • 加载被调入进程的 CPU 寄存器数据
    • 执行 PC 寄存器处的指令

四、进程回调

进程回调依赖于信号系统:进程可以给进程发送信号,进程接收到信号后立即根据信号采取处理行为,实现回调。

4.1 发送信号

Linux 支持 30 种信号,第 k 个信号以一个比特向量中的第 k 位来表示。每个进程都有进程 ID 和进程组 ID,在发送信号时以此作为信号接收者的标志。
发送信号的方式有几种。可以使用键盘发送信号,可以使用 alarm 函数给自己发送 SIGALRM 信号,可以使用 kill 函数(或者 bash 中的 kill 程序) 通过进程 ID 和信号标志向指定进程发送指定的信号。如果进程 ID 为负数,则信号被发送到进程组中的所有进程中。

4.1 接收信号

每当内核把进程从内核模式切换为用户模式时,内核就会检查进程的待处理信号集 pending 和阻塞信号集 blocked。接收到的信号集为 pending & ~blocked
什么时候进程会经历从内核模式-用户模式的切换?进程被调入时和进程执行了一个系统调用时。
发送信号也是一个系统调用,说明进程每次发送信号时都会接收一次信号。

4.2 处理信号

确定接收到的信号集后,内核选择最低位的一个信号 k 进行处理。
每种信号都有默认的处理行为,但是也可以通过自行设定一个回调函数来替代默认的处理行为。这个回调函数只接受一个 int 参数作为信号标志。

4.3 可能会出现的问题

由于每个信号 k 的状态只有一个 Bit 来记录,因此每个信号 k 最多只能存在 1 个,并不是每个信号都会被处理。假设一个进程依赖信号 k 回调来处理每一个事件 k,那程序就会出问题。
在执行信号回调函数时,进程可能被调出再调入,或者进程函数使用了系统调用。这样的行为导致进程经历了内核模式到用户模式的切换,进而有可能导致进程接受处理新的信号。这样的话,即使是单线程的情况下,信号处理函数的执行过程也失去了原子性,变得和主程序以及其它信号处理函数并发执行。于是,并发的烦恼也一并被摆在面前。为了减少出错的几率,要尽量减少信号处理函数和全局变量的互动。在信号处理函数中阻塞所有的信号也是一个办法,但这样一来其它的信号就全部丢失了。
编译器的优化行为也可能会导致问题。比如主程序和信号处理函数共享全局变量 g,而 g 在主程序中只有读行为,于是编译器决定在寄存器中缓存变量 g。这样导致即使信号处理函数中修改了 g,主程序也读不到。解决办法是为变量 g 添加 volatile 限定词。
并发安全是一个宏大的话题,在此处不去讨论所有的处理方法。但是需要注意,即使看起来是串行的信号处理流程,其实也是并发的,哪怕是单线程的情况。

五、总结

异常控制流是 CPU 控制流的异常,为程序提供了必须但又没有实现的功能,实现了进程间的协作。
有两种方式使 CPU 进入异常控制流:设置 CPU 异常状态触发异常处理程序、设置进程信号触发信号处理行为。基于前者实现进程之间的调度,实现了进程的并发。基于后者实现了“进程内部的调度”,也导致了“进程内部的并发”。