文件与 IO 进阶:重定向与文件锁

系统讲解文件描述符与重定向的底层语义、进程替换与 Here 字符串、临时文件配合 trap 的清理策略,以及 flock 文件锁与原子写入在并发安全中的落地实践。

1. 文件描述符与标准流

一句话总结: 每个进程持有 0/1/2 三个默认描述符(stdin/stdout/stderr),Shell 重定向的本质就是「把某个描述符指向另一个文件」。

Linux 里「一切都是文件」,键盘、屏幕、管道、套接字都是文件描述符指向的对象。

# 三个标准流的默认指向
ls -l /proc/$$/fd/0 /proc/$$/fd/1 /proc/$$/fd/2

# 把 stdout 重定向到文件
echo "hello" > /tmp/out.txt

# 把 stderr 单独重定向
ls /no/such/dir 2> /tmp/err.txt
cat /tmp/err.txt

1.1 重定向的三种符号

> 覆盖、>> 追加、< 读入,作用于哪个描述符由数字前缀决定。

# 覆盖写与追加写
echo "第一行" >  /tmp/a.log
echo "第二行" >> /tmp/a.log

# 显式指定描述符
echo "到 stderr" 1>&2        # stdout 复制到 stderr
cat < /tmp/a.log < /dev/null # 从文件读

# 同时写 stdout 和 stderr 到同一文件
echo "out" > /tmp/both.log 2>&1

一句话总结: 2>&1 的顺序很重要:先 > 打开文件再复制描述符,反了会把 stderr 指到旧的 stdout。

1.2 顺序与 dup 语义

重定向按照从左到右的顺序求值,理解这一点才能避开经典坑。

# 错误:stderr 复制到「旧的 stdout」,仍指向终端
echo "错误信息" 2>&1 > /tmp/x.log

# 正确:先打开文件,再把 stderr 复制到新 stdout
echo "错误信息" > /tmp/x.log 2>&1

# 等价写法:&> 一步到位
echo "错误信息" &> /tmp/x.log

2. 进程替换与 Here 文档

一句话总结: 进程替换 <(...) 和 >(...) 把「一个命令的输出」伪装成「一个文件路径」,让 diff、wc 等需要文件参数的命令直接吃命令输出。

管道只能连接 stdout,而 diff、comm 需要两个文件参数,进程替换正好补上这个缺口。

# 比较两个命令的输出,而不是两个文件
diff <(ls /etc) <(ls /usr/local/etc)

# 把输出喂给一个正在写入的进程
tee >(grep ERROR > /tmp/errors.log) < /var/log/syslog

# comm 需要排序输入
comm -12 <(sort a.txt) <(sort b.txt)

2.1 Here 文档与 Here 字符串

Here 文档把多行文本作为 stdin,Here 字符串 <<< 把单行字符串作为 stdin。

# Here 文档:多行输入,带引号的定界符不做变量展开
cat <<'EOF'
$HOME 不会被展开,原样输出
EOF

# 不带引号则会展开变量
name=Alice
cat <<EOF
你好,$name
EOF

# Here 字符串:单行快捷方式
grep "ERROR" <<< "INFO log"

一句话总结: 定界符加引号(<<'EOF')表示「不展开」,这是脚本里写字面量多行文本的关键。

2.2 进程替换与管道的取舍

进程替换本质是「临时文件 + FIFO」,资源开销略高于管道;能用管道的地方优先管道。

# 管道:数据流,无临时文件
cat a.txt b.txt | sort | uniq -c

