Linux shell - script control
运行脚本的方式并不是只能直接在命令行中启动,还有很多方式可以用来运行 shell 脚本。可以对脚本加以控制,包括向脚本发送信号、修改脚本的优先级,以及切换脚本的运行模式。
处理信号
Linux 利用信号与系统中的进程进行通信。你可以通过对脚本进行编程,使其在收到特定信号时执行某些命令,从而控制 shell 脚本的操作。
重温 Linux 信号
Linux 系统和应用程序可以产生超过 30 个信号。最常见的 Linux 系统信号如下:
信号 | 值 | 描述 |
---|---|---|
1 | SIGHUP | 挂起进程 |
2 | SIGINT | 中断进程 |
3 | SIGQUIT | 停止进程 |
9 | SIGKILL | 无条件终止进程 |
15 | SIGTERM | 尽可能终止进程 |
18 | SIGCONT | 继续运行停止的进程 |
19 | SIGSTOP | 无条件停止,但不终止进程 |
20 | SIGTSTP | 停止或暂停,但不终止进程 |
在默认情况下,bash shell 会忽略收到的任何 SIGQUIT(3)信号和 SIGTERM(15)信号(因此交互式 shell 才不会被意外终止)。但是,bash shell 会处理收到的所有 SIGHUP(1)信号和 SIGINT(2)信号。
如果收到了 SIGHUP 信号(比如在离开交互式 shell 时),bash shell 就会退出。但在退出之前,它会将 SIGHUP 信号传给所有由该 shell 启动的进程,包括正在运行的 shell 脚本。
随着收到 SIGINT 信号,shell 会被中断。Linux 内核将不再为 shell 分配 CPU 处理时间。当出现这种情况时,shell 会将 SIGINT 信号传给由其启动的所有进程,以此告知出现的状况。
你可能也注意到了,shell 会将这些信号传给 shell 脚本来处理。而 shell 脚本的默认行为是忽略这些信号,因为可能不利于脚本运行。要避免这种情况,可以在脚本中加入识别信号的代码,并做相应的处理。
产生信号
bash shell 允许使用键盘上的组合键来生成两种基本的 Linux 信号。这个特性在需要停止或暂停失控脚本时非常方便。
中断进程
Ctrl+C 组合键会生成 SIGINT 信号,并将其发送给当前在 shell 中运行的所有进程。通过执行一条需要很长时间才能完成的命令,然后按下 Ctrl+C 组合键,可以对此进行测试:
sleep 60
^C
sleep 命令会按照指定的秒数暂停 shell 操作一段时间,然后返回 shell 提示符。Ctrl+C 组合键会发送 SIGINT 信号,停止 shell 中当前运行的进程。在超时前(60 秒)按下 Ctrl+C 组合键,就可以提前终止 sleep 命令。
暂停进程
你也可以暂停进程,而不是将其终止。尽管有时这可能比较危险(比如,脚本打开了一个关键的系统文件的文件锁),但它往往可以在不终止进程的情况下,使你能够深入脚本内部一窥究竟。
Ctrl+Z 组合键会生成 SIGTSTP 信号,停止 shell 中运行的任何进程。停止(stopping)进程跟终止(terminating)进程不同,前者让程序继续驻留在内存中,还能从上次停止的位置继续运行。
当使用 Ctrl+Z 组合键时,shell 会通知你进程已经被停止了:
sleep 60
^Z
# [1] + 397211 suspended sleep 60
方括号中的数字是 shell 分配的作业号。shell 将运行的各个进程称为作业,并为作业在当前 shell 内分配了唯一的作业号。作业号从 1 开始,然后是 2,依次递增
如果 shell 会话中有一个已停止的作业,那么在退出 shell 时,bash 会发出提醒:
sleep 70
^Z
# [1]+ Stopped sleep 70
exit
# exit
# There are stopped jobs.
可以用 ps 命令查看已停止的作业:
# F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD
# 0 S 1000 397354 396758 0 80 0 - 56727 do_wai pts/2 00:00:00 bash
# 0 T 1000 397554 397354 0 80 0 - 55950 do_sig pts/2 00:00:00 sleep
# 0 T 1000 397555 397354 0 80 0 - 55950 do_sig pts/2 00:00:00 sleep
# 0 R 1000 397556 397354 0 80 0 - 57009 - pts/2 00:00:00 ps
在 S 列(进程状态)中,ps 命令将已停止作业的状态显示为 T。这说明命令要么被跟踪,要么被停止。
如果在有已停止作业的情况下仍旧想退出 shell,则只需再输入一遍 exit 命令即可。shell 会退出,终止已停止作业。
或者,如果知道已停止作业的 PID,那就可以用 kill 命令发送 SIGKILL(9)信号将其终止:
kill -9 397554
# [1]- Killed sleep 60
kill -9 397555
# [2]+ Killed sleep 70
每当 shell 生成命令行提示符时,也会显示 shell 中状态发生改变的作业。“杀死”作业后,shell 会显示一条消息,表示运行中的作业已被“杀死”,然后生成提示符。
在某些 Linux 系统中,“杀死”作业时不会得到任何回应。但当下次执行能让 shell 生成命令行提示符的操作时(比如,按下 Enter 键),你会看到一条消息,表示作业已被“杀死”。
捕获信号
你也可以用其他命令在信号出现时将其捕获,而不是忽略信号。trap 命令可以指定 shell 脚本需要侦测并拦截的 Linux 信号。如果脚本收到了 trap 命令中列出的信号,则该信号不再由 shell 处理,而是由本地处理。
trap 命令的格式如下:
trap commands signals
在 trap 命令中,需要在 commands 部分列出想要 shell 执行的命令,在 signals 部分列出想要捕获的信号(多个信号之间以空格分隔)。指定信号的时候,可以使用信号的值或信号名。
下面这个简单的例子展示了如何使用 trap 命令捕获 SIGINT 信号并控制脚本的行为:
trap "echo ' Sorry! I have trapped Ctrl-C'" SIGINT
echo This is a test script.
count=1
while [ $count -le 5 ]
do
echo "Loop #$count"
sleep 1
count=$[ $count + 1 ]
done
echo "This is the end of test script."
exit
每次侦测到 SIGINT 信号时,本例中的 trap 命令都会显示一行简单的文本消息。捕获这些信号可以阻止用户通过组合键 Ctrl+C 停止脚本:
./trapsignal.sh
# This is a test script.
# Loop #1
# Loop #2
# ^C Sorry! I have trapped Ctrl-C
# Loop #3
# ^C Sorry! I have trapped Ctrl-C
# Loop #4
# Loop #5
# This is the end of test script.
每次使用 Ctrl+C 组合键,脚本都会执行 trap 命令中指定的 echo 语句,而不是忽略信号并让 shell 停止该脚本。
如果脚本中的命令被信号中断,使用带有指定命令的 trap 未必能让被中断的命令继续执行。为了保证脚本中的关键操作不被打断,请使用带有空操作命令的 trap 以及要捕获的信号列表,例如:
trap "" SIGINT
这种形式的 trap 命令允许脚本完全忽略 SIGINT 信号,继续执行重要的工作。
捕获脚本退出
除了在 shell 脚本中捕获信号,也可以在 shell 脚本退出时捕获信号。这是在 shell 完成任务时执行命令的一种简便方法。
要捕获 shell 脚本的退出,只需在 trap 命令后加上 EXIT 信号即可:
trap "echo Goodbye..." EXIT
count=1
while [ $count -le 5 ]
do
echo "Loop #$count"
sleep 1
count=$[ $count + 1 ]
done
exit
./trapexit.sh
# Loop #1
# Loop #2
# Loop #3
# Loop #4
# Loop #5
# Goodbye...
当脚本运行到正常的退出位置时,触发了 EXIT,shell 执行了在 trap 中指定的命令。如果提前退出脚本,则依然能捕获到 EXIT:
./trapexit.sh
# Loop #1
# Loop #2
# Loop #3
# ^CGoodbye...
因为 SIGINT 信号并未在 trap 命令的信号列表中,所以当按下 Ctrl+C 组合键发送 SIGINT 信号时,脚本就退出了。但在退出之前已经触发了 EXIT,于是 shell 会执行 trap 命令。
修改或移除信号捕获
要想在脚本中的不同位置进行不同的信号捕获处理,只需重新使用带有新选项的 trap 命令即可:
trap "echo ' Sorry...Ctrl-C is trapped.'" SIGINT
count=1
while [ $count -le 3 ]
do
echo "Loop #$count"
sleep 1
count=$[ $count + 1 ]
done
trap "echo ' I have modified the trap!'" SIGINT
count=1
while [ $count -le 3 ]
do
echo "Second Loop #$count"
sleep 1
count=$[ $count + 1 ]
done
exit
修改了信号捕获之后,脚本处理信号的方式就会发生变化。但如果信号是在捕获被修改前接收到的,则脚本仍然会根据原先的 trap 命令处理该信号。
./trapmod.sh
# Loop #1
# ^C Sorry...Ctrl-C is trapped.
# Loop #2
# Loop #3
# Second Loop #1
# Second Loop #2
# ^C I have modified the trap!
# Second Loop #3
如果在交互式 shell 会话中使用 trap 命令,可以使用 trap -p 查看被捕获的信号。如果什么都没有显示,则说明 shell 会话按照默认方式处理信号。
也可以移除已设置好的信号捕获。在 trap 命令与希望恢复默认行为的信号列表之间加上两个连字符即可。
trap "echo ' Sorry...Ctrl-C is trapped.'" SIGINT
count=1
while [ $count -le 3 ]
do
echo "Loop #$count"
sleep 1
count=$[ $count + 1 ]
done
trap -- SIGINT
echo "The trap is now removed."
count=1
while [ $count -le 3 ]
do
echo "Second Loop #$count"
sleep 1
count=$[ $count + 1 ]
done
exit
也可以在 trap 命令后使用单连字符来恢复信号的默认行为。单连字符和双连字符的效果一样。
移除信号捕获后,脚本会按照默认行为处理 SIGINT 信号,也就是终止脚本运行。但如果信号是在捕获被移除前接收到的,那么脚本就会按照原先 trap 命令中的设置进行处理:
./trapremoval.sh
# Loop #1
# Loop #2
# ^C Sorry...Ctrl-C is trapped.
# Loop #3
# The trap is now removed.
# Second Loop #1
# Second Loop #2
# ^C
在本例中,第一个 Ctrl+C 组合键用于提前终止脚本。因为信号是在捕获被移除前接收的,所以脚本会按照事先的安排,执行 trap 中指定的命令。捕获随后会被移除,再按 Ctrl+C 组合键就能提前终止脚本了。
以后台模式运行脚本
直接在命令行界面运行 shell 脚本有时不怎么方便。有些脚本可能要执行很长一段时间,而你不想在命令行界面一直干等。但脚本运行不完,就不能在终端会话中执行任何其他操作。幸好有一种简单的方法可以解决这个问题。
使用 ps -e 命令,可以看到 Linux 系统中运行的多个进程:
ps -e
# PID TTY TIME CMD
# 1 ? 00:00:02 systemd
# 2 ? 00:00:00 kthreadd
# 3 ? 00:00:00 rcu_gp
# 4 ? 00:00:00 rcu_par_gp
显然,以上所有进程都未在你的终端上运行。实际上,其中有很多没有运行在任何终端——它们是在后台运行的。在后台模式中,进程运行时不和终端会话的 STDIN、STDOUT 以及 STDERR 关联。
后台运行脚本
以后台模式运行 shell 脚本非常简单,只需在脚本名后面加上&即可:
count=1
while [ $count -le 5 ]
do
sleep 1
count=$[ $count + 1 ]
done
exit
./backgroundscript.sh &
# [1] 2595
在脚本名之后加上&会将脚本与当前 shell 分离开来,并将脚本作为一个独立的后台进程运行。显示的第一行如下所示:
# [1] 2595
方括号中的数字(1)是 shell 分配给后台进程的作业号,之后的数字(2595)是 Linux 系统为进程分配的进程 ID(PID)。Linux 系统中的每个进程都必须有唯一的 PID。
一旦显示出这些内容,就会出现新的命令行界面提示符。返回到当前 shell,刚才执行的脚本则会以后台模式安全地运行。这时,就可以继续在命令行中输入新的命令了。
当后台进程结束时,终端上会显示一条消息:
# [1]+ Done ./backgroundscript.sh
其中指明了作业号、作业状态(Done),以及用于启动该作业的命令(删除了&)。
注意,当后台进程运行时,它仍然会使用终端显示器来显示 STDOUT 和 STDERR 消息:
echo "Starting the script..."
count=1
while [ $count -le 5 ]
do
echo "Loop #$count"
sleep 1
count=$[ $count + 1 ]
done
echo "Script is completed."
exit
./backgroundoutput.sh &
# [1] 2615
# Starting the script...
# Loop #1
# Loop #2
# Loop #3
# Loop #4
# Loop #5
# Script is completed.
# [1]+ Done ./backgroundoutput.sh
你会注意到在上面的例子中,脚本 backgroundoutput.sh 的输出与 shell 提示符混在了一起,这也是 Starting the script 出现在提示符$旁边的原因。
在显示输出的同时,运行以下命令:
./backgroundoutput.sh &
# [1] 2719
# Starting the script...
# Loop #1
# Loop #2
# Loop #3
pwd
# /home/roger/scripts
# Loop #4
# Loop #5
# Script is completed.
# [1]+ Done ./backgroundoutput.sh
当脚本 backgroundoutput.sh 在后台运行时,命令 pwd 被输入了进来。脚本的输出、用户输入的命令,以及命令的输出全都混在了一起。实在让人头晕眼花!最好是将后台脚本的 STDOUT 和 STDERR 进行重定向,避免这种杂乱的输出。
运行多个后台作业
在使用命令行提示符的情况下,可以同时启动多个后台作业:
./testAscript.sh &
# [1] 2753
# This is Test Script #1.
./testBscript.sh &
# [2] 2755
# This is Test Script #2.
./testCscript.sh &
# [3] 2757
# And... another Test script.
./testDscript.sh &
# [4] 2759
# Then...there was one more Test script.
每次启动新作业时,Linux 系统都会为其分配新的作业号和 PID。通过 ps 命令可以看到,所有脚本都处于运行状态:
ps
# PID TTY TIME CMD
# 1509 pts/0 00:00:00 bash
# 2753 pts/0 00:00:00 testAscript.sh
# 2754 pts/0 00:00:00 sleep
# 2755 pts/0 00:00:00 testBscript.sh
# 2756 pts/0 00:00:00 sleep
# 2757 pts/0 00:00:00 testCscript.sh
# 2758 pts/0 00:00:00 sleep
# 2759 pts/0 00:00:00 testDscript.sh
# 2760 pts/0 00:00:00 sleep
# 2761 pts/0 00:00:00 ps
在终端会话中使用后台进程一定要小心。注意,在 ps 命令的输出中,每一个后台进程都和终端会话(pts/0)终端关联在一起。如果终端会话退出,那么后台进程也会随之退出。
先前提到过,当要退出终端会话时,如果还有被停止的进程,就会出现警告信息。但如果是后台进程,则只有部分终端仿真器会在退出终端会话前提醒你尚有后台作业在运行。
如果在登出控制台后,仍希望运行在后台模式的脚本继续运行,则需要借助其他手段。
在非控制台下运行脚本
有时候,即便退出了终端会话,你也想在终端会话中启动 shell 脚本,让脚本一直以后台模式运行到结束。这可以用 nohup 命令来实现。
nohup 命令能阻断发给特定进程的 SIGHUP 信号。当退出终端会话时,这可以避免进程退出。
nohup 命令的格式如下:
nohup command
下面的例子使用一个后台脚本作为 command:
nohup ./testAscript.sh &
# [1] 1828
# nohup: ignoring input and appending output to 'nohup.out'
和普通后台进程一样,shell 会给 command 分配一个作业号,Linux 系统会为其分配一个 PID 号。区别在于,当使用 nohup 命令时,如果关闭终端会话,则脚本会忽略其发送的 SIGHUP 信号。
由于 nohup 命令会解除终端与进程之间的关联,因此进程不再同 STDOUT 和 STDERR 绑定在一起。为了保存该命令产生的输出,nohup 命令会自动将 STDOUT 和 STDERR 产生的消息重定向到一个名为 nohup.out 的文件中。
nohup.out 文件一般在当前工作目录中创建,否则会在$HOME 目录中创建。
nohup.out 文件包含了原本要发送到终端显示器上的所有输出。进程结束之后,可以查看 nohup.out 文件中的输出结果:
cat nohup.out
# This is Test Script #1.
nohup.out 文件中的输出结果和脚本在命令行中运行时产生的一样。
如果使用 nohop 运行了另一个命令,那么该命令的输出会被追加到已有的 nohup.out 文件中。当运行位于同一目录中的多个命令时,一定要当心,因为所有的命令输出都会发送到同一个 nohup.out 文件中,结果会让人摸不着头脑。
有了 nohup,就可以在后台运行脚本。在无须停止脚本进程的情况下,登出终端会话去完成其他任务,随后再检查结果。
作业控制
作业控制包括启动、停止、“杀死”以及恢复作业。通过作业控制,你能完全控制 shell 环境中所有进程的运行方式。本节介绍的命令可用于查看和控制在 shell 中运行的作业。
查看作业
jobs 是作业控制中的关键命令,该命令允许用户查看 shell 当前正在处理的作业。尽管下列脚本并未包含 jobs 命令,但有助于演示该命令的威力:
echo "Script Process ID: $$"
count=1
while [ $count -le 5 ]
do
echo "Loop #$count"
sleep 10
count=$[ $count + 1 ]
done
echo "End of script..."
exit
脚本用$$变量来显示 Linux 系统分配给该脚本的 PID,然后进入循环,每次迭代都休眠 10 秒。
可以从命令行启动脚本,然后使用 Ctrl+Z 组合键停止脚本:
./jobcontrol.sh
# Script Process ID: 1580
# Loop #1
# Loop #2
# Loop #3
# ^Z
# [1]+ Stopped ./jobcontrol.sh
还是同一个脚本,此时利用&将另一个作业作为后台进程启动。出于简化的目的,脚本的输出会被重定向到文件中,以避免出现在屏幕上:
./jobcontrol.sh > jobcontrol.out &
# [2] 1603
通过 jobs 命令可以查看分配给 shell 的作业,如下所示:
jobs
# [1]+ Stopped ./jobcontrol.sh
# [2]- Running ./jobcontrol.sh > jobcontrol.out &
jobs 命令显示了一个已停止的作业和一个运行中的作业,以及两者的作业号和作业使用的命令。
你大概也注意到了 jobs 命令输出中的加号和减号。带有加号的作业为默认作业。如果作业控制命令没有指定作业号,则引用的就是该作业。
带有减号的作业会在默认作业结束之后成为下一个默认作业。任何时候,不管 shell 中运行着多少作业,带加号的作业只能有一个,带减号的作业也只能有一个。
可以使用 jobs 命令的-l 选项(小写字母 l)查看作业的 PID。
# jobs -l
# [1]+ 1580 Stopped ./jobcontrol.sh
# [2]- 1603 Running ./jobcontrol.sh > jobcontrol.out &
jobs 命令提供了一些命令行选项:
选项 | 描述 |
---|---|
-l | 列出进程的 PID 以及作业号 |
-n | 只列出上次 shell 发出通知后状态发生改变的作业 |
-p | 只列出作业的 PID |
-r | 只列出运行中的作业 |
-s | 只列出已停止的作业 |
如果需要删除已停止的作业,那么使用 kill 命令向其 PID 发送 SIGKILL(9)信号即可。最好再确认一下 PID 是否正确,以便误伤其他进程:
jobs -l
# [1]+ 1580 Stopped ./jobcontrol.sh
kill -9 1580
# [1]+ Killed ./jobcontrol.sh
反复检查 PID 有点儿烦人。接下来将介绍如何在不使用 PID 或作业号的情况下与默认进程交互。
重启已停止的作业
在 bash 作业控制中,可以将已停止的作业作为后台进程或前台进程重启。前台进程会接管当前使用的终端,因此在使用该特性时要小心。
要以后台模式重启作业,可以使用 bg 命令:
./restartjob.sh
^Z
# [1]+ Stopped ./restartjob.sh
bg
# [1]+ ./restartjob.sh &
jobs
# [1]+ Running ./restartjob.sh &
因为该作业是默认作业(从加号可以看出),所以仅使用 bg 命令就可以将其以后台模式重启。注意,当作业被转入后台模式时,并不会显示其 PID。
如果存在多个作业,则需要在 bg 命令后加上作业号,以便于控制:
jobs
./restartjob.sh
^Z
# [1]+ Stopped ./restartjob.sh
./newrestartjob.sh
^Z
# [2]+ Stopped ./newrestartjob.sh
bg 2
# [2]+ ./newrestartjob.sh &
jobs
# [1]+ Stopped ./restartjob.sh
# [2]- Running ./newrestartjob.sh &
bg 2 命令用于将第二个作业置于后台模式。注意,当使用 jobs 命令时,它列出了作业及其状态,即便默认作业当前并未处于后台模式。
要以前台模式重启作业,可以使用带有作业号的 fg 命令:
jobs
# [1]+ Stopped ./restartjob.sh
# [2]- Running ./newrestartjob.sh &
fg 2
# ./newrestartjob.sh
# This is the script's end.
由于作业是在前台运行的,因此直到该作业完成后,命令行界面的提示符才会出现。
调整谦让度
在多任务操作系统(比如 Linux)中,内核负责为每个运行的进程分配 CPU 时间。调度优先级[也称为谦让度(nice value)]是指内核为进程分配的 CPU 时间(相对于其他进程)。在 Linux 系统中,由 shell 启动的所有进程的调度优先级默认都是相同的。
调度优先级是一个整数值,取值范围从-20(最高优先级)到+19(最低优先级)。在默认情况下,bash shell 以优先级 0 来启动所有进程。
-20(最低值)代表最高优先级,+19(最高值)代表最低优先级,这很容易记混。只要记住那句俗话“好人难做。”(Nice guys finish last.)即可。越是“谦让”(nice)或是值越大,获得 CPU 的机会就越低。
有时候,你想改变 shell 脚本的调度优先级。不管是降低优先级(这样就不会从其他进程那里抢走过多的 CPU 时间),还是提高优先级(这样就能获得更多的 CPU 时间),都可以通过 nice 命令来实现。
nice 命令
nice 命令允许在启动命令时设置其调度优先级。要想让命令以更低的优先级运行,只需用 nice 命令的-n 选项指定新的优先级即可:
nice -n 10 ./jobcontrol.sh > jobcontrol.out &
# [2] 16462
ps -p 16462 -o pid,ppid,ni,cmd
# PID PPID NI CMD
# 16462 1630 10 /bin/bash ./jobcontrol.sh
注意,nice 命令和要启动的命令必须出现在同一行中。ps 命令的输出证实,谦让度(NI 列)已经调整到了 10。
nice 命令使得脚本以更低的优先级运行。但如果想提高某个命令的优先级,那么结果可能会让你大吃一惊:
nice -n -5 ./jobcontrol.sh > jobcontrol.out &
# [2] 16473
# nice: cannot set niceness: Permission denied
ps -p 16473 -o pid,ppid,ni,cmd
# PID PPID NI CMD
# 16473 1630 0 /bin/bash ./jobcontrol.sh
nice 命令会阻止普通用户提高命令的优先级。注意,即便提高其优先级的操作没有成功,指定的命令依然可以运行。只有 root 用户或者特权用户才能提高作业的优先级。
nice 命令的-n 选项并不是必需的,直接在连字符后面跟上优先级也可以:
nice -10 ./jobcontrol.sh > jobcontrol.out &
# [2] 16520
ps -p 16520 -o pid,ppid,ni,cmd
# PID PPID NI CMD
# 16520 1630 10 /bin/bash ./jobcontrol.sh
然而,当要设置的优先级是负数时,这种写法则很容易造成混淆,因为出现了双连字符。在这种情况下,最好还是使用-n 选项。
renice 命令
有时候,你想修改系统中已运行命令的优先级。renice 命令可以帮你搞定。它通过指定运行进程的 PID 来改变其优先级:
./jobcontrol.sh > jobcontrol.out &
# [2] 16642
ps -p 16642 -o pid,ppid,ni,cmd
# PID PPID NI CMD
# 16642 1630 0 /bin/bash ./jobcontrol.sh
renice -n 10 -p 16642
# 16642 (process ID) old priority 0, new priority 10
# ps -p 16642 -o pid,ppid,ni,cmd
# PID PPID NI CMD
# 16642 1630 10 /bin/bash ./jobcontrol.sh
renice 命令会自动更新运行进程的调度优先级。和 nice 命令一样,renice 命令对于非特权用户也有一些限制:只能对属主为自己的进程使用 renice 且只能降低调度优先级。但是,root 用户和特权用户可以使用 renice 命令对任意进程的优先级做任意调整。
定时运行作业
在使用脚本时,你也许希望脚本能在以后某个你无法亲临现场的时候运行。Linux 系统提供了多个在预选时间运行脚本的方法:at 命令、cron 表以及 anacron。每种方法都使用不同的技术来安排脚本的运行时间和频率。
使用 at 命令调度作业
at 命令允许指定 Linux 系统何时运行脚本。该命令会将作业提交到队列中,指定 shell 何时运行该作业。
at 的守护进程 atd 在后台运行,在作业队列中检查待运行的作业。很多 Linux 发行版会在启动时运行此守护进程,但有些发行版甚至都没安装这个软件包。如果你的 Linux 属于后一种情况,则可以自行安装,软件包的名字如你所料,就是 at。
atd 守护进程会检查系统的一个特殊目录(通常位于/var/spool/at 或/var/spool/cron/atjobs),从中获取 at 命令提交的作业。在默认情况下,atd 守护进程每隔 60 秒检查一次这个目录。如果其中有作业,那么 atd 守护进程就会查看此作业的运行时间。如果时间跟当前时间一致,就运行此作业。
at 命令的格式
at 命令的基本格式非常简单:
at [-f filename] time
在默认情况下,at 命令会将 STDIN 的输入放入队列。你可以用-f 选项指定用于从中读取命令(脚本文件)的文件名。
time 选项指定了你希望何时运行该作业。如果指定的时间已经过去,那么 at 命令会在第二天的同一时刻运行指定的作业。
指定时间的方式非常灵活。at 命令能识别多种时间格式。
- 标准的小时和分钟,比如 10:15。
- AM/PM 指示符,比如 10:15 PM。
- 特定的时间名称,比如 now、noon、midnight 或者 teatime(4:00 pm)。
除了指定运行作业的时间,也可以通过不同的日期格式指定特定的日期。
- 标准日期,比如 MMDDYY、MM/DD/YY 或 DD.MM.YY。
- 文本日期,比如 Jul 4 或 Dec 25,加不加年份均可。
- 时间增量。
- Now + 25 minutes
- 10:15 PM tomorrow
- 10:15 + 7 days
at 命令可用的日期和时间格式有很多种,具体参见/usr/share/doc/at/timespec 文件。
在使用 at 命令时,该作业会被提交至作业队列。作业队列保存着通过 at 命令提交的待处理作业。针对不同优先级,有 52 种作业队列。作业队列通常用小写字母 a~z 和大写字母 A~Z 来指代,A 队列和 a 队列是两个不同的队列。
在几年前,batch 命令也能指定脚本的执行时间。这是个很独特的命令,因为它可以安排脚本在系统处于低负载时运行。现在,batch 命令只不过是一个脚本而已(/usr/bin/batch),它会调用 at 命令将作业提交到 b 队列中。
作业队列的字母排序越高,此队列中的作业运行优先级就越低(谦让度更大)。在默认情况下,at 命令提交的作业会被放入 a 队列。如果想以较低的优先级运行作业,可以用-q 选项指定其他的队列。如果相较于其他进程你希望你的作业尽可能少地占用 CPU,可以将其放入 z 队列。
获取作业的输出
当在 Linux 系统中运行 at 命令时,显示器并不会关联到该作业。Linux 系统反而会将提交该作业的用户 email 地址作为 STDOUT 和 STDERR。任何送往 STDOUT 或 STDERR 的输出都会通过邮件系统传给该用户。
来看一个在 CentOS 发行版中使用 at 命令调度作业的例子:
echo "This script ran at $(date +%B%d,%T)"
echo
echo "This script is using the $SHELL shell."
echo
sleep 5
echo "This is the script's end."
exit
at -f tryat.sh now
warning: commands will be executed using /bin/sh
job 3 at Thu Jun 18 16:23:00 2020
at 命令会显示分配给作业的作业号以及为作业安排的运行时间。-f 选项指明使用哪个脚本文件。now 指示 at 命令立刻执行该脚本。
无须在意 at 命令输出的警告消息,因为脚本的第一行是#!/bin/bash,该命令会由 bash shell 执行。
使用 email 作为 at 命令的输出极不方便。at 命令通过 sendmail 应用程序发送 email。如果系统中没有安装 sendmail,那就无法获得任何输出。因此在使用 at 命令时,最好在脚本中对 STDOUT 和 STDERR 进行重定向,如下例所示:
outfile=$HOME/scripts/tryat.out
echo "This script ran at $(date +%B%d,%T)" > $outfile
echo >> $outfile
echo "This script is using the $SHELL shell." >> $outfile
echo >> $outfile
sleep 5
echo "This is the script's end." >> $outfile
exit
at -M -f tryatout.sh now
# warning: commands will be executed using /bin/sh
# job 4 at Thu Jun 18 16:48:00 2020
cat $HOME/scripts/tryat.out
# This script ran at June18,16:48:21
# This script is using the /bin/bash shell.
# This is the script's end.
如果不想在 at 命令中使用 email 或者重定向,则最好加上-M 选项,以禁止作业产生的输出信息。
列出等待的作业
atq 命令可以查看系统中有哪些作业在等待:
at -M -f tryatout.sh teatime
# warning: commands will be executed using /bin/sh
# job 5 at Fri Jun 19 16:00:00 2020
at -M -f tryatout.sh tomorrow
# warning: commands will be executed using /bin/sh
# job 6 at Fri Jun 19 16:53:00 2020
at -M -f tryatout.sh 20:30
# warning: commands will be executed using /bin/sh
# job 7 at Thu Jun 18 20:30:00 2020
at -M -f tryatout.sh now+1hour
# warning: commands will be executed using /bin/sh
# job 8 at Thu Jun 18 17:54:00 2020
atq
# 1 Thu Jun 18 16:11:00 2020 a christine
# 5 Fri Jun 19 16:00:00 2020 a christine
# 6 Fri Jun 19 16:53:00 2020 a christine
# 7 Thu Jun 18 20:30:00 2020 a christine
# 8 Thu Jun 18 17:54:00 2020 a christine
作业列表中显示了作业号、系统运行该作业的日期和时间,以及该作业所在的作业队列。
删除作业
一旦知道了哪些作业正在作业队列中等待,就可以用 atrm 命令删除等待中的作业。指定要删除的作业号即可:
atq
# 1 Thu Jun 18 16:11:00 2020 a christine
# 5 Fri Jun 19 16:00:00 2020 a christine
# 6 Fri Jun 19 16:53:00 2020 a christine
# 7 Thu Jun 18 20:30:00 2020 a christine
# 8 Thu Jun 18 17:54:00 2020 a christine
atrm 5
atq
# 1 Thu Jun 18 16:11:00 2020 a christine
# 6 Fri Jun 19 16:53:00 2020 a christine
# 7 Thu Jun 18 20:30:00 2020 a christine
# 8 Thu Jun 18 17:54:00 2020 a christine
只能删除自己提交的作业,不能删除其他人的。
调度需要定期运行的脚本
使用 at 命令安排在未来的预设时间运行某个脚本固然不错,但如果需要脚本在每天、每周或每月的同一时间运行呢?这时候与其频繁使用 at 命令,不如利用 Linux 系统的另一个特性。
Linux 系统使用 cron 程序调度需要定期执行的作业。cron 在后台运行,并会检查一个特殊的表(cron 时间表),从中获知已安排执行的作业。
cron 时间表
cron 时间表通过一种特别的格式指定作业何时运行,其格式如下:
minutepasthour hourofday dayofmonth month dayofweek command
cron 时间表允许使用特定值、取值范围(比如 1~5)或者通配符(星号)来指定各个字段。如果想在每天的 10:15 运行一个命令,可以使用如下 cron 时间表字段:
15 10 * * * command
dayofmonth、month 以及 dayofweek 字段中的通配符表明,cron 会在每天 10:15 执行该命令。要指定一条在每周一的下午 4:15(4:15 p.m.)执行的命令,可以使用 24 小时制(1:00p.m.是 13:00,2:00 p.m.是 14:00,3:00 p.m.是 15:00,以此类推),如下所示:
15 16 * * 1 command
可以使用三字符的文本值(mon、tue、wed、thu、fri、sat、sun)或数值(0 或 7 代表周日,6 代表周六)来指定 dayofweek 字段。
这里还有另外一个例子。要想在每月第一天的中午 12 点执行命令,可以使用下列字段:
00 12 1 * * command
dayofmonth 字段指定的是月份中的日期值(1~31)。
你可能会想,如何设置才能让命令在每月的最后一天执行,因为无法设置一个 dayofmonth 值,涵盖所有月份的最后一天。常用的解决方法是加一个 if-then 语句,在其中使用 date 命令检查明天的日期是不是某个月份的第一天(01):
00 12 28-31 * * if [ "$(date +%d -d tomorrow)" = 01 ] ; then command ; fi
这行脚本会在每天中午 12 点检查当天是不是当月的最后一天(28~31),如果是,就由 cron 执行 command。
另一种方法是将 command 替换成一个控制脚本(controlling script),在可能是每月最后一天的时候运行。控制脚本包含 if-then 语句,用于检查第二天是否为某个月的第一天。如果是,则由控制脚本发出命令,执行必须在当月最后一天执行的内容。
命令列表必须指定要运行的命令或脚本的完整路径。你可以像在命令行中那样,添加所需的任何选项和重定向符:
15 10 * * * /home/christine/backup.sh > backup.out
cron 程序会以提交作业的用户身份运行该脚本,因此你必须有访问该脚本(或命令)以及输出文件的合理权限。
构建 cron 时间表
每个用户(包括 root 用户)都可以使用自己的 cron 时间表运行已安排好的任务。Linux 提供了 crontab 命令来处理 cron 时间表。要列出已有的 cron 时间表,可以用-l 选项:
crontab -l
# no crontab for roger
在默认情况下,用户的 cron 时间表文件并不存在。可以使用-e 选项向 cron 时间表添加字段。在添加字段时,crontab 命令会启动一个文本编辑器,使用已有的 cron 时间表作为文件内容(如果时间表不存在,就是一个空文件)。
浏览 cron 目录
如果创建的脚本对于执行时间的精确性要求不高,则用预配置的 cron 脚本目录会更方便。预配置的基础目录共有 4 个:hourly、daily、monthly 和 weekly。
ls /etc/cron.*ly
# /etc/cron.daily:
# apport apt-compat dpkg logrotate man-db
# /etc/cron.hourly:
# /etc/cron.monthly:
# /etc/cron.weekly:
# man-db
如果你的脚本需要每天运行一次,那么将脚本复制到 daily 目录,cron 就会每天运行它。
anacron 程序
cron 程序唯一的问题是它假定 Linux 系统是 7×24 小时运行的。除非你的 Linux 运行在服务器环境,否则这种假设未必成立。
如果某个作业在 cron 时间表中设置的运行时间已到,但这时候 Linux 系统处于关闭状态,那么该作业就不会运行。当再次启动系统时,cron 程序不会再去运行那些错过的作业。为了解决这个问题,许多 Linux 发行版提供了 anacron 程序。
如果 anacron 判断出某个作业错过了设置的运行时间,它会尽快运行该作业。这意味着如果 Linux 系统关闭了几天,等到再次启动时,原计划在关机期间运行的作业会自动运行。有了 anacron,就能确保作业一定能运行,这正是通常使用 anacron 代替 cron 调度作业的原因。
anacron 程序只处理位于 cron 目录的程序,比如/etc/cron.monthly。它通过时间戳来判断作业是否在正确的计划间隔内运行了。每个 cron 目录都有一个时间戳文件,该文件位于/var/spool/anacron:
ls /var/spool/anacron
# cron.daily cron.monthly cron.weekly
$ sudo cat /var/spool/anacron/cron.daily
# 20200619
anacron 程序使用自己的时间表(通常位于/etc/anacrontab)来检查作业目录:
cat /etc/anacrontab
# # /etc/anacrontab: configuration file for anacron
# # See anacron(8) and anacrontab(5) for details.
# SHELL=/bin/sh
# HOME=/root
# LOGNAME=root
# # These replace cron's entries
# 1 5 cron.daily run-parts --report /etc/cron.daily
# 7 10 cron.weekly run-parts --report /etc/cron.weekly
# @monthly 15 cron.monthly run-parts --report /etc/cron.monthly
anacron 时间表的基本格式和 cron 时间表略有不同:
period delay identifier command
period 字段定义了作业的运行频率(以天为单位)。anacron 程序用该字段检查作业的时间戳文件。delay 字段指定了在系统启动后,anacron 程序需要等待多少分钟再开始运行错过的脚本。
anacron 不会运行位于/etc/cron.hourly 目录的脚本。这是因为 anacron 并不处理执行时间需求少于一天的脚本。
identifier 字段是一个独特的非空字符串,比如 cron.weekly。它唯一的作用是标识出现在日志消息和错误 email 中的作业。command 字段包含了 run-parts 程序和一个 cron 脚本目录名。run-parts 程序负责运行指定目录中的所有脚本。
at、cron 和 anacron 在调度作业运行方面各占有一席之地。然而,你可能希望在用户启动新的 bash shell 而不是特定时刻执行某个脚本。
使用新 shell 启动脚本
如果每次用户启动新的 bash shell 时都能运行相关的脚本(哪怕是特定用户启动的 bash shell),那将会非常方便,因为有时候你希望为 shell 会话设置某些 shell 特性,或者希望已经设置了某个文件。
这时可以回想一下当用户登录 bash shell 时要运行的启动文件。另外别忘了,不是所有的发行版都包含这些启动文件。基本上,以下所列文件中的第一个文件会被运行,其余的则会被忽略。
- $HOME/.bash_profile
- $HOME/.bash_login
- $HOME/.profile
因此,应该将需要在登录时运行的脚本放在上述第一个文件中。
每次启动新 shell,bash shell 都会运行.bashrc 文件。对此进行验证,可以使用这种方法:在主目录下的.bashrc 文件中加入一条简单的 echo 语句,然后启动一个新 shell。
cat $HOME/.bashrc
# .bashrc
# Source global definitions
if [ -f /etc/bashrc ]; then
. /etc/bashrc
fi
# User specific environment
PATH="$HOME/.local/bin:$HOME/bin:$PATH"
export PATH
# Uncomment the following line if you don't like systemctl's autopaging feature:
# export SYSTEMD_PAGER=
# User specific aliases and functions
echo "I'm in a new shell!"
bash
# I'm in a new shell!
.bashrc 文件通常也借由某个 bash 启动文件来运行,因为.bashrc 文件会运行两次:一次是当用户登录 bash shell 时,另一次是当用户启动 bash shell 时。如果需要某个脚本在两个时刻都运行,可以将其放入该文件中。