异常控制流

OS 抽象

异常控制流(Exceptional Control Flow, ECF)指程序控制流因系统事件而发生的转移。它是操作系统实现进程、系统调用、信号、上下文切换和调度的基础。

计算机硬件系统主要由 Processor,Main memory,I/O devices组成;操作系统是管理硬件的软件层,为应用提供简单、统一的硬件访问机制,并防止应用直接滥用硬件资源。操作系统提供三个基本抽象:

抽象 作用
Process 正在运行的程序实例
Virtual memory 为每个进程提供独立地址空间的抽象
Files 将普通文件和 I/O 设备统一为字节序列接口

内核与用户模式

进程是正在运行的程序实例。每个程序都在某个进程上下文中运行。进程由由两部分组成:

  • 用户部分:应用代码、用户栈、堆、数据段等。
  • OS 部分:内核维护的进程状态和内核资源。

内核由所有进程共享。进程运行时可能处于用户模式或内核模式:

模式 权限
Kernel mode 可执行任意指令,可访问系统任意内存位置
User mode 不能执行特权指令,不能直接访问内核地址空间

处理器通常用控制寄存器中的 mode bit 区分两种模式。例如 bit 置位表示 kernel mode,清零表示 user mode。

应用不能直接访问 I/O 设备。它通过系统调用进入内核,由内核代表它访问硬件。

虚拟内存

虚拟内存让每个进程看起来独占主存。一个典型虚拟地址空间包含:

每个进程都有私有地址空间。硬件和操作系统共同完成虚拟地址到物理地址的转换。

文件抽象

普通文件是字节序列。Unix 将 I/O 设备也抽象为文件,通过少量系统调用读写。系统中的输入输出通常都通过 Unix I/O 完成。

系统调用

普通跳转、函数调用和返回只能在同一权限模式内改变控制流。用户程序需要进入内核时,使用系统调用。

syscall / sysret 是硬件提供的用户模式与内核模式之间控制转移机制。系统调用是用户程序访问内核服务的过程式接口。

示例:

1
2
3
4
5
6
7
int main()
{
/* fd=1 表示标准输出;第三个参数是写入字节数。 */
write(1, "hello, world\n", 13);
/* 直接通过系统调用接口终止进程,不执行 stdio 缓冲刷新等用户态清理。 */
_exit(0);
}

对应 x86-64 汇编调用:

1
2
3
4
5
6
7
8
9
movq $1, %rax       # write 系统调用号
movq $1, %rdi # stdout 文件描述符
movq $string, %rsi # 字符串地址
movq $len, %rdx # 字符串长度
syscall

movq $60, %rax # _exit 系统调用号
movq $0, %rdi # exit status
syscall

系统调用参数传递:

  • 最多 6 个参数通过寄存器传递:%rdi, %rsi, %rdx, %r10, %r8, %r9。
  • 返回值通过 %rax 返回。
  • %rcx 和 %r11 会被 CPU 用于保存 %rip 和 %rflags,因此会被破坏。
  • 用户模式如需保留 caller-saved register,应自行保存。

常见 Linux x86-64 系统调用:

Number Name Desc. Number Name Desc.
0 read Read a file 33 pause Wait for signal
1 write Write a file 37 alarm Set an alarm clock
2 open Open a file 39 getpid Get process ID
3 close Close a file 57 fork Create a process
4 stat Get file status 59 execve Execute a program
9 mmap Map a file into memory 60 _exit Terminate the process
12 brk Set the top of heap 61 wait4 Wait for process to stop
32 dup2 Duplicate a file descriptor 62 kill Send signal to a process

异常

处理器状态由内部位和信号编码,例如 kernel bit、%rflags 等。事件(event)是处理器状态的重要变化。事件来源包括:

  • 执行 syscall / sysret。
  • 磁盘或网络适配器有数据到达。
  • 指令除零。
  • 缺页或非法内存访问。
  • 定时器到期。

异常是硬件响应事件而把控制转移到内核的一种机制。

异常处理

每类事件有唯一异常号 k。异常表的第 k 项指向对应异常处理程序。异常发生时,处理器根据异常号查表并跳转到内核中的 handler。

异常处理程序运行在 kernel mode。处理器会把恢复被中断程序所需的信息压入内核栈,例如返回地址、RFLAGS、RSP 等。系统调用不一定需要保存同样的信息。

handler 完成后可能:

  • 返回当前指令 Icurr,重新执行被中断指令。
  • 返回下一条指令 Inext。
  • 终止被中断程序。

异常分类:

类别 同步性 原因 返回位置 示例
Interrupt 异步 处理器外部事件 下一条指令 I/O 完成、定时器
Trap 同步 有意触发 下一条指令 syscall、断点
Fault 同步 非有意但可能恢复 当前指令或终止 缺页、保护异常
Abort 同步 非有意且不可恢复 终止 parity error、machine check

同步异常事件一般是由当前程序引起的;异步异常事件则是外部事件触发的。

x86-64 常见异常:

