第 8 章
C H A P T E R 8
异常控制流
从给处理器加电开始,直到你断电为止,程序计数器假设一个值的序列
a0 , a , , … , a ,, _1
其中,每个 ak 是某个相应的指令 I k的地址。每次从 Qk 到 a k一 1 的过渡称为控 制 转移 ( co ntro l trans £er ) 。 这样的 控制转移 序列叫做处理器的控制流( flow of cont rol 或 cont ro l flow ) 。
最简单的一种控制流 是一个“平滑的" 序列, 其中每个 L 和 I尸!在内存中都是相邻
的。这种 平滑流的 突变(也就是 I尸]与 L 不相邻)通常是由诸如跳转 、调用和返回这样一些熟悉的 程序指令造成的 。这样一些指令都是必要的机制, 使得程序能够对由程序变扯表示的内部程序状 态中的 变化做出反应 。
但是系统也必须能够对系统状态的变化做出反应,这 些系统状态不是被内部程序变量捕获的, 而且也不一定要和程序的执行相关。比如, 一个硬件定时 器定期产生信号 , 这个事 件必须得到处理 。包到达网络适配器后,必 须存放在内存中。程序向磁盘请求数据, 然后休眠, 直到被通知说数据巳就绪。当子进程终止时, 创造这些子进程的父进程必须得到通知。
现代系统通过使 控制流发生突 变来对这些情况做出反应。一般而言, 我们把这些突变称为异常控制流 ( Exceptiona l Control Flow, ECF) 。异常控制流发 生在计算机系统的各个层次。比如, 在硬件层, 硬件检测到的 事件会触发控制突 然转移到异常处理程序。在操作系统层 ,内 核通过上下 文切换 将控制从一个用户进程转 移到 另一个用户进程。在应用层, 一个进 程可以发送信 号到 另一个进程, 而接收者会将控制 突然转移到它的一个信号处理程序。一个程序可以通 过回避通常的栈规则 , 并执行到其他 函数中任意位置的非本地跳转来对错误做出反应 。
作为程序员, 理解 ECF 很重要 , 这有很多原因:
理解 ECF 将帮助 你理解重要 的 系统概念。ECF 是操作系统 用来实现 I/ 0 、进程和虚拟内存的基本机制。在能够真正理解这些 重要概念之前 , 你必须理解 ECF 。
理解 ECF 将帮助你理 解应 用程序是如何与操作 系统交互的 。应用程序 通过使用一个叫做陷阱 ( t ra p) 或者 系统调 用 ( s ys tem call ) 的 ECF 形式, 向操作系统请求服务。比如,向磁盘写数据、从网络读取数据、创建一个新进程,以及终止当前进程,都 是通过应用程序调用系统调用来实现的。理解基本的 系统调用机制将帮助你理 解这些服务是如何提供给应用的。
理解 ECF 将帮 助 你 编写 有趣的 新应 用程 序。操作系统为应用程序提供了强大的
ECF 机制,用 来创建新进程、等待进程终止 、通知其他进程系统 中的异常事件, 以及检测和响应这些 事件。如果理解了这些 ECF 机制, 那么你就能用它们来编写诸如 U nix shell 和 Web 服务器 之类的有趣程序 了。
理解 ECF 将帮助你理 解并发 。ECF 是计算机系统 中实现并发的基本机制。在运行中的并发的例子有:中断应用程序执行的异常处理程序,在时间上重叠执行的进程 和线程 , 以及中断应用程序执行的信号处理程序。理解 ECF 是理解并发的第一步。我们会 在第 12 章中更详细地研究并 发。
理解 ECF 将帮助你理解软件异常如何 工作。像 C+ + 和 J a va 这样的语言通过 t r y、c a t c h 以及 t hr o w 语 句 来 提供软件异常机制。软件异常允许程序进行非 本地跳转
(即违反通常的调用/返回栈规则的跳转)来响应错误情况。非本地跳转是一种应用 层 ECF , 在 C 中是通过 s e t j mp 和 l o ng j mp 函 数 提供的。理解这些低级函数将帮助你理解高级软件异常如何得以实现。
对系统的学习,到目前为止你巳经了解了应用是如何与硬件交互的。本章的重要性在 千你将开始学习应用是如何与操作系统交互的。有趣的是, 这些交互都是围绕着 ECF 的。我们将描述存在千一个计算机系统中所有层次上的各种形式的 ECF。从异常开始, 异常位于 硬 件和操作系统交界的部分。我们还会讨论系统调用,它 们 是 为应用程序提供到操作系统 的 入口点的异常。然后, 我们会提升抽象的层次,描 述 进程和信号, 它 们 位 于应用和操作系统的交界之处。最后讨论非本地跳转, 这是 ECF 的一种应用层形式。
1 异常
异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。因为它们有一部分是由硬件实现的,所以具体细节将随系统的不同而有所不同。然而,对于每个系统而言,基本的思想都是相同的。在这一节中我们的目的是让你对异常和异常处理有一个一般性的了解,并 且 向 你 揭示现代计算机系统的一个经常令人感到迷惑的方面。
异常( exception ) 就 是控制流中的突
变,用来响应处理器状态中的某些变化。图 8-1 展示了基本的思想。
在图中,当处理器状态中发生一个
事件在
………….. .
应用程序 异常处理程序
Icurr
重要的变化时,处 理 器正在执行某个当前指令 J curr 。在处理器中,状 态被编码为不同的位和信号。状态变化称为事件(event) 。事件可能和当前指令的执行直接相关。比如,发 生虚拟内存缺页、算
这里发生 / next
术溢出,或者一条指令试图除以零。另 图 8- 1 异常的剖析。处理器状态中的变化(事件 )触发从
应用程序到异常处理程序的突发的控制转移(异
一方面,事件也可能和当前指令的执行
没有关系。比如,一个系统定时器产生信号或者一个1/0 请求完成。
常)。在异常处理程序完成处理后,它将控制返回给被中断的程序或者终止
在任何情况下, 当处 理器检测到有事件发生时, 它 就 会通过一张叫做异常表( excep- tion ta ble ) 的跳转表,进 行 一 个间 接过程调用(异常), 到 一 个 专门设计用来处理这类事件 的 操 作 系统子程序(异常 处理程序( exce pt io n ha ndle r ) ) 。当异常处理程序完成处理后,根 } 据引 起异常的事件的类型, 会发生以下 3 种情况中的一种:
处 理 程 序将控制返回给当前指令 ICUTT ’ 即当事件发生时正在执行的指令。
) 处理程序将控制返回给 [ next • 如果没有发生异常将会执行的下一条指令。
) 处理程序终止被中断的程序。
8. l. 2 节将讲述关于这些可能性的更多内容。
囚 日 硬件异常与软件异常
C ++ 和 J ava 的程序员会 注意 到术语“异常” 也 用 来描述由 C+ + 和 J a va 以 c a t ch、
t h r o w 和 七r y 语句形 式提供的应用级 ECF 。如 果想严格 清晰, 我们必须区别“ 硬件” 和
“软 件” 异 常,但 这通常是不必要的, 因 为从 上 下文中就能够很 清楚 地知道是哪种含义。
1. 1 异常处理
异常可能会难以理解,因为处理异常需要硬件和软件紧密合作。很容易搞混哪个部分执行哪个任务。让我们更详细地来看看硬件和软件的分工吧。
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号 ( exce ptio n n um her ) 。其中一些号码是由处理器的设计者分配的, 其 他 号 码 是 由 操 作系统内核(操作系统常驻内存的部分)的设计者分配的。前者的示例包括被零除、缺页、内存访问违例、断点 以及算术运算溢出。后者的示例包括系统调用和来自外部 I / 0 设备的信号。
在系统启动时(当计算机重启或者加电
时), 操作系统分配和初始化一张称为异常表的跳转表,使 得表目 K 包 含异常 k 的处理程序的地址。图 8-2 展 示了异常表的格式。
在运行时(当系统在执行某个程序时),处 理器检测到发生了一个事件,并且确定了相应 的异常号 k。随后, 处理器触发异常,方 法是执行间 接过程调用,通 过异常表的表目 k , 转到相应的处理程序。图 8-3 展示了处理器如何
二1
异常处理程序0的代码
异常处理程序l的代码异常处理程序2的代码
上
使用异常表来形成适当的异常处理程序的地址。 图 8-2 异常表。异常表是一 张跳转表, 其中表目 K
异常号是到异常表中的索引,异常表的起始地 包含异常k 的处理程序代码的地址
址放在一个叫做异常表基 址寄存器( e xce ption table base register ) 的 特殊 CPU 寄存器里。
异常号
(X 84)
i 异常表
n- 11 I
图 8-3 生成异常处理程序的地址 。异常号是到异常表中的索引
异常类似于过程调用 ,但 是有一些重要的不同之处:
- 过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中。然而,根据异常的类型,返回地址要么是当前指令(当事件发生时正在执行的指令),要么是下一 条指令(如果事件不发生,将 会在当前指令后执行的指令)。
- 处理器也把一些额外的处理器状态压到栈里,在 处理程序返回时, 重新开始执行被中断的程序会需要这些状态。比如, x86-64 系统会将包含当前条件码的 EF LAGS 寄存器和其他内容压入栈中。
如果控制从用户程序转移到内核,所 有这些项目都被压到内核栈中, 而不是压到用户栈中。
异常处理程序运行在内核模式下(见 8. 2. 4 节), 这意味着它们对所有的系统资源都有完全的访间权限。
一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理完事件之后,它通过执行一条特殊的“从中断返回”指令,可选地返回到被中断的程
序,该指令将适当的状态弹回到处理器的控制和数据寄存器中,如果异常中断的是一个用户程序 , 就将状态恢复为用 户模式(见8. 2. 4 节), 然后将控制返回给被中断的程序。
1. 2 异常的类别
异常可以分为四类: 中断 ( interru pt ) 、陷阱( tra p) 、故障( fault) 和终止( abort )。图 8-4 中的表对这些类别 的属性做了小结。
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断 | 来自 1/0 设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
图 8-4 异 常 的 类 别 。 异步异常是由处理器外部的 I/ 0 设备中的事件产生的。同 步异常是执行一条指令的直接产物
中断
中断是 异步发生的 , 是来自处理器外部的I/ 0 设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从 这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理 程序 ( in t er ru pt hand ler ) 。
图 8-5 概述了一个中断的处理。I/ 0 设备, 例如网 络适配器、磁盘控制器和定时 器芯片,通过向处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,来触发中断, 这个异常号标识 了引起中断的设备。
( I ) 在当前指令的执行过程中,中
f cu斤
I
断引脚 电压变高了
next
( 3 ) 中断处
理程序运行
图 8-5 中断处理。中断处理程序将控制返回给应用程序控制流中的下一 条指令
在当前指令完成 执行之后, 处理器注意到中 断引脚的电压变高了, 就从系统总线读取异常号, 然后调用适当的中断处理程序。当处 理程序返 回时,它 就将控制返回给下一条指令(也即如果没有发生中断,在控制流中会在当前指令之后的那条指令)。结果是程序继续执行, 就好像没有发生过中断一样。
剩下的异常类型(陷阱、故障和终止)是同 步发 生的,是 执行当前指令的结果。我们把这类指令叫做故障指令 ( fa ult ing ins t ru ct ion ) 。
陷阱和系统调用
陷阱是有意的 异常 , 是执行一条指令的结果。就像中断处理程序一样, 陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序 和内核之间提供一个像过程一样的接口, 叫做系统调用。
用 户程序经常需 要向内核请求服务, 比如读一个文件 (r e a d ) 、创建一个新的进程( for k ) 、加载一个新的程序( e x e c v e ) , 或者终止当前 进程( e x 江)。为了允许对这些内核服务的受控的访问, 处理器提供了一条特殊的 " s y s c a l l n " 指令, 当用户程序想要请求
服务 n 时, 可以执行这条指令。执行 s y s c a l l 指令会导致一个到异常处理程序的陷阱, 这个处 理程序解析参数, 并调用适当的内核程序。图 8-6 概述了一个系统调用的处理。
( 1 ) 应用程 s ys c a l l 序执行一次系 /next 统调用
( 3 ) 陷阱处理程序运行
图 8-6 陷阱处理。陷阱处 理程序将控制返回给应用程序控制流中的下一条指令
从程序员的角度来看 ,系 统调用和普通的函数调用是 一样的。然而, 它们的实现非常不同。普通的函数运行在 用户 模式中,用 户模式限制了函数可以执行的指令的类型, 而且它们只能访问与调用函数相同的栈。系统调用运行在内核模 式中 ,内 核模式允许系统调用执行特权指令, 并访问定义在内核中的栈。8. 2. 4 节会更详细地讨论用 户模式和内核模式。
故陪
故障由错误 情况引起,它 可能能 够被故障处理程序修 正。当故障发生时, 处理器将控制转移给故 障处理程序。如果处理程序能够修正这个错误情况,它 就将控制返回到引起故障的指令 ,从 而重新执行它。否则, 处理程序返 回到内核中的 a bor t 例程, a b o 江 例程会终止引起故 障的应用程序。图 8-7 概述了一个故障的处理。
( 3 ) 故障处理程序运行
•••••••••••••••••.•… … ..►.
( 4 ) 处理程序要么重新执行当前指令,要么终止
abort
图 8-7 故障处 理。根据故障是否能够被修复,故 障 处 理 程序要么重新执行引起故障的指令,要 么 终 止
一个经典的故 障示例是缺页异常, 当指令引用一个虚拟地址, 而与该地址相对应的物理页面不 在内存中, 因此必须从磁盘中取出时, 就会发生故障。就像我们将在第 9 章中看到的那样 , 一个页面就是 虚拟内存的一个连续的块(典型的是 4K B) 。缺页处理程序从 磁盘加载适当的 页面, 然后将控制返回 给引起 故障的指令。当指令再次执行时, 相应的物理页面已 经驻留在内存中 了, 指令就可以 没有故障地运行 完成了。
终止
终止是不 可恢 复的致命错误造成的结果, 通常是一些硬件错误, 比如 DR AM 或者
SRAM 位被损坏时 发生的奇偶错误。终止处理程序从不将控制返回给应用程序。如图 8-8
所示,处 理程序将控制返 回给一个 a bor t 例程, 该例程会终止这个应用 程序。
1. 3 Linux/ x86-64 系统中的异常
为了使描述更具体 , 让我们来看看为 x86-64 系统定义的一些异常。有高达 256 种不同的异常类型 [ 50] 。0 31 的号码对应的是由 Intel 架构师定义的异常, 因此对任何 x86-64 系统都是一样 的。32 255 的号码对应的是操作系统定义的中断和陷阱 。图 8-9 展示了一些示 例。
( I ) 发生致命I
的硬件错误
curr
( 2 ) 传递控制给处理程序
( 3 ) 终止处理程序运行
……………………..…. ……..►.. abort
( 4 ) 处理程序返回到
abor t 例程
图 8-8 终止处理。终止处理程序将控制传递给一个内核 abor t 例程,该 例 程会终止这个应用程序
。异常号 描述 异常类别
图 8-9 x86-64 系统中的异常示例
Linux/ x86-6 4 故障和终止
除法错误 。当应用试图除以零时, 或者当一个除法指令的结果对于目标操作数来说太大了的时候, 就会发生除法错误(异常 0 ) 。U nix 不会试图从除法错误中恢复, 而是选择终止程序。Linu x s hell 通常会把除法错误 报告为“ 浮点异常 ( F loa ting except io n ) " 。
一般保护故障 。许多原因都会 导致不为人知的一般保护故障(异常 13 ) , 通常是因为一个程序引用了一个未定义的虚拟内存区域, 或者因为程序试图写一个只读的文本段。L in ux 不会尝试恢复这类故障。Lin ux s hell 通常会把这种一般保护故障报告为 "段故樟
( S eg m e n tat io n fa ult ) " 。
缺页(异常 14) 是会重新执行产生故障的指令的一个 异常示例。处理程序将适当的磁盘上虚拟内存的一个页面映射到物理内存的一个页面,然 后重新执行这条产生故障的指令。我们将在第 9 章中看到缺页是 如何工作的细节。
机器桧 查。机 器检查(异常 18 ) 是在导致故障 的指令执行中检测到致命 的硬件错误时发生的。机器检查处理程序从不返回控制给应用程序。
Linux/ 86-64 系统调 用
L in ux 提供几百 种系统调用, 当应用程序想要请求内核服务时可以使用, 包括读文件、写文件或是创建一个新进程。图 8-10 给出了一些常见的 Lin ux 系统调用。每个系统调用都有一个唯一的整数号, 对应于一个到内核中跳转表的偏移址。(注意: 这个跳转表和异常表不一样 。)
C 程序用 s ys c a l l 函数可以直接调用任何系统调用。然而,实 际中几乎没必要这么做。对于大多数系统调用, 标准 C 库提供了一组方便的包装函数。这些包装函数将参数打包到一 起, 以 适当的系统调用指令陷人内核, 然后将系统调用的返回状态传递回调用程序 。在本书中,我们将系统调用和与它们相关联的包装函数都称为系统级函数, 这两个术语可以互换地使用。
在 x86- 64 系统上, 系统调用是通过 一条称为 s ys c a l l 的陷阱指令来提供的。研究程序能够如何使用这条指令来直接调用 L in u x 系统调用是很有趣的。所有到 Lin ux 系统调用的 参 数都是通过通用寄存器而不 是栈传递的。按照惯例, 寄存器%r a x 包含系统 调用号,
寄存器%r d i 、%r s i 、%r d x 、%r 1 0 、r% 8 和%r 9 包含最多 6 个参数。第一个参数在% r 中 中,第
二个在%r s i 中 , 以此类推。从系统调用 返回时, 寄存器%r c x 和%r ll 都 会被破坏,%r a x 包
含返回值。—40 9 5 到一1 之间的 负 数返回值表明发生了错误, 对应于负的 er r n o 。
编号
图 8-10 Linux x86-64 系 统中 常用的系统调用示例
例如, 考 虑 大家熟悉的 h e l l o 程序的下 面这个版本, 用 系统级函数 wr i t e ( 见 1 0 . 4
节)来写,而 不是用 pr i n t f :
int ma i n ()
2 {
3 vrite(l, “hello, vorld\n”, 13) ;
_e xi t ( O) ;
5 }
wr i t e 函 数的第一个参数将输出发送到 s t d o u 七。 第二个参数是要写的字节序列, 而第三个参数是要写的字节数。
图 8-11 给出的是 h e l l o 程序的汇编语言版本, 直 接 使 用 s y s c a l l 指 令 来 调 用 wr i t e
和 e x i t 系统调用。第 9 ~ 1 3 行调用 wr i t e 函 数 。 首先, 第 9 行将系统调用 wr i t e 的 编号存放在%r a x 中 , 第 1 0 ~ 1 2 行设 置 参数 列 表。然后第 1 3 行使用 s y s c a l l 指令来调用系统调用。类 似地,第 1 4 ~ 1 6 行调用_e x i t 系统调用。
code/ecf/hello-asm64.sa
.section .dat a str i ng:
. a s c i i “hello, vorld\n”
s t r i ng _e nd :
.equ len, string_end - string
.section .text
.globl main
main:
First, call write(1, “hello, world\n”, 13)
movq $1, %rax write 1s system call 1
movq $1, %rdi Argl: stdout has descriptor 1
movq $s tr i ng , %rsi Arg2: hello world string
12 | mo v q $ l e n , %rdx | Arg3: string length |
---|---|---|
13 | syscall | Make the system call |
Next, call _exit(O)
- movq $60, %rax
- movq $0, %rdi
- syscall
_ex1 t is system call 60 Arg1: exit status is 0 Make the system call
code/ecfh/ello-asm64.as
图8-11 直接用Linux 系统调用来实现 he ll o 程序
日 日 关千术 语的注释
各种异常类型的 术语根据系统的不同 而有所不同 。处理 器 ISA 规范通常会 区分异步
“中 断” 和同 步“异 常", 但是并没有提供 描述这些非 常相 似的 概念的概括性的术语。为了避免不断地提到“异常和中断”以及“异常或者中断",我们用单词“异常”作为通 用的 术语, 而且 只有在必要时才 区别异 步异 常(中断)和同 步异 常(陷阱、故障和终止)。正如我们提到过的,对于每个系统而言,基本的概念都是相同的,但是你应该意识到一 些制 造厂商的 手册会 用“ 异常” 仅仅 表示同 步事件 引起的 控制流的 改变。
2 进程
异常是允许操作系统内核提供进程( pro cess ) 概念的基本构造块 , 进程是计算机科学中最深刻、最成功的概念之一。
在现代系统上运行 一个程序时 , 我们会得到一个假象, 就好像我们的程序是 系统中当前运行的唯一的程序一样。我们的 程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后 , 我们程序中 的代码和数据好像是系统内存中唯一的对象。这些假象都 是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的 上下文 ( co n t e x t ) 中。上下文是由程序正确 运行所需的状态组成的。这个状 态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变最以及 打开文件描述符的 集合。
每次用 户通过向 s hell 输入一个可执行目 标文件的名字, 运行 程序时, s hell 就会创建一个新的进程, 然后在这个新进程的上下文中运行这个 可执行目标文件。应用程序也能够创建新进程, 并且在这个新进程的上下 文中运行它们自己的代码或其他应用程序。
关千操作系统如何实现进程的 细节的讨论超出了本 书的范围。反之,我 们将关注进程提供给应用程序的关键抽象:
- 一个独立的逻辑控制流 , 它提供一个假象 , 好像我们的程序独占地使用处理器。
- 一个私有的地址空间, 它提供一个假象 , 好像我们的 程序独占地使用内 存系统。让我们更 深入地看看这些 抽象。
8. 2. 1 逻辑控制流
即使在系统中通常有许 多其他程序在运行 , 进程也 可以向每个 程序提供一种假象,好像它在独占地使用处理器。如果想用调试器单步执行程序 , 我们会看到一系列的程序计数器(PC) 的值, 这些值唯一地对应于包含 进程A 进程B 进程C
在程 序的 可执行目标文件中的指令, 或是包含在运行时动态链接到程序的共享
对象中的指令。这个 PC 值的序列叫做逻
时间
辑控 制流,或者 简称逻辑流。
考虑一个运行着 三个 进 程的 系统, 如图 8-12 所示。处理器的一个物理控制
流被分成了三个逻辑流, 每个 进程一个。 图 8-12 逻辑控制流。进程为每个程序提供了一种假象,
每个竖直的条表示一个进程的逻辑流的
一部分。在这个例子中, 二个逻辑流的
好像程序在独占地使用处理器 。每个竖直的条表示一个进程的逻辑控制流的一部分 j
执行是交错的 。进程 A 运行 了一会儿, 然后是进程 B 开始运行到完成。然后, 进程 C 运行了一会儿 , 进程 A 接着运行 直到完成。最后, 进程 C 可以运行到结束了。
图 8-1 2 的关键点在于进程是轮流使 用处理器的。每个 进程执 行它的流的一部分, 然后被抢占 ( preem pted )(暂时挂起), 然后 轮到其他进程。对千一个运行在这些进程之一的上下文中的程序, 它看上去就像是在独占地使用处 理器。唯一的反面例证是 , 如果我们 精确地测扯 每条指令使用的时间, 会发现在程序中一些指令的执行之间, CPU 好像会周期性地停顿 。然而 , 每次处 理器停顿 , 它随后会继续执行我们的程序 , 并不改变程序内存位置或寄存器的内 容。
8. 2. 2 并发流
计算机系统中逻辑 流有许 多不同的形式。异常处理程序、进程、信号处理程序、线程和 Java 进程都是逻辑 流的例子。
一个逻辑流的执行在时间上与另一个流重叠, 称为并发 流 ( co nc urr e n t flow), 这两个流被称为并发 地运行 。更准确地说 ,流 X 和 Y 互相并发, 当且仅当 X 在 Y 开始之后和 Y 结束之前开始,或者 Y 在 X 开始之后和 X 结束 之前开始。例如,图 8-1 2 中,进 程 A 和 B 并发地运行 , A 和 C 也一样。另一方面, B 和 C 没有并发地运行 , 因为 B 的最后一条指令在 C 的第一条指令之前执行。
多个流并发地执行的一般现象被称为并发 ( co ncu rr e ncy ) 。一个进程和其 他进程轮流运行的概念称为 多任务( m ult itas king ) 。一个进程执行它的控制流的 一部分的每一时间段叫做时间 片 ( t im e s lice ) 。因此,多 任务也叫 做时间分 片 ( t im e s licing ) 。例如, 图 8-1 2 中, 进程 A 的流由两个时间片组成。
注意, 并发流的思想与流运行的 处理器核数或者计算机数无关。如果两个 流在时间 上重叠 , 那么它们就是并 发的, 即使它们是运行在同一个处理器上。不 过, 有时我们会发现确认并行 流是很有帮助的,它 是并发流的一个真子集。如果两个流并发地运行 在不同的处理器核或 者计算机上, 那么我们称它们为并行 流( pa ra ll el fl o w ) , 它们并行 地运行 ( ru n ning in para llel) , 且并行地执行( para llel exec ut ion ) 。
让 练习题 8. 1 考虑 三个具有下述起 始和结束 时间的 进程:
起始时间
I 3
结束时间
2
4
5
对于每 对进 程,指 出它 们是 否是 并发地运行 :
8. 2. 3 私有地址空间
进程也为每个 程序提 供一种假象 , 好像它独占地使用 系统地址空间。在一台 n 位地址的机器上 ,地 址空间是 2" 个可能地址的集合, o, 1, … , 2" - 1。进程为每个程序提供它自己的私有地 址空间 。一 般而言, 和这个空间中某个地址相关 联的那个内存字节是不能被
其他进程读或者写的, 从这个意义上说, 这个地址空间 是私有的。
尽管和每个私有 地址空间 相关联的内存的内容一般是不同的, 但是每个这样的空间都有相同的通用结 构。比如,图 8-1 3 展示了一个 x8 6- 64 L in u x 进程的地址空间 的组织结构。
地址空间底部是保留给用户程序的, 包括通常的 代码、数据、堆和栈段。代码段总是从地址 Ox 400000 开始。地址空间顶部保留给内 核(操作系统 常驻内存的部分)。地址空间的这个部分包含内核在代表进程执行指令时(比如当应用程序执行系统调用时)使用的代 码、数据和栈。
248-1—+
内核虚拟内存
(代码、数据、堆、栈)
用户栈
(运行时创建的)
-…
i 用户代码不可见的内存
七 %e sp (栈指针)
,. ,,
之- ,. ·.’·
) -气
共享库的内存映射区域
.•• ., 丸 _. 才. `- – . ; 千 、、,•.、:-.
; i "
运行时堆
(用ma l l oc 创建的)
读/写段
( . da t a、.bss)
只读代码段
.七 br k
Ox 0 0 4 00 0 0 0 –+
( . i ni 七、. t ex t 、.rodata)
,, . i°; i 飞 宁
图 8-13 进程地址空间
8. 2. 4 用户模式和内核模式
为了使 操作系统内核提供一个无懈可击的 进程抽象 , 处理器必须提供一种机制, 限制一个应用可以 执行的指令以及它可以访问的地址空间范围。
处理器通常 是用某个控制寄存器中的一个模式位( m o de b it ) 来提供这种功能的, 该寄
存器描述了进程当前享有的特权。当设置了 模式位时 , 进程就运行在内核 模式中(有时叫做超级 用户 模式)。一个运行 在内核模式 的进程可以执行指令集中的任何指令, 并且可以访问系统中的任何内存位置。
没有设置模式位时 , 进程就运行 在用户 模式中。用户模式中的 进程不允 许执行特权指令( pr ivileged ins t ru ct ion ) , 比如停止处理器、改变模式位,或 者发起一个 1/ 0 操作。也不允许用户模式中的进程直接引用 地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之, 用 户程序必须通过系统凋用接口间 接地访问内核代码和数据 。
运行 应用程序代码的 进程初始时 是在用户模式中 的。进程从 用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递 到异常处理程序, 处理器将模式 从用户模式变为内 核模式。处理程序运行在内核模式中, 当它返回到应用程序代码时,处理器就把模式从内核摸式改回到用户模式。
Lin ux 提供了一种聪明的机制 , 叫做/ pr o c 文件系统 , 它允许用户模式进程访问内核数
据结构的内 容。/ pr oc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。比如,你 可以使用/ pr oc 文件系统找出一般的系统属性,比 如 CPU 类型(/proc/ cpuinfo), 或者某个特殊的进程使用的内存段( / pr oc / <p ro c e s s - i d > / ma ps ) 。2. 6 版本的 Linux 内核引入/ s ys 文件系统,它输 出 关 千系统总线和设备的额外的低层信息。
8. 2. 5 上下文切换
操作系统内核使用一种称为上下文切换 ( context switch ) 的较高层形式的异常控制流来实现多任务 。上下文切换机制是建立在 8. 1 节中已经讨论过的那些较低层异常机制之上的。
内核为每个进程维持一个上下文( conte xt ) 。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程 序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页 表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了
的进程。这种决策就叫做调度( sched uling ) , 是由内核中称为调度器 ( sched ule r ) 的 代码处理的。当内核选择一个新的进程运行时 , 我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到 新的进 程, 上下文切换 1 ) 保存当前进程的上下文, 2 ) 恢复某个先前被抢占的进程被保存的上下文, 3 ) 将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某 个事件 发生而阻塞,那 么 内 核 可以让当前进程休眠,切 换 到另一个进程。比如, 如 果 一 个 r e a d 系统调用需要访问磁盘,内 核 可以选择执行上下文切换, 运行另外一个进程, 而 不是等待数 据从磁盘到达。另一个示例是 s l e e p 系统调用, 它 显 式 地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回 给涸用进程。
中 断也可能引发上下文切换。比如,所 有的系统都有某 种产生周期性定时器中断的机制,通 常为每 1 毫秒或每 10 毫秒。每次发生定时器中断时,内 核 就 能 判定当前进程已经运行了 足够长的时间,并 切 换 到 一 个 新 的 进 程。
图 8-14 展示了一对进程 A 和 B 之间上下文切换的示例。在这个例子中, 进程 A 初始运行 在用户模式中,直 到它通过执行系统调用 r e a d 陷入到内核。内核中的陷阱处理程序请求来 自磁盘控制器的 OMA 传输,并 且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。
时间 进程A 进程B
read········► + 用户模式
芒 内核模式 }上下文切换
磁盘中断 …….. — { 用户模式
从r ead 返回 …..►.
内核模式 }上下文切换
用户模式图 8-14 进程上下文切换的剖析
磁盘取数据要用一段相对较长的时间(数量级为几 十毫秒),所以内核执行从 进程 A 到进程 B 的上下文切换, 而不是在这个间 歇时间内等待, 什么都不做。注意在切换之前, 内核正代表进程 A 在用户模式下执行指令(即没有单独的 内核进程)。在切换的第一部分中, 内 核代表进程 A 在内核模式下执行指令。然后在某一时刻, 它开始代表进程 B ( 仍然是内核模式下)执行指令。在切换之后,内 核代表进程 B 在用户模式下执行指令。
随后,进程 B 在用户模式下运行一 会儿, 直到磁盘发出一个 中断信号, 表示数据已经从磁盘传送到了内存。内核判定进程 B 已经运行了足够长的时间, 就执行一个从 进程 B 到进程 A 的上下文切换 , 将控制返回给进程 A 中紧随 在系统调用 r e a d 之后的那条指令。进程 A 继续运行, 直到下一 次异常发生 ,依 此类推。
8. 3 系统调用错误处理
当 U nix 系统级函数遇到错 误时, 它们通常会返回一1, 并设置全局整数变量 e r r no 来表示什么出错了。程序员应该总是检查错误,但是不幸的是,许多人都忽略了错误检 查, 因为它使 代码变得膀 肿, 而且难以读懂。比如,下 面是我们调用 U n ix f or k 函数时会如何检查错误:
if ((pid = fork()) < 0) {
fprintf(stderr, “fork error: %s\n”, strerror(errno)); exit(O);
}
s t re rr or 函数返回一 个文本串 , 描述了和某个 err n o 值相关联的错误。通过定义下面的错误报告函数,我们能够在某种程度上简化这个代码:
void unix_error(char *msg) I* Unix-style error *I
{
fprintf(stderr, “%s: %s\n”, msg, strerror(errno)); exit(O);
}
给定这个函数, 我们对 f or k 的调用从 4 行缩减到 2 行:
if ((pid = fork()) < 0) unix_error(“fork error”);
通过使用 错误处理 包装函数, 我们可以更 进一步地简化代码, S t eve n s 在[ ll O] 中首先提出了 这种方法。对于一个给定的基本函数 f o o , 我们定义一个具有相同参数的包装函数
Foo, 但是第一个字母大写了。包装函数调用基本函数, 检查错误, 如果有任何问题就终止。比如,下 面是 f o r k 函数的错误处 理包装函数 :
pid_t Fork(void)
{
pid_t pid;
if ((pid = fork()) < 0) unix_error(“Fork error”);
return pid;
给定这个包装函数, 我们对 f o r k 的调用就缩减为 1 行:
pid = Fork() ;
我们将在本书剩余的部分中都使用错误处理包装函数。它们能够保持代码示例简洁,而 又不会给你错误的假象,认为允许忽略错误检查。注意,当在本书中谈到系统级函数时,我 们总是用它们的小写字母的基本名字来引用它们 , 而不是用它们大写的包装函数名来引用。
关千 U nix 错误处理以及本书中使用的错误处理包装函数的讨论, 请参见附录 A 。包装函数定 义在一个 叫做 c s a pp . c 的文件中, 它们的原型定义在一个叫做 c s a p p . h 的头文件中; 可以从 CS : APP 网站上在线地得到这些代码。
8. 4 进程控制
Unix 提供了大量从 C 程序中操作进程的系统 调用。这一节将描述这些重要的函数, 并举例说明如何使用它们。
8. 4. 1 获 取进程 ID
每个进程都有一个唯一的正数(非零)进程 ID( PID) 。ge t pi d 函数返回调用进程的 PIO。
ge七pp i d 函数返回它的父进程的PIO( 创建调用进程的进程)。
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void); pid_t getppid(void);
返回: 调 用者或 其 父进程的 PID,
g e t p i d 和 g e t p p i d 函数返回一个类型为 p i d t 的整数值, 在 Lin ux 系统上它在
t ype s . h 中被定义为 i n 七。
4·. 2 创建和终止进程
从程序员的角度 , 我们可以认为进程总是处于下面三种状态之一:
- 运行。进程要么在 CP U 上执行, 要么在等待 被执行且最终会被内核调度。
- 停止。进程的执行被挂起 ( s us pended ) , 且不会被调度。当收到 SIGS T O P 、S IG T S T P、SIG T T I N 或者 SIG T T O U 信号时, 进程就停止, 并且保持停止直到它收到一个 S IGCO NT 信号, 在这个时刻,进 程再次开始运行。(信号是一种软件中断的形式, 将在 8. 5 节中详细描述。)
- 终止。进 程永远地停止了。进程会因为三种原 因终止: 1) 收到一个信号,该 信 号的默认行为是终 止进程, 2 ) 从 主程序返 回, 3 ) 调用 e x i t 函数。
#include <stdlib.h>
void exit(int status);
该函数不返回 。
e x i t 函数以 s t a t u s 退出状 态来 终止进程(另一种设置退出状态的方法是从主程序中返回一个整数值)。
父进程通过调用 f or k 函数创建一个新的运行的子 进程。
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
返回: 子 进 程 返 回 o, 父进程返 回 子进 程的 PID, 如果出错 ,则 为一 l 。
新创 建的子 进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父 进程任何打开文件描述符相 同的副本, 这就意味着当父进程调用 for k 时,子 进 程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
f or k 函数是有趣的(也常常令人迷惑),因 为它只被调用一次, 却 会返回两 次: 一次是在调用进程(父进程)中,一 次 是 在新创建的子进程中。在父进程中, f o r k 返回子进程的 PID 。在子进程中, f o r k 返回 0。因为子进程的 PID 总是为非零, 返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。
图 8-15 展示了一个使用 f o r k 创建子进程的父进程的示例。当 f or k 调用在第 6 行返回 时 ,在 父 进 程和子进程中 x 的值都为 1。子进程在第 8 行加一并输出它的 x 的副本。相似地 ,父 进 程 在第 13 行减一并输出它的 x 的副本。
1 int main()
2 {
3 pid_t pid;
4 int X = 1·,
5
6 pid = Fork();
if (pid == 0) { I* Child *I
printf (“child : x=加 \ n " , ++x);
9 exit(O);
10 }
11
12 I* Parent *I
13 printf (“parent: x=加 \ n " , –x);
14 exit (0);
15 }
code/ecf/fork.c
code/ecf/fork.c
图 8-15 使用 fo r k 创建一个新进程
当在 U nix 系统上运行这个程序时, 我们得到下面的结果:
linux> . / f ork parent: x=O chil d : x=2
这个简单的例子有一些微妙的方面。
- 调用一次,返 回 两次。 f o r k 函数被父进程调用一次, 但 是 却 返 回 两 次 一 一次是返回到父进程,一 次 是 返回到新创建的子进程。对于只创建一个子进程的程序来说 , 这还是相当简单直接的。但是具有多个 f or k 实例的程序可能就会令人迷惑, 需要仔细地推敲了。
- 并发执行。父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行 它 们 的 逻辑控制流中的指令。在我们的系统上运行这个程序时,父 进程先完成它的pr i n t f 语 句 ,然 后 是 子 进程。然而, 在另一个系统上可能正好相反。一般而言,
作为程序员,我们决不能对不同进程中指令的交替执行做任何假设。
- 相同但是独立的 地址空间。 如果能够在 f or k 函数在父进程和子进程 中返回后立即暂停这两个进程,我们会看到两个进程的地址空间都是相同的。每个进程有相同的 用户栈、相同的本地变量值 、相同的堆、相同的全局变量值, 以及相同的代码。因此, 在我们 的示例程序 中, 当 f or k 函数在第 6 行返回时 , 本地变量 x 在父进程和子进程中都 为 1。然而 , 因为父进程和子进程是独立的进程,它 们都有自己 的私有地址空间。后面, 父进程和子进程对 x 所做的任何改变都是 独立的 , 不会反映在另一个进程的内 存中。这就是为什么当父进程 和子进程调用它们各自的 p r i n t f 语句时, 它们中的变量 x 会有不同的值。
共享文件。 当运行这个示例程序时, 我们注意 到父进 程和子进程都 把它们的输出显示在屏幕上。原因是子进程继 承了父进程所有的 打开文件。当父进程调用 f or k 时, s t d o 江 文件是打开的, 并指向屏幕。子进程继承了这个文件, 因此它的输出也是指向屏幕的。
如果你是第一次学习 for k 函数, 画进程图通常会有所帮助,进程图 是刻画程序语 句的 偏序的一种简单的前趋图。每个顶点a 对应于一条程序语句的执行。有向边 a - b 表示语 句 a 发生在语句 b 之前。边上可以标记出一些信息, 例如一个变量的当前值。对应于 pr i nt f 语句的顶点可以 标记上 pr i n t f 的输出。每张图从一个顶点开始,对应千调用 mai n 的父进程。这个顶 点没有入边,并且只有一个出边 。每个进程的顶点序列结束于一 个对应于 e x i t 调用
的顶点。这个顶点只有一条入边,没有出边。例如, 图 8-16 展示了图 8-15 中示例程序
的进程图 。初始时, 父进程将变量 x 设置为
X==l
ch i l d: x=2 printf
pra ent : x=O
exit
子进程
l 。父进程调用 f or k , 创建一个子进程,它在自己的私有地址空间中与父进程并发执行。对千运行在单处理器上的程序,对应进
main f or k printf exi七
图 8-16 图 8-15 中示 例程序 的进程图
父进程
程图中所 有顶点的 拓扑排序( to po log ica l so r t ) 表示程序中语句的一个可行的全序排 列。下面是一个理解拓扑排序概念的简单方法:给定进程图中顶点的一个排列,把顶点序列从左 到右写成 一行,然后 画出每条有向边。排列 是一个拓扑排序, 当且仅当画出的每条边的方向都是从 左往右的。因此, 在图 8-15 的示例程序中 , 父进程和子进程 的 pr i n t f 语句可以以任意先 后顺序执行, 因为每种顺 序都对应千图顶点的某种拓扑 排序。
进程图特别有 助千理解带 有嵌套 f or k 调用的程序。例如,图 8-17 中的程序源码中两次调用了 f or k。对应 的进程图可帮 助我们 看清这个程序运行 了四个进程, 每个 都调用了一次 pr i n t f , 这些 pr i n t f 可以以 任意顺序执行。
int main()
{
Fork();
Fork(); printf(“hello\n”); exit(O);
hel l o pr i nt f he ll o
f or k printf
hell o
e x i t exi 七
图 8 - 17
main f or k
嵌套 f or k 的进程图
f or k pr i nt f exit
i 练习题 8. 2 考虑下面的程序:
int main()
{
code/ecflforkprobO.c
int X = 1;
if (Fork() == 0)
printf(“p1: x=%d\n”, ++x); printf(“p2: x=%d\n”, –x); exit(O);
codelecflforkprobO.c
- 子进程的输出是什么?
- 父进程的输出是什么?
8. 4. 3 回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中, 直到被它的 父进程回收( r ea ped ) 。当父进程回收己终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃己终止的进程,从此时开始, 该进程就不 存在了。一 个终止了但还未被回收的 进程称为僵 死进 程( zo m bie ) 。
日 日 为什么已终止的子进程被 称为僵死进程?
在民 间传说 中,僵 尸是 活着的 尸体 , 一种半 生半 死的 实体。僵死进程已经终止了, 而内核仍保留着它的某些状态直到父进程回收它为止,从这个意义上说它们是类似的。
如果一个父进程终 止了,内 核会安排 i n i t 进程成 为它的 孤儿进程的养父。 i n i t 进程的 P ID 为 1, 是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵死子进程就终 止了, 那么内核会安排 i n i t 进程去回收它们。不过,长时间运行 的程序,比如 shell 或者服务器,总 是应该回收它们的僵死子进程。即使僵死子进程没有运行,它们仍然消耗系统的内存资源。
一个进程可以 通过调用 wa i t p i d 函数来等待它的 子进程终止或者停止。
#include <sys/types.h>
#include <s ys / wa i t . h >
pid_t waitpid(pid_t pid, int *statusp, int options);
返回: 如 果 成 功 , 则为 子 进 程 的 PIO, 如 果 WNO HAN G , 则 为 o, 如 果其他错误 , 则为— 1.
wa i t p 过 函数有点复杂。默认情况下(当o p巨 o ns = O 时), wa i t p i d 挂起调用进程的执行, 直到它的等待 集合 ( w ait set ) 中的一个子进程终止。如果等待集合 中的一个进程在刚调用的时刻就已经终止了, 那么 wa i t p i d 就立即返回。在这两种情况中, wa i t p i d 返回导致 w ait pid 返回的已终止子进程的 P IO 。此 时, 已终止的子进程巳经被回收, 内核会从系统中删除掉它的所有痕迹。
1 判定等待集合的 成员
等待集合的成员是由参数 p 过 来确定的:
如果 p 过 >O, 那么等待集合就是一个单独的子进程 ,它的 进程 ID 等千 p i d。
如果 pi d= - 1 , 那么等待集合就是由父进程所有的子进程组成的。
wa i t p 迈 函数还支持其他类型的等待集合, 包括 Unix 进程组, 对此我们将不做讨论。
修改默认行 为
可以通过将 op t i on s 设置为常晕 WNO H ANG 、 WU NT RACED 和 WCO NT INUED
的各种组合来修改默认行为:
WNOHANG: 如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为 0 ) 。默认的行 为是挂起调用进程, 直到有子进程终 止。在等待子进程终 止的同时,如果还想做些有用的工作,这个选项会有用。
WUNTRACED: 挂起调用进程的执行, 直到等待集合中的一个进程变成巳终止或者被停止。返回的 PID 为导致返回的已终止或被停止 子进程的 PID。默认的行为是只返回己终止的子进程。当你想要检查己终止和被停止的子进程时,这 个选项会有用。
WCONT I NU ED: 挂起调用进程的 执行, 直到等待集合中一个正在运行的进程终止或等待集合中一个 被停止的进程收 到 S IGCO NT 信号重新开始执行。( 8. 5 节会解释这些信号。)
可以用或运算把这些选项组合起来。例如:
WNOHANG I WUNTRACED: 立即返回,如果等待集合中的子进程都没有被停止或终止, 则返回值为 O; 如果有一个停止或终 止, 则返回值为该子进程的 PID。
检查己回 收子进程的 退出状态
如果 s 七a t us p 参数是非空的, 那么 wa i t p i d 就会在 s 七a t us 中放上关于导致返回的子进程 的状态信息, s 七a t us 是 s t a t us p 指向的值。wa i t . h 头文件定义了解释 s 七a 七us 参数的几个宏:
WIFEXITED(s t a 七us ) : 如果子进程通过调用 e xi t 或者一个返回( ret urn ) 正常终止, 就返回真。
WEXITSTATUS ( s t a 七us ) : 返回一 个正常终止的 子进程的退出状态。只有 在
WIFEXIT ED( ) 返回为真时 , 才会定义这个状态。
WIFSIGNALED(status): 如果子进程是因为一个未被捕获的信号终止的, 那么就返回真。
WTERMSIG(status): 返回导致子进程终止的信号的编号。只有在 WIFSIG NALE D( ) 返回为真时 , 才定义这个状态。
WIF ST OPP ED(s t a 七us ) : 如果引起返回的子进程当前是 停止的, 那么就返回真。
WSTOPSIG(status): 返回引起子进程停止的信号的编号。只有在 WIFSTOPP D ( )
返回为真时,才定义这个状态。
WIFCONTINUED( s t a 七us ) : 如果子进程收到 SIGCONT 信号重新启动, 则返回真。
错误条件
如果调用进程没有子进程, 那么 wa i t p i d 返回—1, 并且设置 err no 为 EC H ILD 。如果 wa i t p 卫 函数被一个信号中断, 那么它返回- 1 , 并设置 err no 为 EINT R。
m 和 Unix 函数相关的 常量
像 WNOHANG 和 WUNT RACED 这样的常量 是由 系统头文件定义的。例如, WNO HANG 和 WU NT RACE D 是由 wa i t . h 头文件(间接)定义的 :
I* Bits
#define
#define
in the third WNOHANG 1
WUNTRACED 2
argument to’waitpid’. */ I* Don’t block waiting. *I
I* Report status of stopped children. *I
为了使用这些常量, 必须在代码中 包含 wa i t . h 头文件:
#include <sys/wait.h>
每个 U nix 函数的 ma n 页列 出 了 无论何 时你在代码中使 用 那个函数都要 包含 的头文件。同时, 为 了检 查诸如 ECH ILD 和 EINT R 之 类的 返回代码, 你必须 包含 er r n o . h 。 为了简化代码示例 , 我们 包含 了 一个称 为 c s a p p . h 的 头 文件, 它 包括 了 本 书 中使 用的 所有函数的头文件。c s a pp . h 头文件可以从 CS: APP 网站在线荻得。
沁凶 练习题 8. 3 列出下面程序所有可能的输出序列:
int main()
{
code/ecf/waitprobO.c
if (Fork() == 0) {
printf(“a”);
}
fflush(stdout);
else {
printf(“b”); fflush(stdout); waitpid(-1, NULL, O);
}
printf(“c”); fflush(stdout); exit(O);
5 . wa i t 函数
wa i t 函数 是 wa i t p i d 函 数 的 简单版本:
code/ecflwaitprob0.c
#include <sys/types.h>
#include <s ys 压 a i t . h> pid_t wait(int *statusp);
返回: 如 果 成 功, 则 为 子进程的 PID, 如 果 出错 , 则为 一1。
调 用 wa i t ( &s 七a t u s ) 等价千调用 wa i t p i d (- l , &s t a t u s , O ) 。
6 使用 wa 江 p i d 的示例
因为 wa i t p i d 函 数有些复杂,看 几 个 例 子会有所帮助。图 8-18 展示了一个程序, 它使 用 wa i 七p 过 ,不 按 照 特定的顺序等待它的所有 N 个子进程终止。在第 11 行, 父进程创建 N 个子进程,在 第 12 行 , 每个子进程以一个唯一的退出状态退出。在我们继续讲解之前 ,请 确 认 你 已经理解为什么每个子进程会执行第 12 行 , 而父进程不会。
在第 15 行, 父进程用 wa i t p i d 作 为 wh i l e 循 环 的 测 试 条 件,等 待它所有的子进程终止 。 因 为第一个参数是 — 1, 所以对 wa i t p i d 的 调 用 会 阻 塞 , 直 到 任意一个子进程终止。在每个子进程终止时, 对 wa i 七p i d 的 调 用 会 返回, 返回值为该子进程的非零的 PID。第1 6 行检查子进程的退出状态。如果子进程是正常终止的一 在此是以调用 e x i t 函数终止 的 那 么 父进程就提取出退出状态,把 它 输 出 到 s t d o u t 上。
code/ecflwaitpidl.c
printf(“child %d terminated normally with exit status=%d\n”,
pid, WEXITSTATUS(status));
else
printf (“child %d terminated abnormally\n”, pid);
21 }
22
I* The only normal termination is if there are no more children *I
if (errno != ECHILD)
unix_error(“waitpid error”);
26
27 exit(O);
28 }
code/ecf/waitpidl.c
图 8-18 使用 wa i t pi d 函数不按照特定的顺 序回收僵死 子进程
当回收了所有的子 进 程之后, 再 调用 wa i t p i d 就返回 — 1, 并且设 置 err n o 为
£ C H IL D。第 24 行检查 wa i 七p i d 函数是正常终止的, 否则就输出一个错误 消息。在我们的 L in ux 系统上 运行 这个程序时 ,它 产生如下输出:
linux> ./ 甘 ai t p i d1
child 22966 terminated normally with exit status=lOO child 22967 terminated normally with exit status=101
注意,程序不会按照特定的顺序回收子进程。子进程回收的顺序是这台特定的计算机系统的属性。在另一个系统上,甚至在同一个系统上再执行一次,两个子进程都可能以相反的顺序被回收。这是非确定性行为的一个示例,这种非确定性行为使得对并发进行推理非常困难。两种 可能的结果都同样是正确的, 作为一个 程序员, 你绝不可以 假设总是会出现某一个结果,无论多么不可能出现另一个结果。唯一正确的假设是每一个可能的结果都同样可能出现。
图 8-19 展示了一个简单的改变,它消除了这种不确定性 , 按照父进程创建子进程的相同顺序来回收这些子进程。在第 11 行中,父进程按照顺 序存储了它的子进程的 PIO, 然后通过用适当的 PIO 作为第一个参数来调用 wa i t p i d , 按照同样的顺序来等待每个子进程。
code/ecflwaitpid2.c
I* Parent reaps N children in order *I
i = O;
while ((retpid = waitpid(pid[i++], &status, 0)) > 0) {
1 7 if (WIFEXITED(status))
printf(“child %ct terminated normally with exit status=%d\n”,
retpid, WEXITSTATUS(status));
else
printf (11child %d terminated abnormally\n11, retpid) ;
22 | } | |
---|---|---|
23 | ||
24 | I* | The only normal termination is if there are no more children *I |
25 | if | (errno != ECHILD) |
26 unix_error(11waitpid error”);
27
28 exit(O);
29 }
code/ecf/waitpid2.c
图 8-19 使用 wa i t pi d 按照创建子进程的顺序来回收这些 僵死子进程
亡 练习题 8. 4 考虑下面的程序:
code/ecflwaitprobl.c
code/ecflwaitprob1.,
- 这个程序会产生多少输出行?
- 这些输 出行的一种 可能的 顺 序是 什么?
8. 4. 4 让进程休眠
s l e ep 函数将一个进程挂起一段指定的时间。
#include <unistd.h>
unsigned int sleep(unsigned int secs);
返回: 还 要 休 眠 的 秒 数 。
如果请求的时间量已经到了, s l e e p 返回 o, 否则返回还剩下的要休眠的秒数。后一种情况 是可能的,如果 因 为 s l e e p 函数被一个信号中断而过早地返回。我们将在 8. 5 节中详细讨论信号。
我们会发现另一个很有用的函数是 p a u s e 函数,该 函 数让 调 用 函数 休 眠 ,直 到 该 进 程收到一 个信号。
#include <unistd.h>
int pause(void);
总是返回一1。
让目 练习题 8. 5 编写 一个 s l e e p 的包 装函数,叫做 s n o o z e , 带有下面的接口:
unsigned int snooze(unsigned int secs);
snooze 函数和 s l e e p 函数的行 为 完全 一样 , 除 了它 会打印 出 一条 消息来描述进程 实际休眠了多长时间:
lept for 4 of 5 secs.
4. 5 加载并运行程序
e xe cve 函数在当前进程的上下文中加载并运行一个新程序。
#include <unistd.h>
int execve(const char *filename, const char *argv[J, const char *envp []) ;
如果成功,则不返回 ,如果错误,则 返回- 1 .
e x e c v e 函 数 加 载 并 运行可执行目标文件 f i l e na me , 且 带 参 数 列 表 ar gv 和环境变量列表 e nv p。只有当出现错误时, 例 如 找 不 到 f i l e n a me , e x e c v e 才会返回到调用程序。所以 , 与 f or k 一次调用返回两次不同, e x e c v e 调 用 一 次并 从 不 返 回 。
参 数 列 表是 用 图 8-20 中的数据结构表示的。ar g v 变量指向一个以 n ull 结尾的指针数组,其 中 每个指针都指向一个参数字符串。按照惯例, a r g v [ OJ 是 可 执行目标文件的名字。环 境变量的列表是由一个类似的数据结构表示的, 如图 8-21 所示。e nv p 变最指向一个以 n ull 结尾的指针数组, 其 中 每个指针指向一个环境变量字符串, 每个串都是形如” na me =v a l u e " 的 名 字—值 对 。
I argv
argv[] argv[O]
I argv [1]
argv [ar gc - 1]
1 ·I
i
#
“ls” “-lt”
皿 L '
佟I 8-20 参数列表的组织结构
I “/ user / i ncl ude” j
「一二 ,
en vp[]
envp [OJ envp [1)
j
envp [n - 1)
NULL
图 8- 21 环境变扯列表的组织结构
在 e x e c v e 加载了 f i l e n a me 之后, 它调用 7. 9 节中描述的启动代码。启动代码设置栈, 并将控制传递给新程 序的主函数, 该主函数 有如下形式的原型
int main(int argc,
或者等价的
int main(int argc,
char **argv,
char *argv [] ,
char **envp);
char *envp(]);
当 ma i n 开始执行时,用 户栈的组织结构如图 8- 22 所示。让我们从栈底(高地址)往栈顶
(低地址 )依次看一看。首先是 参数和环境字符串。栈往上紧随其后的是以 n u ll 结尾的指针数组 , 其中每个指针都指向栈中的一个环境变量字符串。全局变量 e n v ir o n 指向这些指针中的第一个 e n v p [ O J 。 紧随环境变量数组之后的是以 n u ll 结尾的 ar g v [ ]数组, 其中每个元素都指向栈 中的一个参数字符串。在栈的顶部是系统启动函数 l i b c _ s t ar t _ ma i n ( 见
9 节)的栈帧。
栈底
以null结尾的环境变扯字符串
. 以null结尾的命令行字符串
envp[n] == NULL
e nvp [ n - 1 ]
...
e n vp [ O ] •
ar gv [ar g c ) = NUL L
argv[argc-1]
..
. , argv[O]
environ
(全局变量)
argc
(在寄存器%r d i 中 )
·.
l i b c _ s t ar t _ma i n 的栈帧
栈顶
ma i n 的未来的栈帧
图 8-22 一个新程序开始时,用 户 栈的典型组织结构
ma i n 函 数有 3 个 参 数 : l)argc, 它给出 ar g v [ ]数组中非空指针的数量, 2 ) ar g v ,
指向 ar g v [ ] 数组中的第一个条目, 3 ) e n v p , 指 向 e nv p ( ] 数组中的第一个条目。
Lin ux 提供了几个函数来操作环境数组:
#include <stdlib.h>
char *getenv(const char *name);
返回: 若存在则为指 向 name 的 指 针 , 若 无匹 配的 , 则 为 NU LL。
g e t e nv 函 数 在 环境 数组中搜索字符串 " na me =v a l ue " 。 如果找到了, 它 就 返回一个指向 va l ue 的指针,否 则 它 就 返回 NULL 。
#include <stdlib.h>
int setenv(const char *name, const char *newvalue, int overwrite);
返回: 若成功 则 为 0 , 若 错 误 则 为 一1 。
void unsetenv(const char *name);
返回: 无。
如果环境数组包含一个形如 " n a me = o l d v a l u e " 的 字 符 串 ,那 么 u n s e t e nv 会 删除它, 而 s e t e nv 会用 ne wv a l u e 代替 o l d v a l u e , 但是只有在 ov er wir 七e 非 零 时 才会这样。如果 na me 不存在,那 么 s e t e n v 就 把 " n a me =n e wv a l u e " 添加到数组中。
豆 日 程序与进程
这是一个适当的地方,停下来,确认一下你理解了程序和进程之间的区别。程序是 一堆代码和数据;程序可以作为目标文件存在于磁盘上,或者作为段存在于地址空间 中。进程是执行中程序的一个具体的实例;程序总是运行在某个进程的上下文中。如果 你想要 理解 f or k 和 e x e c ve 函数, 理解这个差 异是很 重要 的。f or k 函数在新的子进程中运行 相同的 程序, 新的子进程是父进程的一个复制品。e x e c v e 函数在 当 前进程的上下文中加栽并运行一个新的程序。它会覆盖当前进程的地址空间,但并没有创建一个新 进程。 新的程序仍然有 相同的 PIO, 并且继承了调用 e x e c v e 函数时已打开的 所有文件描述符。
江 练习题 8. 6 编 写 一 个 叫 做 my e c h o 的 程 序, 打 印 出 它 的 命令行 参 数 和 环境 变 量。
例如:
linux> ./myecho arg1 arg2 Command-ine arguments:
argv[ OJ: myecho argv [ 1] : argl argv[ 2]: arg2
Environment variables:
envp[ OJ: PWD=/usrO/droh/ics/code/ecf envp[ 1]: TERM=emacs
envp[25]: USER=droh
envp[26]: SHELL=/usr/local/bin/tcsh envp[27]: HOME=/usrO/droh
4. 6 利 用 f or k 和 e x e c v e 运行 程序
像 U nix s hell 和 We b 服务器这样的程序大量使用了 f or k 和 e xe c ve 函数。s hell 是一个交互型的应用级程序, 它 代表用户运行其他程序。最早的 shell 是 s h 程序,后 面出现了一些 变种,比 如 c s h、t c s h 、ks h 和 b a s h。s hell 执行一系列的读/求值( read / evaluate ) 步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运 行程序。
图 8- 23 展示了一个简单 shell 的 ma i n 例 程 。 s hell 打印一个命令行提示符, 等待用户
在 s t d i n 上输入命令行,然 后对这个命令行求值。
#include “csapp.h”
2 #define MAXARGS 128
3
4 I* Function prototypes *I
5 void eval(char *cmdline);
6 int parseline(char *buf, char **argv);
7 int builtin_command(char **argv);
8
9 int main()
10 {
11 char cmdline[MAXLINE]; I* Command line *I
12
13 while (1) {
14 I* Read *I
printf("> “);
Fgets(cmdline, MAXLINE, stdin);
if (feof (stdin))
exit(O);
19
20 I* Evaluate *I
21 eval (cmdline);
22 }
23 }
code/ecf/shellex. c
code/ecflshellex.c
图 8-23 一个简单的 shell 程序的 ma i n 例程
图 8- 24 展示了对命令行求值的代码。它的首要任务是调用 par s e l i ne 函数(见图8-25) , 这个函数解析了以空格分隔的命令行参数,并 构 造最终会传递给 e xe c ve 的 a r gv 向量。第 一个参数被假设为要么是一个内置的 s hell 命令名, 马上就会解释这个命令, 要么是一个可执行目标文件,会在一个新的子进程的上下文中加载并运行这个文件。
如果最后一个参数是一个 " &.” 字符,那 么 p ar s e l i ne 返回 1, 表示应该在后台执行该程序( s h ell 不会等待它完成)。否则,它 返 回 0 , 表示应该在前台执行 这个 程序( shell 会等待它完成)。
在解析了命令行之后, e v a l 函 数 调 用 b u i l t i n_ c o mma nd 函 数 ,该 函 数 检 查第一个命令 行 参数是 否是一个内置的 s he ll 命令。如果是,它 就 立 即 解 释这个命令,并 返 回值 1。否则 返回 0。简单的 s hell 只有一个内置命令——- qu i t 命令,该 命 令 会 终 止 s hell 。实际使用
的 s h ell 有大僵的命令,比 如 p wd 、 j o b s 和 f g。
如果 b ui l t i n_comma nd 返 回 o, 那 么 s hell 创 建 一 个 子 进 程 ,并 在 子 进程中执行所请求的程序。如果 用户要求在后台运行该程序, 那么 s hell 返回到循环的顶部, 等 待下一个命令 行。否则, s hell 使用 wa 江 p 卫 函数 等 待作 业 终 止 。 当作业终止时, s hell 就 开始下一轮迭代。
code/ecflshellex.c
9 strcpy(buf, cmdline);
1o bg = parseline (buf , argv) ;
if (argv [OJ == NULL)
return; I* Ignore empty lines *I
13
if (!builtin_command(argv)) {
if ((pid = Fork()) == 0) { I* Child runs user job *I
if (execve(argv[O], argv, environ)< 0) {
7 printf ("%s: Command not found. \n", argv [O]);
exit(O);
19 }
20 }
21
I* Parent waits for foreground job to terminate *I
if (!bg) {
int st at us·
if (waitpid(pid, &status, 0) < 0)
unix_error(“waitfg: waitpid err or”) ;
27 }
else
printf("%d %s", pid, cmdline);
30 }
31 return;
32 }
33
I* If first arg is a builtin command, run it and return true *I
int builtin_command(char **argv)
36 {
37 if (!strcmp(argv[O], “quit”)) I* quit command *I
exit(O);
if (!strcmp(argv[O], “&”)) I* Ignore singleton & *I
return 1;
return O; I* Not a builtin command *I
cod e/ecfrshellex.c
图 8-24 eva l 对 shell 命令行求值
code/ecf/shellex.c I* parseline - Parse the command line andbuild the argv array *I
2 int parseline(char *buf, char **argv)
3 {
char *delim; int argc; int bg;
I* Points to first space delimiter *I I* Number of args *I
I* Background job? *I
8 | buf[strlen(buf)-1] =’’; I Replace trailing’\n’with space | *I |
---|---|---|
9 | while (*buf && (*buf ==’’)) I*Ignore leading spaces *I | |
10 | buf++; | |
11 | ||
12 | I* Build the argv list *I | |
13 | argc = O; | |
14 | while ((delim = strchr (buf,’’))) { | |
15 | argv [argc++] = buf ; | |
16 | *delim = ’ \ O’ · | |
1 7 | buf = delim + 1· | |
18 | while (*buf && (*buf ==’’)) I*Ignore spaces *I | |
19 | buf++· | |
20 | } | |
21 | argv [argc] = NULL; | |
22 | ||
23 | if (argc == 0) I* Ignore blank line *I | |
24 | return 1; | |
25 | ||
26 | I* Should the job run in the background? *I | |
27 | if ((bg"’(*argv[argc-1] “’”’’&’))’"‘0) | |
28 | argv [–argc] “‘NULL; | |
29 |
30 return bg;
31 }
图 8-25 par s e li ne 解析 shell 的一个输入行
codelecf/shellex.c
注意 , 这个简单的 s h ell 是有缺陷的, 因为它并 不回收它的后台子进程。修改这个缺陷就要求使用信号,我们将在下一节中讲述信号。
8. 5 信号
到目前为止对异常控制流的学习中,我们已经看到了硬件和软件是如何合作以提供基 本的低层异常机制的。我们也看到了操作系统如何利用异常来支持进程上下文切换的异常 控制流形式 。在本节中, 我们将研究一种更高层 的软件形式的异常, 称为 L in ux 信号,它允许进程和内核中断其他进程。
一个信号就是一条小消息,它 通知进程系统中发生了一个某种类型的事件 。比如,图 8-26
展示了 L in u x 系统上支持的 30 种不同类型的信号。
每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正 常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。比如,如果一个进程试图除以0, 那么内核就发送给它一个SIGFPE信号(号码8)。如果一个进
程执行一条非法指令, 那么内核就发送给它一个SIGILL 信号(号码4) 。如果进程进行非法内存引用,内 核就发送给它一个SIGSEGV信号(号码11)。其他信号对应千内核或者其他用户进程中较高层的软件事件。比如, 如果当进程在前台运行时, 你键入 Ctrl+ CC也就是同时按下 Ctrl 键和 C 键), 那么内核就会发送一个 SIGINT 信号(号码2) 给这个前台进程组中的每个进程。一个进程可以通过向另 一个进程发送一个 SIGKILL 信号(号码9) 强制终止它。当一个子进程终止或者停止时,内核会发送一个SIGCHLD 信号(号码17) 给父进程。
序号 | 名称 | 默认行为 | 相应事件 |
---|---|---|---|
1 | SIGHUP | 终止 | 终端线挂断 |
2 | SIGINT | 终止 | 来自键盘的中断 |
3 | SIGQUIT | 终止 | 来自键盘的退出 |
4 | SIGILL | 终止 | 非法指令 |
5 | SIGTRAP | 终止并转储内存(l) | 跟踪陷阱 |
6 | SIGABRT | 终止并转储内存(!) | 来自 a b or t 函数的终止信号 |
7 | SIGBUS | 终止 | 总线错误 |
8 | SIGFPE | 终止并转储内存° | 浮点异常 |
9 | SIGKILL | 终止© | 杀死程序 |
10 | SIGUSRI | 终止 | 用户定义的信号1 |
11 | SIGSEGV | 终止并转储内存° | 无效的内存引用(段故障) |
12 | SIGUSR2 | 终止 | 用户定义的信号2 |
13 | SIGPIPE | 终止 | 向一个没有读用户的管道做写操作 |
14 | SIGALRM | 终止 | 来自a l a r m 函数的定时器信号 |
15 | SIGTERM | 终止 | 软件终止信号 |
16 | SIGSTKFLT | 终止 | 协处理器上的栈故隐 |
17 | SIGCHLD | 忽略 | 一个子进程停止或者终止 |
18 | SIGCONT | 忽略 | 继续进程如果该进程停止 |
19 | SIGSTOP | 停止直到下一个 SIGCONT@ | 不是来自终端的停止信号 |
20 | SIGTSTP | 停止直到下一个SIGCONT | 来自终端的停止信号 |
21 | SIGTTIN | 停止直到下一个 SIGCONT | 后台进程从终端读 |
22 | SIGTTOU | 停止直到下一个 SIGCONT | 后台进程向终端写 |
23 | SIGURG | 忽略 | 套接字上的紧急情况 |
24 | SIGXCPU | 终止 | CPU 时间限制超出 |
25 | SIGXFSZ | 终止 | 文件大小限制超出 |
26 | SIGVTALRM | 终止 | 虚拟定时器期满 |
27 | SIGPROF | 终止 | 剖析定时器期满 |
28 | SIGWINCH | 忽略 | 窗口大小变化 |
29 | SIGIO | 终止 | 在某个描述符上可执行 J/0 操作 |
30 | SIGPWR | 终止 | 电源故障 |
图 8-26 L in ux 信 号
汪: O 多年前, 主存是用一种称为磁 芯存 储器( core memory) 的技术来实现的。“转储 内存” ( dump ing core ) 是 一个历史术语 ,意 思是把代码和数据内存 段的映像写到磁盘上。
@)这个信 号既 不能被捕获,也 不能被忽略。
(来源: man 7 signal。数据来自 Linux Found ation. )
5. 1 信号术语
传送一个信号到目的进程是由两个不同步骤组成的:
- 发送信号。内核 通过更新目的进程上下文中的 某个状态, 发送(递送)一个信号给目的进程。发送信号可以有如下两种原因: 1) 内 核检测到一个系统事件, 比如除零错误或者子进程终止。2) 一个进程调用了 ki ll 函数(在下一节中讨论),显 式地要求内核发送一个信号给目的进程。一个进程 可以发 送信号给它自己 。
·接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了 信号。进程可以忽略这个信号,终 止或 者 通过执行一个称为信号处理 程序( sig nal han dler ) 的用户层函数捕获这个信号。图 8-27 给出了信号处理程序捕获信号的基本思想。
( I ) 进程接 I
curr
收到信号
In,.,
( 3 ) 信号处理程序运行
图 8- 27 信号处理。接收到信号会触发控制转移到信号处理程序 。在信号处理程序完成处理之 后, 它将控制返回给被中断的程序
一个发出而没有被接收的信号叫做待处理信号( pe nd ing s ig n al) 。在 任何 时 刻 , 一种类型至多 只会有一个待处理信号。如果一个进程有一个类型为 K 的 待 处 理 信 号 ,那 么 任何接下 来发送到这个进程的类型为 K 的 信 号 都 不会排队等待;它 们 只 是 被 简单地丢弃。一个进程 可 以 有 选择性地阻塞接收某种信号 。当一种信号被阻塞时, 它 仍 可 以 被发送, 但是产生的待 处 理信号不会被接收, 直到 进程 取 消对这种信号的阻塞。
一个待处理信号最多只能被接收一次。内核为每个进程在 p e n d i n g 位向量中维护着待处 理信号的集合, 而在 b l o c ke d 位向量e 中维护着被阻塞的信号集合。只要传送了一个类型为 K 的 信 号 ,内 核 就 会 设 置 p e n d i n g 中的第 k 位, 而 只 要 接 收 了 一 个类型为 K 的信号 ,内 核 就 会 清 除 p e n d i ng 中 的 第 K 位。
5. 2 发送信号
U nix 系统提供了大量向进程发送信号的机制。所有这些机制都是基于进程组( pro cess gro u p ) 这个概念的。
进程组
每个进程都只属于一个进程组, 进程组是由一个正整数进程组 ID 来标识的。ge t pgr p
函数返回当前进程的进程组 ID :
#include <unistd.h>
pid_t getpgrp(void);
返回: 调 用进 程的 进 程 组 ID。
默认地,一 个 子 进程和它的父进程同属千一个进程组。一个进程可以通过使用 s e t pg i d 函数来改变自己或者其他进程的进程组:
#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);
返回: 若 成 功 则 为 0 , 若铸 误 则为 一1。
s e 七p g i d 函 数 将 进程 p 过 的进程组改为 pg i d。如果 p i d 是 o, 那么就使用当前进程
8 也称为信号掩码( sig na l ma s k ) 。
的 PID。如果 pg过 是 o, 那么就用 pi d 指定的进程的 PID 作为进程组 ID。例如, 如果进程 1521 3 是调用进程, 那么
setpgid(O, O);
会创建一 个新的进程组,其 进程组 ID 是 15213, 并且把进程 15213 加入到这个新的进程
组中。
用/ b i n / k i l l 程序发 送信号
/ b i n / k过1 程序可以向另外的进程发 送任意的 信号。比如,命 令
linux> /bin/kill -9 15213
发送信号 9(SIGKIL L) 给进程 15213。一 个为负的 PID 会导致信号被发送到进程组 PID 中的每个进程 。比如,命 令
linux> /bin/kill -9 -15213
发送一个 SIGKILL 信号给进程组 15213 中的每个 进程。注意, 在此我们使用完整路径/
bi n / k i ll , 因为有些 U nix s h ell 有自己内 置的 k i ll 命令。
从键盘发送信号
Unix shell 使用作业( jo b ) 这个抽象概念来表示为对一条命令行求值而创建的进程。在任何时刻, 至多只有一个前台作业 和 0 个或多个后台作业。比如 , 键入
linux> ls I sort
会创建一个由两个进程组成的前 台作业, 这两个进程是通过 U n ix 管道连接起来的: 一个进程运 行 l s 程序, 另一个运行 s or t 程序。s h ell 为每个作 业创建 一个独立的 进程组。进程组 ID 通常取 自作 业中父进程中的一个 。比如, 图 8-28 展示了有一个前台作业和两个后台作业的 s h e ll 。前台作业中的父进程 PID 为 20 , 进程组 ID 也为 20。父进程创建两个子进程,每 个也都是进程组 20 的成员 。
p i d = 21 pid=22
pgid=20 pgid=20
-—————
前台进程组20
图 8-28 前 台 和后 台 进程组
在键盘上输入 Ctrl + C 会导致内核发送一个 SIGINT 信号到前台进程组中的每个进程。默认情况下 ,结果 是终止前台作业。类似地, 输入 Ctrl + z 会发送一个 SIGTST P 信号到前台进程组中 的每个进程。默认情 况下, 结果是停止(挂起)前台作业。
用 k i l l 函数发 送信号
进程通过调用 K过 1 函数发送信号 给其他进程(包括它们 自己)。
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
返回: 若 成 功 则 为 o, 若 错 误 则 为 一1。
如果 p i d 大于零, 那么 k i ll 函数发送信号号码 s i g 给进程 p i d 。如果 p i d 等千零 , 那么k i ll 发送信号 s i g 给调用进 程所在进程组中的每个 进程, 包括调用进程自己。如果 p i d 小千零, k i ll 发送信号 s i g 给进程组 I pid I ( p i d 的绝对值)中的每个进程。图 8- 29 展示了一个示例, 父进程用 ki ll 函数发送 SIGK ILL 信号给它的 子进程。
code/ecf/kill.c
#include “csapp.h”
2
3 int main()
4 {
s pid_t pid;
6
7 I* Child sleeps until SIGKILL signal received, then dies *I
8 if ((pid = Fork()) == 0) {
9 Pause(); I* Wait for a signal to arrive *I
10 printf(“control should never reach here!\n”);
11 exit(O);
12 }
13
14 f* Parent sends a SIGKILL signal to a child *I
Kill (pid, SIGKILL) ;
exit(O);
17 }
code/ecf/kill.c
图 8-29 使用 K过 1 函数发送信号 给子进 程
用 a l a rm 函数发送信号
进程可以通过调用 a l a r m 函数向它自己发送 S IGALRM 信号。
a l ar m 函数安排内核在 s e c s 秒后发送一个 S IGALRM 信号给调用进程。如果 s e cs 是零, 那么不会调度安排新的闹 钟( a lar m ) 。在任何情况下, 对 a l ar m 的调用都将取消任何 待处理的( pe nd in g ) 闹钟, 并且返回任何待处理的闹钟在被发送前还剩下 的秒数(如果这次对 a l ar m 的调用没有取消它的 话); 如果没有任何待处理的闹钟,就返 回零。
5. 3 接收信号
当内核把进程 p 从内核模式切换到用户模式时(例如, 从系统调用返回或是完成了一次上下文切换), 它会检查进程 p 的未被阻塞的待处理 信号的集合 ( p e nd i ng & ~b l o c ke d ) 。如果这个集合 为空(通常情况下), 那么内核将控制 传递到 p 的逻辑控制流中的下一条指令 (J next ) 。 然而 , 如果集合是非空的 , 那么内核选择集合中的某个信号 k ( 通常是最小的 k ) , 并且强制 p 接收信号 k 。收到这个信号会触发进 程采取 某种行为。一旦进程完成了这个行为,那 么控制就传递回 p 的逻辑控制流中的下一条指令( J next ) 。 每个信号类型都有一个预定义的默认行为,是下面中的一种:
进程终止。
进程终止并转储内存。
进程停止(挂起)直到被 SIG CO NT 信号重启。
进程忽略该信号。
图 8- 26 展示了与每个信号类 型相关联的默认行为。比 如, 收到 S IG K IL L 的默认行为就是终止 接收进程。另外, 接收到 S IGCH LD 的默认行 为就是忽略这个信号。进程可以 通过使用 s i g na l 函数修改和信号相关联的默认行为。唯一的 例外是 SIGS T OP 和 SIG K I L L , 它们的默认行为是不能修改的。
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
返回: 若 成 功则 为 指 向 前 次 处 理 程 序 的 指 针 , 若 出错 则 为 SIG_ERR C不设 置 err no )。
s i g na l 函数可以通过下列 三种方法之 一来改变和信号 s i g n um 相关联的行 为:
- 如果 h a n d l er 是 SIG _IG N , 那么忽略类型为 s i g num 的信号。
如果 ha nd l er 是 S IG _DF L , 那么类型为 s i g nu m 的 信号行为恢复为默认行 为。
否则, ha ndl e r 就是用户定义的函数的地址,这个 函数被称为信 号处理 程序,只 要进程接收到一个类型为 s i g nwn 的信号, 就会调用这个程序。通 过把处理程序的 地址传递到 s i gna l 函数从而改变默认行为,这 叫做设置信 号处理 程序( installing the han
dler) 。调用信号处理程序被称为捕 获信号。执行信号处理程序被称 为处理信号。
当一个 进程捕 获了一个类型为 K 的信号时, 会调用为信号 k 设置的处理程序, 一个整数参数被设置 为 K。 这个参数允许同一个处理函数捕获不同类 型的信号。
当处理程序执行它 的 r e t ur n 语句时, 控制(通常)传递回控制流中进程被信号 接收中断位置处的指令。我们说“通常”是因为在某些系统中,被中断的系统调用会立即返回一 个错误。
图 8-30 展示了一个 程序,它 捕获用户在键盘上输入 C t rl + C 时发送的 S IG I NT 信号。SIGINT 的默认行为是立 即终止该进程。 在这个示例中, 我们将默认行为修改为捕获信号,输出一条消息,然后终止该进程。
信号处理程序可以被其 他信号处理程序中断, 如图 8-31 所示。在这个例子中, 主程
,, 序捕获到信号 s’ 该 信 号会中断主程序, 将控制转移 到处理程序 S。S 在运行时, 程序捕获信号 t #- s , 该信号会中断 s, 控制转移到 处理程序 T 。当 T 返回时 , S 从它被中断的地方继续 执行。最 后, S 返回, 控制传送回主程序 , 主程序从它 被中断 的地方继续执行。
#include “csapp.h”
2
code/ecf/sigint.c
3 void sigint_handler(int sig) I* SIGINT handler *I
4 {
s printf(“Caught SIGINT!\n”);
6 exit(O);
7 }
8
9 int main()
10 {
I* Install the SIGINT handler *I
2 if (signal(SIGINT, sigint_handler) == SIG_ERR)
3 uni.x_error(“signal error”);
14
15 pause(); I* Wait for the receipt of a signal *I
16
17 return O·
18 }
code/ecf/sigint.c
图 S :111 一 个用信号处理程序捕获 SIGINT 信号 的 程 序
主程序
( I ) 程序捕获信号s
CWT
主程序继续执行 I"°’’
( 2 ) 控制信号传递给处理程序S
处理程序S 处理程序T
图 8飞 l 信号处理程序可以被其他信号处理程序中断
让 练习题 8. 7 编写 一个叫做 s no o z e 的程序 , 它 有 一个命令行参 数, 用 这个参数调用练 习题 8. 5 中的 s n o o z e 函数 , 然 后终 止。编写 程 序, 使 得用 户 可以 通过在键 盘上输入 C t rl + C 中断 s n o o z e 函 数。 比如:
linux> ./ snooze 5
CTRL+C
Slept for 3 of 5 secs. linux>
8. 5. 4 阻塞和解除阻塞信号
User hi t s Cr t l +C after 3 seconds
Linux 提供阻塞信号的隐式和显式的机制:
隐式阻塞机制。内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。 例如,图 8-31 中 , 假设程序捕获了信号 s’ 当前正在运行处理程序 S 。如果发送给该进程另 一 个 信 号 s , 那 么 直 到 处 理 程序 S 返回, s 会 变成待处理而没有被接收。
显式阻寒机制。应用程序可以使用 s i g p r o c ma s k 函 数 和它的辅助函数,明 确地阻塞和解除阻塞选定的信号。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *Set, int signum); int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
返回: 如 果 成 功 则为 0’ 若 出错 则为 - 1。
返回: 若 s i gnum 是 set 的 成 员 则 为 1, 如 果 不是 则 为 0’ 若 出错 则 为 - 1。
s i g p r o c ma s k 函数改变当前阻塞的信号集合C 8. 5. 1 节中描述的 block ed 位向最)。具体的行为依赖 于 h o w 的 值 :
SIG_BLOCK: 把 s e t 中的信号添加到 b l o c ke d 中( b l o c ke d=b l o c ke d I s e t ) 。SIG_ UNBLOCK: 从bl oc ked 中删除 s e t 中的信号( b l o c ke d =b l o c ke d &–se t ) 。SIG_SETMASK: bl oc k=se t 。
如果 o l d s e t 非空, 那么 b l o c ke d 位向量之前的值保存在 o l d s e t 中。
使用下述函数对s e t 信号集合进行操作: s i ge mpt ys e t 初始化 s e t 为空集合。s i g f i ll s e t 函数把每个信号都添加到 s e t 中。s i ga dd s e t 函数把s i g nurn 添加到 s e t , s i gde l s e 七从 s e t 中删除 s i gnurn, 如果 s i g nurn 是 s e t 的成员 , 那么 s i gi s me mber 返回 1, 否则返回0。
例如, 图 8-32 展示了如何用 s i gpr o c ma s k 来临时阻 塞接收 S IG INT 信号。
sigset_t mask, prev_mask;
2
3 Sigemptyset(&mask);
4 Sigaddset(&mask, SIGINT);
5
- I* Block SIGINT a 丑 d save previous blocked set *I
- Sigprocmask(SIG_BLOCK, &mask, &prev_mask);
8 : // Code region that will not be interrupted by SIG INT
9 I* Restore previous blocked set, unblocking SIGINT *I
10 Sigprocmask(SIG_SETMASK, &prev_mask, NULL);
11
5. 5 编写信号处理程序
图 8- 32 临时阻塞接收 一个信号
信号处理是 L in ux 系统编程最棘手的一个问题。处理程序有几个属性使得它们很难推理分析: 1) 处理程序与主程序并发 运行 , 共享同样的全局变量, 因此可能 与主程序和其他处理程序互相干扰; 2 ) 如何以及何时接收信号的规则常常有违人的直觉; 3 ) 不同的系统有不同的信号处 理语义。
在本节中 ,我们 将讲述这些问题, 介绍编写安全、正确和可移植的信号处理程序的一些基本规则 。
安全的信号处理
信号处理程序很麻烦 是因为它们 和主程序以及其他信号处理程序并 发地运行 , 正如我们在图 8-31 中看到的那样。如果处理程序和主程 序并发地访问同样的全局数据结构, 那
么结果可能就不可预知,而且经常是致命的。
我们会在第 12 章详细讲述并 发编程。这里我们的目标是给你一些保守的编写处理程序的原则, 使得这些处理程序能安全地并 发运行 。如果你忽视这些原则, 就可能有引入细微的并发错误的风险。如果有这些错误,程序可能在绝大部分时候都能正确工作。然而当 它出错的时候, 就会错得不可预测和不可重复 , 这样是很难调试的。一定要防患于未然!
- GO. 处理程序要尽可能简单。避免麻烦的最好方法是保持处理程序尽可能小和简单。例如, 处理程序可能只是简单地设置全局标志并立即返回; 所有与接收信号相关的处理都 由主程序执行 , 它周期性地检查(并重置)这个标志。
- Gl. 在处理程序中只调用异步 信号安全的函数。所谓异步信号安全的函数(或简称安全的函数)能够被信号处理程序安全地调,用原 因有二: 要么它是可重入的(例如只访问局部变量, 见 12. 7. 2 节),要么它不能被信号处理程序中断。图 8-33列出了 Linux 保证安全的系统级函数。注意, 许多常见的函数(例如pr i ntf 、s pr i nt f 、mall oc 和 e xi t )都不在此列。
_Exit | fexecve | poll | sigqueue |
---|---|---|---|
exit | fork | posix_trace_event | sigset |
abort | fstat | pselect | sigsuspend |
accept | fstatat | raise | sleep |
access | fsync | read | sockatmark |
aio_error | ftruncate | readlink | socket |
aio_return | futimens | readlinkat | socketpair |
aio_suspend | getegid | recv | stat |
alarm | geteuid | recvfrom | symlink |
bind | getgid | recvmsg | symlinkat |
cfgetispeed | getgroups | rename | tcdrain |
cfgetospeed | getpeername | renameat | tcflow |
cfsetispeed | getpgrp | rmdir | tcflush |
cfsetospeed | getpid | select | tcgetattr |
chdir | getppid | sem_post | tcgetpgrp |
chmod | getsockname | send | tcsendbreak |
chown | getsockopt | sendmsg | tcsetattr |
clock_gettime | getuid | sendto | tcsetpgrp |
close | kill | setgid | time |
connect | link | setpgid | timer_getoverrun |
creat | linkat | setsid | timer_gettime |
dup | listen | setsockopt | timer_settime |
dup2 | lseek | setuid | times |
execl | lstat | shutdo’\ffi | umask |
execle | mkdir | sigaction | 皿 ame |
execv | mkdirat | sigaddset | unlink |
execve | mkfifo | sigdelset | unlinkat |
faccessat | mkfifoat | sigemptyset | ut i me |
fchmod | mknod | sigfillset | utimensat |
fchmodat | mknodat | sigismember | utimes |
fcho= | open | signal | wait |
fchownat | openat | sigpause | waitpid |
fcntl | pause | sigpending | write |
fdatasync | pipe | sigprocmask |
图 8-33 异步信号安全的函数(来源: ma n 7 signal。数据来自 Lin ux Foundation)
信号处理程序中产生输出唯一安全的方法是使用 wr i t e 函数(见10. 1 节)。特别地, 调用 pr i n 七f 或 s p r i n t f 是 不安全的。为了绕 开这个不幸的限制, 我们开发一些 安全的函数,称为 S IO ( 安全的 I/ 0 ) 包,可 以用来在信号处理程序中打印简单的消息。
#include “csapp.h”
ssize_t sio_putl(long v); ssize_t sio_puts(char s[]);
void sio_error(char s[]);
返回: 如 果 成 功则 为 传 送 的 字 节数 ,如 果 出错 , 则 为 一1。
返回: 空。
sio p u t l 和 s i o p u t s 函数分别向标准输出传送一个 l o n g 类型数和一个字符串。
sio e rr or 函数打印一条错误消息并终止。
图 8- 34 给出的是 SIO 包的实现, 它使用了 c s a p p . c 中两个私有的可重入函数。第 3 行的 s i o_ s 七r l e n 函数返回字符串 s 的长度。第 10 行的 s i o _ l 七o a 函数基于来自[ 61] 的it o a 函数, 把 v 转换成它的基 b 字符串表示, 保存在 s 中。第 1 7 行的_ e x i t 函数是 e x 江的一个异步信号安全的变种。
codelsrc/csapp.c
ssize_t sio_puts(char s[]) I* Put string *I
2 {
3 return write(STDOUT_FILENO, s, sio_strlen(s));
4 }
5
6 ssize_t sio_putl(long v) I* Put long *I
7 {
8 char s (128) ;
9
10 sio_ltoa(v, s, 10); I* Based on K&R itoa() *I
11 return sio_puts(s);
12 }
13
14 void sio_error(char s[]) I* Put error message and exit *I
15 {
16 sio_puts(s);
17 _exit(!);
18 }
图 8-3.J 信号处理程序的 SIO C安全 I/0) 包
codelsrc/csapp.c
图 8-3 5 给出了图 8- 30 中 S IG I NT 处理程序的一个 安全的版本。
code/ecflsigintsafe.c
#include “csapp.h”
2
3 void sigint_handler(int sig) I* Safe SIGINT handler *I
4 {
s Sio_puts(“Caught SIGINT!\n”); I* Safe output *I
6 _exit(O); I* Safe exit *I
7 }
code/ecf/sigien.ctsaf
图 8-35 图 8-30 的 SIGINT 处理程序的 一个安全版本
G2. 保存和恢复 err no 。许多 Lin ux 异步信号安全的函数都会在出错返回时设置e rr no 。 在处理 程序中调用 这样的函数可能会干扰 主程序中其他依赖于 e r r no 的部分。解决方法是在进入处 理程序时把 err no 保存在一个局部 变量中 , 在处理程序返回前恢复它。注意,只有在处理程序要返回时才有此必要。如果处理程序调用
_e x i t 终止该进程 , 那么就不需要这样做 了。
G3. 阻塞 所有的信 号, 保护对共享全局数 据结构的访问。如果处理程序和主程序或其他处理程序共享一个全局数据结构, 那么在访问(读或者写)该数据结构时, 你的处理程序和主程序应该暂时阻塞所有的 信号。这条规则的原因 是从主程序访问一个数据结构 d 通常需要一系列的指令 , 如果指令序列被访问 d 的处理程序中断, 那么处理程序可能会发现 d 的状态不一致, 得到不可预知的结果。在访问 d 时暂时阻塞信号保证了处理程序不会中断该指令序列。
G4. 用 vol a t i l e 声明全 局变量 。考虑一个处理程序和一个mai n 函数, 它们共享一个全局变 量 g。处理程序更新 g , mai n 周期性地读 g。对于一个优化编译器而言, mai n 中 g 的值看上去从来没有变化过, 因此使用缓存在寄存器中g 的副本来满足对g 的每次引用是很安全的。如果这样, mai n 函数可能永远都无法看到处理程序更新过的值。
可以用 vol a已 l e 类型限定符来定义一个变量,告 诉编译器不要缓存这个量变。例如:
volatile int g;
vol a巨 l e 限定符强迫编译器每次在代码中引用 g 时, 都要从内存中读取 g 的值。一般来说 , 和其他所有共享数据结构一样, 应该暂时阻塞信号, 保护每次对全局变量的访问。
GS. 用 s i g_a t omi c _ 七 声明标志 。在常见的处理程序设计中, 处理程序会写全局标志来记录收到了信 号。主程序周期性 地读这个标志, 响应信号,再 清除该标志。对千通过这种方式 来共享的标志, C 提供一种整型数据类型 s i g _ a t omi c _ 七,对它的读和写保证会是原子的(不可中断的), 因为可以用 一条指令来实现它们:
volatile sig_atomic_t flag;
因为它们是不 可中断的,所 以可以安全地读和写 s i g _a t omi c _ t 变量,而不需要暂时阻塞信号。注意 , 这里对原子性的保证只适用于单个的读和写, 不适用于像f l a g + + 或 fl a g=fl a g +l O 这样的更新, 它们可能需 要多条指令。
要记住 我们这里讲述的规则是保守的 ,也 就是说它们不总是严格必需的。例如,如果你知道处理 程序绝对 不会修改 err no , 那么就不需要保存和恢复 err no 。或者如果你可以证明 pr i nt f 的实例都不会被处理 程序中断 , 那么在处理程序中 调用 pr i n t f 就是安全的。对共享全局数据结构的访问也是同样。不过,一般来说这种断言很难证明。所以我们建议 你采用保守的方法,遵循这些规则,使得处理程序尽可能简单,调用安全函数,保存和恢
复 er r n o , 保护对共享数 据结构的访问, 并使用 v ol a t i l e 和 s i g _a t omi c _ t 。
正确的信号处理
信号的一个与直觉不符的方面是未处理的信号是不排队的。因为 p e nd i ng 位向量中每种类型的 信号只对应有一位, 所以每种类型最多 只能有一个未处理的信号。因此,如果两个类型 k 的信号发送 给一个目的进程,而 因为目的进程当前正在执行信号 k 的处理程序, 所以信号 k 被阻塞了, 那么第二个信号就简单地被丢 弃了;它 不会排队。关键思想是 如果存在一个未处理的信号就表明至少有一个 信号到达了。
要了解这样会如何影响正确性, 来 看 一个简单的应用, 它 本 质 上 类似于像 sh ell 和
Web 服务器这样的真实程序。基本的结构是父进程创建一些子进程,这 些子进程各自独立运行一 段时间, 然后终止。父进程必须回收子进程以避免在系统中留下僵死进程。但是我们还 希 望 父 进 程 能 够 在 子 进 程 运 行 时 自 由 地 去 做 其 他 的 工 作。所以, 我 们 决 定 用
SIGCHLD 处 理程序来回收子进程, 而不是显式地等待子进程终止。(回想一下, 只 要 有一个子进程终止或者停止, 内 核 就会发 送一个 SIGCHLD 信号给父进程。)
图 8-36 展示 了我们的初次尝试。父进程设 置了一 个 SIGCH LD 处理程序, 然后创建
code/ecf/signall .c
I* WARNING: This code is buggy! *I
2
3 void handlerl(int sig)
4 {
5 int olderrno = errno;
6
7 if ((waitpid(-1, NULL, 0)) < 0)
8 sio_error(“waitpid er or”) ;
Sio_puts(“Handler reaped child\n”);
Sleep(!);
errno = olderrno;
12 }
13
14 int main()
15 {
int i, n;
char buf[MAXBUF];
18
if (signal(SIGCHLD, handler!)== SIG_ERR)
unix_error (“signal error”);
21
22 I* Parent creates children *I
23 for (i = 0; i < 3; i ++) {
24 if (Fork() == 0) {
printf(“Hello from child %d\n”, (int)getpid());
exit(O);
27 }
28 }
29
I* Parent waits for terminal input and then processes it *I
if ((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0)
un i x _err or (“read”) ;
33
printf(“Parent processing input\n”);
while (1)
36
37
38 exit (0);
39 }
cod/eecflignall.c
图8-36 signa ll : 这个程序是有缺陷的 , 因 为它假设信号是排队的
了 3 个子进程。同时, 父进程等待来自终端的一个输入行,随 后 处 理 它 。 这个处理被模型化 为一个无限循环。当每个子进程终止时,内 核 通过发送一个 S IG C H LD 信号通知父进程。父进程捕获这个 SIG C H L D 信号, 回 收 一 个 子 进程, 做 一 些 其他的清理工作(模型化为 s l e e p 语句), 然后返回。
图 8- 36 中的 s i g na l l 程序看起来相当简单。然而, 当 在 L in u x 系统上运行它时, 我
们得到如下输出:
linux> ./signal1
Hello from child 14073 Hello from child 14074 Hello from child 14075 Handler reaped child Handler reaped child CR
Parent processing input
从输出中我们 注意到,尽 管 发送了 3 个 SIGC H LD 信号给父进程,但 是其中只有两个信号被接收了,因此父进程只是回收了两个子进程。如果挂起父进程,我们看到,实际上子进程14075 没有被回收,它成 了一 个僵 死 进程(在p s 命令的输出中由字符串 " de f unc t " 表明):
Ctrl+Z Suspended linux> ps t
PID TTY | STAT | TIME | COMMAND |
---|---|---|---|
14072 pts/3 | T | O: 02 | . /signall |
14075 pts/3 | Z | 0:00 | [signall] <defunct> |
14076 pts/3 | R+ | 0:00 | ps t |
哪里出错了呢?问题就在于我们的代码没有解决信号不会排队等待这样的情况。所发 生的 情 况 是 : 父进程接收并捕获了第一个信号。当处理程序还在处理第一个信号时, 第二个 信 号 就 传 送并 添 加 到了待处理信号集合里。然而,因 为 SIG C H LD 信号被 SIG C H LD 处理程序阻塞了,所 以 第二个信号就不会被接收。此后不久,就 在 处 理 程序还在处理第一个信 号 时 ,第 三个信号到达了。因为已经有了一个待处理的 S IG C H L D , 第三个 S IG C H LD 信号 会被 丢弃。一段时间之后, 处理程序返回,内 核 注意到有一个待处理的 S IG C H LD 信号 , 就迫使父进程接收这个信号。父进程捕获这个信号, 并第二次执行处理程序。在处理程序完成对第二个信号的处理之后, 已经没有待处理的 S IG C H L D 信号了, 而且也绝不会再 有,因 为第三个 S IG C H L D 的所有信息都已经丢失了。由此得到的重要 教 训是, 不 可以用信 号来对其他进程中发 生的 事件计数。
为了修正这个问题,我们必须回想一下,存在一个待处理的信号只是暗示自进程最后 一次收到一个信号以来, 至少已经有一个这种类型的信号被发送了。所以我们必须修改S IG C H L D 的 处 理 程序,使 得每次 S IG C H LD 处理程序被调用时 , 回 收 尽 可能多的僵死子进程。图 8- 37 展示了修改后的 SIGC H L D 处理程序。
当我们在 Lin u x 系统上运行 s i g n a l 2 时, 它 现 在可以正 确地回收所有的僵死子进程了:
linux> ./signal2
Hello from child 15237
Hello from child 15238 Hello from child 15239 Handler reaped child Handler reaped child Handler reaped child CR
Parent processing input
void handler2(int si g)
2 {
3 int olderrno = errno;
4
s while (waitpid(-1, NULL, 0) > 0) {
6 Sio_puts(“Handler reaped ch辽d\ n”) ;
7 }
8 if (errno != ECHILD)
9 Sio_error(“waitpid error”);
Sleep(i);
errno = olderrno; 12 }
code/ecfilgsna/2.c
code/ecfl signal2.c
图 8-3 7 s i gnal 2: 图 8-36 的一个改进版本 , 它能够正确解决信号不 会排队 等待的情况
沁 练习题 8. 8 下 面这 个程序 的输 出是 什么?
volatile long counter= 2;
2
3 void handlerl(int sig)
4 {
5 sigset_t mask, prev_mask;
6
- Sigfillset(&mask);
codelecfls ig nalp ro bO.c
Sigprocmask(SIG_BLOCK, &mask, &pr ev _ma s k) ; I* Block sigs *I
9 Si o_putl (- - c oun t er ) ;
10 Si gpr oc mas k (SI G_SETMAS K , &prev_mask, NULL); I* Restore sigs *I
11
12 _e x i t ( O) ;
13 }
14
15 int main()
16 {
pid_t pid;
sigset_t mask, prev_mask;
19
printf (11%ld11 , counter) ;
f fl us h ( s t dout ) ;
22
23 signal (SIGUSR1, handler!) ;
24 if ((pid = Fork()) == 0) {
25 Yhile(1) {};
26
Kill (pid, SIGUSR1);
Waitpid(-1, NULL, 0);
29
Sigfillset (&mask);
Sigprocmask(SIG_BLOCK, &mask, &prev_mask); I* Block sigs *I
printf (11%ld11 , ++counter) ;
Sigprocmask(SIG_SETMASK, &prev_mask, NULL); I* Restore sigs *I
34
35 exit (0);
36 }
可移植的信号处理
code/ef俎ic gnalprobO.c
U n ix 信号处理的另一个缺陷在于不同的系统有不同的信号处理语义。例如:
s i g n a l 函数的语 义各 有不同。有 些老的 U n ix 系统在信号 K 被处理程序捕获之后就把对信号k 的反应恢 复到默认值。在这些 系统上, 每次运行 之后, 处理程序必须调用 s i g n a l 函数, 显式地重新设置它自己。
系统调用可 以被中断 。像 r e a d 、 wr i 七e 和 a c c e p t 这样的系统调用潜在地会阻塞进程一段较长的时间 , 称为慢 速 系统调用。在 某些较早版本的 U nix 系统 中, 当处理程序捕 获到一个信号时 , 被中断的慢速系统 调用在信号处理程序返回时不再继续, 而是立即返回给用户一个错误条件, 并将 e rr n o 设置为 E I N T R 。在这些系统上, 程序员必须 包括手动重启 被中断的系统调用的代码。
要解决这些问 题, P o s ix 标准定 义了 s i g a c t i o n 函数, 它允许用户在设置信号处理时,明确指定他们想要的信号处理语义。
#include <signal.h>
int sigaction(int signum, struct sigaction *act, struct sigaction *oldact);
返回: 若 成 功则 为 0 , 若 出错 则 为 - I .
s i g a c t i o n 函数运用并不广泛, 因为它要求用户设置一个复杂结构的条目。一个更简洁的方 式, 最初是由 W. Richard Stevens 提出的[ 11 0 ] , 就是定义一个包装 函数, 称为
Signal, 它调用 s i g a c t i o n。图 8-38 给出了 S i g n a l 的定义,它的 调用方式与 s i g na l 函
数的调用方式一样。
S i g n a l 包装函数设置了一 个信号处理程序, 其信号处理语义如下 :
只 有这个处 理程序当前正在处理的那种类型的 信号被阻塞。
和所有信号实现一样,信号不会排队等待。
只要可能 , 被中断的系统调用会自动重启。
一旦设置了信号处理程序, 它就会一直保持, 直到 S i g n a l 带着 h a nd l e r 参数为
S IG _ IG N 或 者 S IG _DF L 被调用。
我们在所有的 代码中实现 Si g n a l 包装函数 。
8. 5. 6 同步流以避免讨厌的并发错误
如何编写读写相同存储位置的并发流程序的问 题, 困扰着数代计算机科学家。一般而
言,流可能交错的数量与指令的数量呈指数关系。这些交错中的一些会产生正确的结果, 而有些则不会。基本的问题是以某种方式同步并发流,从而得到最大的可行的交错的集 合, 每个可行的 交错都能得到正确的结果。
code/src/csapp.c
handler_t *Signal(int signum, handler_t *handler)
{
struct sigaction action, old_action;
action.sa_handler = handler;
sigemptyset(&action.sa_mask); I* Block sigs of type being handled *I action.sa_flags = SA_RESTART; I* Restart syscalls if possible *I
if (sigaction(signurn, &action, &old_action) < 0) unix_error(“Signal error”);
return (old_action.sa_handler);
codelsrdcsapp.c
图8-38 Si gna l : s i ga c t i on 的一个包装函数 , 它提供在 Posix 兼容系统上的可移植的 信号处理
并发编程是一个很深且很重要的问题, 我们将在第 12 章 中更详细地讨 论。不过, 在本章中学习的有关 异常控制流的 知识, 可以让你感觉一下与并发相关的有趣的智力挑战。例如, 考虑图 8-39 中的程序, 它总结了一个典型的 U nix shell 的结构。父进程在一个全局 作业列 表中记录着它的 当前子进程, 每个作 业一个条目。a d d j o b 和 d e l e 七e j o b 函数分别 向这个作业列表 添加和从中删除作业。
当父进程创建一个新的子进程后 , 它就把这 个子进程添加到 作业列表中。当父进程在
SIGCHLD 处理程序中回收一个终止的(僵死)子进程时, 它就从作业列表中删除这个子进程。
乍一看 , 这段代码是对的。不幸的是, 可能发生下面这样的 事件序列 :
- 父进程执行 f o r k 函数,内 核调度新创建的子进程运行 , 而不是父进程。
) 在父进程能 够再次运行之前, 子进程就终止, 并且变成一个僵死进程, 使得内核传递一个 SIGCH LD 信号给父进程。
) 后来, 当父 进程再次变成可运行但又 在它执行之前,内 核注意到有未处理的
SIGCHLD 信号, 并通过在父进程中运行处 理程序接收 这个信号。
) 信号处理程序回收终止的子进程,并 调用 d e l e t e j o b , 这个函数什么也不做, 为父进程还没有把该子进程添加到列表中。
) 在处理程序运行完毕后,内 核运行父进程, 父进程从 f or k 返回, 通过调用 a d d j ob 错误地把(不存在的)子进程添加到作 业列表中。
因此, 对千父进 程的 ma i n 程序和信号处理流的某些交错, 可能会在 a d d j o b 之前调用 d e l e t e j o b 。这导 致作业列 表中出现一个不正确的条目, 对应于一个不再存在而且永远也不会被删 除的作业。另一方面,也 有一些交错 , 事件按照正确的顺 序发生。例如, 如果在 fo r k 调用返回时,内 核刚好调度父进程而不是子进程运行, 那么父进程就会正确地把子进程添加到作业列表中,然后子进程终止,信号处理函数把该作业从列表中删除。
这是一个称为竞争 ( ra ce ) 的经典同步错误的示例。在这个情况中, ma i n 函数中调用
add j ob 和处理程序中调用 d e l e t e j ob 之间存在竞争。如果 a d d j o b 赢得进展,那 么结果
就是正确的。如果它没有, 那么结果就是错误的。这样的错误非常难以调试, 因为几乎不可能测试所有的交错。你可能运行这段代码十亿次, 也 没有一次错误, 但是下一次测试却导致引发竞争的交错。
codelecf/p rocmaskl .c
I* WARNING: This code is buggy! *I
2 void handler(int sig) 3 {
4 int olderrno = errno;
5 sigset_t mask_all, prev_all;
6 pid_t pid;
8 Sigf illset(&mask_all) ;
9 while ((pid = waitpid(-1, NULL, 0)) > 0) { I* Reap a zombie child *I
Sigprocmask(SIG_BLOCK, &mask_all, &pr e v _a ll ) ;
de l et e j ob (pi d) ; I* Delete the child from the job list *I
Sigprocmask(SIG_SETMASK, &prev_all, NULL);
13 }
14 if (errno != ECHILD)
15 Sio_error(“waitpid error”);
16 errno = olderrno·
17 }
18
19 int main(int argc, char **argv)
20 {
int pid;
sigset_t mask_all, prev_all;
23
Sigfillset (&ma s k_a ll ) ;
Signal(SIGCHLD, handler);
initjobs(); I* Initialize the job list *I
27
while (1) {
if ((pid = Fork()) == 0) { I* Child process *I
Execve("/bin/date", argv, NULL);
31 }
Sigprocmask(SIG_BLOCK, &mask_all, &pr ev _a ll ) ; I* Parent process *I
addjob(pid); I* Add the child to the job list *I
Sigprocmask(SIG_SETMASK, &prev_all, NULL);
35 }
36 e x i t ( O) ;
37 }
cod e/ ecf/ p roc maskl.c
图 8-39 一 个 具 有细微同步错误的 shell 程序。如果子进程在父进程能够开始运行前就结束了, 那么
add j ob 和 de l e t e j ob 会以错误的方式被调用
图 8-40 展示 了 消除图 8-39 中竞争 的一种 方 法。通 过 在调用 f or k 之前, 阻塞S IGCH LD 信号, 然后在调用 a dd j o b 之后取消阻塞这些信号, 我们保证了在子进程被添加到作业列表中之后回收该子进程。注意 , 子进程继 承了它们父进程的被阻塞集合, 所以我们必须在调用 e x e c v e 之前,小 心地解除子进程中阻 塞的 SIGCHLD 信号。
code/ecflprocmask2.c
void handler(int sig)
2 {
int olderrno = errno;
sigset_t mask_all, prev_all;
pid_t pid;
Sigfillset (&mask_all);
while ((pid = waitpid(-1, NULL, 0)) > 0) { f* Reap a zombie child *f
Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
deletejob(pid); I* Delete the child from the job list *I
Sigprocmask (SIG_SETMASK, &prev_all, NULL) ; 12 }
3 if (errno != ECHILD)
Sio_error(“waitpid error”);
errno = olderrno; 16 }
17
18 int main(int argc, char **argv) 19 {
int pid;
sigset_t mask_all, mask_one, prev_one;
22
Sigfillset(&mask_all);
Sigemptyset (&mask_one) ;
Sigaddset(&mask_one, SIGCHLD);
Signal(SIGCHLD, handler);
initjobs(); I* Initialize the job list *I
28
while (1) {
· Si gpr oc ma s k (S I G_BLOCK, &mask_one, &prev_one); I* Block SIGCHLD *f
if ((pid = Fork()) == 0) { I* Child process *I
Sigprocmask(SIG_SETMASK, &prev_one, NULL); f* Unblock SIGCHLD *f
Exeeve (11/bin/date11, argv, NULL) ; 34 }
Sigprocmask(SIG_BLOCK, &mask_all, NULL); I* Parent process *I
addjob(pid); I* Add the child to the job list *I
Sigprocmask(SIG_SETMASK, &prev_one, NULL); f* Unblock SIGCHLD *f
38 }
39 exit(O); 40 }
code/ecfl p roc mask2.c
图 8-40 用 s i gpr ocmas k 来同步进程。在这个例子中 ,父进程保证在相应的 del et e job 之前执行 add job
5. 7 显式地等待信号
有时候 主程序需要显式地等待某个信号处理程序运行。例如,当 Linu x shell 创建一个前台作业时 , 在接收下一条用户命令之前, 它必须等待作业终止, 被 SIGCHLD 处理程序回收。
图 8-41 给出了一个基本的思路。父进程设置 SIGINT 和 SIGCH LD 的处理程序, 然后
进入一个无限循环。它阻塞 S IG C H L D 信号, 避免 8. 5. 6 节中讨论过的父进程和子进程之间的竞争。创建了 子进程之后, 把 p 过 重置为 o, 取消阻塞 S IG C H L D , 然后以循环的方式等待 p 迈 变为非零。子进程终止后, 处理程序回收它, 把它非零的 P ID 赋值给全局 p i d
变温。这会终止循环,父进程 继续其他的工作, 然后开始下一次迭代。
code/ecflwaitforsignal.c
#include “csapp.h”
2
3 volatile sig_atomic_t pid;
4
5 void sigchld_handler(int s)
6 {
int olderrno = errno;
pid = wai tpid( 一 1 , NULL, O);
errno = olderrno;
10 }
11
12 void sigint_handler(int s)
13 {
14 }
15
16 int main(int argc, char **argv)
17 {
18 sigset_t mask, prev;
19
Signal(SIGCHLD, sigchld_handler);
Signal (SIGINT, sigint_handler) ;
Sigemptyset(&mask);
23 Sigaddset (&mask, SIGCHLD) ;
24
while (1) {
Sigprocmask(SIG_BLOCK, &mask, &prev); I* Block SIGCHLD *I
27 if (Fork() == 0) I* Child *I
28 exit (0);
29
I* Parent *I
pid = O;
Sigprocmask(SIG_SETMASK, &prev, NULL); I* Unblock SIGCHLD *I
33
34 I* Wait for SIGCHLD to be received (wasteful) *I
35 while (! pid)
36
37
I* Do some work after receiving SIGCHLD *I
printf(".");
40 }
41 exit(O);
42 }
code/ecflwaitforsignal.c
图 8- 41 用循环来等待信号 。这段代码正确, 但循环是一种浪费
当这段代码正确执行的时候,循环在浪费处理器资源。我们可能会想要修补这个问 题, 在循环体内插入 pa us e :
while (! pid) I* Race! *I pause();
注意, 我们仍然需要一个循环, 因 为收到一个或多个 S IGINT 信号, p a u s e 会 被 中断。不过, 这段代码有很严直 的竞 争 条 件: 如果在 wh i l e 测 试 后 和 p a u s e 之前 收到SIGC H LD 信号, p a u s e 会永远睡眠。
另一个选择是用 s l e e p 替换 p a us e :
while (! pid) I* Too slow! *I sleep(!);
当这段代码正确执行时, 它太慢了。如果在 wh i l e 之 后 p a u s e 之 前 收 到 信 号 , 程 序必须 等相当长的一段时间才会再次检查循环的终止条件。使用像 na nos l e e p 这样更高精度的休眠函数也是不可接受的,因为没有很好的方法来确定休眠的间隔。间隔太小,循环 会太浪费。间隔太大,程序又会太慢。
合适的解决方法是使用 s i g s u s p e nd 。
#include <signal.h>
int sigsuspend(const sigset_t *mask);
返回: —1。
s i g s us pe nd 函数暂时用 ma s k 替换当前的阻塞集合, 然后挂起该进程, 直 到收到一个信号,其行为要么是运行一个处理程序,要么是终止该进程。如果它的行为是终止,那 么该进程不从 s i g s us pe nd 返回就直接终止。如果 它的行为是运行一个处 理程序, 那 么s i g u s p e n d 从处理程序返回,恢 复 调 用 s i g s u s pe nd 时 原 有的阻塞集合。
s i g s us pe nd 函数等价于下述代码的原子的(不可中断的)版本:
sigprocmask(SIG_SETMASK, &mask, &prev); pause();
3 sigprocmask(SIG_SETMASK, &prev, NULL);
原子属 性保证对 s i g pr oc ma s k( 第 1 行)和pa us e ( 第 2 行)的调用总是一起发生的,不 会 被中断。这样就消除了潜在的竞争, 即 在 调 用 s i g pr o c ma s k 之后但在调用 pa us e 之前收到了一个信号。
图 8- 42 展示了如何使用 s i g s u s pe nd 来替代图 8- 41 中的循 环。在每次调用 s i g s us pe nd 之前,都 要 阻 塞 SIG CH LD。 s i g s us p e nd 会暂时取消阻塞 S IGCH LD , 然后休眠, 直到父进程捕获信号。在返回之前, 它会恢复原始的阻塞集合, 又再次阻塞 SIG C H L D。如果父进程捕获一个 SIG IN T 信号,那 么 循 环 测 试 成 功 ,下 一 次 迭代又再次调用 s i g s us pe nd。如果 父 进 程 捕 获 一 个 SIGCH LD , 那么循环测试失败,会退出循环。此时, SIGCH LD 是被阻塞的,所 以 我们可以可选地取消阻塞 SIG CH LD。在真实的有后台作业需要回收的 shell 中这样做可能会有用处。
s i g s us pe nd 版本比起原来的循环版本不那么浪费, 避免了引入 p a us e 带来的竞争, 又比 s l e e p 更有 效 率 。
code/ecflsigsuspend.c
1 #include “csapp.h”
2
3 volatile sig_atomic_t pid;
4
5 void sigchld_handler(int s)
6 {
7 int olderrno = errno;
8 . p过 = 扣釭 t p 过( 一1 , NULL, O);
9 errno = olderrno·
10 }
11
12 void sigint_handler(int s)
13 {
14 }
15
16 int main(int argc, char **argv)
17 {
18 sigset_t mask, prev;
19
Signal(SIGCHLD, sigchld_handler);
Signal(SIGINT, sigint_handler);
Sigemptyset(&mask);
Sigaddset(&mask, SIGCHLD); 24
while (1) {
Sigprocmask(SIG_BLOCK, &mask, &prev); I* Block SIGCHLD *I
if (Fork() == 0) I* Child *I
28 exit(O);
29
I* Wait for SIGCHLD to be received *I
pid = O;
while (! pid)
sigsuspend(&prev);
34
I* Optionally unblock SIGCHLD *I
Sigprocmask(SIG_SETMASK, &prev, NULL);
37
I* Do some work after receiving SIGCHLD *I
printf(". “);
40 }
41 exit(O); 42 }
code/ecfl sigs uspend.c
图 8-42 用 s i gs us pe nd 来等待信号
6 非本地跳转
C 语言提供了一种用户级异常控制流形式,称 为非本地跳转( no nloca l jump), 它将控
制 直 接从一个函数转移到另一个当前正在执行的函数,而 不 需 要 经 过 正 常 的 调 用- 返回序
列。非本地跳转是通过 s e t j mp 和 l o ng j mp 函数来提供的 。
#include <setjmp.h>
int int
setjmp(jmp_buf env);
si gset jmp (s 屯 j mp _buf env, int savesigs);
返回: se t jmp 返 回 O, l ong jmp 返 回 非零。
s e t j mp 函数在 e nv 缓冲区中保 存当前调用环境 , 以供后面的 l o n g j mp 使 用 , 并返回
0。调用环境包括程序计数器、栈指针和通用目的寄存器。出于某种超出本书描述范围的 原因, s e t j mp 返回的值不能被赋值给变量:
re= setjmp(env); I* Wrong! *I
不过它可以安全地用在 S W止 c h 或条件语句的测 试中[ 62] 。
#include <setjmp.h>
void longjmp(jmp_buf env, int retval);
void siglongjmp(sigjmp_buf env, int retval);
从不返回 。
l o n g j mp 函数从 e nv 缓冲区中恢复调用环境, 然后触发一个从最近一次初始化 e nv
的 s e t j mp 调用的返回。然后 s e t j mp 返回, 并带有非零的返回值r e t v a l 。
第一眼看过去, s e t j mp 和 l o n g j mp 之间的相互关系令人迷惑。s e t j mp 函数只被调用一次, 但返回多 次: 一次是当第一次调用 s e t j mp , 而调用环境保存在缓 冲区 e nv 中时, 一次是为每个相应 的 l o ng j mp 调用。另一方面, l o ng j mp 函数被调用一次, 但从不返回。
非本地跳转的一个重要应用就是允 许从一个深层嵌套的函数调用中立即返回, 通常是由检测到某个错误 情况引起的。如果在一个深层嵌套的函数调用中发现了一个错误情况, 我们可 以使用非本地跳转直接返回到一个普通的本 地化的错 误处理程序, 而不是费力地解开调用栈 。
图 8-43 展示了一个示例,说 明这可能是如何工作的。ma i n 函数首先调用 s e t j mp 以保存当前的调用环境, 然后调用 函数 f o o , f o o 依次调用函数 bar 。如果 f o o 或者 b ar 遇到一个错误 , 它们立即通过一次 l o ng j mp 调用从 s e t j mp 返回。s e 七 j mp 的 非零返回值指明了错误类型, 随后可以被解码 , 且在代码中的某个位置进行处 理。
code/ecf/setjmp.c
#include “csapp.h” jmp _buf buf;
int error!= O;
int error2 = 1;
vo i d foo(void), bar(void);
图8-43 非本地跳转的示例。本示例表明了使用非本地跳转来从深层嵌套的函数调用中的错误情况恢复, 而不需要解开整个栈 的基本框架
10 int main()
11 {
switch(setjmp(buf)) {
case 0:
14 foo();
15 break;
case 1:
printf(“Detected an errorl condition in foo\n”);
break;
case 2:
printf(“Detected an error2 condition in foo\n”);
break;
default:
printf (“Unknown error condition in foo\n”);
24 }
25 exit(O);
26 }
27
I* Deeply nested function foo *I
void foo(void)
30 {
if (errorl)
longjmp(buf, 1);
bar();
34 }
35
36 void bar(void)
37 {
if (error2)
longjmp (buf, 2);
40 }
code/ecf/setjmp.c
图 8- 43 (续)
l o ng j mp 允 许它跳过所有中间 调用的特性可能产 生意外的后果。例如, 如果中间函数调用中分配了某些数据结构,本来预期在函数结尾处释放它们,那么这些释放代码会被跳 过,因而会产生内存泄涌。
非本地跳转的另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置。图8-44 展示了一个简单的程序,说明 了这种基本技术。当用户在键盘上键入 C trl + C 时, 这个程序用 信号 和非本地跳转来实现软重启。 s i g s e t j mp 和 s i g l o ng j mp 函数是 s e t j mp 和 l o ng j mp 的可 以被信号处理程序使用的版本。
在程序第一次启动时, 对 s i g s e t j mp 函数的初始调用保存调用环境和信号的上下文
(包括待处理的和被阻塞的信号向扯)。随后,主函数进入一个无限处理循环。当用户键入 C t rl + C 时,内 核发送一个 S IG I N T 信号给这个进程,该 进程捕获这个信号。不是从信号处理程序返回,如果是这样那么信号处理程序会将控制返回给被中断的处理循环,反之, 处理程序完成一个非本地跳转, 回到 ma i n 函数的开始处。当我们在系统上运行这个程序时,得到以下输出:
linux> ./restart starting processing .. . processing . . .
Ctrl+C restarting processing… Ctrl+C restarting processing . . .
关千这个程序有两件很有趣的事情。首先, 为了避免竞争,必须 在调用 了 s i g s e t j mp 之后再设 置处 理 程序 。否 则 ,就 会 冒在初始调用 s i gs e t j mp 为 s i g l o ng j mp 设 置调用环境之前运行处理程序的风险。其次,你 可 能 巳 经 注 意 到 了 , s i g s e t j mp 和 s i g l ong j mp 函 数 不 在 图8- 33 中异 步信号安全的函数之列。原因是一般来说 s i g l ong j mp 可以 跳到任意代码,所 以 我们必须小心, 只在 s i g l o ng j mp 可达的代码中调用安全的函数。在本例中, 我们调用安全的 s i o主 u t s 和 s l e e p 函数。不安全的 e x i t 函数是不可达的。
#include “csapp.h”
2
code/ecf/restart.c
3 sigjmp_buf buf;
4
s void handler(int sig)
6 {
7 s iglongjmp (buf , 1) ;
8 }
9
1o int main()
11 {
if (!sigsetjmp(buf, 1)) {
Signal(SIGINT, handler);
Sio_puts(“starting\n”);
15 }
else
Sio_puts (“restarting\n”) ;
18
while(!) {
Sleep(! ) ;
Sio_puts (“processing … \n”);
22 }
23 exit(O); I* Control never reaches here *I
24 }
code/ecflrestart.c
图8-44 当用户键入 Ctrl+ C 时, 使 用 非本地跳转来重启 动它自身的 程序
豆日C++ 和 J a va 中的软件异常
C++ 和 J ava 提供的异常机制是较 高层次的 , 是 C 语言的 s e t j mp 和 l o n g j mp 函数的更加结构化的版本。你可以把 t r y 语句中 的 c a t c h 子句 看做 类似于 s e 七 j mp 函数。相似地, t hr o w 语句就 类似于 l o n g j mp 函数。
8. 7 操作进程的工具
Lin u x 系统提供了大量的监 控和操作进程的有用 工具。
ST RACE : 打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。对于好奇的学生而言,这 是一个令人着迷的 工具。用- s t a t i c 编译你的 程序, 能得到一个更干净的、不带有大量与共享库相关的输出的轨迹。
PS: 列出当前 系统中的进程(包括僵死进程)。
T OP: 打印出关于当前进程资源使用的信息。
PMAP: 显示进程的内存映射。
/ pr o c : 一个虚拟文件系统,以 ASCII 文本格式输出大量内核数据结构的内容, 用户程序可以读取这些内容。比如, 输入 " c a t / p r o c / l o a d a v g” , 可以看到你的 Lin u x 系统上当前的平均负载。
8. 8 小结
异常控制流 ( ECF) 发生在计算机系统的各个层次 , 是计算机系统中 提供并发的 基本机 制。
在硬件层 , 异常是由处理器中的 事件触发的 控制流中的 突变。控制流传 递给一 个软件处理程序,该处理程序进行一些处理 , 然后 返回控制给被中断的 控制流。
有四种不同类 型的异常 : 中断、故障、终止和陷阱 。当一个外部 1/0 设备(例如定时器芯片或者磁盘
控制器)设置了处理 器芯片上的中断管脚时 ,(对于任意指令)中断会异步地发生。控制返回到故障指令后面的那条指令。一条指令的 执行可能导致 故障 和终止同步发生。故障处理程序会重新启动故障指 令, 而终止处理程序从 不将控制返回 给被中断的 流。最后 , 陷阱就像是用来实现向应用 提供到操作系统代码的受控的入口点的系统调用的函数调用。
在操作 系统层,内 核用 ECF 提供进程的 基本概念。进程提供给应 用两个重要的抽象: 1) 逻辑控制 流,它 提供给每个程序一个假象 , 好像它是 在独占 地使用处理器, 2 ) 私有地 址空间 , 它提供 给每个程序一个假象,好像它是在独占地使用主存。
在操作系统和应用程序之间的接口处,应用程序可以创建子进程,等待它们的子进程停止或者终止, 运行新的 程序 , 以及捕获来 自其他进 程的信号。信号处理的语义是微妙的, 并且随系统不同而不同。然而, 在与 Pos ix 兼容的系统 上存在着一些机制 ,允 许程序清楚 地指定期望的信号处理语义。
最后, 在应用层, C 程序可以 使用非本 地跳转来 规避正常的调用/返回栈规则 , 并且直接从 一个函数分支到另一个函数.
参考文献说明 #
Ke r risk 是 Linux 环境编程的 完全参考手册 [ 62] 。Intel ISA 规范包含对 Intel 处理器上的异常和中断的详 细讨论 [ 50] 。操作系统教科书 [ 102. 106, 113] 包括关于异 常、进 程和信号的其他信息。W. Richard St evens 的[ 111 ] 是一本有价值的和可读性很高的 经典著作, 是关于如何 在应用程序中处 理进程和信号的。Bovet 和 Cesati[ 11] 给出了一个关千 Linux 内核的非常清晰的描述, 包括进程和信号实现的 细节。
家庭作业 #
- 8. 9 考虑四个具有 如下开始和结束时间的进程 :
进程 | 开始时间 | 结束时间 |
---|---|---|
A | 5 | 7 |
B | 2 | 4 |
C | 3 | 6 |
D | I | 8 |
对于每对进程,指明它们是否是并发地运行的:
8. 10 在这一章里 , 我们介绍 了一些具有不寻常的调用和返回行为的 函数: s e t j mp 、 l ong j mp 、 e xe c ve
和 f or k。找到下列行为中和每个函数相匹 配的一种 :
- 调用一次, 返回两次。
调用一次,从不返回。
c. 调用一次,返回一次或者多次。
- 调用一次, 返回两次。
8. 11 这个程序会输出多 少个 " hello" 输出行?
妇 ncl ude “csapp.h”
2
codelecf/forkprobl.c
3 int main()
4 {
5 inti;
6
7 for(i = 0; i < 2; i ++)
8 Fork();
9 printf(“hello\n”);
10 exit(O);
11 }
codelecf/forkprobl.c
- 8. 12 这个程序会输出多 少个 " hello" 输出行?
#include “csapp.h”
3 void doit 0
4 {
5 Fork();
6 Fork(); printf(“hello\n”);
8 return·,
9 }
10
11 int main()
12 {
13 doit();
14 printf (“hello\n”) ;
15 exit(O);
16 }
code/ecflforkprob4.c
code/ecflforkprob4.c
- 8. 13 下面程序的 一种可能的输出是 什么?
扣 ncl ude “cs app . h”
codelecf/forkprob3.c
int main()
4 | { | ||
---|---|---|---|
5 | int | X = 3; | |
6 | |||
7 | if | (Fork() != 0) | |
8 | printf(“x=%d\n”, ++x); | ||
9 |
10 printf(“x=%d\n”, –x);
11 exit(O);
12 }
codelecflforkprob3.c
- 8. 14 下 面这个程序会输出多 少个 " hello" 输出行?
1 #include “csapp.h”
2
3 void doitO
4 {
5 if (Fork() == 0) {
Fork();
printf(“hello\n”);
exit(O);
9 }
10 return; 11 }
12
13 int main()
14 {
1s doitO;
printf(“hello\n”);
exit(O);
18 }
codelecflforkprob5.c
codelecflforkprob5.c
- 8. 15 下面这个程序会 输出多 少个 " hello" 输出行?
#include “csapp.h”
2
3 void doit ()
4 {
s if (Fork() == 0) {
Fork();
printf (“hello\n”);
s return·,
9 }
10 return; 11 }
12
13 int main()
14 {
1s doitO;
printf(“hello\n”);
exit(O);
18 }
codelecflforkprob6.c
codelecf/forkprob6.c
- 8. 16 下面这个程序的输出是什么?
codelecf/forkprob7.c
#include “csapp. h” int counter= 1;
int main()
{
辽 (for k () == 0) { counter–; exit(O);
}
else {
Wait(NULL);
printf(“counter = o/,d\n”, ++counter);
}
exit(O);
}
code/ecf/forkprob7.c
列举练习题 8. 4 中程序所有可能的 输出。考虑下面的程序:
#include “csapp.h” void end(void)
{
code/ecflforkprob2.c
printf(“2”); fflush(stdout);
}
int main()
{
if (Fork() == 0) atexit(end);
if (Fork() == 0) {
printf(“O”); fflush(stdout);
}
else {
printf(“1”); fflush(stdout);
}
exit(O);
}
code/ecflforkprob2.c
判断下面哪个输出是 可能的 。注意: a t e x 江 函数以一个指向函数的指针为输入 , 并将它添加到函数列 表中(初始为空), 当 e x 江 函数被调用时 , 会调用该列 表中的函数。
•• 8. 19
A. 112002 B. 211020 C. 102120 D. 122001
下面的函数会打印多 少行输出? 用一个 n 的函数给出答 案。假设 n l 。
code/ecflforkprob8.c
E . 100212
void foo(int n)
{
inti;
for(i = 0; i < n; i ++)
Fork(); printf(“hello\n”); exit(O);
code/ecflrfkoprob8.c
** 8. 20
使用 e xe c ve 编写一个叫做 myl s 的 程 序 ,该 程序的行为和 / bi n / l s 程序的一样。你的程序应该接受相同的命令行参数 , 解释同样的环境变量,并 产 生 相 同 的 输 出 。
l s 程 序从 CO L U M NS 环境变扯中获得屏幕的宽度。如果没有设 置 CO L U MNS , 那么 l s 会假设 屏幕宽 80 列。因此,你 可以 通过把 CO LU M NS 环境设置得小于 80 , 来检查你对环境变址的处理:
linux> setenv COLUMNS 40 linux> ./ myls
II Output is 40 columns wide
linux> unsetenv COLUMNS linux> ./ myls
II Output is now 80 columns wide
** 8. 21
下面的程序可能的输出序列是什么?
int main()
{
codelecflwaitprob3.c
if (fork() == 0) {
printf(“a”); fflush(stdout); exit (O) ;
}
else {
pri ntf (“b”) ; fflush(stdout); waitpid(-1, NULL, 0);
}
printf(“c”); fflush(stdout); exit(O);
*** 8. 22
编写 U nixs ys t e m 函 数的你自己的版本
立 t mysystem(char *command);
code/ecwfa/itprob3 .c
•• 8. 23
mys ys t e m 函 数 通过调用 " / b i n / s h - c c omma nd " 来 执 行 c omma nd , 然 后 在 c omma nd 完成后返回。如果 c omma nd ( 通过 调用 e xi t 函数 或 者 执 行一 条r e t u r n 语 句)正常 退出, 那 么 mys ys t e m 返回 c omma nd 退出状态。例如, 如 果 c o mma nd 通过调用 e xi t (8 ) 终 止,那 么 mys ys t e m 返回值 8。否则,如 果 c o mma nd 是 异常终止的,那 么 mys y s t e m 就 返 回 s he ll 返回的状态。
你的一个同事想要使用信号来让一个父进程对发生在子进程中的事件计数。其想法是每次发生一 个事件时,通过向父进程发送一个信号来通知它,并且让父进程的信号处理程序对一个全局变量 coun t e r 加一, 在子进程终止之后, 父进程就可以检查这个变量。然而, 当他在系统上运行图 8-
45 中的测试程序时,发 现 当父进程调用 pr i n t f 时, c o unt er 的值总是 2 , 即使子进程向父进程发
送了 5 个信号也是如此。他很困惑,向 你 寻 求 帮助。你能解释这个程序有什么错误吗?
codelecf/counterprob.c
#include “csapp.h” int counter= O;
void handler(int sig)
{
counter++;
sleep(1); I* Do some work in the handler *I return;
图8-‘15 家庭作业 8. 23 中引用的计数器程序
10 }
11
12 int main()
13 {
14 int i;
15
16 Signal(SIGUSR2, handler); 17
18 if (Fork() == 0) { I* Child *I 19 for (i = O; i < 5; i++) {
Kill(getppid () , SIGUSR2) ;
printf(“sent SIGUSR2 to parent \n”) ; 22 }
23 exit(O);
24 }
25
Wait (NULL) ;
printf(“counter=%d\n”, counter);
exit(O); 29 }
codelecf/counterprob.c
图 8-45 (续)
\* 8. 24 修改图 8-18 中的程序,以 满足下 面两个条件:
每个子进程在试图写一个只读文本段中的位置时会异常终止。
父进 程打印和下 面所示相同(除了 PID) 的输出:
child 12255 terminated by signal 11: Segmentation fault child 12254 terminated by signal 11: Segmentation fault
提示:请 参 考 ps i g na l (3 )的 ma n 页。
*/ 8 . 25 编写 f ge t s 函 数 的 一 个 版本, 叫做 t f ge t s , 它 5 秒钟后会超时。t f ge t s 函数接收和 f ge t s 相同的输入。如果用户在 5 秒内不键人一个输入行, t f ge t s 返回 NU LL。否则, 它 返 回一 个 指向 输 入
.行的指针。
:: 8 . 26 以图 8-23 中的示例作为开始点,编 写一个 支持作业控制的 s hell 程序。s hell 必须具有以下特性:
- 用 户输 入的命令行由一个 na me 、 零 个 或 者 多 个 参 数 组成,它 们 都 由 一 个 或 者 多 个 空 格分隔开。如果 na me 是 一 个 内 置 命 令 ,那 么 s hell 就 立即处理它,并 等 待 下 一 个 命 令 行 。 否 则 , s hell 就 假设 na me 是 一 个 可执行文件, 在一个初始的子进程(作业)的上下文中加载并运行它。作业的进程组 ID 与子进程的 P ID 相同。
- 每个作业是由一个进程 IDCPID ) 或 者一个作业 ID(J ID) 来标识的,它 是 由 一 个 she ll 分配的任意的小正整数。J ID 在命令行上用前缀 " %" 来表示。比如, " %5" 表示 J ID 5, 而 " s" 表示 PID 5。
- 如果 命令行以 &来结 束 , 那么 shell 就在后台运行这个作业。否则, she ll 就在前台运行这个作业。
- 输入 Ctr l+ C( Ctrl+ Z) , 使得内核发送一个 S IGI NT ( SIGT ST P ) 信号给 s hell , s hell 再转发给前台进程组中的每个进程e
- 内置命令 j ob s 列出所有的后台作业。
- 内置命令 bg j ob 通过发送一个 S IGCO NT 信号重启 j ob, 然后在后台运行它。j ob 参数可以是一个 PID , 也可以是一个 JID。
- 内置命令 f g J动 通过发送一个 SIGCO NT 信号重启 j ob, 然后在前台运行它。
9 注意这是对真实的 shell 工作方式的简化。真实的shell 里, 内核响应Ct rl + C( Ctr!+ Z), 把 SIGINT ( SIGT
STP) 直接发送给终端前台进程组中的 每个进程。shell 用 t c s e t pgr p 函数管理这个 进程组的成员 ,用 t c se t a t t r 函数管 理 终 端 的 属 性 ,这 两个函数都超出了本书讲述的范围。可以参考[ 62] 获 得 详 细信息。
shell 回收它所有的僵死子进程。如果 任何作业 因为收到一个未捕获的信号而终止 , 那么 s hell 就输出一条 消息到终端, 消息中包含该作业的 PID 和对该信号的描述。
图 8- 46 展示了一个 s hell 会话示例。
linux> ./shell
>bogus
bogus: Command not found.
>foo 10
Run your shell program Execve can ’ t find executable
Job 5035 terminated by signal: Interrupt User types Crt l +C
>foo 100 &
[1] 5036 foo 100 &
>foo 200 &
[2] 5037 foo 200 &
>jobs
5036 Running foo 100 &
5037 Running foo 200 &
>fg %1
Job [1] 5036 stopped by signal: Stopped User types Ctrl +Z
>jobs
5036 Stopped foo 100 &
5037 Running foo 200 &
>bg 5035
5035: No such process
>bg 5036
[1] 5036 foo 100 &
>/bin/kill 5036
Job 5036 terminated by si gnal : Terminated
> fg %2 Wait for fg job to finish
>quit
linux> Back to the Uni x shell
图 8- 46 家庭作业 8. 26 的 s hell 会话示例
练习题答案
8. 1 进程 A 和 B 是互相并发的, 就像 B 和 C 一样, 因为它们各自的执行是重叠的, 也就是一个进程在另一个进程结 束前开始 。进程 A 和 C 不是并发的 , 因为它们的执行没有 重叠; A 在 C 开始之前就结束了。
. 2 在图 8- 1 5 的示例程序中 ,父子进程 执行无关的指令集合。然而, 在这个程序中, 父子进程执行 的
指令集合是相关的,这是有可能的,因为父子进程有相同的代码段。这会是一个概念上的啼碍,所 以请确认你理解了本题的答 案。图 8- 47 给出了进 程图。
- 这里的关键点是子进程执行 了两个 pr i nt f 语句。在 f or k 返回之后, 它执行第 6 行的 p r i nt f。然后它从 辽 语句中出来, 执行第 7 行的 pr i n t f 语句。下面是子进程产生 的输出:
父进程只执行 第 7 行的 p r i n t f : p2: x=O
Pl,: .x =2 P2•: • x=l
pr i n 七 f printf exit
x==l I P2: x=O
main f or k pr i n七 f exit
图 8-4 7 练习题 8. 2 的进程图
子进程父进程
8 3 我们知道序列 ache、a bcc 和 bacc 是可能的, 因为它们对应有进程图的拓扑排序(图8-48 ) 。而像
bcac 和 c bca 这样的 序列不对应有任何拓扑排序, 因此它们是不可行的。
. #
ma i n
a
pr i n t f b
p r"i n t f
C
p"r i n 七 f
C
p r i n t f
e x i t
图 8 - 48
练习题 8. 3 的进程图 -
8. 4
只简单地计算进程图(图8 - 4 9 ) 中 pr i n t f 顶点的个数就能确 定输出行数。在这里, 有 6 个这样的顶点, 因此程序会打印 6 行输出。
任何对应有进程图的拓扑排序的 输出序列都是可能的 。例如: He l l o 、 1 、 0 、 Bye 、 2、Bye 是可
能的。
. #
ma i n
He..l.l o p r i n t f
1
pr 一i 。n t f pr i n t f
Bye
pr i n t f
二wa 让 p i d pr i n 七f
B,y.e printf
e x i t
图 8 - 49
练习 题 8. 4 的进程图
8 5
un s i gn ed int snooze(unsigned int secs ) { unsigned int re= sl eep(secs ) ;
code/ecflsnoo ze.c
printf(“Slept for %d of %d s e c s . \ n " , secs-re, secs) ; return re;
8. 6
#incl ude “csapp.h”
i nt ma i n ( i nt ar g c , c har *argv[], char *envp[])
{
codelecfs/nooze.c codelecfm/yecho.c
i nt i ;
pr i nt f (“Comman d - l i n e ar gument s : \ n " ) ; for ( i =O; ar gv [ i ] ! = NULL ; i ++)
printf (” argv[o/.2d] : %s \n” , i , ar g v [i]) ;
printf ("\n");
printf (“Envir onme nt var i a bl e s : \ n " ) ; for ( i =O; envp[i] != NULL ; i ++)
printf (” envp[%2d] : %s \n" , i, envp[i]) ; exit (O) ;
8. 7
co d ele cf/ my ec ho .c
只 要休眠进程收到一个未被忽略的信号, s l e e p 函数就会提前返回。但是 , 因为收到一个 SIGINT 信号的默认行为就是终止进程(图 8- 26 ) , 我们必须设置一个 SIGINT 处理程序来允许 s l e e p 函数返回。处理程序简单地捕获 SIGNA L, 并将控制返回给 s l e e p 函数, 该 函数会立即返回。
code/ecflsnooze.c
#i ncl ude " cs app . h"
3 /• SIGINT handler•/
4 void handler (int sig)
5 {
6 return; /• Catch the signal and return•I
7 }
8
9 unsigned int snooze(unsigned int secs) {
10 unsigned int re= sleep(secs);
11
12 printf(“Slept for %d of %d s e cs . \ n " , secs-re, secs);
13 return re;
14 }
15
16 int main(int argc, char **argv) {
17
if (argc != 2) {
fprintf(stderr, “usage: %s <secs>\n”, argv[O]);
20 exit(O);
21 }
22
if (signal(SIGINT, handler) == SIG_ERR) I• Install SIGINT•I
unix_error(“signal error\n”); /• handler•/
(void)snooze(atoi(argv[l]));
exit(O);
27 }
code/ecf/snooze.c
8. 8 这个 程序打印 字符串 " 213” , 这是卡内 基-梅隆大学 CS: APP 课程的缩写名。父进程开始时 打印
“2”, 然后创 建子进程 , 子进程会陷入一 个无限循环。然 后父进程向 子进程发送 一个信号, 并等待它终止。子进程捕获这个信 号(中断这个无限循环), 对计数器值(从初始值 2) 减一, 打印 “1”’ 然后终止。在父进程回收子进程之后 , 它对计数器值(从初始值 2) 加一, 打印 " 3" , 并且终止。