并发控制与文件锁实战

从竞态条件的本质出发,讲解 flock 的两种用法、锁文件与原子创建、任务去重、锁超时与陈旧锁清理,以及多进程安全追加日志的工程模式。

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 清洗上场了。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 任务编排与 Makefile 实战
  2. 文件监控与事件驱动流水线实战
  3. 结构化数据清洗与报表生成实战