Bash最佳实践:孤独行者
摘要
在开发程序时,我们会尽量规避冲突 (conflicts) 和竞态条件 (race conditions) ,编写bash脚本时同理。 为脚本中增加防止重复运行机制,使脚本进程单实例的运行,可以有效避免冲突和竞态条件的发生。
本文介绍了在bash脚本中基于进程名匹配,PID查找,文件和文件锁的防重机制的实现。
背景
设想有一个备份脚本的功能是把家目录的内容 rsync 到远程服务器,该脚本通过 crontab 设置为每间隔一小时运行一次, 到目前为止一切工作正常直到某天用户拷贝了几个体积很大的 ISO 镜像到家目录,巨量的数量增加导致了 rsync 所需的同步时间远超一个小时。 在第一个实例同步完成前,cron 又启动了第二个同步实例,网络带宽是固定的,这进一步降低了单个实例的同步速率。之后 cron 继续调度了第三个实例, 接着是第四个,第五个……管理员在知晓该问题前,服务器已经不可用了。
防重机制实现
防重机制在bash脚本中,一般通过进程名匹配,PID查找,文件或者是文件锁的机制实现,下文给出了基于这四种实现的示例,解释和分析了实现的优劣势。
进程名匹配
该实现匹配当前脚本的文件名与正在运行的所有进程的commandline
,如果有匹配的进程,则认为该脚本已经有实例正在运行,当前实例则执行退出逻辑。
新建示例程序singleton-by-name.sh
,该示例演示如何使用进程名匹配的方式来实现防重机制。
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_NAME=${0##*/}
if pgrep -f "${SCRIPT_NAME}" >/dev/null; then
echo "another process is running now, exit"
exit 0
fi
# Do something anything here
echo "I'm doing something here, sleep 30s"
sleep 30
执行效果:
bash-5.2$ bash ./singleton-by-name.sh &
[1] 7523
bash-5.2$ I'm doing something here, sleep 30s
bash-5.2$
bash-5.2$ bash ./singleton-by-name.sh
another process is running now, exit
bash-5.2$
可以看到,在重复运行singleton-by-name.sh
时,检测到了之前的脚本还在执行,当前执行的脚本主动退出了。
该实现的优点是代码简单易懂,缺点是当脚本名称的辨识度低时,容易出现误判。
PID查找
脚本运行时把自身的PID写入到文件,并在写入前查找文件中的PID对应的进程是否存在,如果进程存在,则表明该脚本已经有实例正在运行, 当前实例则执行退出逻辑。
新建示例程序singleton-by-pid.sh
,该示例演示如何使用PID查找的方式来实现防重机制。
#!/usr/bin/env bash
set -euo pipefail
if pgrep -F /var/tmp/my-script-singleton.pid 1>/dev/null 2>&1; then
echo "another process is running now, exit"
exit 0
fi
echo $$ >/var/tmp/my-script-singleton.pid
# Do something anything here
echo "I'm doing something here, sleep 30s"
sleep 30
执行效果:
bash-5.2$ bash ./singleton-by-pid.sh &
[1] 15877
bash-5.2$ I'm doing something here, sleep 30s
bash-5.2$
bash-5.2$ bash ./singleton-by-pid.sh
another process is running now, exit
bash-5.2$
该脚本每次运行前查找文件中的PID是否存在,如果不存在,则把自身的PID写入到文件,用于脚本下次执行时查找。 该实现依然很简单易懂,相比于进程名匹配的实现,降低了误判的概率。但由于PID由操作系统分配,仍然存在误判的可能性。
文件
脚本在开始运行时,创建一个文件,在结束运行时,删除该文件。脚本进程的生命周期与文件相同,如果文件存在,则表明该脚本正在运行中。
新建示例程序singleton-by-file.sh
,该示例演示如何通过判断一个文件是否存在来实现防重机制。
#!/usr/bin/env bash
set -euo pipefail
# Check is lock file exists, if not create it and set trap on exit
if {
set -C
echo 2>/dev/null $$ >/var/tmp/my-script-singleton.lock
}; then
trap 'rm -f /var/tmp/my-script-singleton.lock' EXIT
else
echo "another process is running now, exit"
exit 0
fi
# Do something anything here
echo "I'm doing something here, sleep 30s"
sleep 30
执行效果:
bash-5.2$ bash ./singleton-by-file.sh &
[1] 8449
bash-5.2$ I'm doing something here, sleep 30s
bash-5.2$
bash-5.2$ bash ./singleton-by-file.sh
another process is running now, exit
bash-5.2$
从脚本输出可以看到该脚本同样有防重效果。
在该脚本中,set -C
(同set -o noclobber
官方文档
)
表示在使用>
>&
<>
重定向输出时,不覆盖已经存在的文件,同时使用命令组{}
限定set -C
仅在命令组范围内生效。
如果文件/var/tmp/my-script-singleton.lock
不存在,则会创建该文件并把当前进程的PID $$
写入到文件,
在命令组中的两命令成功执行后,trap
命令会在脚本退出的时候,删除掉文件/var/tmp/my-script-singleton.lock
。
建议使用trap
命令移除锁文件,而非在脚本的最后直接用rm
命令移除。原因是存在脚本执行过程中可能会出错退出,或脚本进程意外被终止的情况,
这将导致锁文件没有被正确清理,会一直存在,影响脚本的二次运行。
在重复运行脚本时,由于文件存在且设置了set -C
,echo
命令重定向覆盖文件会失败,此时if
语句走到else
分支,打印提示信息并退出。
此方式也存在误判的概率,如主机突然断电或bash解释器被强制终止(kill -9
)导致锁文件没有被删除的情况,
且需要外部机制介入删除锁文件后才能恢复,不具备自愈能力。
另外用到了bash的命令组{}
,noclobber
和trap
等特性,技术难度上有提升。
文件锁
flock
是一个内核级别的系统调用,同时也是一个命令行工具,命令flock
用于管理从脚本或命令行中发起的系统调用。
脚本进程开始运行时向操作系统申请文件锁,并在其整个生命周期内持有该文件锁。在此期间,由于申请的是排它锁,重复运行的其它进程尝试获取锁时会失败, 从而进入失败流程执行退出逻辑。
新建示例程序singleton-by-flock.sh
,该示例演示如何通过文件锁来实现防重机制。
#!/usr/bin/env bash
set -euo pipefail
# Create a file located at /var/tmp/my-script-singleton.lock
# with file descriptor number 100
exec 100>/var/tmp/my-script-singleton.lock
# Try to acquire the file lock in a non-blocking manner,
# If the lock is successfully obtained, it will be held throughout the life cycle of the process.
# If unsuccessful, it means another process has acquired the lock on this file descriptor,
# so output a message and exit.
# The default behavior of flock is to wait indefinitely to acquire a lock,
# -n, --nb, --nonblock: Fail rather than wait if the lock cannot be immediately acquired.
if ! flock -n 100; then
echo "another process is running now, exit"
exit 0
fi
# Do something anything here
echo "I'm doing something here, sleep 30s"
sleep 30
执行效果:
bash-5.2$ bash ./singleton-by-flock.sh &
[1] 13405
bash-5.2$ I'm doing something here, sleep 30s
bash-5.2$
bash-5.2$ bash ./singleton-by-flock.sh
another process is running now, exit
bash-5.2$
该示例使用了文件锁机制,锁与持有锁的进程的生命周期相同,不会存在误判的情况。
另一个优点是命令flock
有很多控制锁的选项,这为脚本提供了更多的灵活性,如:
- 可以使用
-u
选项释放锁,如flock -u 100
,可以在脚本执行的过程中释放锁,这可以控制锁的粒度,仅在访问会发生冲突的资源时加锁; - 命令
flock
在获取锁时,提供了超时参数-w seconds, --timeout
,如flock -w 5 100
表示尝试在最多5秒的时间内获取到锁; - 默认获取的是排它锁 exclusive lock(也称作写锁 write lock),通过参数
-s, --shared
可以获取到共享锁 shared lock(也称作读锁 read lock); - 在获取到锁的同时还可以执行命令,如
flock -w 5 echo "successfully obtained file lock"
。
结合上一个示例的内容,也可以使用trap
命令在脚本执行结束时移除掉锁文件,trap 'rm -f /var/tmp/my-script-singleton.lock' EXIT
。
由于涉及到了exec
操作文件描述符,文件锁等bash脚本中的高级特性,脚本的技术难度上有更多的提升,但从功能实现,脚本编写的角度看,
该实现是这四个示例中最简洁优雅的。
总结
比较以上四种实现方式,PID查找方案实现简单易懂,没有使用到bash的高级特性,推荐简单场景下使用。 文件锁方案虽使用到了锁,文件描述符等较复杂的高级特性,但其实现代码简洁,且提供了更多的控制选项和灵活性,强烈推荐使用。
总体而言,选择防重机制的实现应从实际的需求场景出发,开发者根据实际情况选择相应的方案。