跳转至

异常控制流

从给处理器加电到断电, 程序计数器都假设一个值的序列 \(a_0, a_1, \ldots,a_{n-1}\) 其中每个 \(a_k\) 是某个相应指令 \(I_k\) 的地址. 每次从 \(a_k\)\(a_{k+1}\) 的过渡称为控制转移(control transfer). 这样的控制转移序列称为处理器的控制流(control flow).

当每个 \(I_k\)\(I_{k+1}\) 在内存中是相邻的, 则称这种控制流是平滑的, 否则存在突变. 系统会通过使控制流突变来对系统状态的变化做出反映, 我们把这些突变称为异常控制流(Exceptional Control Flow, ECF).

异常

异常(Exception) 就是控制流中的突变, 用来响应处理器状态中的某些变化.
在处理器中, 状态被编码为不同的位和虚拟号. 状态变化被称为事件(event).

异常处理

当处理器检测到有事件发生时, 它会通过一张异常表的跳转表, 进行一个间接过程调用, 到一个专门处理这类事件的操作系统子程序, 即异常处理程序(exception handler).
系统为可能的每种类型的异常都分配了一个唯一的非负整数的异常号. 有些号码由处理器设计者分配, 其他号码由操作系统的内核设计者分配, 分别用来表示不同层次的异常. 当系统启动时, 操作系统分配和初始化一张异常表的跳转表, 使得表目 k 包含异常 k 的处理程序的地址.

当处理器检测到发生了一个事件并确定其异常号为 k, 处理器执行间接过程调用, 通过条目 k 转到相应的处理程序. 异常表的起始地址放在异常表基址寄存器的特殊CPU寄存器里.

20220830102312

异常类似于过程调用, 但有一些不同:

  • 根据异常的类型, 返回地址要么是当前指令(事件发生时正在执行的指令), 要么是下一条指令.
  • 处理器把一些额外的处理器状态压到栈里, 在处理程序返回时, 重新开始执行被中断的程序也需要这些状态.
  • 如果控制从用户程序转移到内核, 所有这些项目被压到内核栈中, 而不是用户栈.
  • 异常处理程序运行在内核模式下, 对所有的系统资源有完全的访问权限.

异常类型

类别 原因 异步/同步 返回行为
中断 来自I/O设备的信号 异步 总是返回到下一条指令
陷阱 有意的异常 同步 总是返回到下一条指令
故障 潜在可恢复的错误 同步 可能返回到当前指令
终止 不可恢复的错误 同步 不会返回

在 Linux/x86-64中的常见异常:

异常号 描述 异常类别
0 除法错误 故障
13 一般保护故障 故障
14 缺页 故障
18 机器检查 终止
32~255 操作系统定义的异常 中断或陷阱

异步&同步

异步异常是由于处理器外部状态变化而引起的, 并非是由任意一条专门的指令造成.
同步异常是执行当前指令的结果, 也被称为故障指令(faulting instruction).

中断

中断(Interrupt) 是异步发生的, 是来自处理器外部的 I/O 设备的信号的结果.

在 I/O 设备中, 向处理器芯片上的一个引脚发送信号, 并把异常号放在系统总线上, 来触发中断.
在当前指令执行完后, 处理器注意到中断引脚的电压变高了, 就从系统总线读取异常好, 并调用中断处理程序. 当处理程序返回时, 将控制返回给下一条指令.

陷阱(系统调用)

陷阱(trap) 是有意的异常, 是执行一条指令的结果. 陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口, 称为系统调用.

当用户程序想要向内核请求服务 n 时, 可以执行syscall n. 执行syscall指令导致一个异常处理的陷阱, 这个处理程序解析参数, 并调用适当的内核程序.

注意:

  • 系统调用和函数调用存在不同: 系统调用运行在内核模式下, 而函数调用运行在用户模式.
  • 系统调用都有对应的整数号, 对应一个到内核中跳转表的偏移量. 但这个跳转表和异常表不同.

在 Linux/x86-64 系统中, 系统调用通过syscall执行, 该指令的参数是通过寄存器传递, 而不是栈. 其中,%rax 中包含系统调用号, %rdi, %rsi, %rdx, %r10, %r8 和 %r9 分别用来保存参数. 当从系统调用返回时,会破坏 %rcx 和%r11, 而将返回值保存在% rax 中. -4095 到 -1 之间的负数返回值表明发生了错误, 对应于负的 errno.

C 程序中为我们提供了包装函数. 我们将系统调用与它们相关联的包装函数都称为系统级函数.

故障

