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 配置。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。