异常控制流

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。处理器会把恢复被中断程序所需的信息压入内核栈,例如返回地址、RFLAGSRSP 等。系统调用不一定需要保存同样的信息。

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。

上下文切换

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

触发时机:

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

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 所有子进程

错误情况:

  • 调用进程没有子进程:返回 -1errno=ECHILD
  • 被信号中断:返回 -1errno=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)

休眠与程序加载

sleeppause

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

setenvunsetenv 只修改当前进程及其之后创建的子进程的环境变量,不会修改父进程,也不会永久修改 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 修改默认动作,SIGSTOPSIGKILL 例外,不能修改。

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 会写入 oldsetsigprocmask 可以通过选择阻塞信号来避免并发导致的 race 问题。

安全信号处理

规则

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

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

printfsprintfmallocexit 等不是 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;
}

可移植信号处理

慢系统调用如 readwritewaitaccept 可能阻塞较长时间。不同 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 修改。

forkSIGCHLD 的竞态

典型竞态:

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

SIGCHLDwhile 判断之后、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 中保存的环境。
  • 恢复后,最近一次初始化 envsetjmp 会再次返回,返回值为非零的 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=TcompletionTarrivalT_{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 runTarrivalT_{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 或其变体作为基础调度器