故障由错误情况引起, 它可能能够被故障处理程序修正. 故障发生时处理器将控制转移到故障处理程序, 如果能够修正, 就将控制返回给引起故障的指令, 从而重新执行它. 如果不能, 处理程序就返回到内核中的 abort 例程, 终止引起故障的应用程序.

终止

终止是不可恢复的致命错误造成的结果, 通常是硬件错误. 处理器会将控制返回给一个 abort 例程, 该例程会终止这个应用程序.

进程

异常是允许操作系统内核提供进程(process) 概念的基本构造块.
进程就是一个执行中程序的实例. 系统中的每个程序都运行在某个进程的上下文(context) 中. 上下文是由程序正确运行所需要的状态组成的, 它包括存放在内存中的代码数据, 栈, 通用寄存器, 环境变量等.

当我们执行运行可执行目标文件时, shell 就会创建一个新的进程, 然后在新的进程的上下文中运行这个可执行目标文件.

进程为应用程序提供了关键的抽象:

  • 一个独立的逻辑控制流, 它提供一个假象, 好像我们的程序独占使用处理器.
  • 一个私有的地址空间, 它提供一个假象, 好像我们的程序独占地使用内存系统.

逻辑控制流

当我们用调试器单步调试程序时, 我们会看到一系列程序计数器(PC)的值. 这个 PC 值的序列就叫做逻辑控制流, 或者简称逻辑流.

如图, 这个系统运行了三个进程, 每个进程都有一个逻辑流(黑色竖线). 这个例子中三个逻辑流是交错进行的, 进程轮流使用处理器. 每个进程执行它的流的一部分, 然后被抢占(preempted)(暂时挂起), 然后轮到其他进程. 但从单个进程的角度, 它的逻辑流是连续的, 意味着我们提供了它独占处理器的假象.

并发流

一个逻辑流的执行在时间上与另一个流重叠, 称为并发流(concurrent flow), 这两个流称为并发地运行. 即当逻辑流X在逻辑流Y开始之后和Y结束之前运行, 或逻辑流Y在逻辑流X开始之后和X结束之前运行. e.g. 上图的例子中进程 A 和 B, 以及进程 A 和 C 都是并发运行, 但 B 和 C 不是.
多个流并发地执行称为并发, 一个进程和其他进程轮流运行称为多任务(multitasking). 一个进程执行它控制流的一部分的每一个时间段称为时间片(time slice). 因此多任务也叫时间分片(time slicing).

注意: 并发流的思想和流运行的处理器核数, 计算机数无关. 如果两个流并发地运行在不同处理器核或者计算机上, 则称为并行流(parallel flow). 并行流是并发流的真子集.

私有地址空间

进程也为每个程序提供一个假象, 好像它独占地使用系统地址空间. 地址空间是 \(2^n\) 个可能地址的集合, 和这个空间中某个地址相关联的内存字节是不能被其他进程读或者写的, 从这个意义上说这个地址空间是私有的.

用户模式和内核模式

处理器为进程提供了两种模式, 用户模式和内核模式, 处理器通过某个控制寄存器的模式位(model bit) 来提供这种功能.

  • 当设置了模式位, 进程就运行在内核模式(超级用户模式)中, 一个运行在内核模式下的进程可以执行指令集中的任何指令, 并且可以访问内存中的任何位置.
  • 没有设置模式位, 进行就运行在用户模式中. 用户模式的进程不允许执行特权指令, 也不允许直接引用地址空间中内核区内的代码和数据.

运行应用程序的进程初始为用户模式, 进程从用户模式变为内核模式唯一的方法是通过异常. 当异常发生时它变为内核模式, 当异常返回到应用程序时又改回到用户模式.

Linux 通过 /proc 文件系统, 允许用户模式进程访问内核数据结构的内容.

上下文切换

操作系统内核使用上下文切换(context switch)较高层次形式的异常控制来实现多任务.
内核为每个进程维持一个上下文, 在进程执行的某些时刻, 内核可以决定抢占当前进程, 并重新开始一个先前被抢占了的进程. 这种决策叫调度(scheduling), 是由内核中的调度器(scheduler)的代码处理的. 当内核选择一个新的程序进行时, 我们说内核调度了这个进程. 在内核调度了一个新的进程运行后, 它就抢占当前进程, 并使用一种上下文切换的机制来转移控制到新的进程.

上下文切换:

  • 保存当前进程上下文
  • 恢复某个先前被抢占的进程被保存的上下文
  • 将控制传递给这个新恢复的进程

内核代表用户执行系统调用时可能回发生上下文切换. 如果系统调用因为某个事件发生而堵塞, 内核可以让当前进程休眠, 切换到另一个进程.
中断也能引发上下文切换.

