如何在Bash脚本中避免竞态条件?

5
#!/bin/bash
if [ ! -f numbers ]; then echo 0 > numbers; fi
count=0
touch numbers
echo $count > numbers
while [[ $count != 100 ]]; do
  if ln numbers numbers.lock
  then
    count=`expr $count + 1`
    n=`tail -1 numbers`
    expr $n + 1 >> numbers
    rm numbers.lock
  fi
done

如何避免在同时运行两个脚本时,count=`expr $count + 1`n=`tail -1 numbers` 的竞争条件,使得计数器只增加到100而不是200?我在多个网站上进行了研究,但没有简明的答案,而不需要编写一个庞大的函数。


你为什么要同时运行这个脚本两次?为什么不使用安全的临时文件来存储数据? - Etan Reisner
使用 util-linux 中的 flock - Ben Grimm
@EtanReisner,这让我们了解到存在竞态条件,需要避免它们。 - StingRay21
那么你是想防止脚本同时运行两次吗? - Etan Reisner
不,我正在尝试确保它们都可以运行,以防止竞态条件。 - StingRay21
你的 if ln numbers numbers.lock 测试可以防止同时更新;在 if 语句体中没有竞争条件。 - Jonathan Leffler
3个回答

7

您已经通过锁定文件来安全避免了实际的竞态条件。您所描述的问题可以通过两种方式避免。

(1) 将锁定文件移动到主循环之外,这样程序的两个实例就不能同时运行它们的主循环。如果一个正在运行,另一个将不得不等待直到它完成,然后开始替换输出文件。

#!/bin/bash

# FIXME: broken, see comments

while true; do
    if ! ln numbers numbers.lock
    then
       sleep 1
    else
        if [ ! -f numbers ]; then echo 0 > numbers; fi
        count=0
        touch numbers
        #echo $count > numbers   # needless, isn't it?
        while [[ $count != 100 ]]; do
            count=`expr $count + 1`
            n=`tail -1 numbers`
            expr $n + 1 >> numbers
            rm numbers.lock
        done
        break
    fi
done

(2) 通过检查文件内容,使这两个实例协同工作。换句话说,无论有多少其他进程正在写入此文件,都要强制它们在数字达到100时停止循环。(我猜当有超过100个实例运行时,可能会出现棘手的特殊情况。)

#!/bin/bash
# FIXME: should properly lock here, too
if [ ! -f numbers ]; then echo 0 > numbers; fi
n=0
touch numbers
while [[ $n -lt 100 ]]; do
  if ln numbers numbers.lock
  then
    n=$(expr $(tail -1 numbers) + 1 | tee numbers)
    rm numbers.lock
  fi
done

根据你的需求,当脚本的新实例启动时,你可能确实希望脚本覆盖文件中的任何先前值,但如果不是这样,echo 0 > numbers 也应受锁定文件的控制。
在Bash脚本中,真的要避免使用expr;Bash有内置的算术操作符。我没有尝试在此重构该部分,但你可能应该这样做。也许更喜欢Awk,这样你就可以将tail因素去掉;awk '{ i=$0 } END { print 1+i }' numbers

第一个例子似乎需要在 while true 循环之前加上 touch numbers。如果 numbers 不存在,ln 命令将会失败,因此如果代码中没有已经存在的 numbers,它永远无法被创建。 - SpinUp __ A Davis
我认为在第一个示例中,对于第三行代码使用ln -s代替普通的ln也应该可以工作--这样,如果numbers.lock存在,则命令将失败,但即使numbers尚不存在,它也会继续执行。 - SpinUp __ A Davis
那样做的目的是什么?这个操作的目的是如果有什么不对劲就失败。但是,是的,确实缺少了 touch 命令,感谢您注意到了这一点。 - tripleee
但是,在您的代码中,缺少“numbers”文件并不被视为失败--您明确检查其是否存在,并在“if [!-f numbers]”行中进行必要的创建。如果保留ln(而不是ln -s),那么存在性检查就完全没有意义,因为它已经包含在if!ln ...中了。另外,“rm numbers.lock”应该放在循环之外(while [[ $count!= 100 ]]),否则您将重新引入竞争条件(并向终端生成99个错误消息)。 - SpinUp __ A Davis
1
你说得对...我当时在想什么,现在我完全无法让第一个工作起来。 - tripleee
还不错——我还是点了赞,因为这个例子很接近我的应用程序,帮助我找到了正确的方向。 - SpinUp __ A Davis

0
我在脚本顶部放置了这个一行代码,以使其具有竞态条件安全性:
if [[ -d "/tmp/${0//\//_}" ]] || ! mkdir "/tmp/${0//\//_}"; then echo "Script is already running!" && exit 1; fi; trap 'rmdir "/tmp/${0//\//_}"' EXIT;