异常号 描述 类别
0 Divide error Fault
13 General protection fault Fault
14 Page fault Fault
18 Machine check Abort
32 ~ 255 OS defined exception Interrupt 或 Trap

缺页示例:

若用户写入的地址所在页面尚未准备好(这个涉及后面虚拟内存的 Unallocated 状态),但地址合法:

1
2
3
4
5
6
7
long a[1000];

int main()
{
/* 地址合法但页面可能尚未调入,第一次访问可触发可恢复 page fault。 */
a[500] = 13;
}

处理流程:

  1. 指令访问页时触发 page fault。
  2. page fault handler 将页载入物理内存。
  3. handler 返回 faulting instruction。
  4. 指令第二次执行成功。

若地址非法:

1
2
3
4
5
6
7
int a[1000];

int main()
{
/* 下标越界,访问地址不属于合法对象,通常会触发 SIGSEGV。 */
a[5000] = 13;
}

处理流程:

  1. page fault handler 检测到非法地址。
  2. 内核向用户进程发送 SIGSEGV。
  3. 进程以 segmentation fault 终止。

异步中断与 I/O

异步异常由处理器外部事件引起,通常通过设置处理器中断引脚触发。handler 返回下一条指令。

例如:

  • 键盘输入 Ctrl-C。
  • 网络包到达。
  • 磁盘扇区读取完成。
  • 硬重启中断。
  • 软重启中断。

I/O 设备触发中断时,会在系统总线上放置一个编号,用于标识中断来源。CPU 暂停当前工作,跳转到操作系统中的 interrupt handler。

设备可通过 DMA(Direct Memory Access)自行执行读写总线事务,不需要 CPU 逐字节参与。典型磁盘读取流程:

  1. CPU 向磁盘控制器发起读请求。
  2. 控制器读取磁盘扇区。
  3. 控制器通过 DMA 把数据写入主存。
  4. 控制器发出中断通知 CPU。

异步异常发生时,内核并不一定立刻返回被打断的当前进程。它会先把当前进程的执行现场保存起来,然后根据调度器决定返回哪个进程。当前进程会在之后再次被调度到 CPU 时,从被打断的位置继续执行。

进程与上下文

上下文

从启动到关机,CPU 按物理控制流读取并执行指令。多个进程并发运行时,操作系统通过交错执行不同进程的指令实现多任务。每个进程都有自己的逻辑控制流。两个进程的逻辑控制流在时间上重叠,则称为并发;否则称为顺序执行。

内核为每个进程维护上下文。上下文是内核重启被中断进程所需的状态。上下文包括:

  • 程序代码和数据。
  • PC、通用寄存器、状态寄存器。
  • 用户栈和内核栈。
  • 环境变量。
  • 内核数据结构,例如 process table、page table、file table。

上下文切换

上下文切换是较高层次的异常控制流,建立在底层异常机制之上。

触发时机:

  • 进程执行阻塞型系统调用,例如 read、sleep。
  • 系统调用不阻塞,但内核决定调度其他进程。
  • 定时器中断到达。

scheduler 执行的基本步骤:

  1. 判断是否抢占当前进程。
  2. 选择一个可运行进程。
  3. 保存当前进程上下文。
  4. 恢复目标进程上下文。
  5. 将控制转移给恢复后的进程。

进程状态

状态 含义
Running 正在 CPU 上运行,或等待被调度并最终运行
Stopped / blocked 执行被挂起,不会被调度
Terminated 永久停止
  • 进程收到 SIGSTOP 可进入 stopped。
  • stopped 进程收到 SIGCONT 可恢复为 running。
  • 进程收到默认动作为终止的信号,或从 main 返回 / 调用 exit,会进入 terminated。

系统调用错误处理

Unix 系统级函数出错时通常返回 -1,并设置全局变量 errno 表示错误原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void unix_error(char *msg)
{
/* errno 由失败的 Unix 系统级函数设置,strerror 将其转换为错误字符串。 */
fprintf(stderr, "%s: %s\n", msg, strerror(errno));
exit(0);
}

pid_t Fork(void)
{
pid_t pid;
/* fork 失败时返回 -1;包装函数统一处理错误路径。 */
if ((pid = fork()) < 0)
unix_error("Fork error");
/* 父进程得到子进程 PID,子进程得到 0。 */
return pid;
}

这种封装可减少重复错误检查代码。

创建与终止进程

PID

每个进程有唯一正整数 PID。

1
2
3
4
5
6
7
#include <unistd.h>
#include <sys/types.h>

/* 返回当前进程的 PID。 */
pid_t getpid(void);
/* 返回父进程的 PID。 */
pid_t getppid(void);
  • getpid() 返回调用进程 PID。
  • getppid() 返回父进程 PID。

exit

1
2
3
4
#include <stdlib.h>

/* 用 status 作为退出状态终止当前进程。 */
void exit(int status);

exit(status) 以状态码 status 终止当前进程,不返回。

fork

1
2
3
4
5
#include <unistd.h>
#include <sys/types.h>

/* 创建子进程;父子进程从同一调用点继续执行。 */
pid_t fork(void);