Example

如上图中, read 系统调用需要访问磁盘, 内核中的陷阱处理程序请求来自磁盘控制器的 DMA 传送. 而磁盘读取事件比较耗时, 内核选择了上下文切换先进行另一个进程 B. 当磁盘读取完成后, 磁盘发起中断, 内核判单进程 B 已经进行了足够长的时间, 就执行从进程 B 到进程 A 的上下文切换.

进程控制

错误处理

当 Unix 系统级函数遇到错误时, 它们通常会返回 -1, 并设置全局整数变量 errno 来表示为什么出错了.
strerror 函数返回一个文本串, 描述了和某个 errno 值相关联的错误.
我们通常使用封装的错误报告函数:

void unix_error(char *msg){
  fprintf(stderr, "%s: %s\n", msg, strerror(errno));
  exit(0);
}
pid_t Fork(void){
  pid_t pid;
  if((pid = fork()) < 0)
    unix_error("Fork error");
  return pid;
}

获取进程 ID

每个进程都有一个唯一的正数(非零)进程 ID(PID). getpid 函数返回调用进程的 PID, getppid 返回它父进程的 PID.

#include <unistd.h>
#include <sys/types.h>
pid_t getpid(void);
pid_t getppid(void);

getpidgetppid函数返回一个类型为 pid_t 的整数值, Linux 系统在 types.h 中定义为 int.

创建和终止进程

一个进程有三种可能的状态:

  • 运行: 进程要么在 CPU 上执行, 要么在等待被执行且最终会被内核调度.
  • 停止: 进程的执行被挂起(suspended), 且不会被调度. 当收到 SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU 信号时进程被停止, 并且保持停止直到收到 SIGCONT 信号. 在这个时刻进程再次开始运行.
  • 终止: 进程永远地停止了. 原因在于: 收到一个信号, 信号的默认行为是终止该进程; 从主程序返回; 调用 exit 函数.

终止进程

exit 函数以 status 退出状态来终止进程.

#include <stdlib.h>
void exit(int status);

创建进程

父进程通过调用函数 fork 来创建一个新的运行的子进程.

#include <unistd.h>
#include <sys/types.h>
pid_t fork(void);
  • fork 函数只被调用一次, 但却会返回两次: 一次是在调用进程(父进程)中, 一次是在新创建的子进程中. 在父进程中 fork 返回子进程的 PID; 子进程中 fork 返回 0.
  • 父进程和子进程并发执行, 内核能够以任意方式交替执行它们的逻辑控制中的指令. 我们决不能对不同进程中指令的交替执行做任何假设.
  • 父进程和子进程有相同但独立的地址空间. 两个进程有相同(值相同, 并非同一个对象, 子进程得到的只是父进程的一个副本)的用户栈, 本地变量值, 堆, 全局变量值, 代码. 但后面父进程和子进程对数据做任何改变都是独立的.
  • 共享文件. 子进程还获得与父进程任何打开文件描述符相同的副本, 即子进程可以读写父进程打开的任何文件.

Example

int main()
{
    pid_t pid;
    int x = 1;
    pid = Fork();
    if (!pid)   /* Child */
    {
        printf("child : x=%d\n", ++x);
        exit(0);
    }

    /* Parent */
    printf("parent: x=%d\n", --x);
    exit(0);
}
我们可以画出他的拓扑排序图:
而父子进程的 printf 谁先执行, 取决于具体的调度, 不同的系统上会有不同的结果.

Example

#include <unistd.h>
int main()
{
    Fork();
    Fork();
    printf("hello\n");
    exit(0);
} 

Hint: 有 fork 存在的程序最好画出拓扑图.

回收进程

当一个进程由于某种原因终止时, 它会一直保持在已终止的状态直到被它的父进程回收(reaped). 当父进程回收已终止的子进程时, 内核将子进程的退出状态传递给父进程, 然后抛弃已终止的父进程, 此时该进程不再存在了. 一个终止了还没被回收的进程称为僵死进程(zombie).
如果一个父进程终止了, 内核会安排 init 进程称谓他的孤儿进程的养父. init 进程的 PID 为 1, 是在系统启动后由内核创建的, 它不会终止, 是所有进程的祖先.

一个进程可以通过waitpid函数来等待它的子进程终止或停止, 父进程会得到被回收的子进程 PID, 且内核会清除此僵死进程.

