shell-骨干流程4——命令执行与job控制

总体流程图镇楼:

shell执行流程

命令执行与job控制

经过前面12个步骤的处理,shell命令中引号引用、保留字、别名、展开、替换、重定向等内容都已经设置完成,到了真正来执行命令的阶段。前面所有的步骤都是为了能够顺利执行命令而存在的,可以说命令执行是shell流程的核心。命令执行的流程只有两步(如图中蓝色部分所示),包括寻找可执行命令的第13步和执行该命令的第14步。

第13步中,我们要明确哪些地方能够查找到所需要的可执行命令,以及查找这些地方的顺序。在非复合命令中,首个单词(word)通常指的是需要执行的命令,后面的部分都是该命令的参数。如果找到了可执行命令,那么最终在第14步执行命令(首个单词)+参数(后面所有的单词),并返回结果,命令执行的过程中,还会涉及到执行环境的问题,不同的执行环境会对执行的过程结果有不同影响。如果存在未执行命令则读取下一条命令从第一步再开始处理,若遇到文件结束符(EOF)则完成shell流程。

到这里,shell的骨干流程算是结束了。

然而,对于一个多任务操作系统,bash shell可能启动了多个任务同时运行,因此还需要进行作业的管理和监控。除了Linux内核自动运行的任务/作业管理机制,bash shell也提供了手动调整任务/作业执行流程、方式的机制,主要包括两个机制:作业控制机制和信号机制。作业控制(job control)是针对即将执行和正在执行命令的一套控制机制,也是shell流程中不可缺少的一部分。此外,还可以通过信号的捕获与处理,来与正在运行的任务/作业进行通信,从而实现特定的控制功能。这一部分内容严格来说并不是shell命令处理的骨干流程,但是能够让我们更深刻地理解bash shell的运行方式。

命令查找

根据shell分词的结果,shell会产生**一个简单的命令(首个单词)和一个可选的参数列表。首个单词将会被认为是shell需要执行的命令名称。**这里需要在强调下,shell中的单词和一般语言中的单词是不一样的:单词,word,可以被shell认为是一个单元的一串字符,单词不能包括不带引号的元字符。也就是说,一个字符串只要不能被元字符分割,那就是shell中所谓的单词。

根据《shell骨干流程1——形成初始命令》一文中的说明,shell的分割元字符包括()<>;&| \t\n这几个,因此我们来举几个shell中的单词例子:

1./exec
2/bin/more
3../../test.sh
4"hello world" # 必须要在引号中
5cat

上面的例子都可以称为一个单词来作为简单命令。

那么bash如何来查找这个命令呢?

首先如果这个命令以.或者/开头,则说明用户指定了命令的路径,bash必须根据用户指定的路径去查找是否存在该命令。其中,如果以.开头,则以当前文件夹为基准使用相对路径;如果以/开头,则使用绝对路径。

如果命令中不以.或者/开头,则依以下顺序来查找命令:

  1. 查看命令名称是否为shell函数,如果这个名称是shell函数,那么命令将按照Shell函数中的描述被调用。(可以用set命令查看当前所有定义的函数)
  2. 查看命令是否为内建命令(builtins),如果找到匹配,则调用该内建命令。
  3. 查看$PATH路径,按顺序从左到右依次查找$PATH路径中的每一项,查看该元素下所有可执行文件,直到匹配到命令。
  4. 如果$PATH中没有找到匹配的路径,那么shell会抛出command_not_found错误。

在shell实现时,bash使用哈希表来记住可执行文件的完整路径名,以避免频繁的$PATH搜索。只有在哈希表中找不到该命令时,才会执行$PATH目录中的完整搜索,这通常发生在修改了$PATH变量之后。

命令执行

如果我们在第13步中找到了可行性文件,那么将来到最后一步,执行该命令。这一步是shell运行的最终目的,但也是bash管的最少的一步,因为接下来如何执行该命令就完全交由可执行文件自己决定。

bash要做的只是将执行的命令为位置参数0,并把后面的所有单词作为位置参数传递给可执行文件,之后就等待可执行文件运行结束并收集其退出状态。当然如果该命令是异步执行的,shell就不必等待其结束。

