shell-骨干流程4——命令执行与job控制
总体流程图镇楼:
Figure 1: 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中的单词例子:
上面的例子都可以称为一个单词来作为简单命令。
那么bash如何来查找这个命令呢?
首先如果这个命令以.
或者/
开头,则说明用户指定了命令的路径,bash必须根据用户指定的路径去查找是否存在该命令。其中,如果以.
开头,则以当前文件夹为基准使用相对路径;如果以/
开头,则使用绝对路径。
如果命令中不以.
或者/
开头,则依以下顺序来查找命令:
- 查看命令名称是否为shell函数,如果这个名称是shell函数,那么命令将按照Shell函数中的描述被调用。(可以用
set
命令查看当前所有定义的函数) - 查看命令是否为内建命令(
builtins
),如果找到匹配,则调用该内建命令。 - 查看
$PATH
路径,按顺序从左到右依次查找$PATH
路径中的每一项,查看该元素下所有可执行文件,直到匹配到命令。 - 如果
$PATH
中没有找到匹配的路径,那么shell会抛出command_not_found
错误。
在shell实现时,bash使用哈希表来记住可执行文件的完整路径名,以避免频繁的$PATH
搜索。只有在哈希表中找不到该命令时,才会执行$PATH
目录中的完整搜索,这通常发生在修改了$PATH
变量之后。
命令执行
如果我们在第13步中找到了可行性文件,那么将来到最后一步,执行该命令。这一步是shell运行的最终目的,但也是bash管的最少的一步,因为接下来如何执行该命令就完全交由可执行文件自己决定。
bash要做的只是将执行的命令为位置参数0,并把后面的所有单词作为位置参数传递给可执行文件,之后就等待可执行文件运行结束并收集其退出状态。当然如果该命令是异步执行的,shell就不必等待其结束。
最核心的一步也是shell最简单的一步,颇有一种功成身退,翩然而去的风味。
如果我们在深入的了解一下执行流程,会发现shell在执行命令之前还做了一些环境设置的工作,这些环境设置工作虽然不显山不露水,但是若是不了解,就会产生不少奇怪的问题。
此外,当执行一个简单的命令,而不是一个内建函数或shell函数时,它将在一个独立的执行环境中调用,该环境由以下几部分组成。 除非另有说明,否则这些值是从原shell继承而来的。
我们先来看看shell有哪些命令执行环境:
- 打开的文件信息。Linux中一切皆文件,因此这个打开的“文件”是一个广义概念,目前正在使用的设备、socket等都是文件的范畴,最常见的文件信息就是标准输入输出文件,它们记录了文件输入输出的位置。
- 当前的工作目录。这个环境可通过
cd, pushd, popd
修改也可继承自启动该bash的程序。 - umask信息和文件的读写执行权限有关。
- trap(后面会提,常用于信号的处理)
- 通过set设置或从父shell继承的shell参数
- 在执行期间定义的shell函数或从环境中的shell父项继承的函数
- 在调用时启用的选项(默认或通过命令行参数或set设置)
- 由shopt启用的选项
- 使用alias定义的shell别名
- 各种进程ID,包括包括后台作业信息,$$和$PPID的值
当执行一个$PATH
中的命令,而不是一个内建函数或shell函数时,它将在一个独立的执行环境中调用,该环境由以下几部分组成。
- 打开的文件信息。
- 当前的工作目录。
- umask信息
- trap
- 在环境中传递的标记为
export
的shell变量和函数以及为命令导出的变量
在这个单独的环境中调用的命令不会影响shell的执行环境。
用户手动管理任务
shell每执行一个命令,Linux就相当于启动了一个任务。任务之间未必是一个接一个顺序执行的,bash shell可能启动了多个任务同时运行,因此还需要进行任务管理和调度。对于大多数小型计算机系统来说,任务(进程)都是由内核自动进行调度的,用户几乎无法直接控制任务的执行顺序,至多给他们设置任务优先级,进行间接调控。Unix系统是第一个让用户能够直接控制多个进程的小型操作系统,这个做法评价不一,Linux也继承这个功能,被称为用户控制的多任务。
首先,要区别的进程ID(Process ID)和作业号(Job Number)。当shell开始执行一个命令时,Linux会创建对应的进程并给进程标号,这个标号就是进程ID。进程执行时,默认情况下让bash等待其运行完,如果命令后面加个&
符号,进程会被放到后台执行,bash仍能够和用户交互。示例如下:
其中,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信号表。
如果一个进程收到了信号,可以通过三种方式来响应一个信号:
- 忽略信号,即对信号不做任何处理,其中有两个信号不能忽略:SIGKILL及SIGSTOP。
- 执行缺省操作,Linux对每种信号都规定了默认操作。
- 捕捉信号。
默认情况下,当一个进程接收到信号之后,会根据Linux信号表的默认(缺省)操作行事,或者根据系统情况直接忽略信号。然后,bash给我们提供了一个能够按需要自行处理信号的功能,trap
。trap
命令定义shell脚本在运行时根据接收的信号做相应的处理,该命令对于编写较复杂shell程序有很大意义,提供了类似其他编程语言中异常处理的功能。其使用如下:
1trap [-lp] [[arg] sigspec ...]
2-l # 打印编号1-64编号信号名称
3-p # 查看当前已经设置的trap内容
4arg # 捕获信号后执行的命令或者函数
5signal_spec # 信号名或编号,可以是一个或多个
当接收到特定信号后,trap
检查是否是自己需要处理的信号,如果是则执行arg
指定的命令或函数,执行完后,从刚刚程序中断的地方继续执行。如果命令参数arg
为空字符串或者-
,这时shell进程和shell进程内的子进程都会忽略该信号(相当于什么都不执行)。我们新建一个有执行权限的loop.sh
的文件来举例,其内容如下:
当我们直接执行上述shell脚本时,bash会处于一直等待状态,直到我们使用键盘的键入中断命令ctrl+c。
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
脚本时有:
我们发现,当我们输入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).
最后,还有一点需要说明,如果脚本中针对同一个信号设置了多个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
相似。
此外,还有两个常用的作业控制命令disown
和suspend
,简要介绍下:
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文件中,以便调试。