返回值:

  • 子进程中返回 0。
  • 父进程中返回子进程 PID。
  • 出错返回 -1。

fork 调用一次,返回两次:一次在父进程中,一次在子进程中。

子进程特征:

  • 与父进程 PID 不同。
  • 获得父进程用户级虚拟地址空间的独立副本,包括 text、data、bss、heap、user stack。
  • 获得父进程已打开文件描述符的副本。
  • 父子进程地址空间内容初始相同,但后续修改互不影响。
  • 父子进程共享打开文件,因此可通过同一 stdout 输出。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main()
{
pid_t pid;
int x = 1;

/* fork 后父子进程各自拥有 x 的独立副本。 */
pid = Fork();
if (pid == 0) {
/* 子进程中 pid 为 0,修改的是子进程自己的 x。 */
printf("child : x=%d\n", ++x);
exit(0);
}

/* 父进程中 pid 为子进程 PID,修改的是父进程自己的 x。 */
printf("parent: x=%d\n", --x);
exit(0);
}

父进程和子进程并发执行,输出顺序不可假设。

多次 fork 会产生多个进程:

1
2
3
Fork();                 // 1 次无条件 fork 后共有 2 个进程
Fork(); Fork(); // 2 次无条件 fork 后共有 4 个进程
Fork(); Fork(); Fork(); // 3 次无条件 fork 后共有 8 个进程

一般地,连续执行 nn 次无条件 fork 会产生 2n2^n 个进程。

回收子进程

终止的子进程不会立即从系统中删除。内核保留其终止状态,直到父进程回收它。

  • zombie:已终止但尚未被父进程回收的进程。
  • reaping:父进程获取子进程退出状态后,内核删除该终止进程。

如果父进程终止时仍有 zombie 子进程,内核会安排 init 进程回收它们。init 的 PID 为 1,由内核在系统初始化时创建。

长时间运行的程序,例如 shell 和 server,应主动回收 zombie 子进程。zombie 不运行,但仍占用系统资源。

waitpid

1
2
3
4
5
6
7
#include <sys/types.h>
#include <sys/wait.h>

/* 等待指定 wait set 中的子进程状态变化。 */
pid_t waitpid(pid_t pid, int *status, int options);
/* 等价于 waitpid(-1, status, 0),等待任意子进程终止。 */
pid_t wait(int *status);

返回值:

  • 成功返回导致返回的子进程 PID。
  • 若设置 WNOHANG 且没有子进程状态变化,返回 0。
  • 出错返回 -1。

pid 含义:

pid wait set
pid > 0 PID 等于 pid 的单个子进程
pid = -1 所有子进程

错误情况:

  • 调用进程没有子进程:返回 -1,errno=ECHILD。
  • 被信号中断:返回 -1,errno=EINTR。

options:

选项 含义
0 阻塞等待 wait set 中某个子进程终止
WNOHANG 若没有子进程终止,立即返回 0
WUNTRACED 等待子进程终止或停止
WUNTRACED/WNOHANG 若没有子进程终止或停止,立即返回 0

子进程状态宏

若 status 非空,waitpid 会写入子进程状态。常用宏:

宏 含义
WIFEXITED(status) 子进程是否正常终止
WEXITSTATUS(status) 正常终止时的退出状态
WIFSIGNALED(status) 子进程是否因未捕获信号终止
WTERMSIG(status) 导致终止的信号编号
WIFSTOPPED(status) 子进程是否停止
WSTOPSIG(status) 导致停止的信号编号

回收顺序可能是不确定的:

1
2
3
4
5
while ((pid = waitpid(-1, &status, 0)) > 0) {
/* 只在子进程正常 exit 或从 main 返回时读取退出状态。 */
if (WIFEXITED(status))
printf("child %d exit status=%d\n", pid, WEXITSTATUS(status));
}

若需要固定顺序,可保存子进程 PID,并按 PID 调用 waitpid(pid[i], &status, 0)。

休眠与程序加载

sleep 与 pause

1
2
3
4
5
6
#include <unistd.h>

/* 至少休眠 secs 秒;若被信号中断,返回剩余秒数。 */
unsigned int sleep(unsigned int secs);
/* 挂起直到接收一个信号,正常情况下总是返回 -1。 */
int pause(void);
  • sleep(secs) 挂起进程指定秒数;若提前被信号中断,返回剩余秒数。
  • pause() 挂起进程直到收到信号,总是返回 -1。

execve

1
2
3
4
5
6
#include <unistd.h>

/* filename 指向可执行文件;argv/envp 分别提供参数表和环境变量表。 */
int execve(const char *filename,
const char *argv[],
const char *envp[]);

execve 加载并运行可执行目标文件 filename,参数为 argv,环境变量为 envp。若成功,不返回;只有出错时返回 -1。

新程序启动时,用户栈包含:

504

环境变量接口:

1
2
3
4
5
6
7
8
9
10
#include <unistd.h>

// 读取环境变量,根据环境变量名查找值
char *getenv(const char *name);

// 设置环境变量,overwrite 决定已存在变量是否覆写
int setenv(const char *name, const char *newvalue, int overwrite);

