暗色模式

Linux 文件锁:从 flock 互斥到防止定时任务重复执行

技术教程
2026-09-15
17
0
本文要点
  • 定时任务最典型的翻车方式:上一次备份还没跑完,cron 又起了第二个实例,两个进程同时写同一份数据——flock 是解决它最轻量的办法,不需要写一行代码
  • flock 有且只有三种结果:阻塞等到锁(默认)、立刻失败-n,退出码 1)、等一段时间再失败-w 秒数)——实测等待 5 秒后拿到锁、-n-w 2 在持锁期间都返回 1
  • 锁对象可以直接观察:lslocks 给出路径与 TYPE=FLOCK/proc/locks 只给 设备:inode——实测锁文件 inode 519413 正好对应 fc:01:519413,两边能对上
  • ⚠️ kill -9 掉主脚本不等于释放锁flock 锁挂在「打开文件描述」上,fork 出的子进程继承了 fd 就继承了锁。实测杀掉脚本后抢锁仍然失败,/proc/<子进程>/fd/200 还指着锁文件
  • ⚠️ 删掉锁文件也不等于释放锁:锁跟着 inode 走,rm 之后新进程创建同名新文件就是一把新锁,两个进程会同时以为自己独占
  • 脚本里的标准写法是 exec 200>锁文件; flock -n 200 || exit 1——用文件描述符持锁,脚本一退出(正常或被杀)fd 关闭、锁自动释放
  • flock建议锁,只约束「愿意遵守规则的进程」;它和 POSIX/fcntl 锁、以及 FIFO/共享内存那类 IPC 对象是三件不同的事

Linux 文件锁:从 flock 互斥到防止定时任务重复执行

先看一个几乎人人都踩过的场景:一台机器上配了 */5 * * * * 的备份任务,平时跑 2 分钟就结束。某天数据量涨了、或者网络变慢,这次跑了 8 分钟——于是第 5 分钟 cron 又拉起一个实例,两个进程同时往同一个目标目录写。轻则日志错乱,重则备份文件互相覆盖,等到真要恢复的那天才发现手里是一份坏数据。

这类问题的本质是「同一时刻只允许一个实例」,而 Linux 给的答案简单到一行:flock。它用的是内核提供的建议文件锁,不需要改一行程序代码,也不用装任何东西(util-linux 自带)。

本文在一台 Ubuntu 22.04 云服务器(KVM,1 核、内存 1.9G,内核 5.15.0-30-generic)上,把 flock 的语义、观察方式、以及三个必踩的坑逐个实测了一遍。

第一步:flock 的三种结果

flock 的核心语义只有一句话:对某个文件加一把锁,加不上就按你指定的方式处理。加不上的处理方式决定了它的三种用法。把一个典型场景写成脚本——先起一个持锁 8 秒的后台进程,模拟「上一次任务还在跑」:

#!/bin/bash
# 先起一个持锁 8 秒的后台进程,模拟"已有实例在跑"
flock -n demo.lock -c 'sleep 8' &
sleep 1
echo "--- 已有人持锁时:"
flock -n demo.lock -c 'echo 拿到锁'; echo "非阻塞 rc=$?"
flock -w 2 demo.lock -c 'echo 拿到锁'; echo "超时 rc=$?"
wait
echo "--- 持锁者退出后:"
flock -n demo.lock -c 'echo 拿到锁'; echo "非阻塞 rc=$?"

跑起来看结果:

cd /tmp/flock-demo; ./try-lock.sh
--- 已有人持锁时:
非阻塞 rc=1
超时 rc=1
--- 持锁者退出后:
拿到锁
非阻塞 rc=0

持锁期间 -n 立刻返回 1、-w 2 等两秒后返回 1;持锁者退出后同一把锁立刻能拿到,rc=0

三种用法在这里体现得很清楚:

用法行为实测结果
flock 锁文件 命令默认阻塞:一直等到别人释放持锁者还剩 5 秒时发起,等了 5 秒拿到锁
flock -n 锁文件 命令非阻塞:拿不到立刻失败rc=1,一毫秒都不等
flock -w 2 锁文件 命令最多等 2 秒,超时失败等了 2 秒,rc=1

对于「防重入」这个场景,通常要的是 -n:既然上一次还在跑,这一次就应该干脆利落地退出,而不是排着队等它跑完——排队会把任务越堆越多。

注意上面 -c '命令' 的写法:-c 后面跟一段交给 shell 执行的命令字符串。如果不想用 -cflock 也可以直接跟命令,参数会原样传给对方:

flock -n /var/lock/test.lock /bin/echo "flock 直接跟命令的形式也可以"
flock 直接跟命令的形式也可以

第二步:锁对象长什么样

flock 加的是内核锁,所以能直接看。开一个持锁进程,然后从两个角度看它:

cd /tmp/flock-demo; flock -n demo.lock -c 'sleep 6' & sleep 1; lslocks | grep -E "COMMAND|demo"; stat -c "锁文件 inode=%i" demo.lock; grep FLOCK /proc/locks; wait
COMMAND            PID  TYPE SIZE MODE  M START END PATH
flock           117954 FLOCK      WRITE 0     0   0 /tmp/flock-demo/demo.lock
锁文件 inode=519413
1: FLOCK  ADVISORY  WRITE 117954 fc:01:519413 0 EOF
2: FLOCK  ADVISORY  WRITE 96924 fc:01:72049 0 EOF
4: FLOCK  ADVISORY  WRITE 641 00:1a:1213 0 EOF

lslocks 列出锁的路径、PID、TYPE=FLOCK 与 MODE=WRITE;/proc/locks 给出设备:inode,与 stat 的 inode 519413 对应

两个工具的取舍一目了然:

  • lslocks 是给人看的:有 COMMANDPIDPATH,能直接告诉你「哪个进程锁了哪个文件」。
  • /proc/locks 是内核的原始视图:只有 设备号:inodefc:01:519413),没有文件名。要反查文件,得拿 stat -c %i 的 inode 号去对——上图里 锁文件 inode=519413/proc/locks 第一行的 fc:01:519413 就是这么对上的。

/proc/locks 里还有别的 FLOCK 条目(PID 96924、641),那些是系统里其它进程持有的锁,比如终端会话。排查时不要只看第一行,按 PID 或 inode 认领自己那一把。

MODE 列这次是 WRITE,因为默认加的是独占锁。它和 START END(都是 0 和 EOF)一起说明这把锁覆盖整个文件——flock 不支持只锁文件的一段,要么整把,要么没有。

第三步:脚本里的标准写法与 cron 防重入

命令行演示清楚了,落到脚本里推荐这么写:

#!/bin/bash
# 用文件描述符 200 持有锁:脚本退出(正常或被杀)锁自动释放
exec 200>/tmp/flock-demo/backup.lock
flock -n 200 || { echo "[$(date +%T)] 已有实例在运行, 本次退出"; exit 1; }
echo "[$(date +%T)] 开始备份 (PID $$)"; sleep 8; echo "[$(date +%T)] 备份完成"

三行关键代码:

  1. exec 200>锁文件:以写方式打开一个文件描述符 200。文件不存在会自动创建——所以锁文件不需要事先准备好。
  2. flock -n 200:注意这里给的是 fd 号而不是文件名,flock 会锁住这个 fd 指向的文件。加不上锁就执行 || 后面的退出分支。
  3. 脚本结束时 fd 自动关闭,锁跟着释放,不需要手动解锁。

之所以用 exec + fd 的写法,而不是每次 flock -n 锁文件 -c '整段业务',是因为要在整个脚本执行期间持续持锁——业务逻辑有多个步骤,中间任何一步松了锁都可能被第二个实例钻进来。

让它重叠跑一次:

cd /tmp/flock-demo; ./backup.sh & sleep 2; ./backup.sh; echo "重叠运行 rc=$?"; wait
[21:43:22] 开始备份 (PID 117750)
[21:43:24] 已有实例在运行, 本次退出
重叠运行 rc=1
[21:43:30] 备份完成

第一个实例开始备份,2 秒后第二个实例被锁挡住并退出(rc=1),第一个实例继续跑完

这正是想要的效果:第二个实例在 2 秒内就退出了,没有排队、没有并发写。放进 crontab 就是一行:

*/5 * * * * /usr/bin/flock -n /var/lock/backup.lock /root/backup.sh

flock 自己就带防重入能力,所以连脚本里的 exec 200> 都可以省掉,直接用命令形式包住业务脚本。crontab 的字段语法和排查套路见 Linux crontab 定时任务

第四步:锁什么时候释放——三个必须知道的坑

「进程退出锁就释放」是 flock 最省心的地方,也正是容易想当然的地方。实测下来有三个行为需要记住。

坑一:kill -9 主脚本,锁很可能还没释放

先杀脚本,再立刻抢锁:

[21:42:19] 开始备份 (PID 117271)
--- kill -9 后立刻抢锁:
bash: line 1: 117271 Killed                  ./backup.sh
rc=1                       ← 脚本已经死了,锁却还在
--- 还活着的子进程:
117275 sleep 8
--- /proc/117275/fd:
l-wx------ 1 root root 64 Sep 14 21:42 200 -> /tmp/flock-demo/backup.lock
--- 等子进程结束后再抢:
拿到锁
rc=0

脚本进程 117271 已经被 kill -9 干掉了,可抢锁依然返回 rc=1。原因在 /proc/117275/fd 那行:脚本 fork 出来的子进程 sleep 8(PID 117275)还活着,而它继承了 fd 200,仍然指向锁文件。

flock 的锁挂在内核的「打开文件描述」(open file description)上,而 fork 会让父子进程共享同一个打开文件描述——所以子进程天然继承了那把锁。父进程死了,只要还有一个进程握着这个 fd,锁就不会释放。

应对:真的要强制终止时,把整个进程组一起杀掉(kill -- -<PGID>),或者用 pkill -P <PID> 先清子进程。这也解释了为什么「重启脚本时偶尔会提示已有实例在运行」——上一次的残留子进程还没退干净。

