保证只有一个shell脚本实例在运行的快速而简单的方法

211

如何快速简单地确保一个shell脚本在同一时间只有一个实例正在运行?


与Unix.SE相关:http://unix.stackexchange.com/questions/22044/correct-locking-in-shell-scripts - Palec
43个回答

246

使用flock(1)对文件描述符进行互斥的范围锁定,以实现脚本不同部分的同步。

#!/bin/bash

(
  # Wait for lock on /var/lock/.myscript.exclusivelock (fd 200) for 10 seconds
  flock -x -w 10 200 || exit 1

  # Do stuff

) 200>/var/lock/.myscript.exclusivelock

这样可以确保在()之间的代码只被一个进程运行,并且该进程不会等待过长时间获得锁。

注意:此特定命令是util-linux的一部分。如果您使用的操作系统不是Linux,则可能无法使用该命令。


19
"200" 是什么意思?手册上写着 "fd",但我不知道这是什么意思。 - chovy
7
"文件描述符"是一个整数句柄,用于指定已打开文件。 - Alex B
9
如果有其他人想知道:语法 ( 命令 A ) 命令 B 会为 命令 A 调用一个子 shell。详见 http://tldp.org/LDP/abs/html/subshells.html。但我仍不确定子 shell 和命令 B 的调用时机。 - Dr. Jan-Philip Gehrcke
1
我认为子shell内的代码应该更像这样:if flock -x -w 10 200; then ...Do stuff...; else echo "Failed to lock file" 1>&2; fi 这样,如果超时发生(其他进程已锁定文件),则此脚本不会继续修改文件。可能的反驳是:“但如果它已经花费了10秒钟而锁仍然不可用,那么它永远不会可用”,这可能是因为持有锁的进程没有终止(也许它正在被调试器运行?)。 - Jonathan Leffler
9
“200”是否特殊?还是可以是任何数字?我在每个例子中都看到了“200”。 - Lucas Pottersky
显示剩余10条评论

176

测试“锁文件”是否存在的朴素方法是有缺陷的。

为什么呢?因为它们不会在单个原子操作中检查文件是否存在并创建它。由于这个原因,就会出现竞争条件,一定会导致你试图进行互斥的尝试失败。

相反,您可以使用mkdirmkdir如果目录不存在则创建一个目录,并在目录已经存在时设置一个退出代码。更重要的是,它会在单个原子操作中执行所有操作,在这种情况下非常适用。

if ! mkdir /tmp/myscript.lock 2>/dev/null; then
    echo "Myscript is already running." >&2
    exit 1
fi

所有细节请查看优秀的BashFAQ:http://mywiki.wooledge.org/BashFAQ/045

如果您想处理过期的锁定,fuser(1)非常方便。唯一的缺点是该操作需要大约一秒钟的时间,因此不是即时的。

以下是我曾经编写的使用fuser解决问题的函数:

#       mutex file
#
# Open a mutual exclusion lock on the file, unless another process already owns one.
#
# If the file is already locked by another process, the operation fails.
# This function defines a lock on a file as having a file descriptor open to the file.
# This function uses FD 9 to open a lock on the file.  To release the lock, close FD 9:
# exec 9>&-
#
mutex() {
    local file=$1 pid pids 

    exec 9>>"$file"
    { pids=$(fuser -f "$file"); } 2>&- 9>&- 
    for pid in $pids; do
        [[ $pid = $$ ]] && continue

        exec 9>&- 
        return 1 # Locked by a pid.
    done 
}

你可以像这样在脚本中使用它:

mutex /var/run/myscript.lock || { echo "Already running." >&2; exit 1; }

如果你不关心可移植性(这些解决方案应该可以在几乎任何UNIX系统上运行),Linux的fuser(1)提供了一些额外的选项,还有flock(1)