最核心的一步也是shell最简单的一步,颇有一种功成身退,翩然而去的风味。

如果我们在深入的了解一下执行流程,会发现shell在执行命令之前还做了一些环境设置的工作,这些环境设置工作虽然不显山不露水,但是若是不了解,就会产生不少奇怪的问题。

此外,当执行一个简单的命令,而不是一个内建函数或shell函数时,它将在一个独立的执行环境中调用,该环境由以下几部分组成。 除非另有说明,否则这些值是从原shell继承而来的。

我们先来看看shell有哪些命令执行环境:

  1. 打开的文件信息。Linux中一切皆文件,因此这个打开的“文件”是一个广义概念,目前正在使用的设备、socket等都是文件的范畴,最常见的文件信息就是标准输入输出文件,它们记录了文件输入输出的位置。
  2. 当前的工作目录。这个环境可通过cd, pushd, popd修改也可继承自启动该bash的程序。
  3. umask信息和文件的读写执行权限有关。
  4. trap(后面会提,常用于信号的处理)
  5. 通过set设置或从父shell继承的shell参数
  6. 在执行期间定义的shell函数或从环境中的shell父项继承的函数
  7. 在调用时启用的选项(默认或通过命令行参数或set设置)
  8. 由shopt启用的选项
  9. 使用alias定义的shell别名
  10. 各种进程ID,包括包括后台作业信息,$$和$PPID的值

当执行一个$PATH中的命令,而不是一个内建函数或shell函数时,它将在一个独立的执行环境中调用,该环境由以下几部分组成。

  1. 打开的文件信息。
  2. 当前的工作目录。
  3. umask信息
  4. trap
  5. 在环境中传递的标记为export的shell变量和函数以及为命令导出的变量

在这个单独的环境中调用的命令不会影响shell的执行环境。

用户手动管理任务

shell每执行一个命令,Linux就相当于启动了一个任务。任务之间未必是一个接一个顺序执行的,bash shell可能启动了多个任务同时运行,因此还需要进行任务管理和调度。对于大多数小型计算机系统来说,任务(进程)都是由内核自动进行调度的,用户几乎无法直接控制任务的执行顺序,至多给他们设置任务优先级,进行间接调控。Unix系统是第一个让用户能够直接控制多个进程的小型操作系统,这个做法评价不一,Linux也继承这个功能,被称为用户控制的多任务

首先,要区别的进程ID(Process ID)和作业号(Job Number)。当shell开始执行一个命令时,Linux会创建对应的进程并给进程标号,这个标号就是进程ID。进程执行时,默认情况下让bash等待其运行完,如果命令后面加个&符号,进程会被放到后台执行,bash仍能够和用户交互。示例如下:

1$ which ls &
2[1] 7091
3/usr/bin/ls

其中,7091是Linux系统分配的进程ID,[1]是当前shell给它分配的作业号。作业号只是当前shell给它所启动的任务分配的编号,而进程ID是整个系统中,所有用户正在执行任务的编号。

信号机制与trap

信号(Signal)是在软件层次上对中断机制的一种模拟,一个进程通过给另一个进程发送信号,使其执行相应的处理函数,属于一种进程间通信(Interprocess Communication, IPC)。在shell语境下,bash通常使用kill命令发送信号命令给某一进程(常用进程ID指定),而收到信号的进程使用trap命令处理信号。当然,bash也支持从键盘快捷键直接输入信号,如ctrl+c, ctrl+z等。Linux支持的信号用1-64的数字表示,分为非实时信号(不可靠信号)和实时信号(可靠信号)两种类型,对应于 Linux 的信号值为 1-31 和 34-64。非实时信号,不支持队列,信号可能会丢失,比如发送多次相同的信号,进程只能收到一次,如果第一个信号没有处理完,第二个信号将会丢弃。实时信号支持队列,发多少次进程就可以收到多少次。

我们先看看kill命令格式。

