确保只有一个Bash脚本正在运行
1. 简介
在本教程中,我们将讨论确保只有一个 bash 脚本实例正在运行的不同方法。当我们的脚本需要独占访问某些资源时,这将很有用。一个常见的例子是,当我们需要运行一个cron作业时,它才尚未运行 。
我们可以通过多种方式来预防不止一个实例,这可以分为两大类。一种方法是使用带有flock 或*lockfile *的锁。另一个是确定进程是否已经在运行,例如,使用pid 文件 。
2. 使用flock
我们可以使用flock在文件上创建锁 。这个想法是我们首先尝试获取锁,如果失败,则意味着有另一个实例在运行。
我们可以对这种方法充满信心,因为不会有竞争条件。此外,一旦进程退出,文件上的任何锁都会被释放。这些优势使flock成为确保只有一个实例运行的安全方式。另一个优点是flock程序是flock 系统调用的一个实现。
默认情况下, flock会阻塞,直到锁被释放,然后继续没有错误。我们可以使用参数*-n以非阻塞方式使用flock* 。当文件上存在另一个锁时,这将使flock立即退出并出现错误。
如果我们不希望脚本在它已经运行的情况下再次运行,我们应该使用-n。另一方面,如果我们不使用-n,flock将阻塞,并且一旦前一个实例终止,脚本将再次运行。
我们需要选择一个文件作为 lock。这个文件需要对我们的脚本是唯一的,并且不被其他进程共享。
2.1. 执行外部脚本
我们可以通过两种方式使用flock。其中之一是将脚本放在一个单独的文件中并使用flock来调用它。
这样使用flock,我们不需要修改脚本。当我们想要保护任意脚本或二进制文件时,这很有用。
我们只需要锁定文件的路径和脚本的路径:
$ flock -n <lock file> <script>
**让我们在执行名为dobackup.sh的外部脚本时使用flock,*使用文件/var/lock/dobackup.lock*作为锁:
$ flock -n /var/lock/dobackup.lock ./dobackup.sh
现在,假设我们的脚本当前正在运行。让我们看看如果我们再次运行上面的行会发生什么:
$ flock --verbose -n /var/lock/dobackup.lock ./dobackup.sh
flock: failed to get lock
$ echo $?
1
我们可以看到,flock通知我们它未能获得锁并以值 1 退出(错误)。这意味着另一个实例拥有锁。
当flock失败时,它不会运行脚本参数,从而阻止执行多个dobackup.sh实例。
2.2. 在脚本中使用flock
我们可以使用flock的另一种方法是将它添加到我们的脚本中。在这种情况下,我们使用文件描述符调用flock:
$ flock -n <file descriptor>
要以这种方式使用flock,我们将需要保护的所有内容括在圆括号(一个子shell )中,并将其重定向 到我们用作锁的文件。我们以重定向中使用的文件描述符开头调用flock。然后,如果flock退出并出现错误,我们就知道有另一个实例在运行。
一旦该子外壳终止,锁定文件将关闭并自动释放锁定。
现在,让我们看看我们的脚本dobackup.sh做了什么:
#!/bin/bash
DEST=/home/backup/`date +%s`
mkdir -p "$DEST"
rsync -avz [[email protected]](/cdn_cgi/l/email_protection):/home/web "$DEST/."
然后,让我们在脚本中添加flock:
#!/bin/bash
another_instance()
{
echo "There is another instance running, exiting"
exit 1
}
( flock -n 100 || another_instance DEST=/home/backup/`date +%s` mkdir -p "$DEST" rsync -avz [[email protected]](/cdn_cgi/l/email_protection):/home/web "$DEST/." ) 100>/var/lock/dobackup.lock
在此示例中,我们在重定向到锁定文件时使用文件描述符 100。另外,如果flock失败,我们调用another_instance,通知有另一个实例,然后退出。
3. 使用lockfile
lockfile是由procmail 提供的,我们可以在脚本中使用它,就像我们使用flock一样。如果我们的系统上没有它,我们可以使用包管理器 安装它。
**使用lockfile,我们指定将用作锁的文件。如果文件已经存在,lockfile将退出并报错。**因此,这意味着有另一个实例正在运行。如果lockfile成功完成,则意味着没有其他实例,我们可以继续执行我们的脚本。
**我们必须选择一个脚本将用作锁的文件。**在运行我们的脚本之前,该文件不能存在,因为lockfile将其解释为文件锁定了某些东西。此外,该文件需要对我们的脚本是唯一的,并且不被其他进程共享。最后,我们在脚本退出时删除文件,这可以通过trap来实现。
根据脚本和情况,有三个参数可能有用:
- -l timeout : 建立一个超时时间,以秒为单位,之后锁文件将被强制删除
- -r retries:指定第一次尝试获取锁失败时重试多少次;-1 的值意味着永远重试
- -sleeptime:指定在重试之前它将休眠的秒数
可能存在未删除文件的情况。如果我们认为文件太旧,我们可以使用*-l 超时参数来执行脚本。*
3.1. 例子
现在,我们可以修改之前的dobackup.sh脚本以使用lockfile:
#!/bin/bash
LOCK=/var/lock/dobackup.lock
remove_lock()
{
rm -f "$LOCK"
}
another_instance()
{
echo "There is another instance running, exiting"
exit 1
}
lockfile -r 0 -l 3600 "$LOCK" || another_instance
trap remove_lock EXIT
DEST=/home/backup/`date +%s`
mkdir -p "$DEST"
rsync -avz [[email protected]](/cdn_cgi/l/email_protection):/home/web $DEST/.
这样,我们在执行任何其他操作之前调用lockfile并在lockfile失败时退出。
如果锁定失败,我们指定不重试。如果我们愿意,我们可以使用*-r 5在退出前重试五次以获取锁。此外,我们使用trap来确保每当脚本退出时,都会调用remove_lock* 。最后,我们为锁指定了 3600 秒的超时时间,因此如果存在过期锁,则执行脚本。
4. 将 PID 写入文件
我们也可以选择使用pid 文件 。**这个想法是在检查它不存在之后将我们进程的PID写入文件。**此外,如果文件存在,我们可以使用命令kill -0 $PID查看文件中的 PID 当前是否正在运行。
4.1. 例子
让我们在dobackup.sh示例中使用 PID 文件:
#!/bin/bash
PIDFILE=/var/lock/dobackup.pid
remove_pidfile()
{
rm -f "$PIDFILE"
}
another_instance()
{
echo "There is another instance running, exiting"
exit 1
}
if [ -f "$PIDFILE" ]; then
kill -0 "$(cat $PIDFILE)" && another_instance
fi
trap remove_pidfile EXIT
echo $$ > "$PIDFILE"
DEST=/home/backup/`date +%s`
mkdir -p "$DEST"
rsync -avz [[email protected]](/cdn_cgi/l/email_protection):/home/web $DEST/.
在这种情况下,我们使用*/var/run/dobackup.pid*来存储 pid。
脚本完成后,我们使用陷阱删除 PID 文件。我们必须考虑到陷阱并非在所有情况下都有效,例如SIGKILL。
4.2. 缺点
如果我们使用这种方法,我们必须考虑到可能存在竞争条件。两个进程可以同时执行if语句,而无需创建任何 pid 文件。
此外,在陈旧的 PID 文件中,可能有一个有效的 PID 分配给不同的程序。
5. 使用目录
我们拥有的另一个选项是使用目录来指示正在运行的实例。我们只需要使用mkdir创建一个目录。如果目录已经存在,mkdir失败,这意味着有另一个实例。但是如果它成功退出,就会创建锁定目录,我们可以运行脚本。
这种方法的优点是我们不会在一个步骤中检查锁定是否存在,然后在另一个步骤中创建锁定。两步锁定容易出现竞争条件。相反,使用mkdir,我们可以一步完成。
5.1. 例子
要查看实际情况,让我们编辑dobackup.sh以使用目录:
#!/bin/bash
LOCK=/var/lock/dobackup.lock
remove_lock()
{
rm -rf "$LOCK"
}
another_instance()
{
echo "There is another instance running, exiting"
exit 1
}
mkdir "$LOCK" || another_instance
trap remove_lock EXIT
DEST=/home/backup/`date +%s`
mkdir -p "$DEST"
rsync -avz [[email protected]](/cdn_cgi/l/email_protection):/home/web $DEST/.
5.2. 缺点
我们应该处理创建但没有运行相应进程的旧目录。即使我们使用trap,某些信号(如SIGKILL或断电)也会留下陈旧的目录。
在这种情况下,我们没有使用 pid 文件时创建目录的 PID 信息。这意味着我们无法检查进程是否还活着。
6. 寻找过程
最后,我们可以搜索进程,看看它是否正在运行。我们可以使用pgrep或lsof来实现这一点。
如果我们使用这种方法,我们不需要任何额外的文件。
6.1. 使用pgrep
我们可以使用pgrep来搜索我们的脚本名称,如果找不到就执行它。一种方法是在脚本之外一行:
$ pgrep dobackup.sh || ./dobackup.sh
在这种情况下,我们不需要修改dobackup.sh。 我们的另一个选择是在脚本中添加pgrep:
#!/bin/bash
another_instance()
{
echo "There is another instance running, exiting"
exit 1
}
if [ "$(pgrep dobackup.sh)" != $$ ]; then
another_instance
fi
DEST=/home/backup/`date +%s`
mkdir -p "$DEST"
rsync -avz [[email protected]](/cdn_cgi/l/email_protection):/home/web $DEST/.
在脚本中使用pgrep时,我们总是至少有一个实例。如果只有一个(脚本本身),pgrep将只打印当前 PID。如果有更多实例,将打印所有 PID,并且输出将与我们当前的 PID 不同。
6.2. 使用lsof
使用lsof,我们检查脚本是否被任何进程打开。如果它是打开的,我们将其解释为另一个正在运行的实例。 与pgrep示例类似,我们可以在脚本外部或内部进行检查。让我们先在脚本之外尝试,不修改脚本:
$ lsof dobackup.sh || ./dobackup.sh
作为替代方案,我们可以在脚本中使用lsof:
#!/bin/bash
another_instance()
{
echo "There is another instance running, exiting"
exit 1
}
INSTANCES=`lsof -t "$0" | wc -l` if [ "$INSTANCES" -gt 1 ]; then
another_instance
fi
DEST=/home/backup/`date +%s` mkdir -p "$DEST" rsync -avz [[email protected]](/cdn_cgi/l/email_protection):/home/web $DEST/.
当我们在脚本中运行lsof时,总会有至少一个实例在运行。我们使用了参数*-t所以lsof*不打印任何标题,只打印打开的文件。
我们还使用*$0*使脚本更便携。这样,我们就不必担心实际的脚本名称以防万一发生变化。
6.3. 缺点
在使用这些方法时,我们会遇到假阳性和假阴性。
脚本可能由另一个进程打开,例如文件编辑器。此外,pgrep可能会找到另一个与我们的脚本名称相似的进程。这两种情况都会导致误报,从而阻止我们的脚本运行。
我们还必须考虑假阴性。如果脚本被重命名,我们将搜索旧名称,除非我们更新脚本。当我们使用lsof时,如果有脚本副本在运行,它就不会找到另一个实例。