最简单的一种控制流是一个“平滑的” 序列,其中每个指令IK 和IK+1在内存中都是相邻的。这种平滑流的突变(也就是IK 和IK+1不相邻)通常是由诸如跳转、调用和返回这样一些熟悉的程序指令造成的。这样一些指令都是必要的机制,使得程序能够对由程序变量表示的内部程序状态中的变化做出反应。
但是系统也必须能够对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕获的,而且也不一定要和程序的执行相关。比如,一个硬件定时器定期产生信号,这个事件必须得到处理。包到达网络适配器后,必须存放在内存中。程序向磁盘请求数据,然后休眠,直到被通知说数据已就绪。当子进程终止时,创造这些子进程的父进程必须得到通知。
现代系统通过使控制流发生突变来对这些情况做出反应。一般而言,我们把这些突变称为异常控制流(Exceptional Control Flow , ECF)。
异常控制流发生在计算机系统的各个层次。
硬件层:硬件检测到的事件会触发控制突然转移到异常处理程序。
操作系统层:内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。
应用层:一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序。
一个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应。
异常
异常(exception)就是控制流中的突变,用来响应处理器状态中的某些变化。
状态变化称为事件(event)。事件可能和当前指令的执行直接相关。比如,发生虚拟内存缺页、算术溢出,或者一条指令试图除以零。另一方面,事件也可能和当前指令的执行没有关系。比如,一个系统定时器产生信号或者一个I/O请求完成。
在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表(exception table)的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序exception handler)。
当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下3种情况中的一种:
1) 处理程序将控制返回给当前指令Icurr,即当事件发生时正在执行的指令。
2) 处理程序将控制返回给如果没有发生异常将会执行的下一条指令。
3) 处理程序终止被中断的程序。
异常处理
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)。其中一些号码是由处理器的设计者分配的,其他号码是由操作系统内核(操作系统常驻内存的部分)的设计者分配的。前者的示例包括被零除、缺页、内存访问违例、断点以及算术运算溢出。后者的示例包括系统调用和来自外部I/O设备的信号。
在系统启动时(当计算机重启或者加电时),操作系统分配和初始化一张称为异常表的跳转表,使得表目k包含异常k的处理程序的地址。图8-2展示了异常表的格式。
在运行时(当系统在执行某个程序时),处理器检测到发生了一个事件,并且确定了相应的异常号k随后,处理器触发异常,方法是执行间接过程调用,通过异常表的表目k转到相应的处理程序。
图8-3展示了处理器如何使用异常表来形成适当的异常处理程序的地址。异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器(exception table base register)的特殊CPU寄存器里。
异常类似于过程调用,但是有一些重要的不同之处:
•过程调用时,在跳转到处理程序之前,处理器将返回地址压人栈中。然而,根据异常的类型,返回地址要么是当前指令(当事件发生时正在执行的指令),要么是下一条指令(如果事件不发生,将会在当前指令后执行的指令)。
•处理器也把一些额外的处理器状态压到栈里,在处理程序返回时,重新开始执行被中断的程序会需要这些状态。比如,x86-64系统会将包含当前条件码的EFLAGS寄存器和其他内容压人栈中。
• 如果控制从用户程序转移到内核,所有这些项目都被压到内核桟中,而不是压到用户栈中
• 异常处理程序运行在内核模式下,这意味着它们对所有的系统资源都有完全的访问权限。
异常类别
异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)。
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断 | 来自I/O设备的信号 | 异步 | 总是返回下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
1.中断
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序(interrupt handler)。
图8-5 概述了一个中断的处理。I/O设备,例如网络适配器、磁盘控制器和定时器芯片,通过向处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,来触发中断,这个异常号标识了引起中断的设备。
在当前指令完成执行之后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回时,它就将控制返回给下一条指令(即如果没有发生中断,在控制流中会在当前指令之后的那条指令)。结果是程序继续执行,就好像没有发生过中断一样。
剩下的异常类型(陷阱、故障和终止)是同步发生的,是执行当前指令的结果。我们把这类指令叫做故障指令(faulting instruction)。
2.陷阱和系统调用
陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork)、加载一个新的程序(execve), 或者终止当前进程(exit)。为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的“Syscall n” 指令,当用户程序想要请求服务n时,可以执行这条指令。执行syscall指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序。图8-6 概述了一个系统调用的处理。
从程序员的角度来看,系统调用和普通的函数调用是一样的。然而,它们的实现非常不同。普通的函数运行在用户模式中,用户模式限制了函数可以执行的指令的类型,而且它们只能访问与调用函数相同的栈。系统调用运行在内核模式中,内核模式允许系统调用执行特权指令,并访问定义在内核中的栈。
3. 故障
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。图8-7概述了一个故障的处理。
一个经典的故障示例是缺页异常,当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生故障。,一个页面就是虚拟内存的一个连续的块(典型的是4KB)。缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中了,指令就可以没有故障地运行完成了。
4. 终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。如图8-8所示,处理程序将控制返回给一个abort例程,该例程会终止这个应用程序。
Linux/x86-64 系统中的异常
有高达256种不同的异常类型。0~31的号码对应的是由Intel架构师定义的异常,因此对任何x86-64系统都是一样的。32~255的号码对应的是操作系统定义的中断和陷阱。异常示例:
异常号 | 描述 | 异常类别 |
---|---|---|
0 | 除法错误 | 故障 |
13 | 一般保护故障 | 故障 |
14 | 缺页 | 故障 |
18 | 机器检查 | 终止 |
32~255 | 操作系统定义的异常 | 中断或陷阱 |
1. Linux/x86-64 故障和终止
除法错误:当应用试图除以零时,或者当一个除法指令的结果对于目标操作数来说太大了的时候,就会发生除法错误(异常0)。Unix不会试图从除法错误中恢复,而是选择终止程序。Linux shell 通常会把除法错误报告为“浮点异常(Floating exception)”。
一般保护故障:许多原因都会导致不为人知的一般保护故障(异常13),通常是因为一个程序引用了一个未定义的虚拟内存区域,或者因为程序试图写一个只读的文本段。Linux不会尝试恢复这类故障。Linux shell 通常会把这种一般保护故障报告为“段故障(Segmentation fault)”。
缺页(异常14):是会重新执行产生故障的指令的一个异常示例,。处理程序将适当的磁盘上虚拟内存的一个页面映射到物理内存的一个页面,然后重新执行这条产生故障的指令。
机器检查(异常18):是在导致故障的指令执行中检测到致命的硬件错误时发生的。机器检查处理程序从不返回控制给应用程序。
2. Linux/86-64 系统调用
进程
异常是允许操作系统内核提供进程(process)概念的基本构造块,进程是计算机科学中最深刻、最成功的概念之一。
在现代系统上运行一个程序时,我们会得到一个假象,就好像这个程序是系统中当前运行的唯一的程序。这个程序好像独占地使用处理器和内存。处理器好像是无间断地一条接一条地执行这个程序中的指令。这个程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户通过向shell输人一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
进程提供给应用程序的关键抽象:
• 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
• 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
逻辑控制流
即使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称逻辑流。
考虑一个运行着三个进程的系统,如图8-12所示。处理器的一个物理控制流被分成了三个逻辑流,每个进程一个。
每个竖直的条表示一个进程的逻辑流的一部分。在这个例子中,三个逻辑流的执行是交错的。进程A 运行了一会儿,然后是进程B开始运行到完成。然后,进程C运行了一会儿,进程A接着运行直到完成。最后,进程C可以运行到结束了。
图8-12 的关键点在于进程是轮流使用处理器的。每个进程执行它的流的一部分,然后被抢占(preempted )(暂时挂起),然后轮到其他进程。对于一个运行在这些进程之一的上下文中的程序,它看上去就像是在独占地使用处理器。唯一的反面例证是,如果我们精确地测量每条指令使用的时间,会发现在程序中一些指令的执行之间,CPU好像会周期性地停顿。然而,每次处理器停顿,它随后会继续执行我们的程序,并不改变程序内存位置或寄存器的内容。
一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流被称为并发地运行。
图8-12 中,进程A和B并发地运行,A和C也一样。另一方面,B和C没有并发地运行,因为B的最后一条指令在C的第一条指令之前执行。
多个流并发地执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitaskmg)。一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(time slicing)。例如,图8-12中,进程A的流由两个时间片组成。
并发流的思想与流运行的处理器核数或者计算机数无关。如果两个流在时间上重叠,那么它们就是并发的,即使它们是运行在同一个处理器上。如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它们为并行流(parallel flow), 它们并行地运行(running in parallel), 且并行地执行(parallel execution)。
私有地址空间
进程也为每个程序提供一种假象,好像它独占地使用系统地址空间。在一台n位地址的机器上,地址空间是2n个可能地址的集合,0, 1,…,2n -1。进程为每个程序提供它自己的私有地址空间。
一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,从这个意义上说,这个地址空间是私有的。
尽管和每个私有地址空间相关联的内存的内容一般是不同的,但是每个这样的空间都有相同的通用结构。比如,图8-13展示了一个x86-64 Linux进程的地址空间的组织结构。
址空间底部是保留给用户程序的,包括通常的代码、数据、堆和栈段。代码段总是从地址0x400000开始。地址空间顶部保留给内核(操作系统常驻内存的部分)。地址空间的这个部分包含内核在代表进程执行指令时(比如当应用程序执行系统调用时)使用的代码、数据和栈。
用户模式和内核模式
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction), 比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核摸式改回到用户模式。
Linux提供了一种聪明的机制,叫做/proc文件系统,它允许用户模式进程访问内核数据结构的内容。/proc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。比如,你可以使用/proc文件系统找出一般的系统属性,比如CPU类型(/proc/cpuinfo),或者某个特殊的进程使用的内存段(/proc/<process-id> /maps)。2.6版本的Linux 内核引入/Sys文件系统,它输出关于系统总线和设备的额外的低层信息。
上下文切换
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务,上下文切换机制是建立在那些较低层异常机制之上的。
内核为每个进程维持一个上下文(context),上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling), 是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换
1)保存当前进程的上下文
2)恢复某个先前被抢占的进程被保存的上下文
3)将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果一个read系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从磁盘到达。另一个示例是sleep系统调用,它显式地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。
中断也可能引发上下文切换。比如,所有的系统都有某种产生周期性定时器中断的机制,通常为每1 毫秒或每10毫秒。每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。
图8-14 展示了一对进程A和B之间上下文切换的示例。在这个例子中,进程A初始运行在用户模式中,直到它通过执行系统调用read陷人到内核。内核中的陷阱处理程序请求来自磁盘控制器的DMA传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。
磁盘取数据要用一段相对较长的时间(数量级为几十毫秒),所以内核执行从进程A到进程B的上下文切换,而不是在这个间歇时间内等待,什么都不做。注意在切换之前,内核正代表进程A在用户模式下执行指令(即没有单独的内核进程)。在切换的第一部分中,内核代表进程A在内核模式下执行指令。然后在某一时刻,它开始代表进程B(仍然是内核模式下)执行指令。在切换之后,内核代表进程B在用户模式下执行指令。
随后,进程B在用户模式下运行一会儿,直到磁盘发出一个中断信号,表示数据已经从磁盘传送到了内存。内核判定进程B已经运行了足够长的时间,就执行一个从进程B到进程A的上下文切换,将控制返回给进程A中紧随在系统调用read之后的那条指令。进程A继续运行,直到下一次异常发生,依此类推。
系统调用错误处理
当Unix 系统级函数遇到错误时,它们通常会返回-1, 并设置全局整数变量errno来表示什么出错了。程序员应该总是检査错误,但是不幸的是,许多人都忽略了错误检查,因为它使代码变得臃肿,而且难以读懂。比如,下面是我们调用Unix fork函数时会如何检查错误:
1 if ((pid = fork()) < 0){
2 fprintf(stderr, "fork error: %s\n", strerror(errno));
3 exit(0);
4 }
strerror函数返回一个文本串,描述了和某个errno 值相关联的错误。通过定义下面的错误报告函数,我们能够在某种程度上简化这个代码:
1 void unix_error(char *msg) / Unix-style error */
2 {
3 fprintf(stderr, "%s: %s\n", msg, strerror(errno));
4 exit(0);
5 }
给定这个函数,我们对fork的调用从4 行缩减到2 行:
1 if ((pid = fork()) < 0)
2 unix_error("fork error");
通过使用错误处理包装函数,我们可以更进一步地简化代码。对于一个给定的基本函数foo, 我们定义一个具有相同参数的包装函数Foo, 但是第一个字母大写了。包装函数调用基本函数,检查错误,如果有任何问题就终止。比如,下面是fork函数的错误处理包装函数:
1 pid_t Fork(void)
2 {
3 pid_t pid;
4
5 if ((pid = fork()) < 0)
6 unix_error("Fork error");
7 return pid;
8 }
给定这个包装函数,我们对fork的调用就缩减为1 行
1 pid = Fork();
进程控制
Unix提供了大量从C程序中操作进程的系统调用。
获取进程ID
每个进程都有一个唯一的正数(非零)进程ID(PID)。getpid函数返回调用进程的PID。getppid函数返回它的父进程的PID(创建调用进程的进程)。
#include <sys/types.h>
#include <unistd.h>
pid t getpid(void);
pid_t getppid(void); //返回:调用者或其父进程的PID
getpid和getppid函数返回一个类型为pid_t的整数值,在Linux 系统上它在types.h 中被定义为int。
创建和终止进程
从程序员的角度,我们可以认为进程总是处于下面三种状态之一:
• 运行。进程要么在CPU 上执行,要么在等待被执行且最终会被内核调度。
• 停止。进程的执行被挂起(suspended), 且不会被调度。当收到SIGSTOP 、SIGTSTP、SIGTTIN 或者SIGTTOU 信号时,进程就停止, 并且保持停止直到它收到一个SIGCONT 信号,在这个时刻,进程再次开始运行。
• 终止。进程永远地停止了。进程会因为三种原因终止: 1) 收到一个信号,该信号的默认行为是终止进程, 2) 从主程序返回, 3) 调用exit函数。
--------------------------------------------------------
#include
void exit(int status);
该函数不返回。
--------------------------------------------------------
exit函数以status退出状态来终止进程(另一种设置退出状态的方法是从主程序中返回一个整数值)。
父进程通过调用fork函数创建一个新的运行的子进程。
--------------------------------------------------------
#include
#include
pid_t fork(void);
返回:子进程返回0, 父进程返回子进程的PID, 如果出错,则为-1。
--------------------------------------------------------
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork 时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
fork函数是有趣的(也常常令人迷惑),因为它只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中,fork返回0。
因为子进程的PID总是为非零,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。
图8-15 展示了一个使用fork创建子进程的父进程的示例。当fork调用在第6行返回时,在父进程和子进程中x的值都为1。子进程在第8行加1并输出它的x的副本。相似地,父进程在第13行减1并输出它的x的副本。
当在Unix系统上运行这个程序时,我们得到下面的结果:
linux> ./fork
parent: x=0
child : x=2
• 调用一次,返回两次。fork函数被父进程调用一次,但是却返回两次,一次是返回到父进程,一次是返回到新创建的子进程。
• 并发执行。父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行它们的逻辑控制流中的指令。在我们的系统上运行这个程序时,父进程先完成它的printf语句,然后是子进程。然而,在另一个系统上可能正好相反。
• 相同但是独立的地址空间。如果能够在fork函数在父进程和子进程中返回后立即暂停这两个进程,我们会看到两个进程的地址空间都是相同的。每个进程有相同的用户栈、相同的本地变量值、相同的堆、相同的全局变量值,以及相同的代码。因此,在我们的示例程序中,当fork函数在第6行返回时,本地变量x在父进程和子进程中都为1。然而,因为父进程和子进程是独立的进程,它们都有自己的私有地址空间。后面,父进程和子进程对x所做的任何改变都是独立的,不会反映在另一个进程的内存中。这就是为什么当父进程和子进程调用它们各自的printf语句时,它们中的变量x会有不同的值。
• 共享文件。当运行这个示例程序时,我们注意到父进程和子进程都把它们的输出显示在屏幕上。原因是子进程继承了父进程所有的打开文件。当父进程调用fork时,stdout文件是打开的,并指向屏幕。子进程继承了这个文件,因此它的输出也是指向屏幕的。
图8-16 展示了图8-15中示例程序的进程图。初始时,父进程将变量x设置为1。父进程调用fork,创建一个子进程,它在自己的私有地址空间中与父进程并发执行。
图8-17 中的程序源码中两次调用了fork。对应的进程图可帮助我们看清这个程序运行了四个进程,每个都调用了一次printf,这些printf可以以任意顺序执行。
回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped)。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。一个终止了但还未被回收的进程称为僵死进程(zombie)。
如果一个父进程终止了,内核会安排init进程成为它的孤儿进程的养父。init进程的PID为1,是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵死子进程就终止了,那么内核会安排init进程去回收它们。不过,长时间运行的程序,比如shell或者服务器,总是应该回收它们的僵死子进程。即使僵死子进程没有运行,它们仍然消耗系统的内存资源。
一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止。
--------------------------------------------------------
#include
#include
pid_t waitpid(pid_t pid, int *statusp, int options);
返回:如果成功,则为子进程的PIO, 如果WNOHANG, 则为o, 如果其他错误,则为-1.
--------------------------------------------------------
waitpid函数有点复杂。默认情况下(当options=0 时),waitpid挂起调用进程的执行,直到它的等待集合(wait set)中的一个子进程终止。
1. 判定等待集合的成员
等待集合的成员是由参数pid来确定的:
• 如果pid>0, 那么等待集合就是一个单独的子进程,它的进程ID等于pid。
• 如果pid=-1,那么等待集合就是由父进程所有的子进程组成的。
waitpid函数还支持其他类型的等待集合,包括Unix进程组。
2. 修改默认行为
可以通过将options设置为常量WNOHANG、WUNTRACED 和WCONTINUED的各种组合来修改默认行为:
• WNOHANG: 如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为0)。默认的行为是挂起调用进程,直到有子进程终止。在等待子进程终止的同时,如果还想做些有用的工作,这个选项会有用。
• WUNTRACED:挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止。返回的PID为导致返回的已终止或被停止子进程的PID。默认的行为是只返回已终止的子进程。当你想要检查已终止和被停止的子进程时,这个选项会有用。
• WCONTINUED:挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或等待集合中一个被停止的进程收到SIGCONT信号重新开始执行。
可以用或运算把这些选项组合起来。例如:
• WNOHANG | WUNTRACED: 立即返回,如果等待集合中的子进程都没有被停止或终止,则返回值为0; 如果有一个停止或终止,则返回值为该子进程的PID。
3. 检查已回收子进程的退出状态
如果statusp参数是非空的,那么waitpid就会在status中放上关于导致返回的子进程的状态信息,status是statusp指向的值。wait.h头文件定义了解释status参数的几个宏:
• WIFEXITED(status):如果子进程通过调用exit或者一个返回(return)正常终止,就返回真。
• WEXITSTATUS(status):返回一个正常终止的子进程的退出状态。只有在WIFEXITEDO返回为真时,才会定义这个状态。
• WIFSIGNALED(status):如果子进程是因为一个未被捕获的信号终止的,那么就返回真。
• WTERMSIG(status):返回导致子进程终止的信号的编号。只有在WIFSIGNALEDO返回为真时,才定义这个状态。
• WIFSTOPPED(status):如果引起返回的子进程当前是停止的,那么就返回真。
• WSTOPSIG(status):返回引起子进程停止的信号的编号。只有在WIFSTOPPED()返回为真时,才定义这个状态。
• WIFCONTINUED(status):如果子进程收到SIGCONT信号重新启动,则返回真。
4. 错误条件
如果调用进程没有子进程,那么waitpid返回-1,并且设置errno为ECHILD。如果waitpid函数被一个信号中断,那么它返回-1,并设置errno为EINTR。
旁注 和Unix函数相关的常量
像WNOHANG和WUNTRACED这样的常量是由系统头文件定义的。例如,WNOHANG和WUNTRACED是由wait.h头文件(间接)定义的:
/* Bits in the third argument to 'waitpid'. */
#define WNOHANG 1 /* Don't block waiting. */
#define WUNTRACED 2 /* Report status of stopped children. */
为了使用这些常量,必须在代码中包含wait.h头文件:
#include <sys/wait.h>
每个Unix 函数的man页列出了无论何时你在代码中使用那个函数都要包含的头文件。同时,为了检查诸如ECHILD和EINTR之类的返回代码,你必须包含errno.h。为了简化代码示例,我们包含了一个称为csapp.h 的头文件,它包括了本书中使用的所有函数的头文件。csapp.h 头文件可以从CS: APP网站在线获得。
5. wait 函数
wait函数是waitpid函数的简单版本:
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *statusp);
返回:如果成功,则为子进程的PID, 如果出错,则为-1。
调用wait(&status)等价于调用waitpid(-1,&status,0)。
6. 使用waitpid的示例
图8-18展示了一个程序,它使用waitpid,不按照特定的顺序等待它的所有N个子进程终止。在第11 行,父进程创建N个子进程,在第12 行,每个子进程以一个唯一的退出状态退出。
在第15 行,父进程用waitpid作为while循环的测试条件,等待它所有的子进程终止。因为第一个参数是-1,所以对waitpid的调用会阻塞,直到任意一个子进程终止。在每个子进程终止时,对waitpid的调用会返回,返回值为该子进程的非零的PID。第16 行检查子进程的退出状态。如果子进程是正常终止的,在此是以调用exit函数终止的,那么父进程就提取出退出状态,把它输出到stdout上。
当回收了所有的子进程之后,再调用waitpid就返回-1,并且设置errno为ECHILD。第24行检查waitpid函数是正常终止的,否则就输出一个错误消息。在我们的Linux系统上运行这个程序时,它产生如下输出:
linux> ./waitpid1
child 22966 terminated normally with exit status=100
child 22967 terminated normally with exit status=101
注意,程序不会按照特定的顺序回收子进程。子进程回收的顺序是这台特定的计算机系统的属性。在另一个系统上,甚至在同一个系统上再执行一次,两个子进程都可能以相反的顺序被回收。
图8-19展示了一个简单的改变,它消除了这种不确定性,按照父进程创建子进程的相同顺序来回收这些子进程。在第11行中,父进程按照顺序存储了它的子进程的PID,然后通过用适当的PID作为第一个参数来调用waitpid, 按照同样的顺序来等待每个子进程。
让进程休眠
sleep函数将一个进程挂起一段指定的时间。
#include <unistd.h>
unsigned int sleep(unsigned int secs);
返回:还要休眠的秒数。
如果请求的时间量已经到了,sleep返回0, 否则返回还剩下的要休眠的秒数。
我们会发现另一个很有用的函数是pause函数,该函数让调用函数休眠,直到该进程收到一个信号。
#include <unistd.h>
int pause(void);
总是返回-1
加载并运行程序
execve函数在当前进程的上下文中加载并运行一个新程序。
#include <unistd.h>
int execve(const char *filename, const char *argv[],
const char *envp[]);
如果成功,则不返回,如果错误,则返回-1。
execve 函数加载并运行可执行目标文件filename, 且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename, execve才会返回到调用程序。参数列表是用图8-20中的数据结构表示的。argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例,argv[0]是可执行目标文件的名字。环境变量的列表是由一个类似的数据结构表示的,如图8-21所示。envp变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如"name = value"的名字-值对。
在execve加载了filename之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下形式的原型:
int main(int argc, char **argv , char **envp);
或者等价的
int main(int argc, char *argv[], char *envp[]);
当main开始执行时,用户栈的组织结构如图8-22 所示。让我们从栈底(高地址)往栈顶(低地址)依次看一看。首先是参数和环境字符串。栈往上紧随其后的是以null结尾的指针数组,其中每个指针都指向钱中的一个环境变量字符串。全局变量environ指向这些指针中的第一个envp[0]。紧随环境变量数组之后的是以null结尾的argv[ ]数组,其中每个元素都指向栈中的一个参数字符串。在栈的顶部是系统启动函数libc_start_main的栈帧。
main函数有3个参数:1)argc,它给出argv[ ]数组中非空指针的数量,2)argv,指向argv[ ]数组中的第一个条目,3)envp,指向envp[ ]数组中的第一个条目。
Linux 提供了几个函数来操作环境数组
#include <stdlib.h>
char *getenv(const char *name);
返回: 若存在则为指向name的指针,若无匹配的,则为NULL。
#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运行程序
像Unix shell和Web服务器这样的程序大量使用了fork和execve函数。shell是一个交互型的应用级程序,它代表用户运行其他程序。最早的shell是sh 程序,后面出现了一些变种,比如csh、tcsh、ksh 和bash。shell执行一系列的读/求值(read/evaluate)步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。图8-23 展示了一个简单shell的main例程。shell打印一个命令行提示符,等待用户在stdin上输人命令行,然后对这个命令行求值。
图8-24 展示了对命令行求值的代码。它的首要任务是调用parseline函数(见图8-25),这个函数解析了以空格分隔的命令行参数,并构造最终会传递给execve的argv向量。第一个参数被假设为要么是一个内置的shell 命令名,马上就会解释这个命令,要么是一个可执行目标文件,会在一个新的子进程的上下文中加载并运行这个文件。
如果最后一个参数是一个字符,那么parseline返回1,表示应该在后台执行该程序(shell不会等待它完成)。否则,它返回0, 表示应该在前台执行这个程序(shell会等待它完成)。
在解析了命令行之后,eval函数调用builtin_command 函数,该函数检查第一个命令行参数是否是一个内置的shell命令。如果是,它就立即解释这个命令,并返回值1。否则返回0。简单的shell只有一个内置命令quit命令,该命令会终止shell。实际使用的shell有大量的命令,比如pwd、jobs 和fg。
如果builtin_command返回0,那么shell创建一个子进程,并在子进程中执行所请求的程序。如果用户要求在后台运行该程序,那么shell返回到循环的顶部,等待下一个命令行。否则,shell使用waitpid 函数等待作业终止。当作业终止时,shell就开始下一轮迭代。
注意,这个简单的shell 是有缺陷的,因为它并不回收它的后台子进程。
信号
我们将研究一种更高层的软件形式的异常,称为Linux 信号,它允许进程和内核中断其他进程。
一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。比如,图8-26展示了Linux 系统上支持的30 种不同类型的信号。
每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。
信号提供了一种机制,通知用户进程发生了这些异常。比如,
如果一个进程试图除以0, 那么内核就发送给它一个SIGFPE信号(号码8)。
如果一个进程执行一条非法指令,那么内核就发送给它一个SIGILL信号(号码4)
如果进程进行非法内存引用,内核就发送给它一个SIGSEGV信号(号码11)。
其他信号对应于内核或者其他用户进程中较高层的软件事件,比如:
如果当进程在前台运行时,你键入Ctrl+C,那么内核就会发送一个SIGINT信号(号码2)给这个前台进程组中的每个进程。
一个进程可以通过向另一个进程发送一个SIGKILL 信号(号码9)强制终止它。
当一个子进程终止或者停止时,内核会发送一个SIGCHLD信号(号码17)给父进程。
信号术语
传送一个信号到目的进程由两个不同的步骤组成的:
•发送信号。内核通过更新目的进程的上下文的某个状态,发送(递送)一个信号给目的进程。发送信号可以如下两种原因:1)内核检测到一个系统事件,比如除零错误或子进程终止。2)一个进程调用了kill函数,显示的要求内核发送一个信号给目的进程。一个进程也可以发送信号给自己
•接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序(signal handler)的用户层函数捕获这个信号。一个发出而没有被接收的信号叫做待处理信号(pending signal)。在任何时刻,一种类型至多只会有一个待处理信号。如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待;它们只是被简单地丢弃。一个进程可以有选择性地阻塞接收某种信号。当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。
一个待处理信号最多只能被接收一次。内核为每个进程在pending位向量中维护着待处理信号的集合,而在blocked位向量6中维护着被阻塞的信号集合。只要传送了一个类型为々的信号,内核就会设置pending 中的第立,而只要接收了一个类型为k的信号,内核就会清除pending中的第k位。
发送信号
Unix 系统提供了大量向进程发送信号的机制。所有这些机制都是基于进程组(process group)这个概念的。
1. 进程组
2. 用/bin/kill程序发送信号
3. 从键盘发送信号
4. 用kill函数发送信号
5. 用alarm函数发送信号
接收信号
阻塞和解除阻塞信号
编写信号处理程序
显式地等待信号
操作进程的工具
Linux系统提供了大量的监控和操作进程的有用工具。
STRACE: 打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。对于好奇的学生而言,这是一个令人着迷的工具。用-static 编译你的程序,能得到一个更干净的、不带有大量与共享库相关的输出的轨迹。
PS:列出当前系统中的进程(包括僵死进程)。
TOP:打印出关于当前进程资源使用的信息。
PMAP:显示进程的内存映射。
/proc: 一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。比如,输入“cat/proc/loadavg”,可以看到你的Linux系统上当前的平均负载。