#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *statusp, int options); 
  • 等待集合 pid

    • pid>0, 等待集合就是一个单独的子进程, 它的进程 PID 等于 pid.
    • pid=-1, 等待集合就是由父进程所有的子进程组成的.
  • 等待行为 options

    • 0
      默认选项. waitpid 挂起调用进程的执行, 直到它的等待集合中的一个子进程终止. 如果等待集合中的一个进程在刚调用的时刻已经终止了, 那么 waitpid 就立刻返回. 在这两种情况 waitpid 返回导致 waitpid 返回的已终止的子进程的 PID. 此时已终止的子进程被回收.
    • WNOHANG
      如果等待集合中的任何子进程都还没有终止, 那么就立即返回 0. 如果想在等待子进程终止的同时做些其他工作, 这个选项会有用.
    • WUNTRACED
      挂起调用程序的执行, 直到等待集合中的一个进程变成已终止或被停止.
    • WCONTINUED
      挂起调用程序的执行, 直到等待集合中一个正在运行的进程终止或者等待集合中的一个被停止的进程收到 SIGCONT 信号重新开始执行.
    • 注: 可以用|将选项结合.
  • 检查已回收子进程的退出状态 如果 statusp 参数非空, waitpid 会在 status 中放上关于导致返回的子进程的状态信息, status 是 statusp 指向的值.

    • WIFEXITED(status): 如果子进程通过调用 exit 或者 return 正常终止就返回 true, 此时可通过WEXITSTATUS(status)获得退出状态.
    • WIFSIGNALED(status): 如果子进程因为一个未被捕获的信号终止, 返回 true, 此时可通过WTERMSIG(statusp)获得信号编号.
    • WIFSTOPPED(statusp): 如果引起函数返回的子进程是停止的, 则返回 true, 此时可通过 WSTOPSIG(statusp) 获得引起子进程停止的信号编号.
    • WIFCONTINUED(statusp): 如果子进程收到 SIGCONT 信号重新运行, 则返回 true.
  • 如果调用进程没有子进程, 那么 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)

Example

int main()
{
    if (Fork() == 0)
    {
        printf("a");
        fflush(stdout);
    }
    else
    {
        printf("b");
        fflush(stdout);
        wait(NULL);
    }
    pritnf("c"); fflush(stdout);
    exit(0);
}
画出其拓扑图:

Warning

程序不会按特定的顺序回收子进程.

让进程休眠

#include <unistd.h>
unsigned int sleep(unsigned int secs);
int pause(void);

sleep函数让一个进程挂起一段指定的时间 如果请求的时间到了, sleep就返回 0, 否则返回还剩下要休眠的时间. 当sleep被一个信号中断时, 它可能会过早的返回.

pause函数将进程挂起, 直到该进程收到一个信号.

加载并运行程序

execve函数在当前进程的上下文中加载并运行一个新程序.

#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]); 

execve函数加载并运行可执行文件 filename, 且带参数列表 argv 和环境变量列表 envp. 只有当出现错误(e.g. 找不到 filename), execve才会返回到调用程序.
加载 filename 后, 调用启动代码, 并将控制传递给新程序的主函数.
argv 变量指向一个以 null 结尾的指针数组, 其中每个指针都指向一个参数字符串, envp类似.

用户栈的组织结构: 其中全局变量 environ 指向 envp[0], 因此我们可以通过 environ 来获得环境列表.

这里还有一些函数能够对环境变量进行操作:

#include <stdlib.h>

char *getenv(const char *name); //获得名字为name的环境值
int setenv(const char *name, const char *newvalue, int overwrite); //对环境值进行修改
int unsetenv(const char *name); // 删除环境变量

fork & execve

  • fork函数新建一个不同 PID 的子进程,具有和父进程相同的上下文,是父进程的复制品,运行相同的代码、程序和变量,就是程序不变,而在不同进程. 而execve函数保持 PID 不变,在当前进程的上下文中加载并运行一个新程序,会覆盖当前进程的地址空间,并继承调用execve函数时已打开的所有文件描述符,就是保持进程不变,但是运行完全不同的程序.
  • fork函数调用一次返回两次,而execve函数调用后,只有出现错误才会返回到调用程序.
  • 想要保持当前进行运行的情况下,运行另一个程序,可以先通过fork新建一个进程,然后在子进程中用execve执行另一个程序,此时在父进程就运行原来的程序,而在子进程中就运行另一个程序.

信号

