Contents

确保只有一个Bash脚本正在运行

1. 简介

在本教程中,我们将讨论确保只有一个 bash 脚本实例正在运行的不同方法。当我们的脚本需要独占访问某些资源时,这将很有用。一个常见的例子是,当我们需要运行一个cron作业时,它才尚未运行

我们可以通过多种方式来预防不止一个实例,这可以分为两大类。一种方法是使用带有flock 或*lockfile *的锁。另一个是确定进程是否已经在运行,例如,使用pid 文件

2. 使用flock

我们可以使用flock在文件上创建这个想法是我们首先尝试获取锁,如果失败,则意味着有另一个实例在运行。

我们可以对这种方法充满信心,因为不会有竞争条件。此外,一旦进程退出,文件上的任何锁都会被释放。这些优势使flock成为确保只有一个实例运行的安全方式。另一个优点是flock程序是flock 系统调用的一个实现。

默认情况下, flock会阻塞,直到锁被释放,然后继续没有错误。我们可以使用参数*-n以非阻塞方式使用flock* 。当文件上存在另一个锁时,这将使flock立即退出并出现错误。

如果我们不希望脚本在它已经运行的情况下再次运行,我们应该使用-n另一方面,如果我们不使用-nflock将阻塞,并且一旦前一个实例终止,脚本将再次运行。

我们需要选择一个文件作为 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. 寻找过程

最后,我们可以搜索进程,看看它是否正在运行。我们可以使用pgreplsof来实现这一点。

如果我们使用这种方法,我们不需要任何额外的文件。

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时,如果有脚本副本在运行,它就不会找到另一个实例。