linux cron - 如何避免执行重复的命令

前言

通常我们在使用 cron 执行 linux 命令的时候,需要避免执行相同的重复的命令;那么如何在 linux 上避免重复执行相同命令呢?通常有两种做法,

  1. 通过 Lock file 避免
    通常在 job 执行的时候,生成一个 .lock 文件,来判断当前的 job 是否正在执行;
  2. 通过 PID file 避免
    在 job 执行的时候,生成一个 .pid 文件,来表示当前 job 正在执行;

两种方式都可以避免重复 job 的执行,但是,笔者更倾向于后者,因为不仅 .pid 文件包含了 job 的 PID 信息,而且,很多时候,.lock 文件存在并不意味着 job 正在执行,有可能该 job 意外终止,导致 job 并没有及时删除 .lock 文件,而 .pid 文件因为包含了 PID 信息,可以通过 ps 命令判断当前的 job 是否真的在执行过程中,进而避免通过 .lock 进行判断的局限性;

PID file

本章节将讲述,如何自建 shell 脚本来避免执行重复的 cron 命令;首先,假设我们有一个 job,forever.sh,定义如下,

forever.sh

1
2
#!/bin/bash
sleep 25d

该 job 非常的简单,只要一开始运行便会休眠 25 天直到结束;那么如何通过 .pid 文件来避免该 job 被重复执行呢?首先,我们来看看如何生成 .pid 文件,

生成 .pid 文件

那么,如何生成 .pid 文件呢?

1
2
3
4
5
6
7
8
9
10
11
12
PIDFILE=/home/vagrant/forever.pid
if [ -f $PIDFILE ]
then
# do something
else
echo $$ > $PIDFILE
if [ $? -ne 0 ]
then
echo "Could not create PID file"
exit 1
fi
fi

首先,上述代码第 2 行,判断该 PID 文件是否存在,若不存在,则创建,注意,

1
echo $$ > $PIDFILE

这行命令是把当前的进程号 pid 输出到文件 \$PIDFILE 中,紧接着,一个判断

1
if [ $? -ne 0 ]

该行命令是通过命令 \$? 来检测上一个命令的返回值是否为 0,也就是判断进程号是否成功写入 PID 文件中,否则,输出警告信息,并退出当前进程;至此,我们便成功的生成了 .pid 文件;

核验 .pid 文件中的进程是否真的在执行

但是,我们并不能只通过 .pid 文件是否存在来判断当前的 job 是否在执行,因为,很有可能该 job 异常退出了,.pid 文件并没有来得及清理;因此在启动该 job 的时候,不但需要判断 .pid 文件是否存在,我们还需要检查 .pid 文件中的进程号 PID 是否真的在执行,这一点,也就是通过 .pid 文件来避免重复执行和通过 .lock 文件来避免重复执行的根本区别的地方了;看看我们该如何实现?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
PIDFILE=/home/vagrant/forever.pid
if [ -f $PIDFILE ]
then
PID=$(cat $PIDFILE)
ps -p $PID > /dev/null 2>&1
if [ $? -eq 0 ]
then
echo "Job is already running"
exit 1
else
## Process not found assume not running
echo $$ > $PIDFILE
if [ $? -ne 0 ]
then
echo "Could not create PID file"
exit 1
fi
fi
else
echo $$ > $PIDFILE
if [ $? -ne 0 ]
then
echo "Could not create PID file"
exit 1
fi
fi

新增代码第 4 - 18 行,

1
2
3
4
5
6
7
8
9
PID=$(cat $PIDFILE)
ps -p $PID > /dev/null 2>&1
if [ $? -eq 0 ]
then
echo "Job is already running"
exit 1
else
...
fi

上面新增代码的核心逻辑就是判断 PID 文件中的当前进程是否真的存在?通过 ps -p \$PID 命令检测,然后再次通过 \$? 命令检查其返回结果,若返回值为 0,表示该当前进程是真的存在的;若返回值不为 0,则证明该进程已经退出,那么表示虽然该 .pid 文件依然存在,但是实际上其对应的进程早已死掉了;

汇总 - forever job

经过上述的分析以后,我们来写一下这个完整的 forever job,forever.sh,是怎样的?
forever.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/bin/bash

PIDFILE=/home/vagrant/forever.pid
if [ -f $PIDFILE ]
then
PID=$(cat $PIDFILE)
ps -p $PID > /dev/null 2>&1
if [ $? -eq 0 ]
then
echo "Process already running"
exit 1
else
## Process not found assume not running
echo $$ > $PIDFILE
if [ $? -ne 0 ]
then
echo "Could not create PID file"
exit 1
fi
fi
else
echo $$ > $PIDFILE
if [ $? -ne 0 ]
then
echo "Could not create PID file"
exit 1
fi
fi

sleep 25d
rm $PIDFILE

执行完命令后,删除该 PID 文件即可;这样,就可以保证 cron 执行的时候,不会重复的执行相同的 job 了;

工具

实际上,Linux 中已经有相应的工具可以避免重复的命令执行,

flock

linux distributions

flock 在大多数的 linux distributions 中已经默认安装了,它的实现方式跟 .lock 文件的方式非常的类似,只是特殊的地方是,它的核心实现逻辑是,通过对一个文件加锁来实现,若能够对某个文件进行加锁,则表示当前 job 并没有执行,若加锁失败,则表示当前进行正在执行,