信号就是一条小消息, 它通知进程系统中发生了一个某种类型的事件.

  • 内核检测到了一个系统事件,比如除零错误、执行非法指令或子进程终止,低层次的硬件异常都是由内核异常处理程序处理的,对用户进程是不可见的,但是可以通过给用户进程发送信号的形式来告知,比如除零错误就发送SIGFPE信号,执行非法指令就发送SIGILL信号,子进程终止内核就发送SIGHLD到父进程中,则此时父进程就能对该子进程调用waitpid来进行回收.
  • 内核或其他进程出现了较高层次的软件事件,比如输入组合键,或一个进程尝试终止其他进程,都是显示要求内核发送一个信号给目标进程,比如输入组合键内核会发送SIGINT信号给所有进程,进程可以发送SIGKILL信号给别的进程来进行终止.

注: 异常是由硬件和软件共同实现, 而信号完全由软件实现, 且都是由内核发送.
如下是 Linux 系统支持的不同类型的信号, 每种信号都对应某种系统事件.

传送一个信号到目的进程是由两个步骤组成:

  • 发送信号 内核通过更新目的进程上下文中的某个状态, 发送一个信号给目的进程.
    发送信号可能有两种原因: 内核检测到一个系统时间(如除零, 子进程终止); 一个进程调用了kill函数, 显式地要求内核发送信号. 一个进程可以发信号给他自己.
  • 接收信号 当目的进程被内核强迫以某种方式对信号的发送做出反应时, 它就接受了信号. 进程可以忽略信号, 终止进程, 或执行用户级信号处理程序.

一个发出而没有接收的信号叫做待处理信号(pending signal). 任何时刻一种类型最多只能有一个待处理信号. 如果一个进程有类型 k 的待处理信号, 那么接下来任何发到这个进程的类型 k 信号将被直接丢弃. 当一种信号被堵塞时, 它仍然可以被发送, 只是不会被接收. 一个待处理信号最多被接受一次.

发送信号

Unix 提供向进程发送信号的机制, 都是基于进程组(process group).

进程组

每个进程只属于一个进程组, 进程组由一个正整数进程组 ID 来标识. getpgrp函数返回当前进程的进程组 ID.

#include <unistd.h>
pid_t getpgrp(void); //返回所在的进程组
默认地, 子进程和父进程属于同一个进程组. 一个进程组可以通过set-pgid函数来改变自己或其他进程的进程组.
#include <unistd.h>
int setpgip(pid_t pid, pid_t pgid); //设置进程组
setpgid函数将进程 pid 的进程组改为 pgid. 如果 pid 是 0, 那么就使用当前进程的 PID. 如果 pgid 是 0, 那么就用 pid 指定的进程 PID 作为进程组 ID(创建/加入一个进程组 ID 为 pid 的进程组).

Unix shell 使用作业(job) 的概念来表示对一条命令行求值而创建的进程. 在任何时刻至多有一个前台作业和任意个后台作业.
shell 会为每个作业创建一个独立的进程组, 该进程组 ID 由该作业中任意一个父进程的 PID 决定.

发送信号

  • /bin/kill发送信号 /bin/kill [-信号编号] id可以向另外的进程发送任意的信号.
    e.g. linux> /bin/kill -9 15213发送信号 9(SIGKILL)给进程 15213.

    一个为负的 PID 会导致信号被发送到进程组 PID 的每个进程.
    e.g. linux> /bin/kill -0 -15213发送信号到进程组 15213 的每个进程.

    注: 我们使用完整路径/bin/kill, 因为有些 Unix shell 有自己内置的kill指令.

  • 从键盘发送信号 在键盘上输入Ctrl+C会导致内核发送一个SIGINT信号到前台进程组中的每个进程, 默认情况下的结果是终止前台作业. 类似地, 输入Ctrl+Z会发送一个SIGTSTP信号到前台进程组中的每个进程. 默认情况下结果是停止(挂起)前台作业.

  • kill函数发送信号 进程通过调用kill函数发送信号给其他进程(包括它们自己)

    #include <sys/types.h>
    #include <signal.h>
    int kill(pid_t pid, int sig); 
    
    如果 pid 大于 0, 那么kill函数发送信号 sig 给进程 pid. 如果 pid 等于 0, 那么kill发送信号给调用进程组所在的每个进程(包括调用进程自己) 如果 pid 小于 0, kill发送信号给进程组 \({\left|pid\right|}\) 中的每个进程.

  • alarm函数发送信号 进程可以通过调用alarm函数向它自己发送SIGALRM信号.

    #include <unistd.h>
    unsigned int alarm(unsigned int secs); 
    
    alarm函数安排内核在 secs 秒后发送一个 SIGALRM 信号给调用进程. 一个进程只能有一个闹钟, 如果在调用alarm前已经有待处理的闹钟, 则替换它并返回待处理闹钟剩余的时间. secs 如果为 0 则不会安排新的闹钟.

