[CSAPP笔记][第八章异常控制流][呕心沥血千行笔记]
异常控制流
控制转移
控制流
系统必须能对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕获,也不一定和程序的执行相关。
现代系统通过使控制流 发生突变对这些情况做出反应。我们称这种突变为异常控制流
( Exceptional Control Flow,ECF
)
异常控制流
发生在系统的各个层次。
理解ECF
很重要
- 理解
ECF
将帮助你理解重要的系统概念。 - 理解
ECF
将帮助你理解应用程序如何与操作系统交互- 通过陷阱(
trap
)或者系统调用(system call
)的ECF形式,向操作系统请求服务。
- 通过陷阱(
- 理解
ECF
将帮助你编写有趣的应用程序 - 理解
ECF
将帮助你理解并发 - 理解
ECF
将帮助你理解软件异常如何工作。
这一章你将理解如何与操作系统交互,这些交互都围绕ECF
8.1 异常
异常是异常控制流的一种,一部分由硬件实现,一部分由操作系统实现。
-
异常
(exception)就是控制流的突变,用来响应处理器状态的某些变化。 -
状态变化又叫做
事件
(event)- 事件可能与当前执行指令有关
- 存储器缺页,算数溢出
- 除0
- 也可能与当前执行指令无关
- I/O请求
- 定时器产生信号
- 事件可能与当前执行指令有关
-
通过
异常表
(exception table)的跳转表,进行一个间接过程调用,到专门设计处理这种事件的操作系统子程序(异常处理程序
(exception handler)) -
异常处理完成后,根据事件类型,会有三种情况
- 返回当前指令,即发生事件时的指令。
- 返回没有异常,所执行的下一条指令
- 终止被中断的程序
8.1.1 异常处理
-
为每个异常分配了一个非负的
异常号
(exception number)- 一些号码由处理器设计者分配
- 其他号码由操作系统内核的设计者分配。
-
系统启动时,操作系统分配和初始化一张称为
异常表
的跳转表。- 条目k包含异常k的处理程序的地址。
-
异常表
的地址放在叫异常表基址寄存器
的特殊CPU寄存器中。) -
异常
类似过程调用
,不过有以下不同- 过程调用,跳转到处理程序前,处理器将返回地址压入栈中。对于异常,返回地址是当前,或下一跳指令。
- 处理器会把额外的处理器状态压入栈中。
- 如果控制一个用户程序到内核,那么所有这些项目会被压入内核栈中,而是用户栈。
- 异常处理程序运行在内核模式下,这意味他们对所有系统资源有完整访问权限。
8.1.2 异常的类别
异常分为一下四类:中断
(interrupt),陷阱
(trap),故障
(fault)和终止
(abort)。
前者可以叫异步中断/异常或外中断 ,后三个可以叫同步中断/异常
-
中断
中断
是异步发生,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成,从这个意义上它是异步的。- 硬件中断的异常处理程序通常称为中断处理程序(interrupt handle)
- I/O设备通过向处理器芯片的一个引脚发信号,并将异常号放到系统总线上,以触发中断。
- 在当前指令执行完后,处理器注意到中断引脚的电压变化,从系统总线读取异常号,调用适当的中断处理程序。
- 当处理程序完成后,它将控制返回给下一条本来要执行的指令。
剩下的异常类型(陷阱,故障,终止)是同步发生,执行当前指令的结果。我们把这类指令叫做故障指令(faulting instruction).
-
陷阱和系统调用
陷阱
是有意的异常,是执行一个指令的结果。也会返回到下一跳本来要执行的指令。陷阱
最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用- 用户程序经常需要向内核请求服务。
- 读文件(read)
- 创建进程(fork)
- 新的程序(execve)
- 终止当前进程(exit)
- 为了运行对这些内核服务的受控访问,处理器提供了一条特殊的
syscall n
的指令 - 系统调用是运行在内核模式下,而普通调用是用户模式下。
- 用户程序经常需要向内核请求服务。
-
故障
-
故障由错误引起,可能被故障处理程序修正。
- 如果能被修正,返回引起故障的指令。
- 否则返回
abort
例程,进行终结。
-
-
终止
- 终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM和SRAM被损坏。
- 终止处理程序从不将控制返回给应用程序。返回一个
abort
例程。
8.1.3 Linux/IA32 系统中的异常
- 有高达256种不同的异常
- 0~31 由Intel架构师定义的异常,对任何IA32系统都一样。
- 23~255 对应操作系统定义的中断和陷阱。
-
Linux/IA32 故障和终止
- 除法错误
- 一般保护故障
- 缺页
- 机器检查
-
Linux/IA32 系统调用
-
在IA32系统中,系统调用是通过一条称为
int n
的陷阱指令完成,其中n可能是IA32异常表256个条目中任何一个索引,历史中,系统调用是通过异常128(0x80)提供的。 -
C程序可用
syscall
函数来直接调用任何系统调用- 实际上没必要这么做
- C库提供了一套方便的包装函数。这些包装函数将参数打包到一起,以适当的系统调用号陷入内核,然后将系统调用的返回状态传递回调用函数。
- 我们将系统调用与他们相关联的包装函数称为系统级函数。
研究程序如何使用int指令直接调用Linux 系统调用是很有趣的。所有到Linux系统调用的参数都是通过通用寄存器而不是栈传递。
惯例
- %eax 包含系统调用号
- %ebx,%ecx,%edx,%esi,%edi,%ebp包含六个任意的参数。
- %esp不能使用,进入内核模式后,内核会覆盖它。
-
系统级函数写的hello world
int main() { write(1,"hello,world\n",13); exit(0); }
-
汇编写的hello world
string: "hello world\n" main: movl $4,%eax movl $1,%ebx movl $String,%ecx movl $len,%edx int $0x80 movl $1,%eax movl $0,%ebx int $0x80
8.2 进程
异常
是允许操作系统提供进程
的概念的基本构造快,进程
是计算机科学中最深刻,最成功的概念之一。- 假象,觉得我们的程序是系统中唯一运行着的程序。我们的程序好像独占处理器和存储器。
- 这些假象都是通过进程概念提供给我们的。
进程
经典定义:一个执行中的程序实例.- 系统中每个程序都是运行某个进程的
上下文
中的。- 上下文是由程序正确运行所需的状态组成。
- 这个状态包括存储器中的代码和数据,它的栈,通用目的寄存器,程序计数器,环境变量等。
- 系统中每个程序都是运行某个进程的
进程
提供的假象- 一个独立的
逻辑控制流
。 - 一个私有的
地址空间
。
- 一个独立的
8.2.1 逻辑控制流
- PC值的序列叫做
逻辑控制流
,或者简称逻辑流
8.2.2 并发流
-
逻辑流
也有不同的形式。- 异常处理程序,进程,信号处理程序,线程和Java进程都是逻辑流的例子。
-
一个逻辑流的执行在执行上与另一个流重叠,称为
并发流
,这两个流被称为并发地运行。- 更准确地说,流X和Y互相并发。
-
多个流并发执行的一般现象称为
并发
。- 一个进程和其他进程轮流执行的概念称为
多任务
。 - 一个进程执行它的控制流的一部分的每一时间段叫做
时间片
。 - 因此,多任务 又叫
时间分片
- 一个进程和其他进程轮流执行的概念称为
-
并发
的思想与流运行的处理器核数与计算机数无关。- 如果两个流在时间上重叠,即使运行在同一处理器,也是并发。
- 并行流是并发流的一个真子集。
- 两个流并发地运行在不同的处理器核或者计算机上,我们称为
并行流
。 - 它们并行地运行,且并行地执行
- 两个流并发地运行在不同的处理器核或者计算机上,我们称为
你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持
并发
也不支持并行
。
你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持
并发
。
你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持
并行
。
并发
的关键是你有处理多个任务的能力,不一定要同时。
并行
的关键是你有同时处理多个任务的能力。
8.2.3 私有地址空间
进程
为个程序好像独占了系统地址空间。
- 一个
进程
为每个程序提供它自己的私有地址空间。 - 不同系统一般都用相同的结构。
8.2.4 用户模式和内核模式
处理器提供一种机制,限制一个应用程序可以执行的指令以及它可以访问的地址空间范围。这就是用户模式
和内核模式
。
-
处理器通过控制寄存器中的一个
模式位
来提供这个功能。- 该寄存器描述了进程当前享有的特权。
- 设置了
模式位
后,进程就运行在内核模式中(有时也叫超级用户模式
)- 内核模式下的进程可以执行指令集的任何指令,访问系统所有存储器的位置。
- 没有设置
模式位
时,进程运行在用户模式。- 用户模式不允许程序执行特权指令。
- 比如停止处理器,改变模式位,发起一个I/O操作。
- 不允许用户模式的进程直接引用地址空间的内核区代码和数据。
- 任何尝试都会导致
保护故障
。 - 用户通过
系统调用
间接访问内核代码和数据。
- 用户模式不允许程序执行特权指令。
- 设置了
- 进程从用户模式转变位内核模式的方法
- 通过中断,故障,陷入系统调用这样的异常。
- 在异常处理程序中会进入内核模式。退出后,又返回用户模式。
- 该寄存器描述了进程当前享有的特权。
-
Linux提供一种聪明的机制,叫
/proc
文件系统。- 允许用户模式访问内核数据结构的内容。
/proc
文件将许多内核数据结构输出为一个用户程序可以读的文本文件的层次结构。- 如CPU类型(
/proc/cpuinfo
) - 特殊进程使用的存储器段('/proc/
/maps')
- 如CPU类型(
- 2.6 版本引入Linux内核引入
/sys
文件系统。- 输出关于系统总线和设备的额外的底层信息。
8.2.5 上下文切换
操作系统内核使用一种称为上下文切换的 较高层次 的异常控制流来实现多任务。
- 上下文切换机制建立在之前讨论的较低层次异常机制上的。
内核为每个进程维护一个上下文。
-
上下文就是重新启动一个被抢占的进程所需的状态。
- 由一些对象的值组成
- 通用目的寄存器
- 浮点寄存器
- 程序计数器(PC)
- 用户栈
- 状态寄存器
- 内核栈
- 各种内核数据结构
- 描绘地址空间的页表
- 包含当前进城信息的进程表
- 进程已打开文件信息的文件表
- 由一些对象的值组成
-
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。这种决定叫做调度(
shedule
),由内核中称为调度器(scheduler
)的代码处理的。- 当内核选择一个新的进程运行时,我们就说内核调度了这个进程。
-
当调度进程时,使用一种
上下文切换
的机制来控制转移到新的进程- 保存当前进程的上下文
- 恢复某个先前被抢占的进程被保存的上下文
- 将控制传递给这个新恢复的进程
-
什么时候会发生上下文切换
- 内核代表用户执行系统调用。
- 如果系统调用因为某个事件阻塞,那么内核可以让当前进程休眠,切换另一个进程。
- 或者可以用
sleep
系统调用,显式请求让调用进程休眠。 - 即使系统调用没有阻塞,内核可以决定执行上下文切换
- 中断也可能引发上下文切换。
- 所有系统都有某种产生周期性定时器中断的机制,典型为1ms,或10ms。
- 每次定时器中断,内核就能判断当前进程运行了足够长的时间,切换新的进程。
- 内核代表用户执行系统调用。
高速缓存污染和异常控制流
一般而言,硬件高速缓存存储器不能和诸如中断和上下文切换这样的异常控制流很好地交互,如果当前进程被一个中断暂时中断,那么对于中断处理程序来说高速缓存器是冷的。如果处理程序从主存访问足够多的表项,被中断的进程继续的时候,高速缓存对于它来说也是冷的,我们称中断处理程序污染了高速缓存。使用 上下文切换也会发生类似的现象。
8.3 系统调用错误处理
-
当Unix系统级函数遇到错误时,他们典型地返回-1,并设置全局变量
errno
来表示什么出错了。if((pid=fork()<0){ fprintf(stderr,"fork error: %s\n", strerror(errno)); exit(0); }
-
strerror 函数返回一个文本串,描述了个某个errno值相关联的错误。
8.4 进程控制
8.4.1 获取进程ID
#include<sys/types.h>
#include<unistd.h>
pid_t getpid(void);
pid_t getppid(void);
- PID是每个进程唯一的正数。
getpid()
返回调用进程的PID,getppid()
返回它的父进程的PID。- 返回一个类型
pid_t
的值,在Linux系统下在type.h被定义为int
8.4.2 创建和终止进程
进程总是处于下面三种状态
-
运行。进程要么在CPU中执行,要么等待执行,最终被内核调度。
-
停止。进程的执行被挂起,且不会被调度。
- 收到
SIGSTOP
,SIGTSTP
,SIDTTIN
或者SIGTTOU
信号,进程就会停止。 - 直到收到一个
SIGCONT
信号,在这个时刻,进程再次开始运行。 信号
是一种软件中断的形式。
- 收到
-
终止。进程永远停止。
- 收到一个信号。信号默认行为是终止进程。
- 从主程序返回
- 调用exit函数
- exit函数以
status退出状态
来终止进程(另一种设置方式在main中return )
- exit函数以
子进程
父进程通过调用fork
函数创建一个新的运行子进程
#include<sys/types.h>
#include<unistd.h>
pid_t fork(void);
返回:子进程返回0,父进程返回子进程的PID,如果出错,返回-1;
新创建的子进程几乎但不完全与父进程相同。
-
子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份拷贝。
- 包括文本,数据和bss段,堆以及用户栈。子进程还获得与父进程任何打开文件描述符相同的拷贝。
- 意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。
- 父进程和新创建的子进程之间最大的区别在于有不同的PID 。
-
fork()
函数会第一次调用,返回两次,一次在父进程,一次在子进程。- 返回值用来明确是在父进程还是在子进程中执行。
-
调用一次,返回两次。
- 对于具有多个fork实例的需要仔细推敲了
-
并发执行
- 父进程和子进程是并发运行的独立进程。
- 内核可能以任意方式觉得执行他们的顺序。
- 不能对不同进程中指令的交替执行做任何假设。
-
相同但是独立的地址空间
- 在刚调用时,几乎什么都是相同的。
- 但是它们都有自己的私人空间,之后对x的改变是相互独立的。
-
共享文件
- 父进程和子进程都把他们的输出显示在屏幕上。
- 子进程继承了父进程所有打开的文件。
画进程图会有帮助。
8.4.3 回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终结的状态,知道被它的父进程 回收(reap
)。
当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程。
一个终止了但还未被回收的进程叫做僵死进程
如果父进程没有回收,而终止了,那么内核安排init
进程来回收它们。
init
进程的的PID位1,在系统初始化时由内核创建的。- 长时间运行的程序,如shell,服务器,总是应该回收他们的僵死子进程
一个进程可以通过调用waitpid函数来等待它的子进程终止或停止
#include<sys/types.h>
#include<sys/wait.h>
pid_t waitpid(pid_t pid ,int *status, int options);
返回:如果成功,则为子进程的PID,如果WNOHANG,则为0,如果其他错误,则为-1.
waitpid
函数有点复杂。默认(option=0
)时,waitpid
挂起调用进程的执行,知道它的等待集合中的一个子进程终止,如果等待集合的一个子进程在调用时刻就已经终止,那么waitpid
立即返回。在这两种情况下,waitpid
返回导致waitpid
返回的已终止子进程的PID
,并且将这个已终止的子进程从系统中去除。
-
判断等待集合的成员
等待集合的成员通过参数pid确定
- 如果
pid>0
,那么等待集合就是一个独立的子进程,它的进程ID
等于PID
- 如果
pid=-1
,那么等待集合就是由父进程所有的子进程组成的。 waitpid
函数还支持其他类型的等待集合,包括UNIX进程组等,不做讨论。
- 如果
-
修改默认行为(此处书中有问题,作用写反了)
可以通过将
options
设置为常量WHOHANG
和WUNTRACED
的各种组合,修改默认行为。- WHOHANG: 如果等待集合中的任何子进程都还没有终止,那么立即返回(返回值为0)
- 默认的行为返回已终止的子进程。
- 当你要检查已终止和被停止的子进程,这个选项会有用。
- WUNTRACED:挂起调用进程的执行,知道等待集合中的一个进程变为已终结或被停止。
- 返回的PID为导致的已终止或被停止的子进程
PID
· - 默认的行为是挂起调用进程,直到有子进程终止。
- 返回的PID为导致的已终止或被停止的子进程
- WHOHANG|WUNTRACED: 立即返回,如果等待集合中没有任何子进程被停止或已终止,那么
- WHOHANG: 如果等待集合中的任何子进程都还没有终止,那么立即返回(返回值为0)
-
检查已回收子进程的退出状态
如果
status
参数是非空的,那么waitpid
就会在status
参数中放上关于导致返回的子进程的状态信息。wait.h
头文件定义解释status
参数的几个宏(函数宏):- WIFEXITED(status) : 如果子进程通过调用
exit
或者一个返回(return
)正常终止,就返回真。 - WEXITSATUS(status): 返回一个正常终止的子进程的退出状态。只有在
WIFEXITED
定义为真是,才会定义这个状态。 - WIFSIGNALED(status): 如果子进程是因为一个未被捕获的信号终止的,那么就返回真
- WTERMSIG(status): 返回导致子进程终止的信号的数目,只有在
WIFSIGNALED
返回真时,才会定义这个状态。 - WIFSTOPPED(status): 如果引起返回的子进程当前是被停止的,那么就返回真。
- WSTOPSIG(status): 取得引发子进程暂停的信号代码,只有在WIFSTOPPED为真,才定义这个状态。
- WIFEXITED(status) : 如果子进程通过调用
-
错误条件
- 调用进程没有子进程,那么
waitpid
返回-1
,并且设置errno
为ECHILD
。 - 如果
waitpid
函数被一个信号中断,那么它返回-1
,并且设置errno
为EINTR
。
- 调用进程没有子进程,那么
Q:凭什么输出bcac序列
A:?
-
wait 函数
wait函数是waitpid函数的简单版本:
#include<sys/types.h> #include<sys/wait.h> pid_t wait(int *status);
调用
wait(&status)
等价于调用waitpid(-1,&status,0)
。 -
waitpid实例,按顺序回收僵死进程
8.4.4 让进程休眠
-
sleep
函数将一个进程挂起一段指定时间#include <unistd.h> unsigned int sleep (unsigned int secs); 返回:还要休眠的描述
-
pause
让调用进程休眠,知道该进程收到一个信号#include<unistd.h> int pause(void);
8.4.5 加载并运行一个程序
execve
函数在当前进程的上下文中加载并运行了一个新程序。
#include <unistd.h>
int execve(const char *filename,const char *argv[],const char *envp[]);
execve
函数加载并运行可执行目标文件filename
,且带参数argv
和环境变量列表envp
。
只有当出现错误时,execve才会返回到调用程序
-
*argv[]
参数列表数据结构表示- 指向一个以
null
结尾的指针数组。 - 每个指针指向一个参数串。
- 一般来说,
argv[0]
是可执行目标文件的名字。
- 一般来说,
- 指向一个以
-
*envp[]
环境列表数据结构表示类似- 指向一个以
null
结尾的指针数组。 - 每个指针指向一个环境变量串。
- 每个串都是形如
KEY=VALUE
的 键值对
- 每个串都是形如
- 指向一个以
在execve
加载filename
以后,调用7.9节的启动代码,启动代码设置用户栈。并将控制传递给新程序的主函数。
-
主函数有如下原型
int main(int argc,char **argv,char **envp);
int main(int argc,char *argv[],char *envp[]); -
当开始执行时,用户栈如图。
argc
: 命令行参数个数argv
: 命令行指针数组的地址envp
: 环境变量指正数组的地址
Unix提供一下几个函数来操作环境数组。
-
getenv
#include<stdlib.h> char *getenv(const char *name); //getenv函数在环境变量搜索字符串“name=value"。如果找到了,它就返回一个指向value的指针,否则返回NULL。
-
setenv
和unsetenv
#include<stdlib.h> int setenv(const char *name,const char *newvalue,int overwrite); //成功返回0,错误返回-1 void unsetenv(const char *name); //如果环境数组包含一个形如"name=oldvalue"的字符串,那么unsetenv会删除它 //,而setenv会用newvalue代替oldvalue,但是只有在overwirte非零时才会这样。 //如果name不存在,那么setenv就把”name=newvalue"添加进指针数组。
fork
与execve
区别
fork
:在新的子进程运行相同的程序。- 新进程是父进程的复制品。
execve
:在当前进程的上下文加载并运行一个新的程序。- 覆盖当前进程的地址空间。
- 但没有创建新进程。
- 新的程序仍然有相同的
PID
,并且继承了调用execve
函数时已打开的所有文件描述。
8.4.6 利用fork和execve运行程序
Unix shell和Web服务器 这样的程序大量使用fork
和execve
函数。
shell是一种交互型的应用级程序,代表用户运行其他程序。
- 最早的
shell
是sh
程序。 - 后面出现了
csh
,tcsh
,ksh
,bash
。 - shell执行一系列 read/evaluate
- read:读取来自用户的命令。
- evaluate:解析命令,并代表用户执行程序。
其实shell
也就是一个ACM中很简单的模拟题而已。
- 对字符串的处理。考虑各种
trick
。 - 通过判断命令结尾是否有
&
来决定shell
是否waitpid
。即是否后台运行。
- 输出一个
>
,等待接收命令。 - 调用
eval
对命令运算。
-
parseline
解析以空格分割的命令行参数,并将分割后的值丢入argv
中。- 如果末尾是
&
,则返回1。表示后台运行
- 如果末尾是
-
builtin_command
判断一下是否存在这样的指令。 -
如果
bg=0
,那么等待程序结束,shell
才会继续执行。 -
parseline
具体代码就不贴了。
注意这个简单的shell
是有缺陷的,因为它并不回收它的后台子进程。修改这个缺陷,就必须使用信号。
8.5 信号
研究一种更高层次的软件形式的异常, 也是一种软件中断,称为Unix
信号,它允许进程中断其他进程。
一个信号就是一条小消息,它通知进程系统中发生一个某种类型的事件。
Linux
系统支持30多种信号。
每种信号类型对应于某种系统事件
- 底层的信号。
-
当底层发生硬件异常,信号通知 用户进程 发生了这些异常。
- 除以0:发送
SIGILL
信号。 - 非法存储器引用:发送
SIGSEGV
信号
- 除以0:发送
-
较高层次的软件事件
- 键入ctrl+c:发送
SIGINT
信号 - 一个进程可以发送给另一个进程
SIGKILL
信号强制终止它。 - 子进程终止或者停止,内核会发送一个
SIGCHLD
信号给父进程。
- 键入ctrl+c:发送
-
8.5.1 信号术语
传送一个信号到目的进程有两个步骤。
-
发送信号: 内核通过更新目的进程上下文的某个状态,就说发送一个信号给目的进程。
发送信号有两个原因
- 内核检测到一个系统事件。比如被零除错误,或者子进程终止。
- 一个进程调用了kill函数。显示要求进程发送信号给目的进程。
- 一个进程可以发信号给它自己。
-
接收信号: 当目的进程 被内核强迫以某种方式对信号的发送做出反应。目的进程就接收了信号。
- 进程可以忽略这个信号,终止。
- 或者通过一个称为
信号处理程序
(signal handler)的用户层函数捕获这个信号。
一个只发出而没有被接收的信号叫做待处理信号
(pending signal)
- 一种类型至多只有一个
待处理信号
。- 如果一个进程有一个类型为
k
的待处理信号
。 - 那么接下来发送到这个进程类型为
k
的信号都会被简单的丢弃。
- 如果一个进程有一个类型为
一个进程可以有选择性地阻塞接收某种信号
- 它任然可以被发送。但是产生的待处理信号不会被接收。
一个待处理信号最多被接收一次。内核为每个进程在
pending
位向量维护着待处理信号的集合,而在blocked
位向量维护着被阻塞的信号集合。只要传送一个类型为k的信号,内核就会设置pending
中的第k
位,而只要接收了一个类型为k
的信号,内核就会清除pending
中的第k
位。
8.5.2 发送信号
Unix
系统 提供大量向进程发送信号的机制。所有这些机制都是基于进程组
(process group)。
-
进程组
- 每个进程都属于一个
进程组
。-
由一个正整数
进程组ID
来标示-
getpgrp()
函数返回当前进程的进程组ID
:#include<unistd.h> pid_t getpgrp(void);
-
-
默认,一个子进程和它的父进程同属于一个
进程组
-
一个进程可以通过
setpgid()
来改变自己或者其他进程的进程组。#include<unistd.h> int setpgid(pid_t pid,pid_t pgid); 如果pid是0 ,那么使用当前进程的pid。 如果pgid是0,那么使用指定的pid作为pgid(即pgid=pid)。 例如:进程15213调用setpgid(0,0) 那么进程15213会 创建/加入进程组15213.
-
-
- 每个进程都属于一个
-
用
/bin/kill
程序发送信号-
/bin/kill
可以向另外的进程发送任意的信号。-
比如
unix>/bin/kill -9 15213
发送信号9(
SIGKILL
)给进程15213。 -
一个为负的PID会导致信号被发送到进程组PID中的每个进程。
unix>/bin/kill -9 -15213
发送信号9(`SIGKILL`)给进程组15213中的每个进程。
-
-
用
/bin/kill
的原因是,有些Unix shell 有自己的kill
命令
-
-
从键盘发送信号
作业(job)
:对一个命令行求值而创建的进程。-
在任何时候至多只有一个前台作业和0个或多个后台作业
- 前台作业就是需要等待的
- 后台作业就是不需要等待的
-
键入
unix>ls|sort
- 创建一个两个进程组成的前台作业。
- 两个进程通过Unix
管道
链接。
-
shell
为每个作业创建了一个独立的进程组。- 进程组ID取自作业中父进程中的一个。
在键盘输入
ctrl-c
会发送一个SIGINT
信号到外壳。外壳捕获该信号。然后发送SIGINT
信号到这个前台进程组的每个进程。在默认情况下,结果是终止前台作业类似,输入`ctrl-z`会发送一个`SIGTSTP`信号到外壳,外壳捕获这个信号,并发送`SIGTSTP`信号给前台进程组的每个进程,在默认情况,结果是**停止(挂起)**前台作业(还是僵死的)
-
-
用
kill函数
发送信号-
进程通过调用
kill函数
发送信号给其他进程,类似于bin/kill
int kill(pid_t pid, int sig);
-
pid
>0,发送信号sig
给进程pid
-
pid
<0,发送信号sig
给进程组abs(pid)
-
事例:
kill(pid,SIGKILL)
-
-
用
alarm
函数发送信号进程可以通过调用
alarm
函数向它自己SIGALRM
信号。#include<unistd.h> unsigned int alarm(unsigned int secs); 返回:前一次闹钟剩余的秒数。
alarm
函数安排内核在secs
秒内发送一个SIGALRM
信号给调用进程-
如果
secs=0
那么不会调度闹钟,当然不会发送SIGALRM
信号。 -
在任何情况,对
alarm
的调用会取消待处理(pending
)的闹钟,并且会返回被取消的闹钟还剩余多少秒结束。如果没有pending
的话,返回0
一个例子:
输出
unix> ./alarm BEEP BEEP BEEP BEEP BEEP BOOM! //handler是一个自己定义的信号处理程序,通过signal函数捆绑。
-
8.5.3 接收信号
信号的处理时机是在从内核态切换到用户态时,会执行do_signal()函数来处理信号
当内核从一个异常处理程序返回,准备将控制传递给进程p
时,它会检查进程p
的未被阻塞的待处理信号的集合(pening&~blocked
)。
- 如果这个集合为空,内核将控制传递到p的逻辑控制流的下一条指令。
- 如果非空,内核选择集合中某个信号k(通常是最小的k),并且强制p接收k。收到这个信号会触发进程某些行为。一旦进程完成行为,传递到p的逻辑控制流的下一条指令。
-
每个信号类型都有一个预定义的默认类型,以下几种.
- 进程终止
- 进程终止并转储存器(dump core)
- 进程停止直到被
SIGCONT
信号重启 - 进程忽略该信号
-
进程可以通过使用
signal函数
修改和信号相关联的默认行为。-
SIGSTOP
,SIGKILL
是不能被修改的例外。#include<signal.h> typedef void (*sighandler_t)(int); sighandler_t signal(int signum,sighandler_t handler);
-
signal
函数通过下列三种方式之一改变和信号signum
相关联的行为。- 如果
handler
是SIG_IGN
,那么忽略类型为signum
的信号 - 如果
handler
是SIG_DFL
,那么类型为signum
的信号恢复为默认行为。 - 否则,
handler
就是用户定义的函数地址,这个函数称为信号处理程序- 只要进程接收到一个类型为signum的信号,就会调用handler。
- 设置信号处理程序:把函数传递给signal改变信号的默认行为。
- 调用信号处理程序,叫捕获信号
- 执行信号处理程序,叫处理信号
- 如果
-
-
当处理程序执行它的return语句后,控制通常传递回控制流中进程被信号接收中断位置处的指令。
-
信号处理程序是计算机并发的又一个示例。信号处理程序的执行中断,类似于底层异常处理程序中断当前应用程序的控制流的方式。因为信号处理程序的逻辑控制流与主函数的逻辑控制流重叠,信号处理程序和主函数
并发
地运行。
自我思考:信号是一种
异常/中断
,当接收到信号的时候,会停下当前进程所做的事,立马去执行信号处理程序。并不是多线程/并行
,但确是并发
的。从下面这张图,可见一斑。
8.5.4 信号处理问题
当一个程序要捕获多个信号时,一些细微的问题就产生了。
- 待处理信号被阻塞
Unix
信号处理程序通常会阻塞 当前处理程序正在处理 的类型的待处理信号。
- 待处理信号(被抛弃了)不会排队等待
- 当有两个同类型信号都是待处理信号时,有一个会被抛弃。
- 关键思想:存在一个待处理的信号
k
仅仅表明至少一个一个信号k
到达过。
- 系统调用可以被中断(在某些unix系统会出现)
- 像
read
,wait
和accept
这样的系统调用潜在的阻塞一段较长的时间,称为慢速系统调用。- 当处理程序捕获一个信号,被中断的慢速系统调用在信号处理程序返回后将不在继续,而是立即返回给用户一个错误条件,并将
errno
设置为EINTR
。
- 当处理程序捕获一个信号,被中断的慢速系统调用在信号处理程序返回后将不在继续,而是立即返回给用户一个错误条件,并将
- 像
用一个后台回收僵死子进程的程序,前台读入做例子
-
1.初始简单利用接收
SIGCHLD
信号回收,一次调用只回收一个。- 在调用的过程中,又有信号发送过来,但是被阻塞了。之后又被直接抛弃。
- 如果不处理被阻塞和不会排队等待的问题。会有信号被抛弃。
- 重要教训:不可以用信号对其他进程中发送的事件计数
handle1-code
-
2.一次调用尽可能的多回收,保证在回收过程中,没有遗漏的信号。
handle2-code
-
3.还存在一个问题,在前台中,某些
unix
系统(Solaris
系统)的read
被中断后不会自动重启,需要手动重启,Linux
一般会自动重启。- 之前 read模块
code
-
现在改为如果是
errno==EINTR
手动重启。
-
或者使用
Signal包装函数标准
。8.5.5会提到。
- 之前 read模块
8.5.5 可移植的信号处理
不同系统之间,信号处理语义的差异(比如一个被中断的慢速系统调用是重启,还是永久放弃)是Unix信号系统的一个缺陷。
为了处理这个问题,Posix
标准定义了sigaction
函数,它允许与Linux
和Solaris
这样与Posix
兼容的系统上的用户,明确指明他们想要的信号处理语义。
#include<signal.h>
int sigaction(int signum,stuct sigaction *act,struct sigaction *oldcat);
//若成功则为1,出错则为-1。
sigaction
函数应用不广泛,它要求用户设置多个结构条目。
一个更简洁的方式,是定义一个包装函数,称为Signal
,它调用sigaction
。
- 它的调用方式与signal函数的调用方式一样。
Signal
包装函数设置了一个信号处理程序,其信号处理语义如下(设置标准):- 只有这个处理程序当前正在处理的那种类型被阻塞。
- 和所有信号实现一样,信号不会排队等待。
- 只要可能,被中断的系统调用会自动重启
- 一旦设置了信号处理程序,它就会一直保持,直到
Signal
带着handler参数为SIG_IGN
或者SIG_DFL
被调用。- 在某些比较老的Unix系统,信号处理程序被使用一次后,又回到默认行为。
8.5.6 显示地阻塞和取消阻塞信号
通过sigprocmask
函数来操作。
#include<signal.h>
int sigprocmask(int how,const sigset_t *set,sigset_t *oldset);
sigprocmask
函数改变当前已阻塞信号的集合(8.5.1节描述的blocked
位向量)。-
具体行为依赖
how
值SIG_BLOCK
:添加set
中的信号到blocked
中。SIG_UNBLOCK
: 从blocked
删除set
中的信号。SIG_SETMASK
:blocked=set
。
-
如果
oldset
非空,block位向量
以前的值会保存到oldset
中。
-
还有以下函数操作set集合
#include<signal.h>
int sigemptyset(sigset_t *set);
//置空
int sigfillset(sigset_t *set);
//每个信号全部填入
int sigaddset(sigset_t *set,int signum);
//添加
int sigdelset(sigset_t *set,int signum);
//删除
//成功输出0,出错输出-1
int sigismember(const sigset_t *set,int signum);
//判断
//若signum是set的成员,输出1,不是输出0,出错输出-1。
8.5.7 同步流以避免讨厌的并发错误
如何编写读写相同存储位置的并发流程序的问题,困扰着数代计算机科学家。
- 流可能交错 的数量是与指令数 量呈指数关系
- 有些交错会产生正确结果,有些可能不会。
所谓同步流
就是。以某种方式同步
并发流,从而得到 最大的可行交错的集合 ,每个交错集合都能得到正确的结果。
并发编程是一个很深奥,很重要的问题。在第12章详细讨论。
现在我们只考虑一个并发相关的智力挑战。
code
如果发生以下情况,会出现同步错误。
- 父进程执行
fork
函数,内核调度新创建的子进程运行,而不是父进程。 - 在父进程再次运行前,子进程已经终止,变成僵死进程,需要内核一个
SIGCHLD
信号给父进程 - 父进程处理信号,调用
deletejob
. - 调用
addjob
。
显然deletejob
必须在addjob
之后,不然添加进去的job永久存在。这就是同步错误。
这是一个称为竞争
(race)的经典同步错误的示例。
main
中的addjob
和处理程序中调用deletejob
之间存在竞争。- 必须
addjob
赢得进展,结果才是正确的,否则就是错误的。但是addjob
不一定能赢,所以有可能错误。即为同步错误。 - 因为内核的调度问题,这种错误十分难以被发现。难以调试。
Q:如何消除竞争?
A:可以在fork之前,阻塞SIGCHLD
信号,在调用addjob
后取消阻塞。
- 注意,子进程继承了阻塞,我们要小心地接触子进程中的阻塞。
- 消除竞争的原则就是,让该赢得竞争的对象在任何情况下都能赢。
一个暴露你的代码中竞争的简便技巧
制造一个fork的包装函数Fork,通过随机+休眠,在fork的那一瞬间,让子进程,父进程都有50%机会先运行
8.6 非本地跳转
C语言提供一种用户级异常控制流形式,称为非本地跳转(nonlocal jump)
。
-
它将控制直接从一个函数转移到另一个当前正在执行的函数。不需要经过正常的调用-返回序列。
-
非本地跳转是通过
setjmp
和longjmp
函数来提供。#include<setjmp.h> int setjmp(jmp_buf env); int sigsetjmp(sigjmp_buf env,int savesigs);//信号处理程序使用 //参数savesigs若为非0则代表搁置的信号集合也会一块保存
-
setjmp
函数在env
缓冲区保存当前调用环境,以供后面longjmp
使用,并返回0- 调用环境包括程序计数器,栈指针,通用目的寄存器。
#include
void longjmp(jmp_buf env,int retval);
void siglongjmp(sigjmp_buf env,int retval);//信号处理程序使用longjmp
函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp
调用的返回。然后setjmp
返回,并带有非零的返回值retval
(看清楚从setjmp
返回)setjmp
返回多次,第一次是0
,第二次是retval
.longjmp
从不返回。
-
-
非本地跳转
的重要应用是允许从一个深层嵌套的函数调用立即返回。一般是发现了错误。- 不用费力解开栈。
- 直接返回到一个普通的本地化的错误处理程序。
例子代码
非本地跳转
另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号中断的指令的位置。
代码
程序分析
C++和Java 中的软件异常
C++和Java提供的异常机制是较高层次的,是C语言setjmp和longjmp函数的更加结构化的版本。你可以把try语句中的catch字句看作setjmp函数。相似地,throw语句就类似与longjmp函数。
8.7 操作进程的工具
-
STRACE
(痕迹):打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。- 用
-static
编译,能得到一个更干净,不带有大量共享库相关的输出的轨迹。
- 用
-
PS
(Processes Status): 列出当前系统的进程(包括僵死进程) -
TOP
(因为我们关注峰值的几个程序,所以叫TOP):打印当前进程使用的信息。 -
PMAP
(rePort Memory map of A Process): 查看进程的内存映像信息 -
/proc
:一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构。- 用户程序可以读取这些内容。
- 比如,输入
"cat /proc/loadavg
,观察Linux系统上当前的平均负载。
8.8 小结
-
异常控制流(ECF)
发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制。 -
在硬件层,
异常
是处理器中的事件出发的控制流中的突变。控制流传递给一个异常处理程序,该处理程序进行一些处理,然后返回控制被中断的控制流。- 有四种不同类型的异常:中断,故障,终止和陷阱。
-
定时器芯片或磁盘控制器,设置了处理器芯片上的中断引脚时,
中断
会异步发生。返回到Inext
-
一条指令的执行可能导致
故障
和终止
同时出现。故障
可能返回调用指令。终止
不将控制返回。
-
陷阱
用于系统调用
。结束后,返回Inext
-
- 有四种不同类型的异常:中断,故障,终止和陷阱。
-
在操作系统层,内核用
ECF
提供进程的基本概念。进程
给应用两个重要抽象:- 逻辑控制流
- 私有地址空间
-
在操作系统和应用程序接口处,有子进程,和信号。
-
最后,C语言的
非本地跳转
完成应用程序层面的异常处理。
至此,异常
贯穿了从底层硬件,到抽象的软件层次。