csapp 异常控制流一章介绍了 Unix 系统的异常处理机制(其他系统应该类似). 正常情况下, CPU 按程序逻辑逐步执行流水指令; 所谓异常(exception), 就是 此时发生的外部事件, 一般来自硬件, 系统或者用户操作等. 程序或系统进行异常处 理后(响应或者忽略), 通常情况下, 会再将控制权交回给应用程序.

异常可以分为几种. 来自I/O设备的称为中断(Interrupt); 操作系统预留的信号 称为陷阱(Trap); 错误(Fault)和终止(Abort)就如名字一样, 分别是可恢复错误 和不可恢复错误. 我觉得陷阱这个名字非常贴切, 正在运行的应用程序(进程)接 收到信号, 就会落入内核的掌控, 就如同陷阱一样.

进程和进程组

进程是操作系统为运行的程序所提供的抽象. 每个运行的程序所需的资源由操作 系统分配(私有地址空间等), 并与其他与其无关的程序隔离, 制造由此程序独占 系统资源的假象. 所以进程间的通信必须要借助额外的手段, 比如管道或 socket.

user and kernel mode

为了避免普通进程干涉其他独立进程, 处理器会提供一个机制来限制进程所能够 执行的指令. 运行在 kernel mode 中程序可以执行任意指令(比如系统内核的进 程), 而运行在 user mode 中的程序则必须通过系统调用才能够访问内核代码和 数据.

context switch

内核会保存每一个正在运行的程序的context, 所谓context, 就是重启这个进程 所需要的所有信息, 如通用寄存器, 浮点寄存器, 程序计数器(Program Counter), 用户栈和内核栈, 以及打开的文件等. 内核调度运行程序的时候会进 行 context switch, 保存当前运行程序的 context, 创建或者恢复即将运行的 程序的 context.

内核可能在运行新程序, 执行系统调用(比如文件读取), timer中断等时候进行 context switch. csapp 里有一张示意图:

img

context switch 会对程序造成开销, 所以需要尽量避免在程序中不必要的系统 调用造成context switch.

另外, context 一般译为上下文, 其实很贴切, 不过有点不太符合表达习惯. 感 觉这里叫做环境也可以.

进程操作

fork

pid_t fork(void);

创建一个和当前环境相同的子进程. 子进程独立于父进程, 不过会复制父进程中的变量和打开的文件描述符.

fork 函数会返回两次, 在父进程中返回子进程的pid, 在子进程中返回0. 所 以可以据此来判断代码处于哪个进程中.

if ((pid == fork()) == 0) {
	// child process, do something
} else {
	// parent process, pid is the child's pid
}

fork 之后, 子进程会继续执行代码, 所以多次 fork 极易造成困扰. 可以用分支图来帮助理解

默认情况下, 子进程与父进程同属一个进程组, 可以通过 setpgid 来更改. 对于需要手动管理进程的程序来说很必要, 比如 shell.

waitpid

pid_t waitpid(pid_t pid, int *statusp, int options);

用来回收子进程. 如果 pid=-1 的话, 回收范围为所有子进程, 否则只回收给 定 pid的子进程. statusp 可以用来获得子进程的返回状态, 而 options 则指定在回收子进程的模式:

  • 默认0, 等待一个子进程结束. 如果子进程已结束, 返回0.
  • WNOHANG, 如果没有子进程结束, 立刻返回0.
  • WUNTRACED, 等待子进程结束或者停止(收到 SIGTSTP(Ctrl-Z) 信号).
  • WCONTINUED, 等待子进程结束或者重新启动(收到 SIGCONT 信号).

这些 option 可以互相组合, 比如 WNOHANG | WUNTRACED 就是如果没有子 进程结束和停止就立即返回, 否则返回结束或停止的子进程的 pid.

如果父进程先于子进程结束, 那么子进程就会被init(现在都是 systemd)接管.

守护进程

守护进程(daemon)是长期在背景运行的进程. Unix 里通常使用两次 fork, 让 实际运行的进程由系统进程接管. 多次 fork 的用意是避免子进程成为 group leader 或者 session leader(似乎可以通过 setpgidsetsid 避免).

不过现在都可以通过 systemd 来启动进程了.

信号

内核或进程通过信号向另一个进程发送信号, 告知相应的系统事件. 比如 SIGKILL 终止, SIGTSTP 挂起进程, 子进程终止时会向父进程发送 SIGCHLD 信 号等. 发出而未被处理的信号称为待处理信号, 比如在一个进程接收或处理信号 时到达的相同类型的其他信号. 一个进程也可以主动阻塞某种信号, 相应的信号 也会成为待处理信号, 直到阻塞被取消. 相同的待处理信号最多只有一条, 其后 到达的其他相同信号都会被直接丢弃. 所以信号不会排队, 也不能用作事件计数.

发送信号

可以利用几种方式发送信号:

  • 从键盘发送信号, 比如 Ctrl-c 发送 SIGINT, Ctrl-z 发送 SIGTSTP.

  • kill 函数.

  • alarm 函数向自己发送 SIGALRM 信号.

接受信号

每个系统信号都有其默认的处理行为. 应用程序也可以注册自己的信号处理函数. 当接受到信号时, 信号处理函数就会被调用; 当处理函数执行 return 时, 控 制通常会传递回接收信号中断处的指令.

可以利用

#include <signal.h>

sighandler_t signal(int signum, sighandler_t handler);

来注册信号处理函数. 或者利用 sigaction 来实现可移植的版本:

handler_t *Signal(int signum, handler_t *handler) {
	struct sigaction action, old_action;

	action.sa_handler = handler;
	sigemptyset(&action.sa_mask);
	action.sa_flags = SA_RESTART;

	if (sigaction(signum, &action, &old_action) < 0) {
		unix_error("Signal error");
	}
	return old_action.sa_handler;
}

需要注意的是, 处理信号函数可能会触发新的信号. 比如在本章的 shell lab 作业中, shell 进程将 SIGTSTP 信号转发给子进程时, 子进程会发出 SIGCHLD 信号, 因此信号处理函数需要妥善处理这些信号.

阻塞信号

可以利用 sigprocmask 显式地阻塞或取消阻塞信号.

sigset_t mask, old_mask;
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, &old_mask);

阻塞信号一般用于并发编程中, 防止预期信号过早的到达. 待预处理行为完成后, 再取消阻塞后处理信号.

sleeppause 函数会在接收到信号的时候返回, 因此可以用来等待信号的触发. 但是下面的代码是错误的:

while (signal triggered condition) {
	pause();
}

因为信号可能会在判断条件之后, pause 函数之前到达, 这样信号处理函数返 回后就会被 pause 函数挂起, 直到第二个信号到达.

Linux 提供了一个原子的信号挂起操作 sigsuspend. 将 pause 替换成 sigsuspend 就可以了.

小结

简要叙述了 Unix 系统里的进程的概念和进程操作. 信号用于内核与进程或进程 之间的消息传递. 注意在多余的相同信号会被丢弃, 所以信号不能用于计数. 在 存在并发的情况下, 需要注意信号的到达位置, 必要时需要阻塞信号或采用原子 操作避免竞争.