本文要点
- 定时任务最典型的翻车方式:上一次备份还没跑完,cron 又起了第二个实例,两个进程同时写同一份数据——
flock是解决它最轻量的办法,不需要写一行代码 flock有且只有三种结果:阻塞等到锁(默认)、立刻失败(-n,退出码 1)、等一段时间再失败(-w 秒数)——实测等待 5 秒后拿到锁、-n与-w 2在持锁期间都返回 1- 锁对象可以直接观察:
lslocks给出路径与TYPE=FLOCK,/proc/locks只给设备:inode——实测锁文件 inode519413正好对应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
三种用法在这里体现得很清楚:
| 用法 | 行为 | 实测结果 |
|---|---|---|
flock 锁文件 命令 | 默认阻塞:一直等到别人释放 | 持锁者还剩 5 秒时发起,等了 5 秒拿到锁 |
flock -n 锁文件 命令 | 非阻塞:拿不到立刻失败 | rc=1,一毫秒都不等 |
flock -w 2 锁文件 命令 | 最多等 2 秒,超时失败 | 等了 2 秒,rc=1 |
对于「防重入」这个场景,通常要的是 -n:既然上一次还在跑,这一次就应该干脆利落地退出,而不是排着队等它跑完——排队会把任务越堆越多。
注意上面 -c '命令' 的写法:-c 后面跟一段交给 shell 执行的命令字符串。如果不想用 -c,flock 也可以直接跟命令,参数会原样传给对方:
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; waitCOMMAND 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是给人看的:有COMMAND、PID、PATH,能直接告诉你「哪个进程锁了哪个文件」。/proc/locks是内核的原始视图:只有设备号:inode(fc: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)] 备份完成"三行关键代码:
exec 200>锁文件:以写方式打开一个文件描述符 200。文件不存在会自动创建——所以锁文件不需要事先准备好。flock -n 200:注意这里给的是 fd 号而不是文件名,flock会锁住这个 fd 指向的文件。加不上锁就执行||后面的退出分支。- 脚本结束时 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 秒内就退出了,没有排队、没有并发写。放进 crontab 就是一行:
*/5 * * * * /usr/bin/flock -n /var/lock/backup.lock /root/backup.shflock 自己就带防重入能力,所以连脚本里的 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,这套机制基本就不会再出问题。
评论 (0)
暂无评论,快来抢沙发吧!