// 删除环境变量
void unsetenv(const char *name);

setenv 和 unsetenv 只修改当前进程及其之后创建的子进程的环境变量,不会修改父进程,也不会永久修改 shell 环境。

Shell

shell 是交互式应用程序,代表用户运行其他程序。基本循环是 read / evaluate:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main()
{
char cmdline[MAXLINE];

while (1) {
/* 输出提示符并读取一整行命令。 */
printf("> ");
Fgets(cmdline, MAXLINE, stdin);
/* 用户输入 EOF 时退出 shell。 */
if (feof(stdin))
exit(0);
/* 解析并执行该命令行。 */
eval(cmdline);
}
}

parseline 解析空格分隔的命令行参数,构造 argv,并判断是否后台执行。

  • 若最后一个参数是 &,返回 1,表示后台作业。
  • 否则返回 0,表示前台作业,shell 需要等待它结束。

执行非内置命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if (!builtin_command(argv)) {
/* 外部命令在子进程中执行,避免覆盖 shell 自身。 */
if ((pid = Fork()) == 0) {
/* execve 成功后不会返回;失败说明目标程序无法加载。 */
if (execve(argv[0], argv, environ) < 0) {
printf("%s: Command not found.\n", argv[0]);
exit(0);
}
}

if (!bg) {
int status;
/* 前台作业需要 shell 阻塞等待。 */
if (waitpid(pid, &status, 0) < 0)
unix_error("waitfg: waitpid error");
} else {
/* 后台作业不等待,只打印作业信息。 */
printf("%d %s", pid, cmdline);
}
}

内置命令示例:

1
2
3
4
5
6
7
8
9
10
11
int builtin_command(char **argv)
{
/* quit 是 shell 自身处理的内置命令。 */
if (!strcmp(argv[0], "quit"))
exit(0);
/* 单独的 & 不启动外部程序。 */
if (!strcmp(argv[0], "&"))
return 1;
/* 返回 0 表示不是内置命令,需要 fork/exec。 */
return 0;
}

信号

Unix signal 是较高层的软件异常形式,它允许进程和内核中断其他进程。信号是通知进程某类事件发生的消息。事件来源包括:

  • 低层硬件异常经内核转换后暴露给用户进程。
  • 内核中的软件事件。
  • 其他用户进程显式发送的信号。
常见信号 编号 触发场景
SIGINT 2 前台进程运行时输入 Ctrl-C
SIGILL 4 执行非法指令
SIGFPE 8 除零等算术错误
SIGKILL 9 强制终止进程
SIGSEGV 11 非法内存引用
SIGCHLD 17 子进程终止或停止

信号发送与接收

信号传递分为两步:

  1. Sending:内核通过更新目标进程上下文中的状态来递送信号。
  2. Receiving:目标进程被内核强制对信号作出反应。

信号递送原因:

  • 内核检测到系统事件,例如除零、子进程终止。
  • 进程调用 kill 请求内核向目标进程发送信号。

接收信号后的动作:

  • 忽略信号。
  • 终止进程。
  • 捕获信号,执行用户级 signal handler。

Pending 与 Blocking

pending signal 是已发送但尚未接收的信号:

  • 同一类型最多只有一个 pending signal。
  • pending signal 不排队;若类型 k 已 pending,再次发送类型 k 会被丢弃。
  • pending signal 最多被接收一次。

进程可阻塞某些信号。被阻塞信号可以被递送并置为 pending,但不会被接收(也就是不会调用信号处理函数),直到进程解除阻塞。

内核为每个进程维护:

  • pending bit vector:记录 pending signal 集合。
  • blocked bit vector:记录 blocked signal 集合。

递送信号 k 时,内核置位 pending[k] 为 1。接收并处理信号 k 时,内核清除 pending[k]。

信号发送与接收

进程组

每个进程属于唯一进程组。进程组 ID 是正整数。默认情况下,子进程与父进程属于同一进程组。

1
2
3
4
5
6
#include <unistd.h>

/* 返回当前进程所在进程组 ID。 */
pid_t getpgrp(void);
/* 将 pid 指定的进程放入 pgid 指定的进程组。 */
int setpgid(pid_t pid, pid_t pgid);

setpgid(0, 0) 将当前进程放入一个新进程组,进程组 ID 等于当前进程 PID。

kill 程序与 kill 函数

/bin/kill 可向进程发送任意信号:

1
2
kill -9 15213     # 向 PID 15213 发送 SIGKILL
kill -9 -15213 # 向进程组 15213 中所有进程发送 SIGKILL

对应函数:

1
2
3
4
5
#include <sys/types.h>
#include <signal.h>

/* 向单个进程或进程组发送信号,pid 的正负决定目标类型。 */
int kill(pid_t pid, int sig);
  • pid > 0:向进程 pid 发送信号 sig。
  • pid < 0:向进程组 abs(pid) 中所有进程发送信号 sig。

键盘发送信号

shell 使用 job 表示一次命令行求值产生的进程集合。任意时刻最多有一个前台 job,后台 job 可以有多个。

