Instance lock of shell script

Overview

有时候使用shell实现的业务逻辑中,需要确保单实例运行,避免并发执行影响原有逻辑。

虽然shell理论上不太适合特别重的、复杂的业务,不过需要实例锁的情况还是存在的,有需求就要有对应的实现。

有位前辈说:“所有的单实例锁都是基于文件实现的。”,想了想,要在不同实例间共享一个锁,文件确实应该是一个最简便的方法。在网上看了下现有的实现,也基本上是基于文件的。

shell的文件锁,基本可以分为两大类:自行实现或使用现有工具如flock。

自行实现的文件锁

自行实现的文件锁基本上都是通过创建一个文件或者目录,在不同脚本实例间通过访问同一个文件或目录,用文件状态实现锁逻辑。

1
2
3
4
5
6
7
8
9
10
# Simple file lock.
lock()
{
mkdir ${LOCK} || return 1
}

unlock()
{
rm -rf ${LOCK}
}

上述简单锁实现中,加锁流程中通过mkdir创建一个锁目录,当其他实例加锁时,由于锁目录已经存在,mkdir会返回错误,即可认为加锁失败;释放锁流程中通过直接删除目录完成解锁,当下一次获取锁时可以成功创建。

通过mkdir实现的好处在于加锁操作是原子的,且mkdir在目录存在时会直接返回错误。但有几个明显问题:

  • 锁目录的父目录必须存在,否则mkdir会失败;
  • 持有锁时如果脚本出现异常终止,如收到了特定信号,或设备宕机,无法释放锁;
  • 多个实例之间必须对锁所在目录相互可见,如脚本可能在多容器场景下执行,需要处理目录共享的问题;
  • 锁所在目录须有足够的inode支持锁的创建;
  • 脚本须有对应权限创建锁。

通常情况下会选择将锁放在/run之类的公共tmpfs中,一个原因是符合其使用场景,另一个原因是遇到设备宕机这种场景,锁会随着系统重启自动释放。

但这都不是主要的,最重要的是非宕机场景下的脚本异常终止锁如何释放。加锁容易释放难,锁相关的问题最怕的除了竞争导致死锁外,就是这类锁没有按照预期释放的问题。

通常情况下会使用trap来处理脚本收到的信号:

1
2
# Trap signals.
trap unlock EXIT

这在大多数场景下是有用的,但其无法处理SIGKILL,当脚本被SIGKILL终止时,无法释放锁。这也是基于文件存在与否的锁实现最致命的问题,所以在我看来这是一种不可靠的加锁方式。

flock

flock也是一种流行的shell实例锁实现,它本身也依赖于一个特定的锁文件,但与上述判断文件存在与否的锁实现不同,flock只是需要持有一个锁文件的句柄,当flock进程退出,即便是意外的、被动的退出,也可以释放句柄,即释放锁,所以可以解决上述异常退出无法释放锁的问题。

但即便如此,还是flock有一些局限性,最大的问题就是shell的fd继承问题:

在锁保护下执行的命令,都会继承锁文件的fd,这就对执行的命令有一定的要求,需要保证脚本正常、异常流程中,不会由于某条命令还在执行,导致持有fd,锁无法释放的问题。所以要么对代码进行严格要求,要么在执行的代码中关闭对应的fd,不过两种方式都是相对麻烦的。

其实flock提供了参数-o, --close,用于在执行对应的命令之前,关闭锁fd。以这种模式运行的flock,会独立出一个flock进程,同时锁fd由flock进程持有,其他在锁保护下执行的命令将不会继承锁fd。但它的问题在于,flock进程和子进程没有亲缘关系,当flock进程终止时,子进程不受影响,这样其实锁就失效了。

关于如何解决fd继承的问题,如果可以确保代码执行的可靠性,或在执行的代码中主动关闭继承的fd,flock是可以应对绝大多数场景的。不过仔细思考一下,shell可能本身就不适合实现这种可靠性要求较高的逻辑,毕竟连设置子进程不继承fd都无法做到。

基于实例pid的实例锁

除了上述基于文件的锁以外,还可以直接通过pid去判断是否有其他实例正在运行,最直接的办法就是通过pspidofpgrep之类的命令,去获取进程的pid。