接收信号

当内核把进程 p 从内核模式切换到用户模式时(例如从系统调用返回或者完成一次上下文切换), 它会检查进程 p 的未被阻塞的待处理信号的集合, 即pending & ~blocked 如果这个集合为空, 那么内核将控制传递到 p 的逻辑控制流的下一条指令. 如果集合非空, 内核选择集合中某个信号 k(通常是最小的 k)并强制 p 接收信号 k, 完成信号对应的行为, 再将控制转移到 p 逻辑流的下一条指令.
每个信号类型都有一种预定义的默认行为:

  • 进程终止
  • 进程终止并转储内存
  • 进程停止(挂起)直到被SIGCONT信号重启
  • 进程忽略该信号

我们可以通过signal函数修改与信号相关联的默认行为.(SIGSTOP``SIGKILL它们的默认行为不能被修改)

#include <signal.h>
typedef void (*sighandler_t)(int); 
sighandler_t signal(int signum, sighandler_t handler);

  • 如果 handler 是SIG_IGN, 那么忽略类型为 signum 的信号.
  • 如果 handler 是SIG_DFL, 那么类型为 signum 的信号行为恢复为默认行为.
  • 否则 handler 为用户定义的函数地址, 这个函数称为信号处理程序. 当接收到 signum 信号时就会调用这个程序. 这种行为叫设置信号处理程序.(同一个处理函数可以捕获多种类型的信号) 调用信号处理程序称为捕获信号, 执行信号处理程序称为处理信号.
    当信号处理程序返回时, 控制(通常)传递回控制流中下一条指令.
  • signal执行成功, 返回之前 signal handler 的值, 否则返回SIG_ERR(不设置 errno)

Warning

信号处理程序可以被其他信号处理程序中断.
20220830195314

Warning

在 fork + execve 后, 子进程进入到新的上下文里, 此时原先绑定的信号处理程序不再生效, 而是回到默认状态.

阻塞信号

阻塞, 指信号被发送后暂不接收(处理)此信号, 而非丢弃.

Linux 提供显式和隐式的阻塞机制.

  • 隐式阻塞机制: 内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号. e.g. 程序捕获了信号 s, 正在执行其信号处理程序, 这时再发送一个信号 s, s 会变成待处理而不是被接收.
  • 显式阻塞机制: 应用程序可以调用sigprocmask函数和它的辅助函数, 显式地阻塞和解除阻塞选定的信号.

    #include <signal.h>
    int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
    
    sigprocmask函数改变当前阻塞信号的集合(blocked 位向量).
    通过 how 的值来决定改变集合的方式:

  • SIG_BLOCK: 把 set 中的信号添加到 blocked 中. blocked = blocked | set

  • SIG_UNBLOCK: 从 blocked 中删除 set 中的信号. blocked = blocked & ~set
  • SIG_SETMASK: blocked = mask

如果 oldset 非空, 那么 blocked 位向量之前的值保存在 oldset 中.

这里还有一些其他的函数对 set 信号集合进行操作:

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);

  • sigemptyset: 初始化 set 为空集合
  • sigfillset: 将每个信号都添加到 set 中
  • sigaddset: 将 sigunm 添加到 set
  • sigdelset: 从 set 中删除 signum
  • sigismember: 如果 signum 是 set 的成员则返回 1, 否则返回 0.

编写信号处理程序

信号处理程序有几个棘手属性使得他们很难分析:

  • 处理程序与主程序在相同的进程并发运行, 共享同样的全局变量.
  • 如何以及何时接收信号常常有违人的直觉
  • 不同的系统有不同的信号处理语义