# 进程替换:需要文件参数时使用
wc -l <(curl -s http://example.com/data)

# 进程替换在后台任务里的坑:可能被过早清理
diff <(sleep 1; echo A) <(echo B)   # 一般没问题,但了解即可

3. 临时文件与 trap 清理

一句话总结: mktemp 创建安全唯一的临时文件,配合 trap ... EXIT 在脚本结束时自动清理,避免残留与安全风险。

临时文件最怕两件事:名字冲突、异常退出后残留。

# 安全创建临时文件
tmp=$(mktemp /tmp/myapp.XXXXXX)
echo "数据" > "$tmp"
echo "临时文件: $tmp"

# 安全创建临时目录
tmpdir=$(mktemp -d /tmp/myapp.XXXXXX)

3.1 trap 注册清理

trap 在脚本退出(包括出错)时执行清理函数。

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

tmp=$(mktemp /tmp/build.XXXXXX)
cleanup() {
  rm -f "$tmp"
  echo "已清理 $tmp"
}
trap cleanup EXIT

# 主逻辑
echo "编译日志" > "$tmp"
cat "$tmp"
# 脚本结束自动触发 cleanup

一句话总结: trap cleanup EXIT 让「退出即清理」成为脚本惯例,无论正常退出还是被错误中断。

3.2 多种信号的清理

生产脚本通常同时捕获 EXIT、INT、TERM,并区分「信号导致」与「正常结束」。

#!/usr/bin/env bash
tmpdir=$(mktemp -d /tmp/deploy.XXXXXX)
trap 'rm -rf "$tmpdir"; exit 1' INT TERM HUP
trap 'rm -rf "$tmpdir"' EXIT

echo "解压代码到 $tmpdir"
# 长时间任务
sleep 30
echo "部署完成"

4. 文件锁与并发安全

一句话总结: 多个脚本并发写同一文件会互相覆盖,flock 让「只有拿到锁的进程才能执行关键区」,是实现互斥的可靠手段。

并发场景:多个 cron 任务、多个 worker 同时写日志或处理任务队列。

#!/usr/bin/env bash
# 用 flock 保护临界区
exec 9> /tmp/app.lock
flock -n 9 || { echo "已有实例在运行"; exit 1; }
echo "独占执行:$(date)"
sleep 5
echo "完成"
# 描述符关闭时自动释放锁

4.1 flock 的三种用法

# 方式一:整段脚本交给 flock(最简单)
flock /tmp/app.lock -c 'echo 临界区; sleep 2'

# 方式二:-x 排他锁 / -s 共享锁
flock -x /tmp/db.lock ./update_db.sh

# 方式三:文件描述符持锁
(
  flock -x 9
  echo "多个命令都在锁内"
  sleep 3
) 9> /tmp/job.lock

一句话总结: flock -n 非阻塞「抢不到就失败」适合互斥任务;不加 -n 则排队等待,适合串行消费任务。

4.2 锁的超时与等待

# 等待锁,但限时 10 秒
flock -w 10 /tmp/queue.lock -c 'process_queue'

# 锁文件本身也要清理
flock /tmp/run.lock -c 'your_task'
rm -f /tmp/run.lock

5. 原子写入与持久化

一句话总结: 「写临时文件 + mv 替换」是原子写配置的标准套路:要么旧内容、要么新内容,绝不出现写了一半的中间态。

直接覆盖配置文件可能在写入中途崩溃,留下半截文件。

#!/usr/bin/env bash
# 原子写:先写临时文件,再 rename
cfg=/etc/myapp.conf
tmp=$(mktemp /tmp/myapp.XXXXXX)
cat > "$tmp" <<'EOF'
server=127.0.0.1
port=8080
EOF
mv "$tmp" "$cfg"   # mv 是同文件系统内的原子 rename

5.1 fsync 与持久化

严格场景(如数据库 WAL)需要确保数据落盘,而不仅是进入页缓存。

# 用 dd 同步落盘
dd if=/tmp/data of=/mnt/disk/data.bin conv=fsync

# 检查挂载的同步属性
mount | grep -E 'ext4|xfs' | head -1

一句话总结: 配置类文件用「临时文件 + mv」;需要真正落盘的场景再考虑 fsync,日常脚本不必过度设计。

5.2 目录级原子替换

「整目录替换」用软链接切换,实现零停机发布(发布章节会展开)。

ln -sfn /opt/app/v1.2.0 /opt/app/current
# 访问 /opt/app/current 立即指向新版本

6. 文件描述符的高级用法

一句话总结: 自定义描述符(3~9)可以在脚本生命周期内保持「已打开的文件」,避免反复打开关闭。

#!/usr/bin/env bash
# 打开 9 号描述符写日志
exec 9> /tmp/app.log
echo "启动" >&9
echo "处理中" >&9
exec 9>&-   # 关闭描述符

6.1 双写 stdout 与日志

tee 实现「输出同时给屏幕和文件」,配合进程替换可以做分流。

# tee 同时输出到终端和文件
echo "关键输出" | tee /tmp/out.log

# 追加模式
echo "更多" | tee -a /tmp/out.log

# 进程替换实现「同时过滤」
ls -la | tee >(grep '^d' > dirs.txt) | wc -l

一句话总结: tee 是「复制数据流」的内置工具,tee -a 追加、>(...) 分流过滤是它的两个常用组合。

6.2 命名管道 FIFO

FIFO 让两个独立进程按「文件」方式通信。

# 创建命名管道
mkfifo /tmp/pipe
# 写入端
echo "消息" > /tmp/pipe &
# 读取端
cat < /tmp/pipe
rm -f /tmp/pipe

7. 实战:日志轮转与并发任务

一句话总结: 把「原子写 + 文件锁 + 描述符」组合起来,能写出并发安全的日志写入与任务处理脚本。

7.1 并发安全的日志写入

多个 worker 写同一日志文件时用 flock 保护,避免行交错。

#!/usr/bin/env bash
log_msg() {
  local msg="$1"
  {
    flock -x 9
    echo "$(date '+%F %T') [$BASHPID] $msg" >> /var/log/app.log
  } 9>> /var/log/app.lock
}

# 模拟 5 个并发 worker
for i in 1 2 3 4 5; do
  log_msg "任务 $i 开始" &
done
wait

一句话总结: 追加模式 >> 本身具备一定原子性,但「一行完整日志」的保证需要 flock 包裹 write。

7.2 带锁的任务队列

#!/usr/bin/env bash
set -euo pipefail
queue=/tmp/tasks.txt
lock=/tmp/tasks.lock

flock -w 5 "$lock" bash -c '
  while read -r task; do
    echo "处理: $task"
    sleep 0.5
  done < "$queue"
'

7.3 检查点文件与断点续传

#!/usr/bin/env bash
# 用状态文件记录进度
progress=/tmp/download.progress
done_list=/tmp/download.done

for url in $(cat urls.txt); do
  if grep -qx "$url" "$done_list"; then
    continue  # 已下载,跳过
  fi
  wget -q "$url"
  echo "$url" >> "$done_list"
done

8. 总结

环节要点
描述符0/1/2 默认流,重定向是修改描述符指向
顺序语义2>&1 必须先打开文件再复制,&> 一步到位
进程替换<(...) 把命令输出伪装成文件路径
临时文件mktemp 安全唯一,trap EXIT 自动清理
文件锁flock 互斥,-n 非阻塞、-w 限时等待
原子写「临时文件 + mv」保证不出现中间态
自定义描述符exec 9> 生命周期内保持打开
并发实战flock 包写入、检查点文件实现断点续传

文件与 IO 是脚本的地基:理解了描述符与重定向的顺序语义,2>&1 的坑就再也不会踩;掌握了临时文件 + trap、原子写 + flock,脚本就从「能跑」进阶到「并发安全」。下一步是结构化数据与 jq/yq,让脚本能优雅地处理 JSON 与 YAML 配置。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「shell」更多文章

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