1kill [-s sigspec | -n signum | -sigspec] pid | jobspec ... # 给特定进程发送信号
2# 默认发送信号为 TERM (15)
3-l [sigspec/signum] # 打印名称/编号对应的特定信号编号/名称
4-s  # 使用信号名称
5-n  # 使用信号编号
6-l  # 打印编号1-31信号名称
7pid # 进程ID
8jobapec # 作业号,使用的时候前面加%,例如作业号为1的作业为%1

kill虽然名字叫,却是发送任意信号的命令。之所以叫杀,是因为默认发送的是杀死进程的命令(SIGTERM)。举个例子:

 1# 我们在后台启动一个cat程序
 2$ cat &
 3[1] 31930 # [1]是作业号,属于当前shell,31930是进程ID,属于系统
 4# 我们用kill 发送一个信号终止cat进程
 5$ kill -n 15 31930 # 或者 kill -s SINTERM 31930
 6[1]+  Stopped                 cat
 7$ cat &
 8[2] 31931
 9$ kill -s SIGTERM %2 # 使用作业号
10[2]+  Stopped                 cat

如果SIGTERM(15)信号无法终止,可以再尝试SIGKILL(9)信号,该信号要求立即停止进程,不能捕获,不能忽略。

Linux支持的信号见Linux信号表

如果一个进程收到了信号,可以通过三种方式来响应一个信号:

  1. 忽略信号,即对信号不做任何处理,其中有两个信号不能忽略:SIGKILL及SIGSTOP。
  2. 执行缺省操作,Linux对每种信号都规定了默认操作。
  3. 捕捉信号。

默认情况下,当一个进程接收到信号之后,会根据Linux信号表的默认(缺省)操作行事,或者根据系统情况直接忽略信号。然后,bash给我们提供了一个能够按需要自行处理信号的功能,traptrap命令定义shell脚本在运行时根据接收的信号做相应的处理,该命令对于编写较复杂shell程序有很大意义,提供了类似其他编程语言中异常处理的功能。其使用如下:

1trap [-lp] [[arg] sigspec ...]
2-l  # 打印编号1-64编号信号名称
3-p  # 查看当前已经设置的trap内容
4arg # 捕获信号后执行的命令或者函数
5signal_spec # 信号名或编号,可以是一个或多个

当接收到特定信号后,trap检查是否是自己需要处理的信号,如果是则执行arg指定的命令或函数,执行完后,从刚刚程序中断的地方继续执行。如果命令参数arg为空字符串或者-,这时shell进程和shell进程内的子进程都会忽略该信号(相当于什么都不执行)。我们新建一个有执行权限的loop.sh的文件来举例,其内容如下:

1#! /bin/bash
2# 无限循环睡眠60s的操作
3while true; do
4    sleep 60
5done

当我们直接执行上述shell脚本时,bash会处于一直等待状态,直到我们使用键盘的键入中断命令ctrl+c。

1$ ./loop.sh
2^C
3$ 

Bash所有脚本都自带默认的处理信号的机制,当我们输入ctrl+c之后,相当于向正在执行的loop.sh进程发送了SIGINT(2),并触发了默认处理即中断正在运行的任务。

如果我们希望接收到信号之后,由trap捕获并按照自己的需求处理信号,而非默认方式,例如:

1#! /bin/bash
2trap "echo 'You hit control-C!'" INT # 使用自定义的命令处理SIGINT信号
3# 无限循环睡眠60s的操作
4while true; do
5    sleep 60
6done

当我们再次执行loop.sh脚本时有:

1$ ./loop.sh
2^CYou hit control-C
3^CYou hit control-C

我们发现,当我们输入ctrl+c之后(即向进程发送SIGINT(2)),脚本并没有停止运行,只是返回了'You hit control-C'。在脚本中,trap捕获了SIGINT(2)信号,并通过用户自定义的echo 'You hit control-C!'命令来实现对信号的处理,覆盖了默认的终止操作。我们还可以给脚本添加其他信号处理的trap

1#! /bin/bash
2trap "echo 'You tried to kill me!'" TERM # 使用自定义的命令处理SIGTERM信号(kill的默认信号) 
3trap "echo 'You hit control-C!'" INT # 使用自定义的命令处理SIGINT信号
4# 无限循环睡眠60s的操作
5while true; do
6    sleep 60
7done