安全的信号处理

  • 处理程序尽可能简单.
    e.g. 处理程序只是简单地设置全局标志并立即发挥; 所有与接收信号相关的处理都由主程序执行, 它周期性地检查(并重置)这个标志.
  • 在处理程序中只调用异步信号安全的函数.
    因为它是可重入的(例如只访问局部变量), 要么它是不能信号处理程序中断的.
    下图列出了 Linux 安全的系统级函数. 注意许多常见的函数(printf, sprintf, malloc, exit)都不在此列.

    SIO(安全的 I/O 包)

    信号处理程序中产生输出唯一安全的方法是使用write函数.

    #include "csapp.h"
    
    ssize_t sio_putl(long v);
    ssize_t sio_puts(char s[]);
    //如果成功则返回传送的字节数, 出错返回 -1
    void sio_error(char s[]);  
    
    具体实现如下:
    ssize_t sio_puts(char s[])
    {
        return write(STDOUT_FILENO, s, sio_strlen(s));
    }
    ssize_t sio_putl(long v)
    {
        char s[128];
        sio_ltoa(v, s, 10); /* Based on K&R itoa */
        return sio_puts(s);
    }
    void sio_error(char s[])/* Put error message and exit */
    {
        si_puts(s);
        _exit(1);
    }
    

    • 保存和恢复 errno
      许多 Linux 异步信号安全的函数都会在出错返回时设置 errno. 在处理程序中调用这样的函数可能会干扰主程序中其他依赖于 errno 的部分.
      解决方法是在进入处理程序时把 errno 保存在一个局部变量中, 在处理程序返回前恢复它. 只有在处理程序要返回时才有必要这么做.
    • 阻塞所有信号, 保护对共享全局数据结构的访问 对全局数据结构访问时, 处理程序和主程序应该暂时阻塞所有的信号.
    • volatile声明全局变量
      考虑一个处理程序和一个 main 函数(它们在同一进程中), 它们共享一个全局变量 g. 处理程序更新 g, main 周期性地读 g. 对一个优化编译器, main 中 g 的值看上去从没有变化过, 因此使用缓存在寄存器中 g 的副本来满足对 g 的每次引用是很安全的, 这就导致 main 函数可能永远无法看到 g 更新后的值.
      可以用volatile类型限定符定义一个变量, 告诉编译器不要缓存这个变量.
      volatile限定符强迫编译器每次引用变量时都要从内存中读取.
      注: 声明/访问 g 时也要阻塞信号.
    • sig_atmoic_t声明标志
      sig_atmoic_t声明变量, 保证对它们的读写是原子的(不可中断的), 因此我们不需要暂时阻塞信号. 大多数系统中sig_atmoic_tint类型的.
      : 对原子性的保证只适用于单个读/写, flag++``flag=flag+10这样的更新可能需要多条指令.

正确的信号处理

信号的一个与直觉不符的方面就是未处理的信号是不排队的. 因为 pending 位向量中每种类型的信号只对应有一位, 因此每种类型最多只能有一个未处理的信号. 如果存在一个未处理的信号那么就表明至少有一个信号到达了.
注: 不可以用信号来对其他进程中发生的事件计数.

回收子进程

我们用SIGCHILD来回收子进程, 而不是显式地等子进程终止.(当子进程终止或停止, 内核会发送一个SIGCHILD信号给父进程.) 20220830213813 这个代码的问题在于: 父进程接收并捕获了第一个信号, 当处理程序还在处理第一个信号时, 第二个信号和第三个信号就发送来了, 但引物 SIGCHILD 信号被 SIGCHILD 处理程序堵塞了, 这两个发送来的信号其中一个会处于待处理, 而另一个会被直接丢弃.
改进:

void handler2(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;
}
我们在每次处理信号时, 尽可能多地回收僵死进程.

可移植的信号处理

Unix 信号处理的另一个缺陷在于不同的系统有不同的信号处理语义, 例如:

  • signal函数的语义各有不同.
  • 系统调用可以被中断. 像read``write``accept这样的系统调用潜在地阻塞进程很长一段时间, 称为慢速系统调用. 早期 Unix 系统中, 在执行慢速系统调用时,如果进程接收到一个信号,可能会中断该慢速系统调用,并且当信号处理程序返回时,无法继续执行慢速系统调用,而是返回一个错误条件,并将 errno 设置为EINTR.
    Posix 标准定义了sigaction函数, 允许用户在设置信号处理时, 明确指定他们想要的信号处理语义.

sigaction

#include <signal.h>

int sigaction(int signum, struct sigaction *act, struct sigaction *oldact);         
//成功返回 0, 出错返回 -1
可以类似signal函数那样使用,信号处理语义为:

  • 只有当前信号处理程序正在处理的信号类型会被阻塞
  • 只要可能,被中断你的系统调用会自动重启
  • 一旦设置了信号处理程序,就会一直保持

其他

同步流以避免讨厌的并发错误

父进程在一个全局作业列表中记录着当前的子进程, 每个作业一个条目. addjobdeletejob函数分别向这个作业列表添加和删除作业.
当父进程创建一个新的子进程后, 就把这个子进程添加到作业列表中. 当父进程在SIGCHILD处理程序中回收一个终止的僵死子进程, 它就从作业列表中删除这个子进程.
20220830225106 这个代码存在一定问题, 因为可能发生这样的事件序列:

  • 父进程执行 fork 函数, 内核调度新创建的子进程运行, 而不是父进程.
  • 父进程能再次运行之前子进程就终止并变为一个僵死进程, 内核给父进程发送一个SIGCHILD信号.
  • 父进程再次变为可运行但又在它执行之前, 内核注意到有未处理的SIGCHILD信号, 运行处理程序接收这个信号.
  • 信号处理程序中deletejob, 但这时进程还没有被加入作业列表, 这个函数什么也不错.
  • 处理完毕, 父进程通过调用addjob将已经被回收的子进程加入到作业列表中.

