UNIX操作系统在一些概念上建立了良好的声誉,它们都很简单但功能强大。前面已经介绍了一些,如标准输入/输出、管道、文本过滤实用程序、树形结构文件系统等。UNIX作为第一批小型机操作系统声名远扬,它使每个用户都可以控制多个进程。我们称此功能为用户控制多任务。
如果UNIX是你唯一熟悉的操作系统,你可能对其他主流操作系统在该方面的缺乏感到惊讶。例如,Microsoft的MS-DOS,为了实现对IBM PC的兼容性,根本没有多任务,更不用说用户控制多任务了。IBM自己的针对大型机的VM/CMS系统可以处理多用户,但要求每个用户只能是一个进程。DEC的VAX/VMS有用户控制多任务功能,但却受到限制,很难使用。最新的一代小型机操作系统,如Apple的Macintosh OS系统7,IBM的OS/2版本2以及Microsoft的Windows NT最后在操作系统层级上都包含了用户控制多任务。
但如果读完了本书,你可能就不会认为多任务是一个很了不起的想法了。你可能已习惯于通过在命令行后加符号&在后台运行一个进程。第四章中在介绍如何运行shell脚本时也介绍了子shell的概念。
本章介绍bash中多任务和进程处理的大部分特性。这里说大部分是因为其中一些只面对底层系统程序员,像前面章节介绍过的文件描述符。
首先介绍一些用以标识进程,以及在登录会话期间和脚本内控制进程的重要的原理。然后在更高层次上进行讲解,介绍进程间如何进行通信。我们会在概念上对已介绍过的内容进行深入讲解,如管道和子shell。
不要被UNIX的底层技术细节所困扰。我们只给出对解释高层特性必须的技术信息,再加上用来满足你的好奇心的一些其他说明。如果你对该领域的详细内容感兴趣,请参考UNIX程序员手册或一本适合的关于UNIX内部原理的书籍,你会发现《UNIX Power Tool》一书(O'Reilly & Associates出版)很有价值。
这里建议你试一下本章的例子。其中涉及到多进程的代码行为不像本书大部分其他例子那样容易理解。
**进程ID和作业编号
UNIX在创建进程时给所有的进程编号,称为进程ID。你会注意到当通过附加&符号后台运行一个命令时,shell会响应如下行:
$ alice &
[1] 93
此例中,93为alice进程的进程ID。[1]为shell(不是操作系统)设置的作业编号。其差别在于,作业编号指向当前运行在用户shell下的后台进程,而进程ID指向当前运行在整个系统上的所有用户的所有进程。术语作业基本指的是从shell中调用的一个命令行。
如果在第一个作业运行时,你又启动了其他后台作业,shell会为其编号2,3等。例如:
$ duchess &
[2] 102
$ hatter &
[3] 104
很明显,1,2和3比93,102和104容易记。
一个后台进程完成时,shell会给出作业编号信息,如下:
[1]+ Done alice
以后再解释加号的含义。如果作业以非0状态退出(见第五章),shell会指出其退出状态。
[1]+ Exit 1 alice
shell在后台作业发生反常事件时会打印出其他类型信息;在本章后面会介绍这一点。
**作业控制
为什么要关心进程ID和作业编号呢?实际上,没有进程ID你的UNIX也可能会工作的很好(除非你使用一个视窗工作平台——下面会介绍)。作业编号更重要一些,你可以使用它们在shell命令中进行作业控制。
前面已经介绍过控制一个作业的最明显的方式,就是使用&在后台创建一个作业。一旦作业在后台运行,你可以让其一直运行,或把它放在前台,或向其发送一个称为信号的消息。
**前台和后台
内置命令fg将后台作业放到前台。正常情况下这意味着作业拥有了对终端或窗口的控制,并因此可以接受用户输入。换句话说,作业开始像你所键入的不带&的命令一样。
如果只有一个后台作业在运行,可以使用不带参数的fg,shell会把该作业放到前台。但如果有多个作业在后台运行,shell会挑出最新在后台运行的作业放到前台,如果想要将其他作业放到前台,需要给出前面加上百分号(%)的作业命令名,或使用作业编号前面加百分号(%),也可以是不带百分号的进程ID。如果你不记得哪个作业在运行,可以使用命令jobs列出后台作业。
为清楚起见,下面给出一些例子。这里创建了三个后台作业。如果键入jobs,会看到:
[1] Running alice &
[2]- Running duchess &
[3]+ Running hatter &
jobs有一些很有趣的选项,jobs -l列出进程ID:
[1] 93 Running alice &
[2]- 102 Running duchess &
[3]+ 104 Running hatter &
-p选项通知jobs只列出进程ID:
93
102
104
(这对命令替换很有用,见任务8-1)-n选项只列出上次shell报告后状态发生变化的作业——无论是使用jobs还是其他命令。-r选项将列表限制为正在运行的作业,-s选项将列表限制为被停止的作业。例如,正在等待键盘输入。最后,你可以使用-x选项执行一个命令。任何提供给命令的作业编号都将被替换成作业的进程ID。例如,如果alice运行在后台,那么执行jobs -x echo %1会打印alice的进程ID。
如果键入fg并且不带参数,shell会把hatter放在前台,因为它是最新在后台运行的作业。但如果键入fg %duchess(或fg %2),duchess会进入前台。
还可以通过%+引用被放到后台的最新作业。类似的,%-引用下一个最近被放到后台的作业(这里为duchess)。加减符号的含义为:加号显示状态已改变的最新作业,减号显示下一个最近被调用的作业。
如果多个后台命令的名字相同,那么%command会通过选择最近被调用的作业(正如所料)区分它们。如果它不是你想要的,就需要使用作业编号而不是命令名。然而,如果这些命令具有不同的参数,可以使用%?string而不是%command。%?string引用其命令包含给定字符串的作业。例如,假定启动下面的后台作业:
$ hatter mad &
[1] 189
$ hatter teatime &
[2] 190
$
然后可以使用%?mad和%?teatime引用每个作业,但实际上使用%?ma和%?tea足以区分它们了。
表8-1列出了引用后台作业的所有方式。假定人们很少使用作业控制命令,作业的编号或命令名就足够了,其他方式都是多余的。
表8-1 引用后台作业的方式
引用 后台作业
%N 作业编号N
%string 其命令以string开始的作业
%?string 其命令包含string的作业
%+ 最近被调用的后台作业
%% 同上
%- 第二个最近被调用的后台作业
**挂起一个作业
正像可以使用fg把后台作业放到前台来一样,你也可以把一个前台作业放到后台。这涉及挂起一个作业,这样shell就会重新获得对终端的控制。
要挂起一个作业,在其运行时键入CTRL-Z即可。它与键入CTRL-C类似(或键入用户的中断键),不同的是挂起后还可以恢复该作业。当键入CTRL-Z时,shell会响应如下消息:
[1]+ Stopped command
然后返回shell提示符。要恢复一个挂起作业使其继续在前台执行,键入fg即可。如果由于某原因,你键入了CTRL-Z后把其他作业放到了后台,使用带有一个作业名或编号的fg。
例如:
alice is running...
CTRL-Z
[1]+ Stopped alice
$ hatter &
[2] 145
$ fg %alice
alice resumes in the foreground...
当你拥有一个常规终端(与视窗工作站相反),并且正在使用一个类似vi的文本编辑器编辑一个需要被处理的文件时,在前台挂起和恢复一个作业的能力是很方便的。例如,如果你正在使用troff文本处理器编辑一个文件时,可以执行如下操作:
$ vi myfile
edit the file...
CTRL-Z
Stopped [1] vi
$ troff myfile
troff reports an error
$ fg
vi comes back up in the same place in your file
程序员在调试源代码时经常使用同样的技术。
你可能还会发现在挂起并恢复一个后台作业而不是前台作业也是很有用的。你可能在前台启动一个作业,发现它运行时间比预料的要长。例如,一个grep,sort或数据库查询。你需要完成该命令,但还想控制终端做其他操作,如果键入CTRL-Z后跟bg,就会把该作业放到后台。
还可以使用CTRL-Y挂起一个作业。它与CTRL-Z有点差别,在该情况下只有当进程试图从终端读取输入时才被挂起。
**信号
前面提过键入CTRL-Z挂起一个作业和键入CTRL-C停止一个作业类似,不同的是你后来又可以恢复它。实际上它们在更深层次上是类似的:它们都是向进程发送信号的一种特例。
信号是一个消息,当某些意料外事件发生或它想要其他进程执行某些功能时该进程会向其他进程发送它。大部分时间,一个进程都会向其创建的子进程发送信号。毫无疑问你已经可以接受通过一个I/O管道行在进程之间的互相通信的思想;将信号看成进程间互相通信的另一种方式(实际上,任何操作系统书籍都会告诉你这是进程间互相通信或IPC的一般概念)。
依据UNIX版本,有大约两到三打类型的信号,其中包括程序员指定意图的信号。信号具有编号(从1到系统支持的信号数)和名称;我们后面会使用它们。你可以通过键入kill -l取得系统上所有信号名称和编号的列表。但要记住,在编写涉及信号的shell代码时,信号名比信号编号更容易移植到UNIX其他版本。
**控制键信号
键入CTRL-C时,shell发送INT(中断)信号给当前作业;键入CTRL-Z时shell则发送TSTP(大多数系统上表示“终端停止”)。也可以向当前作业发送一个QUIT信号,方法是键入CTRL-\(控制键-反斜线)。它是CTRL-C的“增强性”版本。当(只有当)CTRL-C不起作用时才可使用CTRL-\。
还有一种“应急”信号KILL(下面介绍),你可以在CTRL-\也不起作用的情况下向进程发送该信号。但它并未捆绑到任何控制键。这意味着你不能使用它来停止当前运行进程。INT、TSTP和QUIT是你唯一可以使用的控制键。
可以使用stty命令的选项定制发送信号的控制键。这一点随系统的不同而变化——具体命令请参考帮助页——但通常的语法是stty signame char。signame是信号名,不过它常与这里使用的名字不一样。第一章中表1-7列出了所有UNIX版本上找到的信号的stty名字。char是控制字符,可通过使用^符号表示“控制”后跟控制字符给出。例如,要将INT键设置为大多数系统上的CTRL-X,可使用:
stty intr ^X
前面给出了加入控制键的方式,这里还要给出的没有提到过的一点是,改变控制键可能会使得其他人在必须停止你的机器上运行的进程时遇到麻烦。
大多数其他信号都被操作系统用来通知进程有错误发生,比如一个错误的机器代码指令,错误的内存地址,被0除或一些其他问题,如计时器(闹钟)到点等。其余的信号被用于一些深奥的错误条件,只有底层系统程序员才有兴趣。新版本的UNIX加入了更多的信号类型。
**kill
可以使用内置shell命令kill向你创建的任何进程发送一个信号——不只是当前运行作业。kill的参数为进程ID、作业编号或要向之发送信号的进程的命令名。默认情况下,kill发送TERM(terminate,结束)信号,其效果与使用CTRL-C发送的INT信号一样。但使用信号名做选项,前加一个斜线可以指定一个不同的信号。
kill由于TERM信号的默认行为而得名,此外还有另一个原因,它与UNIX通常
处理信号的方式有关。完整细节太复杂,但下面的解释就足够了。
大多数信号使得一个接受该信号的进程死亡。因此,如果你发送了这样的信号,则“取消”了接受信号的进程。然而,程序可被设置为“陷人”特定信号并执行其他行为。例如,一个文本编辑器在接受了诸如INT、TERM或QUIT信号时保存中断前被编辑的文件。判断各种信号到来时执行何种功能是UNIX系统编程的一个很有趣的工作。
下面是kill的例子。这里后台有一个alice进程,进程ID为150,作业编号为1,它需要被停止。开始使用如下命令:
$ kill %1
如果成功,会看到如下消息:
[1]+ Terminated alice
如果没有看到该消息,TERM信号中断作业失败,下一步再试试QUIT:
$ kill -QUIT %1
如果工作正常,会看到:
[1]+ Exit 131 alice
131是alice返回的退出状态。但如果QUIT也不正常运行,最后一种方法是使用KILL:
$ kill -KILL %1
这就产生消息如下:
[1]+ Killed alice
一个进程不可能陷入一个KILL信号——操作系统会立即无条件的中断该进程。如果不是,那么或者是你的进程正处于一种“古怪状态”(下一章会介绍),或者是(几乎不可能)你的UNIX版本有故障。
下面给出另一个例子。
任务8-1
编写脚本killalljobs,“取消”所有的后台作业。
该任务解决方案很简单,依赖于jobs -p:
kill "$@" $(jobs -p)
你也许想直接使用KILL信号而不是首先使用TERM(默认)和QUIT,但最好不要这样做。TERM和QUIT的设计使得进程在退出前有机会进行“清理”,而KILL将会停止进程,不管它是否处于计算过程中。KILL只作为最后的补救措施!
可以对任何用户自己创建的进程使用kill命令,而不只是当前shell下的后台作业。例如,如果你使用了一个视窗系统,那么你可能有几个终端窗口,每一个都运行自己的shell。如果一个shell正在运行你要停止的进程,你可以从另一个窗口中kill它——但你不能使用作业编号引用它,因为它运行于一个不同的shell内。你必须使用其进程ID。
**ps
ps可能是用户要知道一个进程ID的唯一的方法。不过,命令ps还能给出许多其他信息。
ps是一个复杂命令,它接受几个选项,其中一些在不同的UNIX版本间互有不同。有点混乱的是,要得到相同的信息,在不同的UNIX版本上可能要使用不同的选项。这里将使用两个主要的UNIX系统类型上的可用选项,它们来源于系统V(如大部分Intel 386/486 PC上的版本、IBM的AIX和Hewlett-Packard的HP/UX)和BSD(DEC的Ultrix、SunOS和BSD/OS)。如果你不能确定所使用的是哪种UNIX版本,首先尝试系统V选项。
可以以最简单不带选项的形式调用ps。这种情况下,它会打印出当前登录shell和其中运行的所有进程的信息行(亦即后台作业)。例如,如果调用了本章前面介绍的三个后台作业,来源于系统V的ps命令会输出如下:
PID TTY TIME COMD
146 pts/10 0:03 -bash
2349 pts/10 0:03 alice
2367 pts/10 0:17 hatter
2389 pts/10 0:09 duchess
2390 pts/10 0:00 ps
来源于BSD的输出如下:
PID TT STAT TIME COMMAND
146 10 S 0:03 /bin/bash
2349 10 R 0:03 alice
2367 10 D 0:17 hatter teatime
2389 10 R 0:09 duchess
2390 10 R 0:00 ps
(你可以忽略了STAT列。)输出有点像jobs命令。PID是进程ID;TTY(或TT)是进程被调用的终端(如果你使用的是视窗系统则为伪终端);TIME是进程使用的处理器时间数(不是真正的时间);COMD(或COMMAND)是命令。注意,BSD版本包括命令的参数;还要注意,第一行给出父shell进程,最后一行为ps进程本身的信息。
不带参数的ps列出从当前终端或伪终端启动的所有进程。但因为ps不是一个shell命令,它不会把进程的ID和shell的作业编号联系起来。它也不会有助于查询在另一shell的窗口内运行的进程的ID。
要取得该信息,使用ps -a(对“所有”),它依据用户的UNIX系统列出不同的进程集合上的信息。
**系统V
来源于系统V的UNIX系统的ps -a列出与任何终端相关的所有非“组长”进程,而不是列出指定终端下启动的进程。这里,“组长”为一个终端或窗口的父shell。因此,如果正使用一个视窗系统,ps -a列出所有窗口下(所有用户)启动的作业,但不包括其父shell。
假定前面的例子中只有一个终端或窗口,则ps -a除第一行以外将打印出与纯ps相同的输出,因为它是父shell。这样做似乎没什么意义。
但可能出现打开多个窗口的情况,假定有三个窗口都运行了类似X Window系统的xterm的终端模拟器。用户在窗口内启动了后台作业alice,duchess和hatter,伪终端号分别为1、2和3。该状态显示在图8-1中。
假定你在最上面的那个窗口内,如果键入ps,可以看到:
PID TTY TIME COMD
146 pts/1 0:03 bash
2349 pts/1 0:03 alice
2390 pts/1 0:00 ps
但如果键入ps -a,可以看到:
PID TTY TIME COMD
146 pts/1 0:03 bash
2349 pts/1 0:03 alice
2367 pts/2 0:17 duchess
2389 pts/3 0:09 hatter
2390 pts/1 0:00 ps
现在你就会明白ps -a是如何跟踪一个失控进程的了。如果它是hatter,可以键入kill 2389。如果无效,尝试kill -QUIT 2389,或最坏情况下尝试kill -KILL 2389。
**BSD
在派生于BSD的系统上,ps -a列出在任何终端上启动的所有作业。换句话说,它有点类似于把系统上每个用户的纯ps的结果结合起来。给定上面假设,ps -a显示出系统V显示的所有进程以及组长(父shell)。
不过,ps -a(在任何UNIX版本上)不会给出某特定条件下的进程:在该条件下,它们“忘记”了其调用shell和所属终端。这样的进程被称为“古怪”或“孤儿”。如果你遇到严重的进程失控问题,就可能是进程进入了该状态。
不要担心为什么和如何处理这种方式。需要知道的一点是当键入ps -a时这些进程不会显示,可用ps的另一选项看到它们:在系统V上为ps -e(“所有”),而在BSD上为ps -ax。
这些选项通知ps列出不是从终端启动的或“忘记”其启动终端的进程。前者包含许多你可能不知道的进程:它们包含了使系统运行,被称为“守护程序”的基本进程。守护程序用于处理诸如邮件、打印、网络文件系统等系统服务。
实际上,ps -e或ps -ax的输出是学习UNIX系统内核的好素材。如果你对其感到好奇,可以在系统上运行这些命令对列表中每一行,对进程名调用man,或在系统的UNIX编程者指南中查找它们。
用户shell和进程被列在ps -e或ps -ax输出的最底部。在这里你可以查找失控进程。注意,列表中许多进程拥有符号?,而不是终端。也可能它们没有被终端所有(如基本守护程序)或是处于失控状态。因此,如果ps -a没有找到你要“取消”的进程,ps -e(或ps -ax)在TTY列(或TT)列出它们时带有?,这种情况是有可能的。你可以通过查看COMD(或COMMAND)列判断是否为所要的进程。
**trap
介绍了信号如何作用于临时用户后,下面讨论shell程序员如何使用它们。对此我们不会讨论太深,因为实际上它已是系统程序员的范畴。
曾经提到过,通常程序可以设置为“捕获”特定信号并以自己的方式处理它们。trap内置命令使你可以在一个shell脚本中完成此功能。trap对大规模“安全性好”的shell程序很重要,因为这样它们才可以对意外事件做出适当反映——就像其他语言编写的程序对无效输入要做出反应一样。对某些系统编程任务来说它也很重要,下一章会介绍这一点。
trap的语法是:
trap cmd sig1 sig2 ...
意思是当sig1、sig2等被接收时,运行cmd;然后恢复执行;cmd完成后,脚本在被打断的命令后恢复执行。
当然,cmd可为脚本或函数。sigs可用名称或数字指定。还可以不带参数调用trap,这种情况下,shell使用信号的象征名打印任何已被设置的陷入列表。
下面是一个显示trap功能的简单例子。假定这里有一个shell脚本称为loop,代码为:
while true; do
sleep 60
done
其功能是暂停60秒(sleep命令),并无限重复。true是一个“空”命令,其退出状态总是为0。键入此脚本并调用它,让其运行一段时间,然后键入CTRL-C(假定这是中断键)。它应该会停止,并得到shell提示符。
下面在脚本开始位置插入如下行:
trap "echo 'You hit control-C!'" INT
再次调用脚本,现在按下CTRL-C,中断sleep命令(而不是true命令)的机会很高。你会看到信息“You hit control-C!”,并且脚本不会停止运行;而是sleep命令退出,脚本会循环回来并启动另一个sleep。点击CTRL-Z使其停止,然后键入kill %1。
接着,通过键入loop &在后台运行该脚本,键入kill %loop(亦即向其发送TERM信号);脚本会中断,将TERM加入trap命令,以便其内容如下:
trap "echo 'You hit control-C!'" INT TERM
重复下面的过程:在后台运行它,并键入kill %loop。如前,看到了该信息和进程仍保持运行,键入kill -KILL %loop可停止它。
注意,当使用kill时,该信息是不合适的,修改此脚本使其打印出对应于kill情况更适合的消息:
trap "echo 'You hit control-C!'" INT
trap "echo 'You tried to kill me!'" TERM
while true; do
sleep 60
done
现在用在前台使用CTRL-C和在后台使用kill两种方法再试,会看到不同的消息。
**陷阱和函数
陷阱和shell函数之间的关系很直接,但有些细微的差别值得讨论。要理解的最重要的是函数被看作调用它们的shell的一部分。这意味着在调用的shell中定义的陷阱会在函数内被识别,更为重要的是,任何定义在函数内的陷阱一旦函数被调用就会被调用的shell所识别。考虑如下代码:
settrap () {
trap "echo 'You hit control-C!'" INT
}
settrap
while true; do
sleep 60
done
如果调用该脚本,并点击用户中断键,则会打印"You hit control-C!"。当函数退出时在settrap内定义的陷阱仍存在。
现在考虑:
loop () {
trap "echo 'How dare you!'" INT
while true; do
sleep 60
done
}
trap "echo 'You hit control-C!'" INT
loop
运行该脚本时,点击中断键,它会打印“How dare you!”。这里陷阱被定义在调用脚本内,但当函数被调用时,重新定义陷阱。第一个定义失去意义,类似情况如下:
loop () {
trap "echo 'How dare you!'" INT
}
trap "echo 'You hit control-C!'" INT
loop
while true; do
sleep 60
done
再次,陷阱在函数内被重新定义,一旦进入loop,则这就是所用的定义。
下面给出更实用的陷阱的例子。
任务8-2
作为电子邮件系统的一部分,编写一个shell代码,使用户编写一个消息。
基本思路是使用cat在一个临时文件中创建消息,然后把文件名传递给一个实际发送信息给目标的程序。创建文件的代码很简单:
msgfile=/tmp/msg$$
cat > $msgfile
因为不带参数的cat从标准输入中进行读取,它等待用户键入信息,并以文本结束字符CTRL-D结束。
**进程ID变量和临时文件
此脚本出现的唯一新内容是文件名表达式内的$$。它是一个特殊shell变量,取值为当前shell的进程ID。
要理解$$的工作方式,键入ps并注意shell进程(bash)的进程ID,然后键入echo "$$"。shell会响应以同样的数字。现在键入bash启动一个子shell,当得到提示符时,重复该过程,你会看到不同的数字,比上一个可能要大一点。
相关的内置shell变量是!(其值为$!),它包含了最近被调用的后台作业的进程ID。要查看其工作方式,在后台调用一个作业,注意shell在紧挨着[1]的位置打印出的进程ID,然后键入echo "$!",你会看到同样的数字。
返回我们的邮件例子:因为系统上所有的进程必须有唯一的进程ID,$$在构建临时文件名方面非常合适。
目录/tmp一般用于临时文件。许多系统还有另一目录/usr/tmp,它们的作用是一样的。
无论如何,在退出一个程序前应该清除这样的文件以避免占用不必要的磁盘空间。在代码中实现此功能很容易,方法是在实际发送消息的代码后加入一行rm $msgfile。但如果程序在执行期间收到一个信号该怎么办?例如,如果用户改变发送消息的主意,点击CTRL-C停止该进程。我们需要在退出前进行清除。我们仿效实际UNIX的邮件系统,它把被正在编写的消息保存在当前目录下一个名为dead.letter的文件中。可以使用trap并带有包含一个exit命令的命令字符串实现此功能:
trap 'mv $msgfile dead.letter; exit' INT TERM
msgfile=/tmp/msg$$
cat > $msgfile
# 发送$msgfile的内容给指定的邮件地址...
rm $msgfile
当脚本收到一个INT或TERM信号时,它删除临时文件,然后退出。注意,直到需要运行命令字符串时才对其求值,这样$msgfile才能包含正确的取值。这也是把字符串括在单引号内的原因。
但在创建msgfile前脚本如果收到一个信号该怎么办(虽然不一定会发生这种情况)?mv会试图将不存在的文件重命名。要解决该问题,需要在试图删除前测试文件$msgfile的存在性。此代码很难被放到一个单一命令字符串内,所以这里使用一个函数:
function cleanup {
if [ -e $msgfile ]; then
mv $msgfile dead.letter
fi
exit
}
trap cleanup INT TERM
msgfile=/tmp/msg$$
cat > $msgfile
# 发送$msgfile的内容给指定的邮件地址...
rm $msgfile
**忽略信号
有时你不想对到来的信号做任何事情。如果给trap一个null字符串(""或'')作为命令参数,那么shell会有效的忽略该信号。要忽略的信号的典型例子是HUP(挂断)。在某些UNIX系统上当挂断(使用调制解调器时断连称为“挂断”)或其他网络故障发生时常出现信号HUP。
HUP一般的默认行为是:它“取值”收到信号的进程。但有时当一后台作业收到挂断信号后,你却不想令其立即中断。
要实现该功能,可以编写一个简单的函数,如下:
function ignorehup {
trap "" HUP
eval "$@"
}
可以编写这样的一个函数而不是脚本,原因在本章结尾讲解子shell的细节时会更清楚。
实际上,有一个称为nohup的UNIX命令可以精确的实现该功能。最后一章的start脚本包含了nohup:
eval nohup "$@" > logfile 2>&1 &
该代码阻止HUP中断命令,并把标准输出和错误输出保存到一个文件。实际上使用下列代码即可:
nohup "$@" > logfile 2>&1 &
如果理解了这里使用nohup时为什么eval是多余的,那么你就基本掌握了前一章的内容。注意,如果你不指定命令输出的重定向,nohup会把它放到文件nohup.out中。
**disown
忽略HUP信号的另一种方式是使用disown内置命令。disown接收一个作业指定的参数,如进程ID或作业编号,并从作业列表中删除该进程。该进程从此时起被shell置于“无主”状态,即你只能通过其进程ID引用它,因为它已不在作业列表中。
disown -h选项执行与nohup同样的功能。它指定shell在一定条件下阻止挂起信号到达某进程。与nohup不同的是,它由你指定进程输出的位置。
**重置陷阱信号
另一种trap命令的“特例”发生在将短划线(-)指定为命令参数时。它会将收到信号时的行为重置为默认行为,通常是进程的终止。
这里仍以任务8-2的邮件程序为例。用户发送完信息后,临时文件被删除,因为此时不再需要进行清除,我们可以将陷阱信号重置为其默认状态。该代码与函数定义分别编写:
trap abortmsg INT
trap cleanup TERM
msgfile=/tmp/msg$$
cat > $msgfile
# 发送$msgfile的内容给指定的邮件地址...
rm $msgfile
trap - INT TERM
代码的最后一行重置INT和TERM信号的处理过程。
这里你可能会认为一个人必须在一个shell脚本中进行信号处理。产品级的程序确实有一定量的代码处理信号。但这些程序几乎都很大,使得信号处理代码只是其中很微小的部分。例如,你可以认为真正的UNIX mail系统具有很好的安全性,可以防范各种危险信号。
然而,你可能从没编写过如此复杂并且需要周密考虑以能很好的进行诸多信号处理的程序。你可能编写一个与mail代码量一样大的程序原型,但该原型在定义上就不需要那么复杂的安全性要求。
因此,你不必担心把信号处理代码放到你编写的20行的shell脚本中。我们的建议是判断一下信号是否会使得你的程序产生严重的错误并且需要加入代码处理这些错误。什么是严重的错误?结合上面例子,可以认为HUP使得作业终止就是一个严重的错误,而我们的邮件程序中的临时文件就不是严重错误。
**协同程序
最后花些时间介绍一下进程行为的细节。这里不再继续深入讲解,而是介绍一些关于进程的高层概念。
本章前面,我们介绍了在一个交互式登录会话中控制多个并发作业的方式。现在考虑在shell程序内多进程的控制问题。当两个(或多个)进程显式编程来同时运行并且可能互相通信时,我们称之为协同程序。
它实际上不是新概念:管道行就是一个协同程序的例子。shell的管道行结构封装了相当高级的规则集,给出了进程间交互的方式。如果仔细看一下这些规则,就可以理解处理协同程序的其他方式——其中部分都比管道行简单。
当调用一个简单的管道行时,例如ls | more,shell调用一系列UNIX原语操作,即系统调用。结果是shell通知UNIX执行以下操作;这里是你会感兴趣的内容,我们把它每一步使用的系统调用放到圆括号内:
1.创建两个子进程,称为P1和P2(fork系统调用)。
2.建立两个进程之间的I/O,这样P1的标准输出就会进入P2的标准输入(pipe)。
3.在进程P1中启动/bin/ls(exec)。
4.在进程P2中启动/bin/more(exec)。
5.等待两个进程完成(wait)。
当管道行涉及多于两个进程时,上述步骤如何变化呢?
下面对事情进行简化处理。我们将看到如果进程不需要通信,多个进程是如何同时运行的。例如,要把alice和hatter运行为一个shell脚本里的协同程序,彼此不需要通信。最初的方案为:
alice &
hatter
假定此时hatter是脚本里的最后一个命令,上述代码工作正常——但只有当alice首先完成时。如果当脚本完成时,alice仍运行,那么它就完成了孤儿,亦即进入了本章前面提到的“古怪状态”。不要去管孤儿状态的细节,只需要知道它是我们不想发生的情况即可。如果发生了,就需要使用对付“失控进程”的方法停止它,这在本章前面讨论过。
**wait
有一种方式可以确保alice完成前脚本不会完成:内置命令wait。不带参数时,wait只是等待,直至所有后台作业完成,因此要确保上述代码工作正常,加入wait如下:
alice &
hatter
wait
这里,如果hatter先完成,父shell在结束自己前会等待alice完成。
如果脚本有多个后台进程,需要等待指定的一些进程完成,可以向wait给出作业的进程ID。
然而,你可能发现不带参数的wait就足以满足你编写的所有协同程序了。需要等待某些特定后台作业的情况很复杂,超出了本书范畴。
**协同程序的优点和缺点
实际上,你可能会问为什么需要编写彼此间无需通信的协同程序。例如,为什么不是在alice之后像平常那样运行hatter呢?同时运行两个进程的优点是什么?
即使运行在只有一个处理器(CPU)的计算机上,这样做也会有性能优势。
粗略的说,可以以三种方式总结一个进程使用系统资源的特性:是否为CPU密集型的(即进行频繁的CPU计算)I/O密集型的(进行频繁的写磁盘操作)或交互的(需要用户交互)。
第一章说明了在后台运行一个交互作业没有意义。但与此不同的是,对两个或多个不同种类的进程,进程越多同时运行它们就越有好处。例如,在和一个长的,I/O操作频繁的数据库查询同时运行时,一个数值统计计算的作业会很有效率。
另一方面,如果两个进程以类似方式使用资源,则同时运行它们比按次序运行它们效率差一些。原因是在该条件下,操作系统经常需要按时间片抢夺资源。
例如,如果两个进程都进行频繁的磁盘操作,操作系统会进入一种模式,在两个竞争的进程之间会不断的来回切换对磁盘的控制权。系统进行切换操作的时间至少会和它处理进程本身操作的时间一样长。该现象称为系统颠簸(thrashing),最严重时,会使系统处于一种虚拟停顿状态。系统颠簸是一个常见问题,系统管理员和操作系统设计者会花费很多时间试图使其最小化。
**并行化
但如果你的计算机有多个CPU(如一个Pyramid,Sequent或Sun MP)就不必太在意系统颠簸。而且,协同程序在这类机器上速度显著提高。它们常被称为并行计算机。类似的,把一个进程分成协同程序有时称为并行化作业。
正常情况下,当你在一个多CPU计算机上启动一个后台作业时,计算机会将其赋予下一个可用的处理器。这意味着两个作业实际上(而不是想像的)运行在同一时刻。
这种情况下,协同程序的运行时间基本上等于运行时间较长的那个作业的运行时间加上一些系统开销,而不是所有进程的运行时间的和(但如果CPU共享一个公用的磁盘驱动器,则可能存在与I/O相关的系统颠簸)。最好的情况(所有作业具有相同的运行时间,且没有I/O冲突)下,会得到与CPU数目相同的加速度。
并行化一个程序不是很容易;可能存在一些与此相关的小问题并且会很容易出错。然而,无论你是否有一个并行机,都应该知道如何并行化一个shell脚本,特别是现在这样的机器越来越常见。
下面解释了并行化的实现方式,并给出相关问题的基本思路。以一个使用并行化解决方案的简单任务讲解。
任务8-3
编写一个实用程序,允许你对一个文件同时进行多个复制。
这里称其为mcp。命令mcp filename dest1 dest2 ...将filename复制到所有给定目标。代码很简单:
file=$1
shift
for dest in "$@"; do
cp $file $dest
done
现在假定有一台并行计算机,要使该命令尽可能快的运行。要并行化此脚本,较简单的方式是把cp命令放到后台,并在结尾加上wait:
file=$1
shift
for dest in "$@"; do
cp $file $dest &
done
wait
这很简单,但还有些小问题:如果用户指定完全相同的目标该怎么办?如果幸运,文件会被复制到同一位置两次。否则,相同的cp命令会彼此干扰,结果导致产生一个文件包含两个相互交叉的原文件复本。比较起来,如果你给正规cp命令的两个参数指定了同一文件,则会打印错误信息,并且不执行任何操作。
要解决该问题,必须编写代码检查复制的参数列表。虽然这不是太难(见本章结尾处的练习),但该代码的运行时间抵消了并行化得到的加速时间。另外,执行检查的代码也使得原来结构清晰的脚本变得复杂。
正如所见,即使是一个看似很微不足道的任务也会在并行访问给定系统资源的多进程中发生问题(比如这里的文件复制)。这样的问题称为并发控制问题,会随着应用复杂度的增加变得越来越难。与程序应执行的实际代码相比,复杂的并发程序常常多出许多对于特殊事件的处理代码。
因此,对并行化的研究理所当然的变得越来越多了。设计一个工具的最终目标是实现代码的自动并行(这样的工具已存在;它们通常在问题的一个较小子集上做文章)。即使你没有一个多CPU的机器,并行化一个脚本也是一个便于你熟悉与协同程序相关的问题的很好练习。
**子shell
本章最后给出一种简单的进程间关系:子shell和父shell之间的关系。第三章中曾介绍过,当运行一个shell脚本时,实际上是调用shell的另一副本,它是主或父shell进程的一个子shell。下面将详细介绍有关子shell的内容。
**子shell继承
关于子shell最关键的一点是它们从其父shell获得或继承了哪些特性。这些特性如下:
·当前目录。
·环境变量。
·标准输入、标准输出和标准错误,以及其他任何打开的文件描述符。
·被忽略的信号。
子shell未从其父shell继承的内容也很重要,包括:
·shell变量,除了环境变量和定义在环境文件(通常为.bashrc)中的变量。
·没有被忽略的信号处理。
上述内容在第三章给出,但它们很容易被混淆,因此需要再次说明一下。
**嵌套子shell
子shell不需要放在单独的脚本中;你也可以在与父shell相同的脚本中启动子shell。其方式类似于上一章介绍的命令块。把某些shell代码放到圆括号内(不是大括号),则该代码将运行于子shell。我们称之为嵌套子shell。
例如,下面是上一章的计算器程序,使用一个子shell而不是命令块:
( while read line; do
echo "$(alg2rpn $line)"
done
) | dc
圆括号内代码会运行为一个单独的进程。这通常不如一个命令块效率高。子shell和命令块在功能上的差别很少;它们之间主要区别是作用域;亦即在该范围内一些定义是已知的,如shell变量和信号陷阱。首先,嵌套子shell内的代码服从上述子shell继承规则,除此之外还知道外部shell中定义的变量。比较起来,块可以看作继承了外部shell的一切内容的代码单元。第二,一个命令块中定义的变量和信号陷阱对块后的shell代码是已知的,而在子shell中则不是。
例如,考虑下面的代码:
{
hatter=mad
trap "echo 'You hit CTRL-C!'" INT
}
while true; do
echo "\$hatter is $hatter"
sleep 60
done
如果运行该代码,每隔60秒就可看到消息$hatter is mad。如果键入CTRL-C,会看到消息You hit CTRL-C!。需要键入CTRL-Z结束代码(不要忘了使用kill %+取消该进程),下面将其改为嵌套子shell:
(
hatter=mad
trap "echo 'You hit CTRL-C!'" INT
)
while true; do
echo "\$hatter is $hatter"
sleep 60
done
如果运行它,会看到消息$hatter is,外部shell不知道hatter的子shell定义。因此认为其为null。而且,外部shell不知道INT信号的子shell中的陷阱,因此如果键入CTRL-C,脚本会终止。
如果一种语言支持代码嵌套,那么嵌套单元内的定义应只限定在嵌套单元内。换句话说,与命令块相比,嵌套子shell使你可以更好的控制变量和信号陷阱的范围。因此,我们的建议是,如果代码包含变量定义或信号陷阱——除非效率是关键考虑的因素——则应该使用子shell代替命令块。
**进程替换
bash独有但很少用到的特性是进程替换。假设一个程序有两个版本,它产生大量的输出。要查看两个版本的输出之间的差别,可以运行两个程序,先将其输出重定向到文件中,然后使用cmp工具查看它们的不同。
另一种方式就是使用进程替换。该替换有两种形式。一种是对进程的输入:>(list);一种是对进程的输出:<(list)。list是通过命名管道将其输入或输出连接到某处的一个进程。一个命名管道是一个临时文件,其功能类似于一个有名字的管道。
这里,我们通过命名管道把两个程序的输出连接到cmp的输入中:
cmp <(prog1) <(prog2)
prog1和prog2同时运行,并且其输出连接到命名管道。cmp从每个管道中进行读取,并比较信息,打印它们之间的差别。
本章介绍了许多内容。下面给出一些练习以帮助你尽快掌握它们。如果你对最后一个很头疼,不要担心,它本来就特别难。
1.编写一个shell脚本pinfo,结合jobs和ps命令打印带有作业编号、相应进程ID、运行时间和完全命令的作业列表。
2.对一个正规的shell脚本,使用信号陷阱增加其安全性。
3.对一个正规的shell脚本,尽量使其并行化。
4.编写代码检查mcp脚本中重复的参数。记住,不同的路径名可以指向同一文件(提示:如果$i是"1",那么eval 'echo \${$i}'会打印出第一个命令行参数,确信你已理解其原因)。