3
你可以将if ! mkdir部分与检查存储在lockdir中(在成功启动时)的PID对应的进程是否正在运行并且与脚本的稳定性保护相同结合起来。这样还可以防止在重启后重新使用PID,甚至不需要fuser命令。 - Tobias Kienzler
7
“mkdir”并没有被 定义 为原子操作,因此“副作用”是文件系统的实现细节。如果他说NFS没有以原子方式实现它,我完全相信他。虽然我不认为你的“/tmp”是NFS共享,并且很可能由实现原子 mkdir 的文件系统提供。 - lhunath
5
如果想要检查一个普通文件是否存在并在不存在时原子性地创建它,有一种方法:使用ln从另一个文件创建硬链接。如果你使用的是奇怪的文件系统,无法保证该方式能正常工作,你可以之后检查新文件的i节点(inode),看它是否与原文件相同。 - Juan Cespedes
6
检查文件是否存在并在单个原子操作中创建文件的方法是使用 open(... O_CREAT|O_EXCL)。您只需要一个合适的用户程序来执行此操作,例如lockfile-create(在lockfile-progs中)或dotlockfile(在liblockfile-bin中)。确保正确清理文件(例如,使用trap EXIT),或测试过期的锁定(例如,使用--use-pid)。 - Toby Speight
7
所有测试“锁定文件”存在性的方法都有缺陷。为什么?因为没有办法在单个原子操作中检查文件是否存在并创建它。要使其具有原子性,必须在内核级别完成 - 并且可以使用flock(1)(https://linux.die.net/man/1/flock),该工具从版权日期上看至少存在于2006年。因此,我给出了一个负投票(-1),这与个人无关,只是坚信使用由内核开发人员提供的内核实现工具是正确的。 - Craig Hicks
显示剩余9条评论

125

以下是使用 锁文件 并将 PID 输出到其中的实现。这可以保护进程在删除 pidfile 前被杀死:

LOCKFILE=/tmp/lock.txt
if [ -e ${LOCKFILE} ] && kill -0 `cat ${LOCKFILE}`; then
    echo "already running"
    exit
fi

# make sure the lockfile is removed when we exit and then claim it
trap "rm -f ${LOCKFILE}; exit" INT TERM EXIT
echo $$ > ${LOCKFILE}

# do stuff
sleep 1000

rm -f ${LOCKFILE}

这里的技巧是 kill -0命令,它不会发送任何信号,而只是检查指定 PID 的进程是否存在。另外,调用trap命令将确保即使您的进程被杀死(除了kill -9),也会删除锁定文件


83
正如在另一个回答的评论中已经提到的,这种方法存在致命的缺陷——如果在检查和输出之间,另一个脚本启动了,那么你就彻底失败了。 - Paul Tomblin
1
符号链接技巧虽然不错,但是如果锁文件的所有者被 kill -9 或者系统崩溃了,仍然存在竞态条件去读取符号链接、注意到所有者已经离开,然后删除它。我还是坚持使用我的解决方案。 - bmdhacks
12
您可以使用 flock(1) 或 lockfile(1) 在Shell中进行原子检查和创建。有关详细信息,请参见其他答案。 - dmckee --- ex-moderator kitten
3
查看我的回复,了解一种可移植的原子检查和创建方法,无需依赖于像flock或lockfile这样的实用工具。 - lhunath
3
这不是原子性的,因此没有用。你需要一个原子性的机制来进行测试和设置。 - K Richard Pixley
显示剩余7条评论

45

在flock(2)系统调用周围有一个包装器,称为flock(1),这使得相对容易地可靠地获取独占锁,而无需担心清理等问题。在手册页面上有如何在shell脚本中使用它的示例。


4
flock() 系统调用不符合 POSIX 标准,并且不能用于 NFS 挂载的文件。 - maxschlepzig
20
在 Cron 作业中运行时,我使用 flock -x -n %lock file% -c "%command%" 确保只有一个实例在执行。 - Ryall
哎呀,与其使用毫无想象力的flock(1),他们应该选择类似flock(U)这样的东西……它有一些熟悉感。好像我以前听过一两次。 - Kent Kruckeberg
值得注意的是,flock(2)文档规定仅与文件一起使用,但flock(1)文档指定可与文件或目录一起使用。flock(1)文档在创建时未明确说明如何区分,但我认为可以通过添加最后的“/”来完成。无论如何,如果flock(1)可以处理目录而flock(2)不能,则flock(1)不仅仅是在flock(2)上实现。 - Craig Hicks

29
为了使锁定可靠,需要进行原子操作。上述提议中的许多都不是原子操作。建议使用lockfile(1)实用程序,因为其手册中提到它“抵抗NFS”。如果您的操作系统不支持lockfile(1),并且您的解决方案必须在NFS上运行,则选择有限...。
NFSv2有两个原子操作:
符号链接 重命名
使用NFSv3,创建调用也是原子的。
在NFSv2和NFSv3下,目录操作不是原子的(请参阅Brent Callaghan的书籍“NFS Illustrated”,ISBN 0-201-32570-5; Brent是Sun的NFS老手)。
知道这一点,您可以为文件和目录实现自旋锁(在shell中,而不是PHP):
锁定当前目录:
while ! ln -s . lock; do :; done

锁定文件:

while ! ln -s ${f} ${f}.lock; do :; done

解锁当前目录(假设正在运行的进程已经真正获得了锁):

mv lock deleteme && rm deleteme

解除文件锁定(假设,当前进程已真正获取了锁):

mv ${f}.lock ${f}.deleteme && rm ${f}.deleteme

移除操作也不是原子的,因此首先进行重命名(这是原子性操作),然后再进行删除。

对于符号链接和重命名调用,两个文件名都必须驻留在同一文件系统上。我的建议是:仅使用简单的文件名(没有路径)并将文件和锁定放入同一目录中。


NFS Illustrated的哪些页面支持“在NFS上,mkdir不是原子操作”的说法? - maxschlepzig
感谢这个技巧。我的新shell库中提供了一个shell互斥实现:https://github.com/Offirmo/offirmo-shell-lib,参见“mutex”。如果可用,它使用`lockfile`,否则回退到此`symlink`方法。 - Offirmo
很好。不幸的是,这种方法无法自动删除过期的锁定。 - Richard Hansen
对于两阶段解锁(mvrm),如果两个进程P1,P2同时进行,应该使用rm -f而不是rm吗?例如,P1开始使用mv解锁,然后P2锁定,然后P2解锁(mvrm都执行),最后P1尝试rm并失败。 - Matt Wallis
1
@MattWallis 最后一个问题可以很容易地通过在${f}.deleteme文件名中包含$$来缓解。 - Stefan Majewsky

27

您需要一个原子操作,比如flock,否则这将最终失败。

但如果flock不可用怎么办?嗯,有mkdir。那也是原子操作。只有一个进程会成功创建目录,其他所有进程都会失败。

所以代码是:

if mkdir /var/lock/.myscript.exclusivelock
then
  # do stuff
  :
  rmdir /var/lock/.myscript.exclusivelock
fi

你需要注意处理过期的锁,否则在崩溃后你的脚本将永远无法运行。


1
同时运行这个脚本几次(例如"./a.sh & ./a.sh & ./a.sh & ./a.sh & ./a.sh & ./a.sh & ./a.sh &"),脚本将会泄漏几次。 - Nippysaurus
8
这种锁定方法不会泄露。你所看到的是初始脚本在启动所有副本之前终止,因此另一个副本能够(正确地)获取锁。为了避免这种误报情况,在rmdir之前加入sleep 10,然后再次级联尝试 - 没有东西会“泄漏”。 - Sir Athos
其他来源声称像NFS这样的某些文件系统上mkdir不是原子性的。顺便说一下,我曾经看到过在NFS上并发递归mkdir有时会导致错误,有时会出现jenkins矩阵作业。所以我非常确定这是事实。 但是在我看来,对于要求不那么严格的用例,mkdir还是很好用的。 - akostadinov
你可以在常规文件中使用Bash的noclobber选项。 - Palec

25

当作为sem调用时,您可以使用GNU Parallel作为互斥量。因此,具体而言,您可以使用:

sem --id SCRIPTSINGLETON yourScript

如果你也想使用超时功能,可以使用以下方法:

sem --id SCRIPTSINGLETON --semaphoretimeout -10 yourScript

如果信号量在超时时间内未被释放,则超时时间<0表示退出而不运行脚本,超时时间>0表示仍然运行脚本。

请注意,您应该给它一个名称(使用--id),否则它将默认为控制终端。

GNU Parallel 在大多数Linux/OSX/Unix平台上都非常简单,只需安装一个Perl脚本即可。


很遗憾,人们不愿意对无用的答案进行点踩,这导致新的相关答案被掩埋在一堆垃圾中。 - Dmitry Grigoryev
4
我们只需要得到很多赞。这是一个简洁而鲜为人知的答案。(尽管严谨点说,原帖要求“快速粗糙”,而这个回答却是“快速清晰”!)有关sem的更多信息,请参见相关问题http://unix.stackexchange.com/a/322200/199525。 - Partly Cloudy

24

另一种选项是通过运行set -C来使用shell的noclobber选项。然后,如果文件已经存在,>会失败。

简而言之:

set -C
lockfile="/tmp/locktest.lock"
if echo "$$" > "$lockfile"; then
    echo "Successfully acquired lock"
    # do work
    rm "$lockfile"    # XXX or via trap - see below
else
    echo "Cannot acquire lock - already locked by $(cat "$lockfile")"
fi

这会导致shell调用:

open(pathname, O_CREAT|O_EXCL)

原子地创建文件,如果文件已经存在则会失败。


根据BashFAQ 045的评论,这可能会在ksh88中失败,但在我所有的shell中都有效:

$ strace -e trace=creat,open -f /bin/bash /home/mikel/bin/testopen 2>&1 | grep -F testopen.lock
open("/tmp/testopen.lock", O_WRONLY|O_CREAT|O_EXCL|O_LARGEFILE, 0666) = 3

$ strace -e trace=creat,open -f /bin/zsh /home/mikel/bin/testopen 2>&1 | grep -F testopen.lock
open("/tmp/testopen.lock", O_WRONLY|O_CREAT|O_EXCL|O_NOCTTY|O_LARGEFILE, 0666) = 3

$ strace -e trace=creat,open -f /bin/pdksh /home/mikel/bin/testopen 2>&1 | grep -F testopen.lock
open("/tmp/testopen.lock", O_WRONLY|O_CREAT|O_EXCL|O_TRUNC|O_LARGEFILE, 0666) = 3

$ strace -e trace=creat,open -f /bin/dash /home/mikel/bin/testopen 2>&1 | grep -F testopen.lock
open("/tmp/testopen.lock", O_WRONLY|O_CREAT|O_EXCL|O_LARGEFILE, 0666) = 3

有趣的是,pdksh添加了O_TRUNC标志,但很明显这是多余的:
无论是创建空文件还是不做任何操作。


rm 的执行方式取决于您希望如何处理不正常退出。

在正常退出时删除

新运行失败,直到导致不正常退出的问题得到解决并手动删除锁定文件。

# acquire lock
# do work (code here may call exit, etc.)
rm "$lockfile"

在任何退出时删除

只要该脚本没有正在运行,新的运行就会成功。

trap 'rm "$lockfile"' EXIT

非常新颖的方法...这似乎是使用锁文件而不是锁目录来实现原子性的一种方法。 - Madison Caldwell
1
不错的方法。 :-) 在 EXIT 陷阱中,它应该限制哪个进程可以清理锁文件。例如:trap 'if [[ $(cat "$lockfile") == "$$" ]]; then rm "$lockfile"; fi' EXIT - Kevin Seifert
1
文件锁在NFS上不是原子性的,这就是为什么人们转而使用锁目录的原因。 - K Richard Pixley
在我看来,这是一个不错的开始,但不幸的是至少bash手册没有说明它必须使用特定标志打开文件,只是说noclobber不会覆盖现有文件。在bash中有多少代码路径以及在不同情况下可能使用哪些给定标志是不清楚的。目前这个答案可能是完全正确的,但没有规范可以证明这一点,也没有维护者承诺坚持这一点。在我看来,应该以此答案为基础创建锁文件,而不会危及现有锁文件,然后使用flock或类似工具获取锁。 - AnyDev