但是现有的简单获取pid的方式都有一定问题,如一般脚本的进程名就是脚本名,但是如果脚本被改名,或者通过链接执行,就很可能出现同一实例但command line不同的问题,此时通过进程名去获取pid,是不完备的。

1
2
3
4
5
ln -sfT f.sh f2.sh
./f.sh
./f2.sh
# Can't get pid of f2.sh.
pgrep -f f.sh

为了解决这个问题,一个简单的做法是实例启动之前将自身pid写入一个文件,需要获取实例pid的时候,从pid文件获取。

1
2
3
4
5
6
7
8
9
10
11
12
PIDFILE=/run/instance.pid

lock()
{
[ -s ${PIDFILE} ] && [ -d "/proc/$(cat ${PIDFILE})" ] && return 1
echo $$ > ${PIDFILE}
}

unlock()
{
> ${PIDFILE}
}

但即便像上面一样保证了pid文件内容的正确性,也不能保证锁机制的完备,因为pid可能被复用。

一个极端的场景:第一个实例运行并将当前pid写入文件,在解锁之前异常退出了,此时pid文件并没有清理。随后一个正常的其他进程启动,并复用了第一个实例运行时相同的pid。当后续实例启动时,检查有对应进程正在使用该pid,认为有实例正在运行,加锁失败,导致无法正常运行。

即,pid复用会导致这种实现下的锁无法释放。

对此,我思考了一种改进方案:既然pid复用的场景可能会导致锁无法释放,那只要再检查一下此pid是不是彼pid即可,通过一些额外信息,避免pid复用造成的锁无法释放问题。

至于额外信息,需要是能够确认进程唯一性的。查看了一下man proc,决定选用进程的启动时间来唯一确定一个进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
/proc/[pid]/stat
Status information about the process. This is used by
ps(1). It is defined in the kernel source file
fs/proc/array.c.
.....
(22) starttime %llu
The time the process started after system boot. In
kernels before Linux 2.6, this value was expressed
in jiffies. Since Linux 2.6, the value is
expressed in clock ticks (divide by
sysconf(_SC_CLK_TCK)).

The format for this field was %lu before Linux 2.6.

pid对应的stat下,记录了进程的starttime,在2.6版本以上的内核中,starttime为进程启动到系统启动之间经过的时钟周期。

对于进程,可以做一个合理假设:使用同一个pid的不同进程,不会有相同的starttime。即在给定pid的情况下,该pid对于的starttime可以确定是否发生了pid复用。

据此,改进后的锁实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PIDFILE=/run/instance.pid
LOCKFILE=/run/.lock

lock()
{
local pid
local starttime

(
flock -s 200
pid=$(cat ${${PIDFILE}} | awk '{print $1}')
starttime=$(cat ${${PIDFILE}} | awk '{print $2}')

[ -n "${pid}" -a -n "${starttime}" ] && [ "${starttime}" = "$(cat /proc/${pid}/stat | awk '{print $22}')" ] && return 1

echo "$$ $(cat /proc/$$/stat | awk '{print $22}')" > ${PIDFILE}
) 200 <> ${LOCKFILE}
}

unlock()
{
> ${PIDFILE}
}

加锁时,将自身进程pid和starttime写入pid文件。当pid文件中对应pid正在被使用,且starttime和文件中记录一致时,认为加锁失败。由于加锁的过程不原子,使用flock保护对pid文件的写入过程。

释放锁时,直接清空pid文件即可。

对于异常场景:

  • pid文件写入失败:无解,任何需要写入文件实现的锁都需要保证足够的空间和inode,即便只需要几个字节;
  • pid字段写入过程中进程异常终止且pid被复用:此时不存在starttime字段,不满足[ -n "${pid}" -a -n "${starttime}" ],可以正常加锁;
  • starttime字段写入过程中进程异常终止且pid被复用:可以认为starttime字段不完整或异常,不满足[ "${starttime}" = "$(cat /proc/${pid}/stat | awk '{print $22}')" ],可以正常加锁;
  • 释放锁之前进程异常终止且pid被复用:pid被复用后的进程starttime和文件内记录的不同,不满足[ "${starttime}" = "$(cat /proc/${pid}/stat | awk '{print $22}')" ],可以正常加锁。
  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.
  • Copyrights © 2021-2023 Martzki
  • Visitors: | Views:

请我喝杯咖啡吧~

支付宝
微信