shell 通常为每个 job 创建单独进程组。

  • 输入 Ctrl-C:shell 捕获 SIGINT,再向前台进程组发送 SIGINT,默认结果是终止前台 job。
  • 输入 Ctrl-Z:shell 捕获 SIGTSTP,再向前台进程组发送 SIGTSTP,默认结果是停止前台 job。

alarm

1
2
3
4
#include <unistd.h>

/* 安排 secs 秒后向当前进程发送 SIGALRM。 */
unsigned int alarm(unsigned int secs);

alarm(secs) 安排内核在 secs 秒后向调用进程发送 SIGALRM。

  • secs=0 表示不设置新 alarm。
  • 新调用会取消之前未到期的 alarm。
  • 返回之前 alarm 剩余秒数;若无未到期 alarm,返回 0。

信号接收

内核从异常处理程序返回并准备将控制交给进程 p 时,会检查:

1
pending & ~blocked

若集合为空,内核将控制交给下一条指令。若集合非空,内核选择某个未阻塞 pending signal(通常是编号最小者),强制进程接收它。

每类信号都有默认动作:

  • 终止进程。
  • 终止进程并 core dump。
  • 停止进程,直到收到 SIGCONT。
  • 忽略信号。

SIGKILL 默认终止进程;SIGCHLD 默认忽略。

进程可用 signal 修改默认动作,SIGSTOP 和 SIGKILL 例外,不能修改。

1
2
3
4
5
#include <signal.h>

typedef void handler_t(int);
/* 安装 signum 的处理函数,返回旧处理函数指针。 */
handler_t *signal(int signum, handler_t *handler);
handler 行为
SIG_IGN 忽略该信号
SIG_DFL 恢复默认动作
handler 函数地址 捕获信号并调用 handler

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void handler(int sig)
{
/* sig 可用于区分触发 handler 的具体信号编号。 */
printf("Caught SIGINT\n");
exit(0);
}

int main()
{
/* 将 Ctrl-C 对应的 SIGINT 动作改为调用 handler。 */
if (signal(SIGINT, handler) == SIG_ERR)
unix_error("signal error");
/* 等待信号到达。 */
pause();
exit(0);
}

同一个 handler 可处理多类信号,handler 参数 sig 用于区分信号类型。

显式阻塞信号

signal handler 可被其他类型信号打断。内核会隐式阻塞当前 handler 正在处理的同类型信号,但不同类型信号仍可打断。

显式阻塞通过 sigprocmask 和信号集合函数完成,其改变当前阻塞的信号集合并在离开时恢复:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <signal.h>

/* 原子地修改当前进程的 blocked signal set。 */
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
/* 初始化为空集合。 */
int sigemptyset(sigset_t *set);
/* 初始化为包含所有信号的集合。 */
int sigfillset(sigset_t *set);
/* 向集合加入 signum。 */
int sigaddset(sigset_t *set, int signum);
/* 从集合删除 signum。 */
int sigdelset(sigset_t *set, int signum);
/* 测试 signum 是否属于集合。 */
int sigismember(const sigset_t *set, int signum);

how 参数:

how 效果
SIG_BLOCK blocked = blocked | set,添加set信号至阻塞集合
SIG_UNBLOCK blocked = blocked & ~set,从阻塞集合删除set信号
SIG_SETMASK blocked = set,直接将set设置为阻塞集合

若 oldset 非空,旧 blocked set 会写入 oldset。sigprocmask 可以通过选择阻塞信号来避免并发导致的 race 问题。

安全信号处理

规则

主程序和 signal handler 并发执行,因此 signal handler 中容易出现并发 bug。

规则 内容
G0 handler 尽量简单,例如只设置全局 flag 后返回
G1 handler 中只调用 async-signal-safe 函数
G2 保存并恢复 errno
G3 访问共享全局数据结构前阻塞所有相关信号
G4 全局变量用 volatile 声明
G5 flag 用 sig_atomic_t 声明

printf、sprintf、malloc、exit 等不是 async-signal-safe。_exit 是 async-signal-safe 的退出函数。

在 handler 函数中使用 printf 这样的函数可能引发死锁等并发错误,应当使用安全 I/O:

1
2
3
4
5
6
7
8
9
10
11
12
ssize_t sio_puts(char s[])
{
/* write 是 async-signal-safe,可在 handler 中调用。 */
return write(STDOUT_FILENO, s, sio_strlen(s));
}

void sio_error(char s[])
{
sio_puts(s);
/* _exit 直接结束进程,避免调用非安全的 exit 清理逻辑。 */
_exit(1);
}

回收所有子进程

错误写法只回收一个子进程:

1
2
3
4
5
6
7
8
9
void handler1(int sig)
{
/* 保存 errno,避免 handler 改写主程序正在依赖的错误码。 */
int olderrno = errno;
/* 该写法只回收一个子进程,可能遗漏同时结束的其他子进程。 */
if ((pid = waitpid(-1, NULL, 0)) < 0)
sio_error("waitpid error");
errno = olderrno;
}

