第36章:新奇事物
在这个我们旅程的最后章节中,我们来看一些杂七杂八的什物。虽然我们已经在前些章节中学习了相当多的内容,但还是有许多 bash
的功能还没接触过。其中大部分是相当的晦涩,主要用于将 bash
集成到 Linux 发行版中的功能。然而,有一些并不常用的,却对某些程序问题有所助益。我们就在这里学一学。
组命令和 subshell
bash
允许将命令组合在一起。有两种方式可以做到,一是组命令(group command),二是 subshell。
下面有一个组命令的句法示例:
{ command1; command2; [command3; ...] }
下面是一个 subshell 的句法:
(command1; command2; [command3;...])
这两种形式的区别在于,组命令用花括号将命令括起来,而 subshell 用的是圆括号。重要的是要记住,由于 bash
实施组命令的方式,花括号和命令之间必须有一个空格作分隔,并且在后花括号之前的最后一个命令之后,必须加分号或换行符。
那么,组命令和 subshell 有什么好处?这两者之间有一个重要的区别(我们随后就会讲到),不过它们都是用来管理重定向的。
上面三个命令相当简洁,将它们的输出重定向到一个名为 output.txt
的文件。使用一个组命令,我们可以如下编写:
使用 subshell 也是类似的。
使用这项技术可以节省我们输入的时间,但是组命令和 subshell 真正闪光的地方是和管道命令一起使用。当构建一个命令管道的时候,通常需要组合几个命令的结果为一个单一的文本流。组命令和 subshell 能轻松完成这项工作。
这里我们组合了三条命令的输出并将其管道输入给 lpr
以制作一份可打印的报表。
在下面的脚本中,我们将使用组命令并学习几种可以用关联数组连接的编程技巧。这个脚本名为 array-2
,当给出一个目录名,它会将目录中的文件名和文件属主与属组列表打印出来。列表末尾,脚本会打印属于每个属主和属组的文件数目的小计。我们来看下当给出 /usr/bin
目录时,该脚本运行的结果(为简洁而有缩减):
下面是脚本清单(带行号):
来学习一下这个脚本的技巧:
第 5 行:关联数组必须用带 -A
选项的 declare
命令创建。这个脚本中,我们创建了五个数组:
files
包含目录中的文件名,索引为文件名。file_group
包含每个文件的属组,索引为文件名。file_owner
包含每个文件的属主,索引为文件名。groups
包含属于已索引属组的文件数目。owners
包含属于已索引属主的文件数目。
第 7-10 行:这几行检查被传递到位置参数的是否是一个合法的目录名。如果不是,会显示一条用法消息,且脚本会结束运行,退出状态为 1
。
第 12-20 行:这几行在目录中的文件中循环。使用 stat
命令,第 13 和 14 行提取了文件属主和属组的名称,并将其分配到各自的数组中(第 16 和 17 行),以文件名作为数组索引。同理,文件名自身被分配到 files
数组中(第 15 行)。
第 18-19 行:属于文件属主和属组的总计数字自增一。
第 22-27 行:输出文件列表。使用了可以扩展为整个数组元素的 "${array[@]}"
参数扩展,每个元素被当作一个分隔的单词。这样就允许文件名中可以包含空白字符。还要注意这整个循环被花括号包围,因此而形成了一个组命令。这就允许整个循环的输出被管道输入到 sort
命令中。这是必须的,因为数组元素的扩展是没有被排序过的。
第 29-40 行:这两个循环类似于文件列表循环,只是它们使用了 "${!array[@]}"
扩展,而扩展为数组索引的列表,不是数组元素的列表了。
进程替换
虽然组命令和 subshell 看起来很相像,也都能用来为重定向组合文本流,但是两者之间还是有一个重要的差别。有别于一个组命令会在当前 shell 执行其全部命令,一个 subshell(一如其名称所示)则会在当前 shell 的一个子副本中执行其全部命令。这意味着当前环境被复制并给到了一个新的 shell 实例中。当退出 subshell 时,副本环境也就消失了,所以任何对 subshell 环境所作的更改(包括变量赋值)也就都消失了。所以,在大多数案例中,除非一个脚本需要 subshell,否则组命令比 subshell 更可取。组命令更快,也更节省内存。
在第 28 章「读取键盘输入」中,我们看到过一个 subshell 环境问题的例子,我们发现在管道中的 read
命令并没有如我们直觉所期待的那样运行。回顾一下,如果我们这样构建一个管道命令:
REPLY
变量的内容总是空的,因为 read
命令是在 subshell 中被执行的,当 subshell 终结时,REPLY
的副本也就被摧毁了。
因为管道中的命令总是在 subshell 中被执行,所有赋值给变量的命令都会遇到这个问题。幸运的是,shell 提供了一种奇特的名为进程替换(process substitution)的扩展形式,可以用来处理这个问题。
进程替换有两种表达方式。
对于产生标准输出的进程,是这样的:
<(list)
或者,对于读取标准输入的进程,是这样的:
>(list)
其中,list
是一个命令列表。
要解决 read
带来的问题,我们可以这样使用进程替换:
进程替换允许我们将 subshell 的输出看成一个普通文件,以作为重定向的目的。事实上,由于这是一种扩展形式,我们可以检测其真实的值。
通过使用 echo
观察扩展的结果,我们看到 subshell 的输出是通过一个名为 /dev/fd/63
的文件来提供的。
进程替换常用于包含 read
的循环。这里有一个关于 read
循环的示例,处理一个由 subshell 创建的目录内容:
这个循环对目录内容的每一行执行 read
命令。列表自身则是由脚本的最后一行所生成的。这末尾一行将进程替换的输出重定向为循环的标准输入。其中被包含在进程替换管道的 tail
命令消除了列表的第一行,因为用不着。
当执行后,脚本产生的输出如下:
陷阱
在第 10 章「进程」中,我们看到了程序如何响应信号。我们可以把这项功能添加到脚本中。尽管目前为止我们所写的脚本还不需要这个功能(因为这些脚本的执行时间都很短,且不会产生临时文件),不过更大而且更复杂的脚本却能受益于信号处理例程。
当我们设计一个大型复杂脚本时,有一点很重要,考虑一下,如果在脚本运行时,用户退出了登录或者关闭了计算机,会发生什么?当这样的一个事件发生时,一个信号会被发送到所有受影响的进程。反过来,程序会代表那些进程执行一些行为,以确保程序正常有序的终止。假设,例如,我们写了一个在执行过程中创建了临时文件的脚本。在良好的设计过程中,我们将使脚本在完成其工作后删除临时文件。如果收到一个表明程序将要提前终止的信号,让脚本删除文件也是很明智的。
bash
提供了一个以此为目的的机制,称为陷阱(trap)。陷阱由一个恰如其名的内建命令 trap
负责实施。trap
使用如下句法:
trap argument signal [signal...]
其中 argument
是一个字符串,被当作一个命令来读取和对待,signal
是一个会触发解释命令执行的信号规范。
这里有个简单的示例:
这个脚本定义了一个陷阱,当脚本运行过程中,只要接收到 SIGINT
或 SIGTERM
信号,每次都会执行一个 echo
命令。当用户按下 Ctrl-c
试图终止脚本时,程序执行起来看起来是这样的:
可以看到,每当用户试图中止程序时,消息就会被打印出来。
构造字符串以形成有用的命令序列可能很麻烦,因此通常的做法是将 shell 函数指定为命令。在本例中,为每个被处理的信号指定一个独立的 shell 函数:
这个脚本具有两个 trap
命令,每个信号各一个。每个陷阱依次指定接收到特定信号时要执行的 shell 函数。注意每个信号处理函数中包含了一个 exit
命令。没有这个 exit
,脚本会在完成函数之后继续执行。
当用户在这个脚本执行过程中按下 Ctrl-c
,结果看起来是这样的:
临时文件
脚本中包含信号处理的一个原因是,要移除脚本可能在执行过程中为了保存中间结果而创建的临时文件。命名临时文件是一种艺术。传统地,类 Unix 系统在
/tmp
目录中中创建其临时文件,这是一个为此类文件设计的共享目录。然而,由于是共享的,这带来了某些安全方面的问题,特别是那些由超级用户权限运行的程序。除了为暴露给系统所有用户的文件设置适当权限的明显步骤外,为临时文件提供不可预测的文件名也很重要。这样可以避免临时文件隐患(temp race attack)的攻击。 创建一个不可预测的(但仍具有描述性)名称有一个办法如下:
tempfile=/tmp/$(basename $0).$$.$RANDOM
这样将创建一个由程序名、进程 ID(PID)、和随机整数构成的文件名。注意,shell 变量
$RANDOM
仅返回一个在 1-32767 范围内的数值,这个范围在计算机世界中不算很大,所以,变量的单个实例不足以克服一个确定的攻击者。一个更好的办法是使用
mktemp
程序(不要和mktemp
标准库函数混淆)来创建并命名临时文件。mktemp
程序接收一个模板作为参数用来创建文件名。模板应该包含一系列会被相应数目的随机字母数字的 "X" 字符。"X" 字符的序列越长,随机字符的序列也就越长。例如:
tempfile=$(mktemp /tmp/foobar.$$.XXXXXXXXXX)
这就创建了一个临时文件并将其文件名赋值给变量
tempfile
。模板中的 "X" 字符会被替换为随机字母和数字,所以最终的文件名(在本例中,依旧包含了由指定参数$$
扩展获取的 PID)可能看起来是这样的:
/tmp/foobar.6593.UOZuvM6654
对于由常规用户执行的脚本,避免使用
/tmp
目录,为临时文件在用户家目录中创建一个目录,是更聪明的办法,代码如下:
[[ -d $HOME/tmp ]] || mkdir $HOME/tmp
异步执行
有时候,会需要在同一时刻执行多个任务。我们已经看到所有现代的操作系统即使不支持多用户,也起码支持多任务。可以将脚本构造为以多任务方式运行。
通常,这涉及到运行一个脚本,依次的,运行一个或多个子脚本,以便在父脚本持续运行的同时执行一项额外的任务。然而,当一系列脚本以这种方式运行时,如何保持父项和子项的协调是个问题。换言之,如果父项或子项依赖于另一个脚本,而一个脚本必须先等待另一个脚本完成其任务,然后再完成自己的脚本,该怎么办?
bash
有一个内建命令来帮助管理类似这样的异步执行(asynchronous execution)。wait
命令导致一个父脚本暂停,直到一个指定进程(如一个子脚本)完成为止。
wait
我们先来演示一下 wait
命令。要完成演示,需要两个脚本。首先是父脚本。
其次是子脚本。
在这个例子中,我们看到子脚本非常简单。真正的操作都是在父脚本中执行。在父脚本中,运行了子脚本并将其置于后台。我们用 $!
这个 shell 参数的值记录下子脚本的进程 ID 并赋值给 pid
变量,$!
总是包含了置于后台的最后一项任务的进程 ID。
父脚本持续运行,然后执行一个带有子进程 PID 的 wait
命令。这导致父脚本暂停,直到子脚本退出为止,而后父脚本终止。
当执行时,父脚本和子脚本生成了如下输出:
命名管道
在多数类 Unix 系统中,都可以创建一类特殊的文件,叫做命名管道(named pipe)命名管道用来创建一个在两个进程之间的连接,可以如其它类型的文件般来使用。它们并不很流行,不过了解一下是有好处的。
有一种称为客户端服务器(client-server)的通用编程体系结构,该体系结构可以利用诸如命名管道之类的通信方法,以及诸如网络连接之类的其他进程间通信(interprocess communication)。
最广泛应用的客户端服务器系统,当然是网络浏览器和与之通信的网络服务器。浏览器的行为像是客户端,发出对服务器的请求,而服务器用网页响应浏览器。
命名管道的行为类似文件,单实际上形成先进先出(FIFO first-in-first-out)的缓冲。作为普通的(未命名的)管道,数据从一端进入,从另一端出去。而命名管道,就可能建立如下的事物:
process1 > named_pipe
和这个:
process2 < named_pipe
而其行为类似:
process1 | process2
建立一个命名管道
首先,必须创建一个命名管道。这需要用到 mkfifo
命令。
这里,我们用 mkfifo
创建了一个名为 pipe1
的命名管道。我们用 ls
检测了这个文件,并看到其属性的第一个字母是 "p
",指示其为一命名管道。
使用命名管道
要演示命名管道是如何工作的,我们需要两个终端窗口(或者,两个虚拟终端)。在第一个终端,我们键入一个简单命令,并将其输出重定向到命名管道。
在按下 Enter
键之后,这个命令看起来被挂起了。这是因为还没有从管道的另一端接收到数据。发生此类情形,就是说这个管道破裂了(blocked)。一旦我们将过程附加到另一端并开始从管道读取输入,这个条件将会清除。使用第二个终端窗口,我们键入下面这个命令:
从第一个终端窗口生成的目录清单,作为 cat
命令的输出,出现在了第二个终端中。一旦管道不再破裂,第一个窗口中的 ls
命令就成功完成了。
总结
好了,我们已经完成了整个教程。现在所剩下的唯一的事情就是练习、练习、练习。尽管我们游历多方,但也仅仅是习得了命令行的一点儿皮毛罢了。还有数以千计的命令行程序有待发现和学习。去挖一挖 /usr/bin
,你就会看到!
扩展阅读
bash
手册页的 "Compound Commands" 章节包含了关于组命令和 subshell 表示法的完整描述。bash
手册页的 "EXPANSION" 章节中有一小节讲述进程替换。Linux Journal 有两篇关于命名管道的文章。
Last updated
Was this helpful?