现在执行loop.sh后,不管是使用默认的kill还是直接键盘输入ctrl+c,都不会终止程序,反而会给我们返回信息。

1$ ./loop.sh
2^CYou hit control-C!
3^Z # ctrl+z 放到后台并终止
4[1]+  Stopped                 ./loop.sh
5$ kill %1
6Terminated # 接收到终止信号,并没有实际终止loop.sh
7You tried to kill me!
8$ jobs # 表明loop.sh还在运行
9[1]+  Running                 ./loop.sh &

如果我们希望杀死该运行中的脚本,需要使用其他信号,例如SIGKILL(9).

1$ kill -9 %1
2$ jobs
3[1]+  Killed                  ./loop.sh
4$ jobs

最后,还有一点需要说明,如果脚本中针对同一个信号设置了多个trap,那么后一个执行的trap会覆盖之前的trap,即对于同一个信号,只有最后一次trap生效。另外,trap只在本进程内有效,它的子进程不会继承trap的设置。例子如下:

1#! /bin/bash
2trap "echo 'Frist trap: You hit control-C!'" INT
3trap "echo 'Second trap: You hit control-C!'" INT
4# 无限循环睡眠60s的操作
5while true; do
6    sleep 60
7done
8trap "echo 'Third trap: You hit control-C!'" INT

在执行此脚本后,键盘使用ctrl+c结果是^CSecond trap: You hit control-C!。因为shell在顺序执行时,第二个trap覆盖了第一个trap的操作,同时由于陷入了while true死循环,第三个trap一直没有执行到,因此第三个trap也一直没有生效。最后结果就是第二个trap生效。

作业控制

我们前一节已经了解了基于信号的任务控制管理机制,例如kill, trap等,而bash为了方便进程管理,也有自己一套作业控制系统,包括&, bg, fg, disown, suspend等。作业控制系统不仅支持使用进程ID来指定要管理的进程,也支持通过作业号(%符号, jobspec)指定。

最常见的作业控制符号就是&,当一个命令以&结尾时,意味着这条命令放到后台执行。现在我们打开三个后台执行的命令:

 1$ less /etc/cron.d/anacron &
 2[1] 17357
 3$ vim &
 4[2] 18781
 5
 6[1]+  Stopped                 less /etc/cron.d/anacron
 7$ cat &
 8[3] 20137
 9
10[2]+  Stopped                 vim

这三个命令分别形成了当前bash的三个作业,如果我们要查看当前bash的作业情况,可使用jobs命令。

1jobs [-lnprs] [ jobspec ... ]
2    -l     列出当前作业信息(包括进程ID)
3    -n     仅显示有关自上次通知用户以来,状态已更的作业信息。
4    -p     仅列出作业进程组组长的进程ID。
5    -r     仅显示running状态的作业。
6    -s     仅显示stopped状态的作业

当前shell执行jobs效果如下:

1$ jobs -l
2[1]  17357 Stopped (tty output)    less /etc/cron.d/anacron
3[2]- 18781 Stopped (tty output)    vim
4[3]+ 20137 Stopped (tty input)     cat

第一行表示的就是作业号(jobspec),后面的+表示最近添加到作业列表中的作业,-表示倒数第二最近添加到作业列表中的作业。第二组数字表示进程号,第三组表示状态,当前三个作业都是停止状态。目前此shell中,有三个处于后台的作业,即我们刚才启动的作业。如果我们希望把后台的作业调到前台来继续执行,可以使用fg命令,其使用方式为

 1fg [%][jobspec] 
 2# 在指定作业号时,加不加%符号没有区别。如果不加任何参数,那么会将最近添加到作业列表中的作业(带+号)放到前台
 3fg %1
 4# /etc/cron.d/anacron: crontab entries for the anacron package
 5
 6SHELL=/bin/sh
 7PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
 8
 930 7    * * *   root    test -x /etc/init.d/anacron && /usr/sbin/invoke-rc.d anacron start >/dev/null