这样我就不需要考虑进程竞争的问题了。

代码解释:

  1. [[ -d "/tmp/${0//\//_}" ]] 检查锁定目录 /tmp/_path_to_script_scriptname.sh/ 是否存在。注意:$0 包含脚本名称
  2. mkdir "/tmp/${0//\//_}" 如果不存在则创建该目录。
  3. then ... exit 1 如果锁定目录已经存在,则中止脚本(意味着脚本已经在运行)。
  4. trap 'rmdir "/tmp/${0//\//_}"' EXIT 如果脚本退出,将自动删除锁定目录(由于trap命令是定义在后面,因此不会出现竞争情况)。
注意:在非常罕见的情况下,例如服务器崩溃,锁定目录不会被删除。为此,您可以考虑使用 cronjob 检查过时的锁定目录。如果您的脚本需要 trap(不能设置两次),那么请使用 不同的多重 trap 解决方案之一

0

最近我不得不创建自己的“flock”函数,因为我正在编写的脚本需要使用TRAP,而这个命令不能与flock命令一起使用。

以下是代码:

#!/bin/bash
flock() {
    local lock_name lock_path lock_pid check_pid script_arg script_source script_pid
    lock_name="${1}"
    script_pid="$$"
    script_source="${BASH_SOURCE[0]}"
    script_arg=("${BASH}" "${script_source}")
    lock_path="$(dirname -- "$(realpath "${script_source[0]}}")")/${lock_name}_flock"
    for ((i=0;i<${#BASH_ARGV[@]}; i++)); do 
        script_arg+=("${BASH_ARGV[~i]}")
    done
    if [[ -f "${lock_path}" ]]; then
        read -r lock_pid < "${lock_path}" > /dev/null 2>&1
        if [[ -n "${lock_pid[0]}" ]]; then
            check_pid=$(ps -eo pid,cmd \
                | awk -v a="${lock_pid}" -v b="${script_arg[*]}" '$1==a && $2!="awk" && index($0,b) {print $1}')
            if [[ -n "${check_pid}" ]]; then
                printf "%s\n" "Script is already running"
                exit 0
            fi
        fi
    fi
    read -r check_pid < <(ps -eo pid,cmd | awk -v a="${script_arg[*]}" '$2!="awk" && index($0,a) {print $1}')
    if [[ "${script_pid}" -ne "${check_pid[0]}" ]]; then
        printf "%s\n" "Race condition prevented"
        exit 0
    fi
    printf '%s' "${script_pid}" | tee "${lock_path}" > /dev/null 2>&1
}

要使用这个功能,只需添加函数,然后跟上您想要锁定文件的名称。

flock script_name
解释 该函数使用BASH, BASH_SOURCEBASH_ARGV来创建进程状态下CMD列的外观。
例如:./test.sh arg1 arg2 "this arg3" CMD: /bin/bash ./test.sh arg1 arg2 this arg3 然后,我们使用进程状态来仅显示与CMD匹配的任何项目,除此之外,我们将它们所有的PID放入一个数组中。
接下来,我们仅返回该数组的第一个值,并将其与脚本的PID进行比较,如果脚本的PID与我们的数组中的第一个PID不匹配,则退出,否则我们将脚本的PID存储在我们的锁定文件中。
如果锁定文件已经存在并包含现有的PID,则该函数将检查脚本是否仍在运行,方法是通过搜索PID和CMD值,如果正在运行,则退出,否则锁定文件中的PID将更新为新的PID。
该函数不仅可以防止竞争条件,而且即使系统崩溃,它也会继续工作,因为它不依赖于锁定文件来确定脚本是否在运行。

扩展为 shell 的进程 ID。在子 shell 中,它扩展为调用 shell 的进程 ID,而不是子 shell 的进程 ID。

一个数组变量,其成员是对应的 shell 函数名在 FUNCNAME 数组变量中定义的源文件名。shell 函数 ${FUNCNAME[$i]} 在文件 ${BASH_SOURCE[$i]} 中定义并从 ${BASH_SOURCE[$i+1]} 调用

一个包含当前bash执行调用堆栈中所有参数的数组变量。最后一个子例程调用的参数位于堆栈顶部;初始调用的第一个参数位于底部。当执行子例程时,所提供的参数被推入BASH_ARGV。仅当处于扩展调试模式时(请参阅Shopt内置程序的extdebug选项的描述),shell才设置BASH_ARGV。在脚本开始执行之后设置extdebug或在未设置extdebug的情况下引用此变量可能导致不一致的值。 BASH_ARGV以相反的顺序返回参数。为了解决这个问题,在函数中运行以下代码,感谢用户232326的帖子,它适用于bash 4.2+。
for ((i=0;i<${#BASH_ARGV[@]}; i++)); do 
    script_arg+=("${BASH_ARGV[~i]}")
done

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