异常
概念:
异常就是控制流的突变
当系统检测到程序发生异常时,处理器通过异常表跳转到处理特定异常的异常处理程序之中
异常处理程序运行在内核模式下,具有访问系统资源的权限
系统为每一种异常分配了唯一的异常号,处理器通过异常号来索引异常表
常见的异常包括除以0,内存缺页,内存访问违规,算数计算溢出等等
类别:
异常有如下几种类型:
- 中断(interrupt): 由外部事件引起的异常,例如网络适配器,键盘向处理器发信号等
- 陷阱(trap): 由程序执行引起的异常,例如读文件,创建新进程等
- 故障(fault): 由程序执行引起的异常,但可以通过修复后继续执行,例如缺页异常等
- 终止(abort): 由程序执行引起的异常,无法修复,必须终止程序,例如一些硬件错误等
Linux系统调用:
系统调用是用户程序与内核进行交互的接口,用户程序通过系统调用请求内核提供服务,例如文件操作,进程控制,网络通信等
x86-64架构中,系统调用通过syscall指令实现,系统调用号通过寄存器传递。例如rax寄存器中存储系统调用号,rdi,rsi,rdx等参数寄存器中存储系统调用参数
例如调用系统函数write(1, "Hello, World!\n", 14),其中系统调用号为1,参数分别为文件描述符1,字符串地址和字符串长度,对应的汇编如下
movq $1, %rax # 系统调用号1,表示write系统调用
movq $1, %rdi # 参数1:文件描述符1,表示标准输出
movq $message, %rsi # 参数2:字符串地址
movq $14, %rdx # 参数3:字符串长度
syscall # 触发系统调用进程
进程是执行中程序的示例,程序存储在磁盘中,操作系统将程序加载到内存中执行便形成了一个进程
- 并发: 同时运行多个逻辑流成为并发,并发不需要求多个处理器,单处理器系统也可以通过时间分片来实现并发
- 用户模式与内核模式: 处理器有两种运行模式,用户模式和内核模式,用户程序运行在用户模式下,无法直接访问系统资源,而内核代码运行在内核模式下,具有访问系统资源的权限。通常设置某个寄存器中的模式位区分两种模式
- 上下文: 进程的上下文包括程序计数器,寄存器状态,内存映射等信息,当发生异常时,处理器会保存当前进程的上下文,以便异常处理程序执行完毕后能够恢复到异常发生前的状态继续执行
下图展示了CPU在用户模式和内核模式之间切换的过程:当进程A要求读文件,CPU从用户模式切换到内核模式处理异常,结果是将A进程挂起执行B进程,等待内核完成文件读取后再切换回用户模式继续执行A进程
进程控制
获取进程ID:
每个进程都有一个唯一的进程ID(pid),可以通过系统调用getpid()获取当前进程的pid
#include <unistd.h>
pid_t pid = getpid();pid_t是一个整数类型,Linux中被定义为int类型
进程的状态:
进程只处于下面三种状态之一:
- 运行: 进程正在CPU上执行,或者等待被CPU调度执行
- 停止:进程被挂起,而且不会被CPU调度执行,直到接收到信号
- 终止:进程永远停止了
创建进程:
Linux中通过系统调用fork()创建新进程,fork()会创建一个与当前进程几乎完全相同的新进程,新的进程被称为子进程,原来的进程被称为父进程
两个进程共享代码,内存空间等。子进程试图修改共享资源时会触发写时复制机制,内核会为子进程创建一个新的内存空间,并将父进程的内存内容复制到子进程中
#include <unistd.h>
pid_t pid = fork();对于父进程来说,fork()返回子进程的pid;对于子进程来说,fork()返回0;如果创建进程失败,fork()返回-1
回收子进程:
当子进程通过调用exit()或者return终止时,并没有被内核立刻清除,而是保留了一些信息等待父进程来回收
一个终止了但是没有被回收的子进程被称为僵尸进程(zombie process)
一个进程可以通过调用waitpid等待子进程终止
当父进程终止时,所有未被回收的子进程会被init进程收养,init进程会负责回收这些子进程,避免僵尸进程的产生
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *statusp, int options);waitpid经过参数设置可以选择等待子进程的终止,或者非阻塞地检查子进程的状态,也可以选择终止的子进程的pid,或者等待任意子进程的终止
wait函数是waitpid的一个简化版本,等价于waitpid(-1, statusp, 0),即等待任意子进程的终止,并且阻塞等待
进程休眠:
进程可以通过调用sleep函数休眠指定的秒数
pause函数可以让进程休眠直到接收到一个信号
#include <unistd.h>
unsigned int sleep(unsigned int secs);
int pause(void);加载并运行程序:
execve函数可以加载一个新的程序并运行,新的程序替换当前进程的内存空间,代码段,数据段等
新程序的PID和打开的文件标识符保持不变
#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp[]);下图描述了程序启动时候的栈帧模型
信号
概念
异常是硬件层面的,由内核异常处理程序处理,一般对于用户不可见
信号提供了一种方法通知用户进程发生了这些异常
例如,当用户按下Ctrl-C时,内核会向当前运行的进程发送一个SIGINT信号,通知进程发生了中断异常。一个进程试图除以0,内核会向该进程发送一个SIGFPE信号,通知进程发生了算术异常。如果进程试图访问非法内存,内核会向该进程发送一个SIGSEGV信号,通知进程发生了段错误异常
信号传递由两个阶段
- 发送信号:内核更新目的进程上下文的某个状态
- 接受信号:目的进程被内核强迫以某种方式处理信号
一个发出但是未被接受的信号被称为待处理信号。
对一个进程来说,一种信号至多只会有一个待处理信号,多余的信号被丢弃
内核为进程维护pending和blocked向量,每一位表示一种信号,pending向量表示哪些信号已经到达但还没有被处理,blocked向量表示哪些信号被进程阻塞了。当内核检查到有待处理的信号时,如果该信号没有被阻塞,则内核会强迫进程处理该信号
发送信号
进程组是一个或多个进程的集合,进程组由一个唯一的进程组ID(pgid)标识
getpgid函数可以获取一个进程的pgid,setpgid函数可以设置一个进程的pgid
默认父子进程在创建时属于同一个进程组
发送信号有多种方式:
/bin/kill程序可以对另外的进程发送信号,例如kill -9 1234向pid为1234的进程发送SIGKILL信号
- 键盘发送信号:
shell程序使用作业的概念描述通过命令行创建的进程,至多只有一个前台作业。键盘上输入Ctrl-C会向前台作业发送SIGINT信号,结果是终止。输入Ctrl-Z会向前台作业发送SIGTSTP信号,结果是挂起
下图展示了shell程序中的前台作业和后台作业
- 程序调用
kill函数与alarm函数发送信号
#include <signal.h>
int kill(pid_t pid, int sig);
unsigned int alarm(unsigned int seconds);kill函数可以向指定pid的进程发送信号,如果pid为-1,则向所有进程发送信号
alarm函数可以设置一个定时器,当定时器到期时,内核会向调用进程发送一个SIGALRM信号
接收信号
内核将进程从内核模式切换到用户模式时会检查所有未被阻塞的信号,如果有待处理信号,内核会强迫进程处理这些信号
程序可以使用信号默认的行为,或者使用signal函数指定一个函数处理信号
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);阻塞: 阻塞就是进程显示地忽略某些信号,即使信号到达进程也会视而不见
程序使用sigprocmask函数来阻塞和解除阻塞信号
信号处理
安全函数
由于程序在接受到信号时如果没有设置阻塞,会跳转到信号处理函数中执行。如果信号处理函数修改了全局变量,或者调用了非异步信号安全的函数,那么可能会导致程序出现不可预期的行为。例如程序本来在遍历一个链表,结果在信号处理函数中修改了链表的结构,处理完毕信号之后跳转继续执行,程序崩溃。
许多常见的函数,例如printf,malloc,free等都不是异步信号安全的函数,不能在信号处理程序中调用
信号处理函数最好遵循以下原则:
- 尽可能简单
- 只调用异步信号安全的函数
- 保存与恢复
errno:errno是一个全局变量,许多系统调用和库函数在发生错误时会设置errno - 阻塞信号,保护全局数据结构:在主程序和信号处理程序中访问同一个全局变量时,应该使用
sigprocmask函数阻塞相关信号,保护全局数据结构的完整性 - 使用
volatile sig_atomic_t类型的全局变量:volatile告诉编译器该变量可能会被异步修改,禁止编译器对其进行优化;sig_atomic_t是一个整数类型,保证在访问时不会被中断
正确处理信号
编写正确的信号处理函数是比较困难的,必须时刻考虑信号与主程序的异步性,以避免产生竞态条件和死锁等问题
限于篇幅,这里简单总结了书中所说的几个例子,详情请参考书中Chapter 8.5.5
- 处理信号覆盖: 当一个信号处理程序正在执行时,如果又有同样的信号到达,那么新的信号会覆盖正在执行的信号处理程序
- 处理进程阻塞:当程序使用
read函数被阻塞时,如果此时有一个信号到达,内核会中断read函数的执行,转而执行信号处理程序。为了完整read函数的执行可能需要不停循环直到完成。signal包装函数通过使用sigaction函数来处理信号覆盖和进程阻塞的问题 - 竞争:当主程序和信号处理程序访问同一个全局变量时,可能会产生竞态条件,导致程序出现不可预期的行为。解决方法是使用
sigprocmask函数阻塞相关信号,保护全局数据结构的完整性 - 等待: 当主程序需要等待一个信号时,应该使用
sigsuspend函数,而不是使用pause函数。如果条件为真但是pause还未执行时信号已经到达,程序就会错过这个信号,永远留在pause函数中等待。sigsuspend函数可以原子地检查条件和等待信号,避免了这个问题