由于同类 pending signal 不排队,多个子进程几乎同时终止时,SIGCHLD 可能只触发一次。正确写法应循环回收:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void handler2(int sig)
{
int olderrno = errno;
pid_t pid;

/* WNOHANG 避免 handler 在没有已终止子进程时阻塞。 */
while ((pid = waitpid(-1, NULL, 0)) > 0)
sio_puts("Handler reaped child\n");
/* 没有子进程时 errno=ECHILD,是正常结束条件。 */
if (errno != ECHILD)
sio_error("waitpid error");

errno = olderrno;
}

可移植信号处理

慢系统调用如 read、write、wait、accept 可能阻塞较长时间。不同 Unix 系统对 signal handler 的语义可能不同,例如:

  • 捕获信号后是否重置 handler 为默认动作。
  • 信号中断慢系统调用后是否自动重启。

可手动处理 EINTR:

1
2
3
4
while ((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0)
/* EINTR 表示 read 被信号中断,可重新执行。 */
if (errno != EINTR)
unix_error("read");

更推荐使用 sigaction 封装稳定语义:

1
2
3
4
5
6
#include <signal.h>

/* sigaction 提供比 signal 更明确、可移植的信号处理语义。 */
int sigaction(int signum,
struct sigaction *act,
struct sigaction *oldact);

CSAPP Signal 封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
handler_t *Signal(int signum, handler_t *handler)
{
struct sigaction action, old_action;

/* 指定新的用户级 handler。 */
action.sa_handler = handler;
/* handler 执行时额外阻塞的信号集合为空;同类信号仍由内核隐式阻塞。 */
sigemptyset(&action.sa_mask);
/* 被信号中断的慢系统调用尽量自动重启。 */
action.sa_flags = SA_RESTART;

if (sigaction(signum, &action, &old_action) < 0)
unix_error("Signal error");
/* 返回旧 handler,便于恢复。 */
return old_action.sa_handler;
}

、

  • handler 执行时只阻塞当前处理的同类信号。
  • 信号不排队。
  • 被中断的系统调用尽可能自动重启。
  • handler 安装后保持有效,直到再次调用 Signal 修改。

fork 与 SIGCHLD 的竞态

典型竞态:

  1. 父进程 fork。
  2. 内核调度子进程。
  3. 子进程终止,内核向父进程发送 SIGCHLD。
  4. 父进程 handler 执行 deletejob(pid)。
  5. 父进程从 fork 返回后执行 addjob(pid)。

结果是已终止子进程可能被加入 job list。

修正方法:在 fork 前阻塞 SIGCHLD,父进程完成 addjob 后恢复信号掩码;子进程在 execve 前恢复信号掩码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
sigset_t mask_all, mask_one, prev_one;

/* mask_all 用于短时间保护整个 job list。 */
Sigfillset(&mask_all);
Sigemptyset(&mask_one);
/* mask_one 只包含 SIGCHLD,用于阻止子进程先于 addjob 被回收。 */
Sigaddset(&mask_one, SIGCHLD);

Signal(SIGCHLD, handler);
initjobs();

while (1) {
/* fork 前阻塞 SIGCHLD,关闭 fork/addjob 之间的竞态窗口。 */
Sigprocmask(SIG_BLOCK, &mask_one, &prev_one);
if ((pid = Fork()) == 0) {
/* 子进程继承阻塞集合,exec 前必须恢复,避免新程序屏蔽 SIGCHLD。 */
Sigprocmask(SIG_SETMASK, &prev_one, NULL);
Execve("/bin/ls", argv, NULL);
}

/* 父进程修改共享 job list 时进一步阻塞所有信号。 */
Sigprocmask(SIG_BLOCK, &mask_all, NULL);
addjob(pid);
/* addjob 完成后恢复 fork 前的阻塞集合,此时可处理 SIGCHLD。 */
Sigprocmask(SIG_SETMASK, &prev_one, NULL);
}

显式等待信号

等待 SIGCHLD 时,不应使用空转:

1
2
3
while (!pid)
/* 忙等会持续占用 CPU。 */
;

该空转循环浪费 CPU。

也不能简单改为:

1
2
3
while (!pid)
/* SIGCHLD 可能在条件检查之后、pause 之前到达,导致永久睡眠。 */
pause();

若 SIGCHLD 在 while 判断之后、pause 之前到达,那么 pause() 实际开始运行的时候已经没有需要处理的信号了。进程可能永久睡眠。

sleep(1) 或 nanosleep 也不合适:间隔过小浪费 CPU,间隔过大响应慢,不容易调整。

正确方法是 sigsuspend:

1
2
3
4
#include <signal.h>

/* 原子地替换阻塞集合并挂起,直到接收到一个未阻塞信号。 */
int sigsuspend(const sigset_t *mask);

sigsuspend(mask) 临时把当前 blocked set 替换为 mask(含有 SIGCHLD),并挂起进程直到收到一个信号。若 handler 返回,sigsuspend 返回并恢复原 blocked set。

它等价于以下三步的原子版本:

1
2
3
4
/* 非原子版本:在解除屏蔽和 pause 之间存在竞态窗口。 */
sigprocmask(SIG_SETMASK, &mask, &prev);
pause();
sigprocmask(SIG_SETMASK, &prev, NULL);

这样改写避免了进程在某个危险时刻结束,导致 SIGCHLD 被提前处理而使 pause 永久睡眠。

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
volatile sig_atomic_t pid;

void sigchld_handler(int s)
{
/* handler 中保存 errno,避免影响主控制流。 */
int olderrno = errno;
/* 回收一个已终止子进程,并用 pid 通知主循环。 */
pid = waitpid(-1, NULL, 0);
errno = olderrno;
}

while (1) {
/* 阻塞 SIGCHLD,避免子进程在 pid 清零前结束。 */
Sigprocmask(SIG_BLOCK, &mask, &prev);
if (Fork() == 0)
exit(0);

pid = 0;
while (!pid)
/* 原子恢复 prev 并等待信号,避免 pause 竞态。 */
sigsuspend(&prev);

/* 恢复进入循环前的 signal mask。 */
Sigprocmask(SIG_SETMASK, &prev, NULL);
}

在这个版本中,进入循环先屏蔽 SIGCHLD 并将原来的 blocking 数组存在 prev 中,并在执行到 pause() 时恢复接收 SIGCHLD 信号。由于 sigsuspend(&prev) 是一个原子操作,SIGCHLD 不会在修改 blocking 位之后和执行 pause() 之前被处理掉,因此避免了永久睡眠的问题。

非本地跳转

非本地跳转(nonlocal jump)允许控制流从一个函数直接跳转到另一个仍在执行的函数,不经过正常调用返回序列。C 通过 setjmp / longjmp 提供用户级异常控制流。

1
2
3
4
5
6
#include <setjmp.h>

/* 保存当前执行环境,首次调用返回 0。 */
int setjmp(jmp_buf env);
/* 恢复 env 对应环境,使 setjmp 再次返回 retval。 */
void longjmp(jmp_buf env, int retval);

语义:

  • setjmp(env) 保存当前调用环境,包括 PC、stack pointer 和通用寄存器,首次返回 0。
  • longjmp(env, retval) 恢复 env 中保存的环境。
  • 恢复后,最近一次初始化 env 的 setjmp 会再次返回,返回值为非零的 retval。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
jmp_buf buf;

int main()
{
/* setjmp 首次返回 0;之后 longjmp 会使其返回非零错误码。 */
switch (setjmp(buf)) {
case 0:
/* 正常路径:调用可能出错的函数。 */
foo();
break;
case 1:
printf("Detected error1 in foo\n");
break;
case 2:
printf("Detected error2 in bar\n");
break;
default:
printf("Unknown error\n");
}
exit(0);
}

void foo(void)
{
if (error1)
/* 直接跳回 main 中 setjmp 的位置,并让 setjmp 返回 1。 */
longjmp(buf, 1);
bar();
}

void bar(void)
{
if (error2)
/* 直接跳回 main 中 setjmp 的位置,并让 setjmp 返回 2。 */
longjmp(buf, 2);
}

大致流程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
main
↓
setjmp(buf) 第一次返回 0
↓
foo() → longjmp(buf, 1) → 回到 setjmp(buf)
↓ ↓
bar() setjmp(buf) 第二次返回 1
↓ ↓
longjmp(buf, 2) 执行 case 1
↓
回到 main 中 setjmp(buf) 的位置
↓
setjmp(buf) 第二次返回 2
↓
执行 case 2

信号场景可使用 sigsetjmp / siglongjmp:

1
2
3
/* 信号场景下的非本地跳转版本,可选择保存和恢复 signal mask。 */
int sigsetjmp(sigjmp_buf env, int savemask);
void siglongjmp(sigjmp_buf env, int retval);

若 savemask 非零,当前 signal mask 会保存到 env,后续 siglongjmp 会恢复该 mask。

进程调度

调度讨论从机制与策略开始:

  • 机制 Mechanisms:实现功能的低层方法,例如 context switch、paging。
  • 策略 Policies:在机制之上作决策的算法,例如选择运行哪个进程、替换哪个页面。

调度策略决定在可运行进程中选择哪个进程使用 CPU。

Workload 假设

为分析简单算法,先假设:

  • 每个 job 运行时间相同。
  • 所有 job 同时到达。
  • job 一旦开始就运行到完成。
  • job 只使用 CPU,不做 I/O。
  • job 运行时间已知。

这些假设后续逐步松弛。

周转时间

周转时间是 job 完成时间减去到达时间:

Tturnaround=Tcompletion−TarrivalT_{turnaround}=T_{completion}-T_{arrival}

该指标关注 job 从进入系统到完成的总时间。

FIFO / FCFS

FIFO(First In, First Out)也称 FCFS(First Come, First Served),按到达顺序运行 job。

若 A、B、C 同时到达,各运行 10s:

Tavg=10+20+303=20T_{avg}=\frac{10+20+30}{3}=20

若 A 运行 100s,B、C 各运行 10s:

Tavg=100+110+1203=110T_{avg}=\frac{100+110+120}{3}=110

短 job 被长 job 阻塞的现象称为 convoy effect。

SJF

SJF(Shortest Job First)先运行最短 job。在所有 job 同时到达且不可抢占的条件下,SJF 对平均周转时间最优。

若 A=100s,B=10s,C=10s 且同时到达:

Tavg=10+20+1203=50T_{avg}=\frac{10+20+120}{3}=50

若 A 在 t=0t=0 到达并运行 100s,B、C 在 t=10t=10 到达并各运行 10s,非抢占 SJF 仍需等 A 完成:

Tavg=100+100+1103=103.33T_{avg}=\frac{100+100+110}{3}=103.33

STCF

STCF(Shortest Time-to-Completion First)是抢占式 SJF。每当新 job 到达,调度器选择剩余时间最短的 job。

在上述例子中,B、C 在 t=10t=10 到达后可抢占 A:

Tavg=10+20+1203=50T_{avg}=\frac{10+20+120}{3}=50

STCF 改善周转时间,但需要知道剩余运行时间。

响应时间与 Round Robin

交互式系统还关注响应时间:

Tresponse=Tfirst run−TarrivalT_{response}=T_{first\ run}-T_{arrival}

响应时间衡量 job 到达后第一次被调度的等待时间。

Round Robin(RR)机制:

  1. 运行一个 job 一个时间片。
  2. 切换到运行队列中的下一个 job。
  3. 重复直到所有 job 完成。

时间片也称 scheduling quantum,通常是定时器中断周期的倍数,例如 10 ms 的整数倍。

上下文切换有成本:

  • 权限切换。
  • 保存和恢复上下文。
  • 可能刷新 TLB 和流水线 / 乱序执行状态。

时间片过短会增加切换成本;时间片过长会增加响应时间,需要权衡。

策略 周转时间 响应时间
SJF 通常较好 可能较差
RR 通常较差 通常较好

纳入 I/O

当 job 发起 I/O 请求:

  1. job 调用系统调用。
  2. 内核阻塞该 job。
  3. 调度另一个 ready job 使用 CPU。

当 I/O 完成:

  1. 设备向 CPU 发送中断。
  2. job 变为 ready。
  3. 调度器决定之后运行哪个 job。

调度应尽量重叠 CPU 与 I/O,提高系统利用率。

多级反馈队列

MLFQ(Multi-Level Feedback Queue)目标:

  • 在不知道 job 长度的情况下优化周转时间。
  • 降低交互式任务响应时间。

核心思想:调度器根据 job 运行历史推测其行为。短作业和频繁让出 CPU 的交互式作业保持高优先级;长时间占用 CPU 的作业逐步降级。

基本规则

MLFQ 有多个队列,每个队列有不同优先级。一个 ready job 位于某一个队列中。

规则 内容
Rule 1 若 Priority(A) > Priority(B),运行 A
Rule 2 若 Priority(A) = Priority(B),A 和 B 按 RR 运行
Rule 3 新 job 进入系统时放入最高优先级队列
Rule 4a job 用完整个时间片后,优先级降低
Rule 4b job 在时间片用完前让出 CPU,则保持当前优先级

该规则使短 job 先以高优先级运行。若 job 实际较长,会逐渐下降到低优先级。频繁 I/O 的交互式 job 往往不会用完整时间片,因此可保持较高优先级。

问题 表现
Starvation 若交互式 job 太多,长时间 CPU-bound job 可能长期得不到 CPU
Gaming scheduler 进程在时间片用完前主动发起 I/O,避免降级,从而获得更多 CPU
行为变化 进程可能从 CPU-bound 阶段转为交互式阶段,旧优先级不再合适

Rule 5(Priority Boost):每隔时间 SS,将系统中所有 job 移到最高优先级队列。该改进防止长期饥饿,并让已经改变行为的进程重新获得较高优先级。

为防止 gaming,细化 Rule 4:只要 job 在某一优先级累计用完其时间配额,不论中间主动让出 CPU 多少次,都降低优先级。

调度器记录进程在当前优先级已经使用的 CPU 时间。一旦达到该层配额,将其降到下一层队列。

MLFQ 需要调节的参数:

  • 队列数量。
  • 每个队列的时间片长度。
  • priority boost 的周期 SS。

常见设置:

  • 高优先级队列使用较短时间片,适合交互式 job。
  • 低优先级队列使用较长时间片,适合 CPU-bound job。

这些参数通常依赖经验和典型工作负载。

MLFQ 最终规则:

  1. 若 Priority(A) > Priority(B),运行 A。
  2. 若 Priority(A) = Priority(B),A 和 B 按 RR 运行。
  3. 新 job 进入系统时放入最高优先级队列。
  4. job 在某层累计用完时间配额后,优先级降低。
  5. 每隔时间 SS,将所有 job 移到最高优先级队列。

MLFQ 不要求预先知道 job 长度,而是观察运行行为并调整优先级。许多系统使用 MLFQ 或其变体作为基础调度器