1. 竞态是怎么产生的
一句话总结: 只要「检查条件」和「执行动作」之间存在时间窗口,多个进程就可能同时通过检查,这就是 TOCTOU 竞态。
最典型的错误是「先判断文件是否存在,不存在才创建」:
# 危险:检查与创建之间有窗口,两个进程可能都通过判断
if [[ ! -f /tmp/job.lock ]]; then
touch /tmp/job.lock
run_job
fi
两个进程可以同时看到 /tmp/job.lock 不存在,然后都执行 touch,任务跑了两次。touch 是幂等的,但 run_job 往往不是。
1.1 原子操作是唯一解
一句话总结: 消除竞态的办法不是缩小窗口,而是把「检查 + 动作」变成一个内核保证的原子操作。
内核提供了几种原子原语:mkdir(目录已存在则失败)、O_CREAT|O_EXCL 打开(文件已存在则失败)、flock 系统调用(锁被占用则阻塞或失败)。用它们替代「先查后做」。
# 原子创建锁目录:成功即拿到锁,失败说明别人持有
if mkdir /tmp/job.lock.d 2>/dev/null; then
trap 'rmdir /tmp/job.lock.d' EXIT
run_job
else
echo '已有实例在运行,退出'
fi
1.2 锁原语的原子性保障
一句话总结: 锁目录的创建与删除都是原子的,而且
mkdir失败语义清晰,比「检查文件是否存在」可靠得多。
# 锁目录方案完整骨架
LOCKDIR=/var/lock/myapp.lock.d
acquire() {
mkdir "$LOCKDIR" 2>/dev/null && return 0
echo "锁被占用: $(cat "$LOCKDIR/pid" 2>/dev/null)" >&2
return 1
}
acquire || exit 1
echo $$ > "$LOCKDIR/pid"
trap 'rm -rf "$LOCKDIR"' EXIT INT TERM
2. flock 的两种用法
一句话总结:
flock既可以在命令行包住一条命令,也可以用文件描述符在脚本内部精细控制,前者简单后者灵活。
flock 是 Linux 上的 advisory 锁,基于文件描述符。进程退出时锁自动释放,这一点让它比手写锁目录更不容易留下陈旧锁。
# 用法一:包住整条命令,-n 非阻塞
flock -n /var/lock/myapp.lock -c '/usr/local/bin/backup.sh'
# 用法二:脚本内用文件描述符,锁在 fd 关闭时释放
exec 9>/var/lock/myapp.lock
flock -n 9 || { echo '已有实例在运行'; exit 1; }
run_job
2.1 阻塞、非阻塞与超时
一句话总结: 默认阻塞等待,
-n立即失败,-w 秒数限时等待,选哪个取决于「等待」还是「放弃」更符合业务语义。
# 非阻塞:拿不到锁立刻退出,适合定时任务防重叠
flock -n 9 || exit 0
# 限时等待 30 秒,超时则失败
flock -w 30 9 || { echo '等待锁超时'; exit 1; }
# 默认阻塞:排队执行,适合必须串行的关键区
flock 9
critical_section
2.2 共享锁与独占锁
一句话总结:
-s是共享读锁、-x是独占写锁,多个读者可并存,写者必须独占。
# 读操作加共享锁,可并发
exec 8</data/config.json
flock -s 8
jq -r '.name' <&8
exec 8<&-
# 写操作加独占锁,互斥
exec 9>/data/config.json
flock -x 9
jq '.count += 1' <&9 > /tmp/new.json && mv /tmp/new.json /data/config.json
3. 任务去重与单实例
一句话总结: 单实例保证同一时刻只有一个进程在跑,去重保证同一任务不会被重复提交,两者用同一套锁原语实现。
#!/usr/bin/env bash
set -euo pipefail
LOCK=/var/lock/report.lock
exec 9>"$LOCK"
if ! flock -n 9; then
echo '上一次报表任务尚未结束,本次跳过' >&2
exit 0
fi
generate_report
3.1 按任务键加锁
一句话总结: 用任务 ID 生成锁文件名,让不同任务可以并行,同一任务串行。
# 每个用户一个锁,互不干扰
run_for_user() {
local uid="$1"
exec 9>"/var/lock/user-$uid.lock"
flock -n 9 || { echo "用户 $uid 的任务正在运行"; return 0; }
process_user "$uid"
}
# 批量并发处理不同用户
for uid in $(list_users); do run_for_user "$uid" & done
wait
3.2 用锁实现幂等
一句话总结: 先查结果再执行,并用锁包住整段逻辑,避免两个进程同时发现「结果不存在」而重复计算。
ensure_artifact() {
local out="$1"
[[ -s "$out" ]] && { echo "已存在,跳过"; return 0; }
exec 9>"$out.lock"
flock 9
[[ -s "$out" ]] && return 0 # 双重检查:可能已被别的进程生成
build_artifact > "$out"
}
4. 锁超时与陈旧锁
一句话总结: 锁文件不会自己消失,进程被 kill -9 后会留下陈旧锁,所以锁必须带「持有者身份」与「超时判定」。
flock 的锁随 fd 关闭自动释放,天然免疫这个问题;手写的锁目录方案则需要自己处理。
# 记录持有者 PID 与开始时间,便于判定陈旧
LOCKDIR=/var/lock/app.lock.d
if ! mkdir "$LOCKDIR" 2>/dev/null; then
pid=$(cat "$LOCKDIR/pid" 2>/dev/null || echo 0)
if ! kill -0 "$pid" 2>/dev/null; then
echo "陈旧锁:进程 $pid 已不存在,清理" >&2
rm -rf "$LOCKDIR"
mkdir "$LOCKDIR" || exit 1
else
echo '锁被活跃进程持有' >&2; exit 1
fi
fi
4.1 基于时间的过期
一句话总结: 除了看进程是否存在,还可以看锁的年龄,超过最大执行时间就强制清理。
# 锁文件超过 2 小时视为过期
MAX_AGE=7200
if [[ -d "$LOCKDIR" ]]; then
age=$(( $(date +%s) - $(stat -c %Y "$LOCKDIR" 2>/dev/null || stat -f %m "$LOCKDIR") ))
if (( age > MAX_AGE )); then
echo "锁已过期 $age 秒,强制清理" >&2
rm -rf "$LOCKDIR"
fi
fi
4.2 trap 保证释放
一句话总结: 无论正常退出、出错还是收到信号,trap 都要负责释放锁,否则一次异常就永久锁死。
cleanup() {
rm -rf "$LOCKDIR"
echo '锁已释放' >&2
}
trap cleanup EXIT INT TERM HUP
5. 并发写同一文件
一句话总结: 多个进程追加写同一文件时,短行追加通常是原子的,但读改写必须加锁,否则会互相覆盖。
# 追加短行:内核保证 O_APPEND 的单次写原子性
printf '%s\n' "$(date +%s) event" >> /var/log/events.log
# 读改写:必须加锁,否则两个进程的修改会丢失一个
exec 9>/var/lib/counter
flock 9
n=$(cat /var/lib/counter)
printf '%d' "$(( n + 1 ))" > /var/lib/counter
exec 9>&-
5.1 原子替换而非就地修改
一句话总结: 写临时文件再
mv覆盖,配合锁,能避免读者看到半个文件。
update_config() {
local tmp; tmp=$(mktemp)
jq "$1" /etc/app/config.json > "$tmp"
# mv 在同一文件系统内是原子的,读者要么看到旧的要么看到新的
mv "$tmp" /etc/app/config.json
}
exec 9>/etc/app/config.lock
flock 9
update_config '.debug = true'
5.2 并行写分片再合并
一句话总结: 与其让多个进程争抢一个文件,不如各写各的分片,最后顺序合并,把锁的粒度降到最低。
# 每个 worker 写自己的分片,无锁
for i in $(seq 1 8); do
( process_part "$i" > "/tmp/part-$i.txt" ) &
done
wait
# 主进程顺序合并,天然无竞态
cat /tmp/part-*.txt | sort -u > /tmp/merged.txt
6. 分布式与跨主机锁
一句话总结: 文件锁只在同一主机同一文件系统内有效,跨主机要靠 NFS 锁、数据库行锁或外部协调服务。
# NFS 上 flock 支持有限,需确认挂载选项与锁服务
mount | grep -E 'nfs|lock'
# 用数据库行锁实现跨主机互斥(以 PostgreSQL 为例)
psql -c "SELECT pg_advisory_lock(42)" && do_job
psql -c "SELECT pg_advisory_unlock(42)"
# 用对象存储的条件写做乐观锁(伪代码示意)
# 上传时带 If-None-Match: * ,已存在则失败
6.1 锁的粒度与死锁
一句话总结: 需要多把锁时,所有进程必须按同一顺序获取,否则会互相等待形成死锁。
# 错误:A 先锁 x 再锁 y,B 先锁 y 再锁 x,可能死锁
# 正确:全局约定按路径字典序获取
acquire_all() {
for f in $(printf '%s\n' "$@" | sort); do
exec {fd}>"$f.lock"
flock "$fd"
done
}
6.2 锁与超时配合避免永久阻塞
一句话总结: 即使顺序正确,也建议给每把锁设超时,让死锁退化为可重试的失败。
# 带超时的锁获取,失败则整体回滚
exec 9>/tmp/a.lock; flock -w 10 9 || exit 1
exec 8>/tmp/b.lock; flock -w 10 8 || exit 1
7. 实战:带锁的定时备份脚本
一句话总结: 把单实例锁、超时保护、陈旧锁清理、trap 释放组合起来,就是一个生产可用的定时任务骨架。
#!/usr/bin/env bash
set -euo pipefail
LOCK=/var/lock/backup.lock
LOG=/var/log/backup.log
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" | tee -a "$LOG" >&2; }
# 非阻塞获取独占锁,拿不到说明上一轮还在跑
exec 9>"$LOCK"
if ! flock -n 9; then
log '上一轮备份尚未结束,跳过本次'
exit 0
fi
cleanup() { log '备份结束,释放锁'; }
trap cleanup EXIT
log "开始备份,PID $$"
7.1 备份主体与失败处理
一句话总结: 备份内容写入临时目录,成功后原子替换,失败则保留现场便于排查。
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
if ! rsync -a --delete /data/ "$STAGE/data/"; then
log 'rsync 失败,保留临时目录以便排查'
trap - EXIT
exit 1
fi
target="/backup/$(date +%F).tar.gz"
tar -czf "$target" -C "$STAGE" data
log "备份完成: $target ($(du -h "$target" | cut -f1))"
7.2 锁的可观测性
一句话总结: 把「谁持有锁、持有多久」写进日志或指标,锁问题才可排查。
# 记录锁等待信息
waited_start=$(date +%s)
if ! flock -w 60 9; then
log "等待锁超时 $(( $(date +%s) - waited_start )) 秒"
exit 1
fi
log "已获得锁,等待 $(( $(date +%s) - waited_start )) 秒"
8. 总结
| 环节 | 要点 |
|---|---|
| 竞态本质 | 检查与动作之间的时间窗口,TOCTOU |
| 原子原语 | mkdir、O_CREAT 排他、flock 系统调用 |
| flock 用法 | 命令行包命令,或 fd 在脚本内精细控制 |
| 等待语义 | 默认阻塞,-n 立即失败,-w 限时 |
| 锁类型 | -s 共享读,-x 独占写 |
| 陈旧锁 | 记录 PID 与时间,按进程存活或年龄清理 |
| 释放 | trap EXIT INT TERM 兜底,fd 关闭自动释放 |
| 跨主机 | 文件锁不跨机器,改用数据库或协调服务 |
并发控制的核心不是「加锁」这个动作,而是「明确互斥范围、保证任何路径都能释放、让异常退化为可重试的失败」。把锁的获取、释放、超时和可观测性都写进模板,多实例部署才不会被一个残留的锁文件拖垮。并发之后,下一个常见任务是处理外部导入的结构化数据,这就轮到 CSV 清洗上场了。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。