16

对于shell脚本,我倾向于使用mkdir而不是flock,因为它使锁更具可移植性。

无论哪种方式,仅使用set -e是不够的。这只会在任何命令失败时退出脚本,但你的锁仍将留下。

为了进行适当的锁清理,你真的应该设置你的陷阱为类似于这个伪代码(从实际使用的脚本中提取、简化且未经测试):

#=======================================================================
# Predefined Global Variables
#=======================================================================

TMPDIR=/tmp/myapp
[[ ! -d $TMP_DIR ]] \
    && mkdir -p $TMP_DIR \
    && chmod 700 $TMPDIR

LOCK_DIR=$TMP_DIR/lock

#=======================================================================
# Functions
#=======================================================================

function mklock {
    __lockdir="$LOCK_DIR/$(date +%s.%N).$$" # Private Global. Use Epoch.Nano.PID

    # If it can create $LOCK_DIR then no other instance is running
    if $(mkdir $LOCK_DIR)
    then
        mkdir $__lockdir  # create this instance's specific lock in queue
        LOCK_EXISTS=true  # Global
    else
        echo "FATAL: Lock already exists. Another copy is running or manually lock clean up required."
        exit 1001  # Or work out some sleep_while_execution_lock elsewhere
    fi
}

function rmlock {
    [[ ! -d $__lockdir ]] \
        && echo "WARNING: Lock is missing. $__lockdir does not exist" \
        || rmdir $__lockdir
}