1
$ flock -xn /home/vagrant/forever.lck -c /var/tmp/forever.sh

  • -x
    这里区别于-s,是生成一个排他性锁,也就是写锁,这个是默认行为
  • -n
    当加锁失败后,使用 non-blocking 模式,也就是指返回一个错误码,并不会阻塞后续命令的执行;这样,我们可以通过 \$? 得到命令执行的结果,进行判断处理;
  • -c
    执行对应的命令

看两个例子,

1
2
3
shell1> flock /tmp -c cat
shell2> flock -w .007 /tmp -c echo; /bin/echo $?
Set exclusive lock to directory /tmp and the second command will fail.
  • shell1 在执行 cat 命令的时候,给 /tmp 目录上了一个排他性所,既写锁,因此第二个命令再次执行的时候会失败
1
2
3
shell1> flock -s /tmp -c cat
shell2> flock -s -w .007 /tmp -c echo; /bin/echo $?
Set shared lock to directory /tmp and the second command will not fail. Notice that attempting to get exclusive lock with second command would fail.

相反,如果我们通过-s上的是一个 share lock,也就是读锁,那么 shell2 会执行成功;综上,也就是说,如果要避免重复 job 执行,必须使用-x给被执行的 job 加上一个排他性锁;

下面,测试一个重要的例子,就是当某个正在执行的 job 被 kill 掉的情形,
forever.sh

1
2
3
4
#!/bin/bash
echo "start to sleep 25 days"
sleep 25d
echo "sleep end"

  1. 执行

    1
    2
    3
    $ flock -n forever.lck ./forever.sh; echo $?
    start to sleep 25 days
    0

    新开 command 窗口,再次执行

    1
    2
    $ flock -n forever.lck ./forever.sh; echo $?
    1

    可以看到,再次执行失败

  2. kill 掉正在执行 job
    注意,这类需要使用命令 kill -9 命令强制退出,模拟意外情况;
  3. 再次执行

    1
    2
    $ flock -n forever.lck ./forever.sh; echo $?
    1

    可以看到,当正在执行的 job 意外终止以后,flock 锁有可能并不会释放,导致即便是该 job 当前并没有在执行,但是新的 job 依然不能执行;那么,我们该如何避免这种情况呢?该情况在 MacOS 中依然存在,为了统一描述,解决办法见下一章节;

MacOS

MacOS 上如何执行 flock 命令?

因为 macos 上并没有默认安装 flock,因此,这里需要额外的安装,这里使用到的是 https://github.com/discoteq/flock 通过如下的命令进行安装,

1
2
$ brew tap discoteq/discoteq
$ brew install flock

那么,我们试着将本文一开始的 forever.sh 使用 flock 的方式来看,如何避免它被重复执行,我们在 ~/tmp 目录中创建,
forever.sh

1
2
3
4
#!/bin/bash
echo "start to sleep 10000 seconds"
sleep 10000
echo "sleep end"

注意,在 mac 的 sleep 命令只有 seconds,所以不能使用 sleep 25d 这样的方式

  1. 开始执行

    1
    tmp$ flock forever.lck ./forever.sh > /dev/null 2>&1 &
  2. 重复执行验证

    1
    2
    tmp$ flock -n forever.lck ./forever.sh; echo $?
    1

    可以看到,当我们重复再次执行的时候,返回错误码 1;

但是,如果 #1 的 job 被 killed,这个时候,forever.lck 的锁并不会被释放,如果执行 #2 的命令,依然会返回错误代码 1,这是笔者不期望看到的,看来,如果这种情况发生,必须得有相关的饿补救措施,于是创建了如下的执行脚本
flock_forever.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PID=$(ps -ef | grep forever.sh | grep -v grep | awk '{print $2}')
ps -p $PID > /dev/null 2>&1
if [ $? -ne 0 ]
then
echo "the forever job NOT exists"
echo "try to unlock forever.lck"
flock -un ~/tmp/forever.lck
if [ $? -ne 0 ]
then
echo "unlock the forever.lck file failed, try to remove it"
rm ~/tmp/forever.lck
fi
fi
flock -n forever.lck ./forever.sh; echo $?

备注,同样的测试,在 Linux Distributions 中就没有这种问题,即便是被 kill,forever.lck 的锁会自动释放,不会影响 job 的再次执行;flock_forever.sh 分别在 MacOS 和 Linux 上执行的日志,

linux

1
2
3
4
5
the forever job NOT exists
try to unlock forever.lck
flock: bad file descriptor: '/home/macooo/tmp/forever.lck'
unlock the forever.lck file failed, try to remove it
start to sleep 25 days

注意,如果是 linux 上,当 job 被意外 killed,当通过 flock -u 去释放锁的时候,会报 flock: bad file descriptor: ‘/home/macooo/tmp/forever.lck’ 这样的错误,这个时候,需要删除锁文件即可

macos

1
2
3
the forever job NOT exists
try to unlock forever.lck
start to sleep 10000 seconds

solo program

参考 http://timkay.com/solo/ 它不但可以通过 .lock 文件来限制同一时间不能有重复的 job 执行,还能通过锁定端口来显示重复的 jobs;

References

https://bencane.com/2015/09/22/preventing-duplicate-cron-job-executions/