深入理解计算机系统8——异常控制流
假设有这么一个序列:a0 a1 a2 ... an-1
ak是某个相应指令Ik的地址。每次从ak到ak-1的过渡称为控制转移(control transfer)。
这样的控制转移序列叫作处理器的控制流(control flow)。
最简单的控制流是一个平滑的序列,其中每个Ik和Ik+1在存储器中都是相邻的。
当平滑流产生突变,也就是Ik和Ik+1不相邻,是由诸如跳转、调用和返回这样一些熟悉的程序指令造成的。
这样的一些指令都是必要的机制,使得程序能够对由程序变量表示的内部程序状态中的变化做出反应。
但是系统也必须对系统状态的变化做出反应。
这些系统状态不是被内部程序变量捕获的,而且也不一定要和程序的执行相关。
例如:一个硬件定时器产生信号;
现代系统通过使控制流发生突变来对这些情况做出反应。
一般而言我们把这种突变叫作异常控制流(Exceptional Control Flow, ECF)。
异常控制流发生在计算机系统的各个层次。
比如在硬件层,硬件检测到的时间会触发控制突然转移到异常处理程序;
在操作系统层,内核通过上下文转换将控制从一个用户进程转移到另一个用户进程;
在应用层,一个进程可以发送信道到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序。
理解ECF非常重要,通常有以下几个原因:
1)理解ECF帮助理解重要的系统概念;ECF是操作系统用来实现I/O、进程和虚拟存储器的基本机制。
2)理解ECF帮助理解应用程序是如何与操作系统交互的;应用程序通过一个叫做陷阱(trap)或者系统调用(system call)的ECF形式,向操作系统请求服务。
3)理解ECF将帮助你编写有趣的新应用程序;操作系统为应用程序提供强大的ECF机制,用来创建新进程、等待进程终止、通知其他进程系统中的异常事件等。
4)理解ECF将帮助你理解并发;理解ECF是理解并发的第一步;ECF是计算机中实现并发的基本机制;
5)理解ECF将帮助你理解软件异常如何工作;软件异常允许程序进行非本地跳转来响应错误情况。非本地跳转是一种应用层ECF。
我们将描述存在于计算机系统中所有层次上的各种形式的ECF。
从异常开始,异常位于硬件和操作系统交界的部分。
我们还会讨论系统调用,它们是为应用程序提供到操作系统的入口点的异常。
接下来,会提升抽象的层次,描述进程和信号,它们位于应用和操作系统的交界之处。
最后,将讨论非本地跳转,这是ECF的一种应用层形式。
===================================================
一、异常
异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现;
异常(Exception)就是控制流中的突变,用来响应处理器状态中的某些变化。
首先要理解处理器的状态,状态被编码为不同的位和信号。
状态变化被称为事件(Event)。
事件可能和当前指令的执行相关,也可能和当前指令的执行没有关系。
当处理器检测到有事件发生时,它就会通过一张叫作异常表(exception table)的跳转表,
进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序)。
当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下三种情况中的一种:
1)处理程序将控制返回给当前指令Icurr,即当事件发生时正在执行的指令。
2)处理程序将控制返回给Inext,即如果没有发生异常将会执行的下一条指令。
3)处理程序终止被中断的程序。
1.1异常处理
异常可能会难以理解,因为处理异常需要软件和硬件的配合。很容易搞混哪个部分执行哪个任务。
接下来看一下硬件和软件的分工吧。
系统很可能为每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)。
其中一些可能是处理器的设计者分配的:被零除、缺页、存储器访问违例、断点以及算术溢出;
还有一些是由操作系统内核的设计者分配的:系统调用、来自外部I/O设备的信号;
在系统启动时,操作系统分配和初始化一张称为异常表的跳转表。
这个跳转表使得条目k包含异常k的处理程序的地址。
在运行时,处理器检测到发生了一个事件,并且确定了相应的异常号k。
随后,处理器触发异常,方法是执行间接过程调用。通过异常表的条目k跳转到相应的处理程序。
异常类似于过程调用,但是有一些重要的不同之处。
对于过程调用,在跳转到处理程序之前,处理器将返回地址压入栈中。
对于异常来说,会根据异常的类型,返回地址要么是当前指令,要么是下一条指令;
处理器也把一些额外的处理器状态压入栈中。在处理程序返回时,重新开始被中断的程序会需要这些状态。
如果控制从一个用户程序转入到内核,那么所有这些项目都被压倒内核栈中,而不是压到用户栈中。
异常处理程序运行在内核模式下,这意味着它们对所有的资源都有完全的访问权限;
一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成的。
在处理程序处理完事件之后,它通过执行一条特殊的“从中断返回”指令,可选地返回到被中断的程序,该指令将适当的状态弹回到处理器的控制和数据寄存器中。
如果异常中断的是一个用户程序,就将状态恢复为用户模式,然后将控制权返回给被中断的程序。
1.2 异常的类别
异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)、终止(abort);
中断:是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上讲它是异步的。硬件中断的异常处理程序通常称为中断处理程序。
中断的过程一般是这样的,一些I/O设备通过向处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,以触发中断,这个异常号标识了引起中断的设备。
在当前指令执行结束后,处理注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序。
当处理程序返回时,它就将控制返回给下一条指令。结果是程序继续执行,就像没有发生过中断一样。
剩下的几个异常类型是同步发生的。
陷阱和调用:
陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。
陷阱的最重要用途是在用户程序和内核之间提供一个像过程一样的接口,叫作系统调用。
用户程序经常向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork)、加载一个新的程序(execve),或者终止当前进程(exit)。
为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的“syscall n”指令,当用户程序想要请求服务n时,可以执行这条指令。
执行syscall 指令会导致一个到异常处理程序的陷阱,这个处理程序对参数解码,并调用适当的内核程序。
从程序员角度来看,系统调用和普通函数调用时一样的。
然而它们的实现时非常不同的。普通函数运行在用户模式中。用户模式限制了函数可以执行的指令的类型,而且它们只能访问与调用函数相同的栈。
系统调用运行在内核模式中,内核模式允许系统调用执行指令,并访问定义在内核中的栈。
故障:
故障由错误情况引起,它可能被故障处理程序修复。
当故障发生时,处理器将控制转移给故障处理程序。
如果故障处理程序能够修复这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。
否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。
一个经典的故障示例是缺页异常,当指令引用一个虚拟地址,而与该地址相对应的物理页面不在存储器中因此必须从磁盘中取出是,就会发生故障。
一个页面就是虚拟存储器的一个连续的块(典型的是4KB)。缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。
当指令再次执行时候,相应的物理页面已经驻留在存储器中,指令就可以没有故障地运行完成了。
终止:
终止是不可恢复的致命错误造成的结果。通常是一些硬件错误,终止处理程序不会将控制返回给应用程序。
处理程序将控制返回给一个abort例程,该例程会终止这个应用程序。
1.3 Linux/IA32系统中的异常
为了使描述更加具体,接下来看看IA32系统定义的一些异常类型。
有高达256种不同的异常类型。
0~31号的号码对应的是Intel架构师定义的异常;
32~255号对应的是操作系统定义的中断和陷阱。
1、Linux/IA32故障和终止
除法错误:Unix不会试图从除法错误中恢复,而是选择终止程序。
Linux shell通常会把除法错误报告为“浮点异常”;
一般保护故障:许多原因都会导致恶名昭著的一般保护故障。通常是因为一个程序引用了一个未定义的虚拟存储器区域,或者因为程序试图写一个只读文本段。
Linux不会尝试恢复这类故障。 Linux shell通常把这种一般保护故障报告为“段故障”。
缺页:会重新执行产生故障的指令的一个异常示例。处理程序将磁盘上虚拟存储器对应的页面映射到物理存储器的一个页面,然后重新开始这条产生故障的指令。
机器检查:是在导致故障的指令执行中检测到致命的硬件错误时发生的。机器检查处理程序从不返回控制给应用程序。
2、Linux/IA32系统调用
Linux提供上百种系统调用,当应用程序想要请求内核服务时可以使用,包括读文件、写文件或是创建一个新进程。
在IA32系统上,系统调用是通过一条int n的陷阱指令来提供的,其中n可能是IA32异常表中256个条目中任何一个的索引。在历史上,系统调用是通过异常128来提供的。
C程序用syscall函数可以直接调用任何系统调用。但是实际中几乎没有必要这么做。
对于大多数系统调用,标准C库提供了一组方便的包装函数,这些包装函数将参数打包到一起,以适当的系统调用号陷入内核,然后将系统调用的返回状态传递回调用程序。
我们将系统调用和与它们相关的包装函数称为系统级函数。
所有到linux系统调用的参数都是通过通用寄存器而不是栈传递的。
寄存器%eax包含系统调用号,寄存器%ebx、%ecx、%edx、%esi、%edi和%ebp包含最多六个任意的参数。
栈指针%esp不能使用,因为当进入内核模式时,内核会覆盖它。
关于异常术语:
异常作为通用的术语,而且只有在必要时才区别异步异常(中断)和同步异常(陷阱、故障和终止)。
对于每个系统而言,基本的概念都是相同的。
一些制造厂商的手册会用“异常”仅仅表示同步事件引起的控制流的改变。
===================================================
二、进程
异常是允许操作系统提供进程的概念所需要的基本构造块;
进程是计算机科学中最深刻最成功的概念之一。
在现代系统中运行一个程序,会得到一个假象。就好像我们的程序是系统中当前运行着的唯一的程序。
我们的程序好像是独占地使用处理器和存储器。
处理器好像是无间断地一条接一条地执行程序中地指令。
最后,我们的程序中的代码和数据好像是系统存储器中唯一的对象。
这些假象都是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中的程序的实例。
系统中的每个程序都是运行在某个进程的上下文中的。
上下文是由程序正确运行所需的状态组成的。
这个状态包括:放在存储器中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户通过外壳输入一个可执行目标文件的名字,并运行一个程序时,外壳就会创建一个新的进程,然后在这个新进程的上下文中运行和这个可执行目标文件。
应用程序也能够创建新进程,且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
进程提供给应用程序的关键抽象:
一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用存储器系统。
2.1 逻辑控制流
如果想用调试器单步执行程序,我们会看到一系列的程序计数器PC的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时的动态链接到程序的共享对象中的指令。
这个PC值得序列叫做逻辑控制流,或者简称逻辑流。
考虑一个运行着三个进程的系统,处理器的一个物理流被分成了三个逻辑流。每个进程一个。
三个逻辑流的执行时交错的。进程A运行了一会儿,然后是进程B开始运行到完成,然后,进程C运行了一会儿,然后进程A接着运行直到完成。最后,进程C可以运行到结束了。
关键点在于进程是轮流使用处理器的。每个进程执行它的流的一部分,然后被抢占(暂时挂起),然后轮到其他进程。
对于一个运行在这些进程之一的上下文中的程序,它看上去就像是在独占地使用处理器。
唯一的反面例证是,如果我们精确地测量每条指令使用的时间,会发现在程序中一些指令的执行之间,CPU好像会周期性地停顿。
然而,每次处理器停顿,它随后继续执行我们的程序,并不改变程序存储器位置或寄存器的内容。
2.2 并发流
一个逻辑流的执行在时间上与另一个流重叠,称为并发流。
这两个流被称为并发地运行。
多个流并发地执行的一般现象称为并发。
一个进程和其他进程轮流运行的概念称为多任务。
一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。
多任务也叫做时间分片。例如进程A的流由两个时间片段组成的。
注意:并发地思想与流运行的处理器核数或者计算机无关。
如果两个流在时间上重叠,那么它们就是并发的,即使它们是运行在同一个处理器上的。
并行流是并发流的一个真子集。
如果两个流并发地运行在不同的处理器核或者计算机上,那么称它们为并行流。
它们并行地运行,且并行地执行。
2.3 私有地址空间
进程也为每个程序提供一个假象,好像它独占地使用系统地址空间。
在一台有n位地址的机器上,地址空间是2的n次方个可能地址的集合。
一个进程为每个程序提供它自己的私有地址空间。
一般而言,和这个空间中某个地址关联的那个存储器字节是不能被其他进程读或者写的,从这个意义上说,这个地址空间是私有的。
尽管每个私有地址空间相关联的存储器的内容一般是不同的,但是每个这样的空间都有相同的通用结构。
地址空间底部是保留给用户程序的,包括通常的文本、数据、堆和栈段。
对于32位进程来说,代码段从地址0x08048000开始,对于64位进程来说,代码段从地址0x00400000开始。
地址空间顶部是保留给内核的。
地址空间的这个部分包含内核在代表进程执行指令时使用的代码、数据和栈。
2.4 用户模式和内核模式
为了使操作系内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。
处理器通常是用某个控制寄存器的一个模式位(mode bit)来提供这种功能的,该寄存器描述了当前进程享有的特权。
当设置了模式位时,进程就运行在内核模式中(有时候叫做超级用户模式)。
一个运行在内核模式的进程可以执行指令集中的任何指令,并可以访问系统中任何存储器的位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令。例如停止处理器、改变模式位,或者发起I/O操作。
也不允许用户模式中的进程直接引用地址空间中内核区的代码和数据。任何这样的尝试都会导致致命的保护故障。
因此用户必须通过系统调用接口间接地访问内核代码和数据。
运行应用程序的代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方式是通过诸如中断、故障或陷入系统调用这样的异常。
当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式切回用户模式。
Linux提供了一种聪明的机制,叫做/proc文件系统,它允许用户模式进程访问内核数据结构的内容。/proc文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。
例如,你可以用/proc文件系统找出一般的系统属性,如CPU类型(/proc/cpuinfo)。
Linux内核引入了/sys文件系统,它输出关于系统总线和设备的额外底层信息。
2.5 上下文切换
操作系统使用一种称为上下文切换的较高层次的异常控制流来实现多任务。
内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。
上下文由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描绘地址空间的页表,包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
当进程执行到某个时刻时,系统可以决定抢占该进程,并重新开始一个先前被抢占的进程。
这种决定就叫做调度,是由内核中称为调度器的代码处理的。
当内核选择一个新的进程运行时,我们就说内核调度了该进程。
在内核调度了一个新的进程运行后,它就抢占了当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程上面去。
上下文切换:
1)保存当前进程的上下文;
2)恢复某个先前被抢占的进程被保存的上下文;
3)将控制传递给这个新恢复的进程;
当内核代表用户执行系统调用时,可能会发生上下文切换。
如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前执行系统调用的进程休眠,切换到另一个进程。
比如,如果一个read系统调用请求一个磁盘访问,内核可以选择执行上下文切换,运行另一个进程,而不是等待数据从磁盘到达。
另一个示例是,sleep系统调用,它显式地请求让调用进程休眠。
一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。
中断也可能引发上下文切换。比如,所有的系统都有某种产生周期性定时器中断的机制,典型值为1ms或者10ms。
每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间了,并切换到另一个进程。
例如,磁盘读取需要用相对较长的时间(数量级为几十毫秒),所以内核执行从进程A到进程B的上下文切换,而不是在这个间歇时间内等待,什么都不做。
注意,在切换之前,内核正代表进程A在用户模式下执行指令。在切换的第一部分中,内核代表进程A在内核模式下执行指令。
然后在某一时刻,它开始代表进程B执行指令。在切换后,内核代表进程B在用户模式下执行指令。
随后进程B在用户模式下运行了一会儿,知道磁盘发出了中断信号,表示数据已经从磁盘传送到了存储器,内核判定进程B已经运行了足够长的时间,就执行一个从进程B到进程A的上下文切换,将控制权返回给进程A中紧随在系统调用read之后的那条指令。进程A继续运行,直到下一个异常发生为止。
高速缓存污染和异常控制流:
硬件高速缓存不能和诸如中断和上下文切换这样的异常控制流很好地交互。
如果当前进程被一个中断暂时中断,那么对于中断处理程序来说,高速缓存是冷的(cold),冷的意思是程序所需要的数据都不在高速缓存中。
如果程序从主存中访问了足够多的表项,那么当被中断的进程继续时,高速缓存对他来说也是冷的。
在这种情况下,我们就说中断处理程序污染了高速缓存。
使用上下文切换也会发生相关现象。
当一个进程在上下文切换之后继续执行时,高速缓存对于应用程序而言也是冷的,必须再次热身。
===================================================
三、系统调用错误处理
当Unix系统级函数遇到错误时,它们会典型地返回-1,并且设置全局整数变量errno来表示什么出错了。
程序员应该总是检查错误,但是不幸的是,很多人忽略了检查错误,因为它使得代码变得臃肿,而且难以读懂。
比如,下面我们调用Unix fork函数时会如何检查错误:
if(pid = fork()<0)
{
fprintf(stderr, "fork error: %s\n", stderror(errno));
exit(0);
}
strerror函数返回一个文本串,描述了和某个errno值相关的错误。
通过定义下面的错误报告函数(error-reporting function),我们能够在某种程度上简化这个代码:
void unix_error(char *msg)
{
fprintf(stderr, "%s : %s \n", msg, strerror(errno));
exit(0);
}
给定这个函数,我们队fork的调用从4行简化到2行:
if((pid =fork())<0)
unix_error("fork error");
通过使用错误包装函数(error-handling wrapper),我们可以更进一步简化我们的代码。对于一个给定的基本函数foo,我们定义一个具有相同参数的包装函数Foo,但是是第一个字母大写了。
包装函数调用基本函数,检查错误,如果有任何问题就终止。
下面是fork函数的错误处理包装函数:
pid_t Fork(void)
{
pid_t pid;
if((pid = fork())<0)
unix_error("Fork error");
return pid;
}
给定这个包装函数,我们对fork的调用就缩减为1行了;
pid = Fork();
使用错误处理包装函数能够使代码简洁。
包装函数定义在一个叫做csapp.c的文件中,它们的原型定义在一个叫做csapp.h的头文件中;
========================================
四、进程控制
Unix提供了大量从C程序中操作进程的系统调用。这里将描述这些重要的函数,并举例说明如何使用它们。
4.1 获取进程ID
每个进程都有一个唯一的正数(非零)进程ID(PID)。
getpid函数返回调用进程的PID。
getppid函数返回它的父进程的PID(创建调用进程的进程)。
getpid和getppid函数返回一个类型为pid_t的整数值,在Linux系统上它在types.h中被定义为int。
4.2 创建和终止进程
从程序员的角度,我们可以认为进程总是处于下面三种状态之一:
运行。进程要么在CPU上执行,要哦在等待被执行且最终会被内核调度。
停止。进程的执行将被挂起(suspend),且不会被调度。 当收到SIGSTOP、SIGTSTP、SIGTTIN或者SIGTTOU信号时,进程就停止,并且保持停止直到它收到一个SIGCONT信号,在这个时刻,进程再次开始运行。(信号时一种软件中断的形式)
终止。进程永远地停止了,进程会因为三种原因终止:1)收到一个信号,该信号的默认行为是终止进程,2)从主程序返回,3)调用exit()函数。
void exit(int status)
exit函数以status退出状态来终止进程。
父进程通过调用fork函数创建一个新的运行子进程:
pid_t fork(void); // 子进程返回0,父进程返回子进程的PID,如果出错,则为-1
fork函数时有趣的,因为它只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。
在父进程中,fork返回子进程的PID。在子进程中,fork返回0。
因为子进程的PID总是非零的。返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的一份拷贝,包括文本、数据和bss段、堆以及用户栈。
子进程还获得与父进程任何打开文件描述符相同的拷贝,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。
父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
接下来看一下父进程使用fork创建子进程的一段程序:
#include "csapp.h"
int main()
{
pid_t pid;
int x = 1;
pid = Fork();
if(pid == 0){
printf("child : x = %d \n", ++x);
exit(0);
}
printf("parent : x = %d \n", --x);
exit(0);
}
这个简单的例子有一些微妙的地方:
调用一次,返回两次。fork函数被父进程调用一次,但是却返回两次。一次是返回到父进程,一次是返回到新创建的子进程。对于只创建一个子进程的程序来说,这还是相当简单直接的。但是具有多个fork实例的程序可能就会令人迷惑,需要仔细地推敲了。
并发执行。父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行它们的逻辑控制流中的指令。当我们在系统上运行这个程序时,父进程先完成它的printf语句,然后是子进程。然而,在另一个系统上可能恰好相反。一般而言,作为程序员,我们决不能对不同进程中指令的交替执行做任何假设。
相同的但是独立的地址空间。如果能够在fork函数在父进程和子进程中返回后立即暂停这两个进程,我们会看到每个进程的地址空间都是相同的。每个进程由相同的用户栈、相同的本地变量值、相同的堆、相同的全局变量值、以及相同的代码。因为父进程和子进程是独立的进程,它们都有自己的私有地址空间。父进程和子进程对x所做的任何改变都是独立的,不会反映在另一个进程的存储器中。这就是为什么当父进程和子进程调用它们各自的printf语句时,它们中的变量x会有不同的值的原因。
共享文件。当运行这个示例程序时,我们注意到父进程和子进程都把它们的输出显示在屏幕上。原因是子进程继承了父进程所有的打开文件。当父进程调用fork时,stdout文件是被打开的,并指向屏幕的。子进程继承了这个文件,因此它的输出也是指向屏幕的。
4.3 回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。
相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reap)。
当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程。
从此时开始,该进程就不存在了。
一个终止了但还未被回收的进程称为僵死进程(zombie)。
如果父进程没有回收它的僵死子进程就终止了,那么内核就会安排init进程来回收它们。
init进程的PID为1,并且是在系统初始化时由内核创建的。
长时间运行的程序,比如外壳或者服务器,总是应该回收它们的僵死子进程。
即使僵死子进程没有运行,它们仍然消耗系统的存储器资源。
一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止。
pid_t waitpid(pid_t pid, int *status, int options);
waitpid函数有点复杂。默认地,waitpid挂起调用进程的执行,直到它的等待集合中的一个子进程终止。
如果等待集合中的一个进程在刚调用的时刻就已经终止了,那么waitpid就立即返回。
在这两种情况下,waitpid返回导致waitpid返回的已终止子进程的PID,并且将这个已终止的子进程从系统中去除。
4.4 让进程休眠
sleep函数将一个进程挂起一段指定的时间。
unsigned int sleep(unsigned int secs);
如果请求的时间量已经到了,sleep返回0;
否则返回还剩下的要休眠的秒数。
后一种情况是可能的,如果因为sleep函数被一个信号中断而过早地返回。
还有一个很有用的函数是pause函数,该函数让调用函数休眠,直到该进程收到一个信号。
int pause(void);
4.5 加载并运行程序
execve函数在当前进程的上下中加载并运行一个新程序;
int execve(const char *filename, const char *argv[], const char *envp[]);
如果成功,则不返回;如果错误,则返回-1;
这个argv变量指向一个以null结尾的指针数组,每个指针都指向一个参数串;
环境变量列表envp和参数列表类似;也是一个指针数组,每个指针指向一个环境变量串;
每个串都是形如:“NAME=VALUE”的名字——值对;
在execve加载了filename之后,它调用了启动代码,启动代码设置栈,并将控制权传递给新程序的主函数,该主函数的原型如下:
int main(int argc, char **argv, char **envp);
或者如下:
int main(int argc, char *argv[], char *envp[]);
getenv函数
char *getenv(const char *name);
getenv函数在环境数组中搜索字符串“name = value”。如果找到了,它就返回一个指向value的指针,否则它就返回NULL;
setenv函数
int setenv(const char *name, const char *newvalue, int overwrite);
若成功则返回0,若错误则为-1;只有在overwrite非零时才会用newvalue替换oldvalue。
unsetenv函数
void unsetenv(const char *name);
程序和进程:
程序是一堆代码和数据。程序可以作为目标模块存在于磁盘上,或者作为段存在于地址空间中。
进程是执行中程序的一个具体的实例;程序总是运行在某个进程的上下文中。
理解这种差异,对于理解fork函数execve函数是很重要的。
fork函数在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品。
execve函数在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间,但没有创建一个新的进程。
新的程序仍然有相同的PID,并且继承了调用execve函数时已打开的所有文件描述符。
4.6 利用fork和execve运行程序
像Unix外壳和Web服务器这样的程序大量地使用了fork和execve函数。
外壳是一个交互型的应用级程序,它代表用户运行其他程序。最早的外壳是sh程序,后面出现了一些变种csh、tcsh、ksh和bash。
外壳执行一系列的读 read/求值 evaluate步骤,然后终止。
读步骤来自用户的一个命令行,求值步骤解析命令行,并代表用户运行程序。
一个简单的外壳main例程如下:
1 #include "csapp.h" 2 #define MAXARGS 128 3 4 void eval(); 5 int parseline(); 6 int builtin_command(); 7 8 int main() 9 { 10 char cmdline[MAXLINE]; 11 12 while(1) 13 { 14 printf(">"); 15 Fgets(cmdline, MAXLINE, stdin); 16 if(feof(stdin)) 17 exit(0); 18 19 eval(cmdline); 20 } 21 }
parseline返回1,表示应在后台执行该程序(外壳不会等待它完成);
否则它返回0,表示应该在前台执行这个程序(外壳会等待它完成);
解析了命令行之后,eval函数调用builtin_command函数,该函数检查第一个命令行参数是否是一个内置的外壳命令。
如果是,它就立即解释这个命令,并返回1,否则返回0;
实际使用的外壳含有大量地命令;
如果builtin_command返回0,那么外壳创建一个子进程,并在子进程中执行所请求的程序。
如果用户要求在后台运行该程序,那么外壳返回到循环的顶部,等待下一个命令行。
否则,外壳使用waitpid函数等待作业终止。当作业终止时,外壳就开始下一轮迭代。
简单的外壳是有缺陷的,它并不回收它的后台子进程。
修改这个缺陷就要求使用信号。
========================================
五、信号
这是一种更高层的软件形式的异常——称为Unix信号;
它允许进程中断其他进程;
一条信号就是一条小消息;
它通知进程,系统中发生了一种某种类型的事件。
每种信号类型都对应于某种系统事件。
低层的硬件异常时由内核异常处理程序处理的,正常情况下,对用户进程而言不可见。
信号提供一种机制,通知用户进程发生了这些异常。
5.1 信号术语
发送一个信号到目的进程由两个步骤组成:
1)发送信号
内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。
发送信号可以有如下两个原因:
1)内核检测到一个系统事件,比如被零除错误或者子进程终止;
2)一个进程调用了kill函数,显式地要求内核发送一个信号给目的进程;一个进程可以发送信号给它自己。
2)接收信号
当目的进程被内核强迫以某种方式对信号的发送做出反应时。
目的进程就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序的用户层函数捕获这个信号。
一个只发出而没有被接收的信号称为待处理信号(pending signal)。
在任何时刻,一种类型至多只会有一个待处理信号。
如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待,它们只是被简单地丢弃。
一个进程可以有选择性地阻塞接收某种信号。当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。
5.2 发送信号
Unix系统提供了大量向进程发送信号的机制。所有这些机制都是基于进程组这个概念的。
进程组:每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。
getpgrp函数返回当前进程的进程组ID;
默认情况下,一个子进程和它的父进程同属于一个进程组。一个进程可以通过使用setpgid函数来改变自己或者其他进程的进程组;
int setpgid(pid_t pid, pid_t pgid); //成功返回0,错误返回-1
setpgid函数将进程pid的进程组改为pgid。如果pid是0,那么就使用当前进程的PID。如果pgid是0,那么就用pid指定的进程的PID作为进程组ID。
用/bin/kill程序发送信号
/bin/kill -9 15213,指的是发送信号9(SIGKILL)给进程15213。一个为负的PID会导致信号被发送到进程组PID中的每个进程。
比如命令: /bin/kill -9 -15213
从键盘发送信号
Unix外壳使用作业(job)的这个抽象概念来表示为对一个命令行求值而创建的进程。
在任何时刻,至多只有一个前台作业和0个或多个后台作业。
外壳为每个作业创建一个独立的进程组。典型的,进程组ID是取自作业中父进程中的一个。
输入ctrl-c会导致啮合向每个前台进程组中的成员发送一个SIGINT信号。SIGINT信号是终止信号,来自键盘的中断;
用kill函数发送信号
进程通过调用kill函数发送信号给其他进程(包括它们自己)。
int kill(pid_t pid, int sig); //若成功则为0,若错误则为-1
如果pid大于零,进程发送sig给进程pid。若pid小于零,那么kill发送信号sig给进程组abs(pid)中的每个进程。
用alarm函数发送信号
unsigned int alarm(unsigned int secs); //返回前一次闹钟剩余的秒数,若以前没有设定闹钟,则为0
alarm函数安排在内核在secs秒内发送一个SIGALRM信号给调用进程。
如果secs是零,那么不会调度新的闹钟(alarm)。
5.3 接收信号
当内核从一个异常处理程序返回,准备将控制传递给进程p时,它会检查进程p未被阻塞的待处理信号的集合。
如果这个集合为空,那么内核将控制传递到p的逻辑控制流中的下一条指令(Inext)。
如果,集合是非空的,那么内核选择集合中的某个信号k,并且强制p接收信号k。
收到信号会触发进程的某种行为,一旦进程完成了这个行为,那么控制就传递回p的逻辑控制流中的下一条指令(Inext)。
每个信号类型都有一个预定义的默认行为,是下面中的一种:
进程终止;
进程终止并转存储器;
进程停止直到被SIGCONT信号重启;
进程忽略该信号;
进程可以使用signal函数修改和信号相关联的默认行为。
唯一的例外是SIGSTOP和SIGKILL,它们的默认行为是不能被修改的。
sighandler_t signal(int signum, sighandler_t handler); //若成功则为指向前次处理程序的指针,若出错则为SIG_ERR;
signal函数可以通过下列三种方法之一来改变和信号signum相关联的行为:
如果handler是SIG_IGN,那么忽略类型为signum的信号;
如果handler是SIG_DFL,那么类型为signum的信号行为恢复为默认行为;
否则,handler就是用户定义的函数的地址,这个函数称为信号处理程序,只要进程收到一个类型为signum的信号,就会调用这个程序。
通过把处理程序传递给信号处理程序的方式,来改变默认行为。这叫做设置信号处理程序。
调用信号处理程序称为捕获信号,执行信号处理程序叫做处理信号。
一个用信号处理程序捕获SIGINT信号的程序示例:
1 #include "csapp.h" 2 3 void handler(int sig) 4 { 5 printf("Caught SIGINT\n"); 6 exit(0); 7 } 8 9 int main() 10 { 11 if(signal(SIGINT, handler) == SIG_ERR) 12 unix_error("signal error"); 13 14 pause(); 15 16 exit(0); 17 }
信号处理程序是计算机系统中并发的又一个示例。
信号处理程序的执行中断main C函数的执行,类似于低层异常处理程序中断当前应用程序的控制流的方式。
因为信号处理程序的逻辑控制流与主函数的逻辑控制流重叠,信号处理程序和主函数并发地运行。
5.4 信号处理问题
当一个程序要捕获多个信号时,一些细微的问题就产生了:
1)待处理信号被阻塞
Unix信号处理程序通常会阻塞当前处理程序正在处理的类型的待处理信号。
2)待处理信号不会排队等待
任意类型至多只有一个待处理信号。第二个该类型的处理信号到来时会被丢弃掉。
3)系统调用可以被中断
像read、wait和accept这样的系统调用会潜在地阻塞进程一段较长的时间,称为慢速系统调用。
在某些系统中,当处理程序捕获到一个信号时,被中断的慢速系统调用在信号处理程序返回时不再继续,而是立即返回给用户一个错误条件,并将errno设置为EINTR。
如果进程在执行一个低速系统调用而阻塞期间,捕捉到一个信号,则该系统调用就被中断而不再继续执行。该系统调用返回出错,其error被设置为EINTR。即信号中断系统调用的执行。
这是早期Unix系统的缺陷,现在Linux系统会自动重启被中断的系统调用。
为了编写可抑制的代码,要考虑手动重启系统调用。用errno中的EINTR判断系统调用是否被信号中断而提前返回。
5.5 可移植的信号处理
5.6 显式地阻塞和取消阻塞信号
5.7 同步流以避免讨厌的并发错误
如何编写读写相同存储位置的并发流程序的问题。
基本的解决方法是以某种方式同步并发流。
这里举一个例子:
父进程在一个作业列表中记录着它的当前子进程,每个作业一个条目。
addjob和deletejob函数分别向这个作业列表添加和从中删除作业。
当父进程创建一个新的子进程时,它就把这个子进程添加到作业列表中。
当父进程在SIGCHLD处理程序中回收一个终止的(僵死)的子进程时,它就从作业列表中删除这个子进程。
乍一看,这段代码看上去是对的。
1 void handler(int sig) 2 { 3 pid_t pid; 4 while(pid = waitpid(-1,NULL,0)>0) 5 deletejob(pid); 6 if(errno != ECHILD) 7 unix_error("waitpid error"); 8 } 9 10 11 int main(int argc, char **argv) 12 { 13 int pid; 14 15 Signal(SIGCHLD, handler); 16 initjobs(); 17 18 while(1){ 19 if((pid = Fork()) == 0){ 20 Execve("/bin/date",argv,NULL); 21 } 22 23 addjob(pid); 24 } 25 exit(0); 26 }
1)父进程执行fork()函数时,内核调度新创建的子进程运行,而不是父进程。
2)在父进程能够再次运行之前,子进程就终止了,并且变成一个僵死进程,使得内核传递一个SIGCHLD信号给父进程。
3)后来,当父进程再次变成可运行但又在它执行之前,内核注意到待处理SIGCHLD信号,并通过在父进程中运行处理信号接收这个信号。
4)处理程序回收终止的子进程,并调用deletejob,这个函数什么也不做,因为父进程还没有把该子进程加入到列表中。
5)在处理程序运行完毕之后,内核运行父进程,父进程从fork返回,通过调用addjob错误地把不存在的子进程添加到作业列表中。
因此,对于父进程的main函数流和信号处理流的某些交错,可能会在addjob之前调用deletejob。
这是一个竞争的经典同步错误地示例。
竞争的错误非常难调试,因为不可能测试所有的交错。
在这里消除竞争的一种方式是,在调用fork之前,阻塞SIGCHLD信号,然后在调用完addjob之后就取消阻塞这些信号,这样保证了在子进程被添加到作业列表中之后就回收该子进程。
注意,子进程继承了它们父进程的被阻塞集合,所以我们必须在调用execve之前,小心地解除子进程中阻塞的SIGCHLD信号。
========================================
六、非本地跳转
C语言提供了一种用户级异常控制流形式,称为非本地跳转,它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的 调用-返回 序列。
非本地跳转是通过setjmp和longjmp函数来提供的。
非本地跳转的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回。
通常是由检测到某个错误情况引起的。
如果在一个深层嵌套的函数调用中发现了一个错误,我们可以使用非本地跳转直接返回到一个普通的本地化的错误处理程序,而不是费力地解开调用栈。
C++和Java提供的异常机制是较高层次的,是C语言的setjmp和longjmp函数的更加结构化的版本。
可以把try语句中的catch子句看做类似于setjmp函数。
相似地,throw语句就类似于longjmp函数。
接下来是一个非本地跳转的示例:
1 #include "csapp.h" 2 3 jmp_buf buf; 4 5 int error1 = 0; 6 int error2 = 1; 7 8 void foo(void), bar(void); 9 10 int main() 11 { 12 int rc; 13 14 switch(setjmp(buf)); 15 if(rc == 0) 16 foo(); 17 else if (rc == 1) 18 printf("Detected an error1 condition in foo\n"); 19 else if (rc == 2) 20 printf("Detected an error2 condition in foo\n"); 21 else 22 printf("Unknown error condition in foo\n"); 23 exit(0); 24 25 } 26 27 28 void foo(void) 29 { 30 if(error1) 31 longjmp(buf,1); 32 bar(); 33 } 34 35 void bar(void) 36 { 37 if(error2) 38 longjmp(buf, 2); 39 }
程序运行到switch(setjmp(buf))时,调用setjmp保存当前的调用环境到buf中。
然后调用foo函数,foo依次调用bar函数。如果foo或bar遇到一个错误,它们立即通过依次longjmp调用从setjmp返回。
setjmp的非零返回值指明了错误类型,随后就可以被解码,并且在代码中的某个位置进行处理。
int setjmp(jmp_buf env);
env参数用于保存当前调用环境,用于后面longjmp使用,并返回0。
通用环境包括程序计数器、栈指针和通用目的寄存器。
void longjmp(jmp_buf env, int reval);
env是用来传递给longjmp的调用环境参数,reval用于告诉setjmp返回时要返回什么;
===================================================
七、操作进程的工具
Linux系统提供了大量的监控和操作进程的有用工具;
STRACE: 打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。
PS: 列出当前系统中的进程(包括僵死进程)。
TOP: 打印出关于当前进程资源使用的信息。
PMAP: 显式进程的存储器映射。
/proc:一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。
比如:输入"cat /proc/loadavg",观察在Linux系统上当前的平均负载。
===================================================
八、总结
异常控制流(ECF)发生在计算机系统各个层次,是计算机系统中提供并发的基本机制。
在硬件层,异常是由处理器中的事件触发的控制流中的突变。控制流传递给一个软件处理程序,该处理程序进行一些处理,然后返回控制给被中断的控制流。
有4种不同类型的异常:中断、故障、终止和陷阱。
当一个外部I/O设备,例如定时器芯片或者一个磁盘控制器,设置了处理器芯片上的中断引脚时,(对于任意指令)中断会异步地发生。
控制返回到故障指令后面的那条指令。
一条指令的执行可能导致故障和终止同时发生。故障处理程序会重新启动故障指令,而终止处理程序从不将控制返回给被中断的流。
最后,陷阱就像是用来实现向应用提供到操作系统代码的受控的入口点的系统调用的函数调用。
在操作系统层,内核用ECF提供进程的基本概念。进程提供给应用两个重要的抽象:
1)逻辑控制流:它给程序提供一个假象,好像是它在独占地使用处理器;
2)私有地址空间:它提供给程序一个假象,好像它是在独占地使用主存;
在操作系统和应用程序之间的接口处,应用程序可以创建一个子进程,等待它们的子进程停止或者终止,运行新的程序,以及捕获来自其他进程的信号。
信号处理的语义是微妙的,并且随系统的不同而不同。
在于Posix兼容的系统上存在着一些机制,允许程序清楚地指定期望的信号处理语义。
最后,在应用层,C程序可以使用非本地跳转来规避正常的调用/返回栈规则,并且直接从一个函数分支到另一个函数。