坑二:删掉锁文件不等于释放锁

cd /tmp/flock-demo; flock -n demo.lock -c 'sleep 5' & sleep 1; rm -f demo.lock; flock -n demo.lock -c 'echo 删掉锁文件后-新锁拿到了'; echo "rc=$?"; wait
删掉锁文件后-新锁拿到了
rc=0

第一个进程还在持锁,rm 掉锁文件之后,第二个进程立刻就拿到了「同一把锁」。原因是锁跟着 inode 走,rm 只是删掉了目录项;第二个进程重新创建同名文件,拿到的是一个全新的 inode,自然是一把全新的锁。此刻两个进程都认为自己独占,防重入彻底失效。

应对:锁文件放在不会被自动清理的目录里。/tmp/var/tmp 会被系统的临时文件清理机制定期清理(见 Linux 临时文件自动清理),把锁文件放那儿等于定时埋雷,推荐 /var/lock//run/lock/。同理,运维脚本里也不要「顺手清理一下锁文件」。

坑三:阻塞等待会一直等下去

默认的 flock 锁文件 命令 是阻塞的,实测它会老老实实等到别人释放:

默认阻塞-等到了持锁者退出
等待了 5 秒

这在交互式操作里没问题,但在 cron 里是灾难——上一次任务卡住不退出,后面每一次都排着队等,进程越积越多,最后连 SSH 都挤不进去。定时任务里永远用 -n 或带超时的 -w

第五步:共享锁 -s 与独占锁

flock 默认加的是独占锁(-x),同一时刻只允许一个持有者。还有一个 -s(共享锁):多个读者可以同时持有,但读者和写者互斥。实测两者的区别:

共享锁-拿到了
rc=0
rc=1

第一个进程持共享锁时,第二个共享锁顺利拿到(rc=0),而随后的独占锁被拒(rc=1)。在 /proc/locks 里,这两种模式就体现在 MODE 列是 WRITE 还是 READ

共享锁适合「读多写少」的场景:比如多个进程只读同一份配置/缓存,偶尔有一个进程要重建它——重建时用独占锁把读者挡住即可。不过日常防重入用独占锁就够了,共享锁属于知道有这回事即可。

第六步:flock 是建议锁,也不是 IPC

最后厘清三个容易混淆的概念。

第一,flock 是建议锁(advisory locking)。 内核对它不加任何强制力——一个不调用 flock 的程序照样能读写被「锁住」的文件。它约束的只有「愿意遵守同一套规则的进程」,所以两边都必须是你的脚本。想强制隔离,那是 chattr +i 或者文件权限/强制访问控制的领域。

第二,flock 和 POSIX/fcntl 锁是两套不同的机制。 用 Python 的 fcntl.lockf 加一把 POSIX 独占锁,/proc/locks 里的 TYPE 列会明明白白地写出区别:

1: POSIX  ADVISORY  WRITE 118038 fc:01:519419 0 EOF
2: FLOCK  ADVISORY  WRITE 96924 fc:01:72049 0 EOF

两者最容易踩混的差别是归属flock 的锁属于「打开文件描述」,所以 fork 会继承、dup 会共享(前面坑一就是这么来的);而 POSIX 锁属于进程,并且进程只要 close 掉指向该文件的任意一个 fd,就会丢掉它在该文件上的全部锁——这个反直觉的行为是很多「锁莫名其妙没了」的根源。做跨进程互斥,脚本层面用 flock 更省心。

第三,flock 不是 IPC。 命名管道 FIFO、System V 共享内存、信号量那一套解决的是「进程之间怎么传数据」(见 Linux 进程间通信),flock 只解决「同一时刻谁能动手」,它一个字节的数据都不传。硬要归类,它属于同步机制而非通信机制。

另外,如果锁文件放在 NFS 上,跨主机的互斥不要依赖 flock——历史上它的实现只在本地生效,各发行版和挂载选项的行为也不一致。跨主机互斥请用专门的分布式锁。

小结

flock 的价值在于「用最小的代价解决一个高频问题」。给日常运维留一张速查表:

场景写法
定时任务防重入flock -n /var/lock/任务名.lock /path/to/script.sh
脚本内全程持锁`exec 200>/var/lock/x.lock; flock -n 200 \\exit 1`
拿不到锁时等待一段时间flock -w 10 锁文件 命令
查看谁锁了什么lslocks(有路径)
按 inode 核对内核视图stat -c %i 锁文件 对比 grep FLOCK /proc/locks
读多写少场景flock -s(共享)/ flock -x(独占,默认)

再记住三句话:锁跟着打开文件描述走(所以子进程会继承)锁跟着 inode 走(所以删文件不等于解锁)它是建议锁(所以只有守规矩的进程才被挡住)。把锁文件放在 /var/lock/,在定时任务里一律用 -n,这套机制基本就不会再出问题。

发表评论

暂无评论,快来抢沙发吧!