10

当我们恢复作业号为1的任务时,效果如上,将less /etc/cron.d/anacron命令调到了前台。如果我们想在把它放回后台,可以使用ctrl+z,即可挂起该进程变放入后台。

若我们仅仅是想让一个任务在后台执行起来而不用调到前台,可以使用bg命令直接在后台恢复执行,用法和fg相似。

1bg [jobspec …]
2# 在后台恢复每个挂起的作业jobspec,就好像它已经以‘&’开始。 如果不加任何参数,那么启动最近添加到作业列表中的作业(带+号)

此外,还有两个常用的作业控制命令disownsuspend,简要介绍下:

 1# 从当前shell的作业列表中移除作业
 2disown [-ar] [-h] [jobspec … | pid … ]
 3    -h    标记每个作业标识符,这些作业将不会在shell接收到sighup信号时接收到sighup信号。
 4    -a    移除所有的作业。
 5    -r    移除运行的作业。
 6    jobspec(可选):要移除的作业标识符,可以是一到多个。
 7    pid(可选):要移除的作业对应的进程ID,可以是一到多个。
 8
 9# 暂停目前正在执行的shell。若要恢复,则必须使用SIGCONT信息。
10suspend [-f]
11-f  若目前执行的shell为登入的shell,则suspend预设无法暂停此shell。若要强迫暂停登入的shell,则必须使用-f参数。

在此,我们完成了对shell骨干流程的梳理。这个笔记涵盖了shell执行流程中的大部分问题,还有一些小的方面比如进程协同、多进程、进程替换等并未说明,这是由于用的比较少,等到用到的时候再去学习吧。Keep going!

Linux信号表

编号 信号名称 缺省动作 描述
1 SIGHUP 终止 终止进程,挂起
2 SIGINT 终止 键盘输入中断命令,一般是CTRL+C
3 SIGQUIT CoreDump 键盘输入退出命令,一般是CTRL+\
4 SIGILL CoreDump 非法指令
5 SIGTRAP CoreDump trap指令发出,一般调试用
6 SIGABRT CoreDump abort(3)发出的终止信号
7 SIGBUS CoreDump 非法地址
8 SIGFPE CoreDump 浮点数异常
9 SIGKILL 终止 立即停止进程,不能捕获,不能忽略
10 SIGUSR 终止 用户自定义信号1,像Nginx就支持USR1信号,用于重载配置,重新打开日志
11 SIGSEGV CoreDump 无效内存引用
12 SIGUSR 终止 用户自定义信号2
13 SIGPIPE 终止 管道不能访问
14 SIGALRM 终止 时钟信号,alrm(2)发出的终止信号
15 SIGTERM 终止 终止信号,进程会先关闭正在运行的任务或打开的文件再终止,有时间进程在有运行的任务而忽略此信号。不能捕捉
16 SIGSTKFLT 终止 处理器栈错误
17 SIGCHLD 可忽略 子进程结束时,父进程收到的信号
18 SIGCONT 可忽略 让终止的进程继续执行
19 SIGSTOP 停止 停止进程,不能忽略,不能捕获
20 SIGSTP 停止 停止进程,一般是CTRL+Z
21 SIGTTIN 停止 后台进程从终端读数据
22 SIGTTOU 停止 后台进程从终端写数据
23 SIGURG 可忽略 紧急数组是否到达socket
24 SIGXCPU CoreDump 超出CPU占用资源限制
25 SIGXFSZ CoreDump 超出文件大小资源限制
26 SIGVTALRM 终止 虚拟时钟信号,类似于SIGALRM,但计算的是进程占用的时间
27 SIGPROF 终止 类似与SIGALRM,但计算的是进程占用CPU的时间
28 SIGWINCH 可忽略 窗口大小改变发出的信号
29 SIGIO 终止 文件描述符准备就绪,可以输入/输出操作了
30 SIGPWR 终止 电源失败
31 SIGSYS CoreDump 非法系统调用

CoreDump(核心转储):当程序运行过程中异常退出时,内核把当前程序在内存状况存储在一个core文件中,以便调试。