Linux/Bash:用 flock 阻止定时任务重叠执行

35 次浏览4 条回复

同一个定时任务如果上一次还没结束,下一次又被调度,可能重复写文件或同时占用外部资源。Linux 上可以在脚本入口用 flock 对文件描述符加非阻塞锁;拿不到锁时立即退出。

环境:Linux;Bash 4.1+;util-linux 提供的 flock;coreutils 提供的 install

创建 single-run.sh

#!/usr/bin/env bash
set -Eeuo pipefail

runtime_dir="${XDG_RUNTIME_DIR:-$HOME/.cache}/report-job"
install -d -m 700 -- "$runtime_dir"

exec {lock_fd}>"$runtime_dir/run.lock"
if ! flock -n "$lock_fd"; then
  printf 'another instance is already running\n' >&2
  exit 75
fi

printf 'started: pid=%d\n' "$$"
sleep 5
printf 'finished: pid=%d\n' "$$"

赋予执行权限,并启动两个实例验证:

chmod +x single-run.sh

./single-run.sh &
first_pid=$!
sleep 0.2

set +e
./single-run.sh
second_status=$?
set -e

wait "$first_pid"
printf 'second status=%d\n' "$second_status"

第一个实例持锁期间,第二个实例会输出提示并以 75 退出;第一个实例结束后,文件描述符由内核关闭,锁自动释放。锁文件本身可以继续存在,互斥关系取决于打开文件上的锁,而不是靠删除锁文件实现。

把锁目录放在当前用户可写且权限受控的位置,可以避免多个无关用户共享同一个可替换的临时路径。若任务需要排队等待而不是快速退出,可将 flock -n 改为 flock -w 30,最多等待 30 秒。

补一个锁作用域的边界:这里的 $XDG_RUNTIME_DIR$HOME/.cache 都按 Unix 用户隔离,因此示例保证的是“同一账号内单实例”。如果同一任务可能由两个系统账号触发,即使锁文件名相同,也会落到不同目录,仍然可能并发。

若保护的是主机级共享资源,可以在部署阶段预建统一锁目录。环境:Linux、GNU coreutils;假设服务账号 reportjob 已存在。

sudo install -d -o reportjob -g reportjob -m 0750 /run/lock/report-job

脚本中再固定使用:

runtime_dir=/run/lock/report-job
exec {lock_fd}>"$runtime_dir/run.lock"

所有触发入口应以该服务账号运行;如果必须由不同账号触发,则需要预先设计共享组权限。这样锁的范围才与被保护资源的范围一致。

再补一个部署和清理时容易忽略的边界:锁跟随已打开文件对应的 inode,而不是路径名。因此任务运行期间不能删除或替换 run.lock;否则后启动的进程会创建新文件,并在新 inode 上成功加锁,两个实例就可能同时运行。

环境:Linux、Bash、util-linux flock、coreutils。下面可直接验证:

tmp=$(mktemp -d)
exec 9>"$tmp/run.lock"
flock -n 9

rm "$tmp/run.lock"
(
  exec 8>"$tmp/run.lock"
  flock -n 8 && printf 'second lock acquired on a new inode\n'
)

exec 9>&-
rm -rf "$tmp"

所以锁文件应长期保留;发布脚本可以原子替换脚本本身,但清理程序不要把锁文件或整个锁目录一起轮换。若确实要清理,应先确保没有任务持锁。

再补一个语义边界:flock 是协作式锁,不会直接阻止未遵循锁协议的进程读写被保护资源。定时器、手工命令和补偿任务等入口都需要先取得同一个锁,不能让其中某个入口绕过包装脚本。

环境:Linux、Bash 4.1+、util-linux flock、coreutils。下面的例子中,子进程拿不到锁,但仍能改写另一个文件:

tmp=$(mktemp -d)
exec {lock_fd}>"$tmp/run.lock"
flock -n "$lock_fd"
printf 'owner\n' >"$tmp/shared.txt"

(
  exec {lock_fd}>&-
  exec {other_fd}>"$tmp/run.lock"
  if ! flock -n "$other_fd"; then
    printf 'second process did not acquire lock\n'
  fi
  printf 'uncoordinated writer\n' >>"$tmp/shared.txt"
)

cat "$tmp/shared.txt"
exec {lock_fd}>&-
rm -rf -- "$tmp"

因此更稳妥的部署方式是只暴露一个入口脚本:入口先加锁,再用 exec 启动实际任务;所有调度配置都调用该入口。锁保护的是一套参与者共同遵守的协议,而不是数据文件本身的访问权限。

再补一个生命周期边界:锁附着在打开的文件描述上,而该描述符默认会被后续子进程继承。若受锁脚本启动后台子进程后立即退出,锁可能继续由子进程持有,表现为“主任务已经结束,但下一次仍拿不到锁”。

环境:Linux、Bash 4.1+、util-linux flock、coreutils。下面的示例会先看到锁仍被占用,后台 sleep 退出后才能重新取得:

tmp=$(mktemp -d)

(
  exec 9>"$tmp/run.lock"
  flock -n 9
  sleep 2 &
)

(
  exec 8>"$tmp/run.lock"
  if flock -n 8; then
    printf 'unexpected: acquired early\n'
  else
    printf 'still locked by the child\n'
  fi
)

sleep 2.1
(
  exec 8>"$tmp/run.lock"
  flock -n 8 && printf 'acquired after child exit\n'
)

rm -rf -- "$tmp"

因此受保护的任务最好保持前台运行,直到工作真正完成。若某个无关的长生命周期子进程不应延长锁的生命周期,可在启动它的子 shell 中先执行 exec 9>&-;但仍在访问受保护资源的子进程不能提前关闭锁描述符。