这是一个竞争(race) 的经典同步错误的示例.

20220830225925

修改之后, 对于父进程我们在 fork 之前就阻塞了 SIGCHILD 信号, 在addjob 之后才取消阻塞, 这样子进程一定在被添加到作业列表之后才会被回收.

显式地等待信号

有时候主程序需要显式地等待某个信号处理程序运行. 如 Linux shell 创建一个前台作业, 在接受下一条用户命令之前它必须等待作业终止, 被SIGCHILD处理程序回收.

20220831091143

父进程(shell)设置SIGINTSIGCHILD的处理程序, 然后进入一个无限循环. 它阻塞SIGCHILD. 创建子进程后, 把 pid 设 0, 取消阻塞SIGCHILD, 然后以循环的方式等待 pid 变为非零. 子进程终止后, 处理程序回收它, 把非零的 pid 赋给全局变量, 这会终止循环, 父进程这才继续其他工作.

但这段代码中, 循环在浪费处理器资源. 我们在循环体中插入pause, 等待的时候把进程挂起做其他事.

while(!pid)
    pause();
但这样也会面对竞争: 如果在while测试后pause之前收到SIGCHILD信号, pause会永远睡眠.
或者改用sleep函数:
while(!pid)
    sleep(1);
但这样太慢了, 如果在while测试之后sleep之前收到信号, 程序必须等相当长的一段时间才会再次检测循环的终止条件.

我们可以使用sigsuspend函数

#include <signal.h>

int sigsuspend(const sigset_t *mast);
//返回 -1
sigsuspend暂时用 mask 替换当前阻塞集合, 然后挂起该进程, 直到收到一个虚拟号. 其行为要么是运行一个处理程序要么是终止该进程.
它等价于下列代码的原子(不可中断)版本.
sigprocmask(SIG_SETMASK, &mask, &prev);
pause();
sigprocmask(SIG_SETMASK, &prev, NULL); 

非本地跳转

C 语言提供了一种用户级异常控制流形式, 称为非本地跳转(nonlocal jump). 它将控制直接从一个函数转移到另一个正在执行的函数, 而不需要经过正常的调用-返回序列.

#include <setjmp.h>
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int retval);
setjmp函数在 env 缓冲区保存当前调用环境, 以供后面longjmp使用, 并返回 0. setjmp返回的值不能赋值给变量.
longjmp函数从 env 缓冲区中恢复调用环境, 然后触发一个从最近一次初始化 env 的setjmp调用的返回. 然后setjmp返回, 并带有非零的返回值 retval.

从深层嵌套函数中返回

main函数中,首先在 12 行中执行setjmp(buf)函数将当前调用环境保存到 buf 中,并返回 0,所以就调用foo函数和bar函数,当这两个函数中出现错误,则通过longjmp(buf, retval)恢复调用环境,并跳转回第 13 行,然后让setjmp函数返回 retval 的值,由此就无需解析调用栈了。但是该方法可能存在内存泄露问题.

使信号处理程序分支到一个特殊的代码位置

#include <setjmp.h>
int sigsetjmp(sigjmp_buf env, int savesigs);
//若 savesigs != 0, 则会把堵塞的信号集合也保存.  
void siglongjmp(sigjmp_buf env, int retval); 

sigsetjmpsiglongjmp是可以被信号处理程序使用的版本.
其中sigsetjmp函数还会将待处理信号和被阻塞信号保存到 env 中.

下面的例子, 当用户在Ctrl+C时, 这个程序用信号和非本地跳转实现软重启.

首先,在main函数中第 12 行通过sigsetjmp函数将调用环境保存到 buf 中,并返回 0,随后设置信号处理程序。当用户输入Ctrl+C时,会调用信号处理程序handler,此时会通过siglongjmp恢复调用环境,然后跳转回第 12 行,然后让sigsetjmp返回1,此时就避免了返回到中断的下一条指令处.

注意: signal设置处理程序要在调用sigsetjmp之后,避免还未设置sigsetjmp就接收到信号而执行siglongjmp; 而且sigsetjmpsiglongjmp不在异步信号安全的函数之列.

评论