#-----------------------------------------------------------------------
# Private Signal Traps Functions {{{2
#
# DANGER: SIGKILL cannot be trapped. So, try not to `kill -9 PID` or 
#         there will be *NO CLEAN UP*. You'll have to manually remove 
#         any locks in place.
#-----------------------------------------------------------------------
function __sig_exit {

    # Place your clean up logic here 

    # Remove the LOCK
    [[ -n $LOCK_EXISTS ]] && rmlock
}

function __sig_int {
    echo "WARNING: SIGINT caught"    
    exit 1002
}

function __sig_quit {
    echo "SIGQUIT caught"
    exit 1003
}

function __sig_term {
    echo "WARNING: SIGTERM caught"    
    exit 1015
}

#=======================================================================
# Main
#=======================================================================

# Set TRAPs
trap __sig_exit EXIT    # SIGEXIT
trap __sig_int INT      # SIGINT
trap __sig_quit QUIT    # SIGQUIT
trap __sig_term TERM    # SIGTERM

mklock

# CODE

exit # No need for cleanup code here being in the __sig_exit trap function

以下是将发生的事情。所有陷阱都会产生一个退出信号,因此函数__sig_exit将始终发生(除非有SIGKILL),这将清除您的锁。

注意:我的退出值不是低值。为什么?各种批处理系统制作或具有0到31的数字预期。将它们设置为其他值,我可以使我的脚本和批处理流对先前的批处理作业或脚本做出相应。


2
你的脚本太啰嗦了,我认为可以更简短一些,但总体而言,是的,你必须设置陷阱才能正确地执行此操作。另外,我会添加SIGHUP。 - mojuba
这个工作得很好,除了它似乎检查$LOCK_DIR而删除$__lockdir。也许我应该建议,在删除锁定时使用rm -r $LOCK_DIR? - bevada
谢谢您的建议。上面的代码是抽象的,需要根据人们的使用进行调整。然而,在我的情况下,我故意选择了rmdir,因为rmdir只会安全地删除空目录。如果人们在其中放置资源,例如PID文件等,则应将其锁定清理更改为更积极的rm -r $LOCK_DIR,甚至必要时强制执行(在特殊情况下,例如保留相对临时文件时,我也这样做)。干杯。 - Mark Stinson
你测试过 exit 1002 吗? - Gilles Quénot
对于任何在十年后查看此答案的其他人:不要使用像1002等值作为退出代码。这可能是特定于shell的,但通常情况下返回代码将在255之后循环。尝试运行以下代码以查看其效果:fun() { return 255; }; fun; echo $?fun() { return 256; }; fun; echo $? - ACK_stoverflow

14

真的 快速且 简单粗暴? 把这个一行代码放在你的脚本顶部即可:

[[ $(pgrep -c "`basename \"$0\"`") -gt 1 ]] && exit

当然,只要确保您的脚本名称是唯一的即可。 :)


我该如何模拟测试这个?有没有一种方法可以在一行中启动脚本两次,并且如果它已经在运行,则可能会收到警告? - rubo77
3
完全无效!为什么要检查“-gt 2”?grep并不总是在ps的结果中找到自己! - rubo77
pgrep 不在 POSIX 中。如果您想要在不同系统上使用它,您需要使用 POSIX 的 ps 命令并处理其输出。 - Palec
在OSX上,-c不存在,你需要使用| wc -l。关于数字比较:检查-gt 1是因为第一个实例看到了自己。 - Benjamin Peter

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接