第 9 章 · 06 健壮性第二件:trap 信号与退出清理


文档摘要

第 9 章 · 06 健壮性第二件:trap 信号与退出清理 本节摘要:上一节的 让脚本「出错就停」,但「停」本身带来了新问题:脚本建了临时目录、写了锁文件、开了子进程,突然中断时这些东西怎么办?答案是 ——Shell 的信号处理与退出钩子。 让脚本「无论以何种方式退出(正常结束、命令失败、Ctrl+C、被 kill)都先执行清理函数」。本节要讲透 的两个核心用法:一是 信号——脚本退出时无条件触发,是「保证清理」的万能钥匙;二是 / 等真实信号——让你能响应 Ctrl+C 和终止请求。最经典的模式是:脚本开头建临时目录、把删除逻辑挂到 上,从此「哪怕中途崩溃也不留垃圾」。掌握 ,你的脚本就从「能跑」升级到「能被信任」。

第 9 章 · 06 健壮性第二件:trap 信号与退出清理

本节摘要:上一节的 set -euo pipefail 让脚本「出错就停」,但「停」本身带来了新问题:脚本建了临时目录、写了锁文件、开了子进程,突然中断时这些东西怎么办?答案是 trap——Shell 的信号处理与退出钩子。trap 'cleanup' EXIT INT TERM 让脚本「无论以何种方式退出(正常结束、命令失败、Ctrl+C、被 kill)都先执行清理函数」。本节要讲透 trap 的两个核心用法:一是 EXIT 信号——脚本退出时无条件触发,是「保证清理」的万能钥匙;二是 INT/TERM 等真实信号——让你能响应 Ctrl+C 和终止请求。最经典的模式是:脚本开头建临时目录、把删除逻辑挂到 trap ... EXIT 上,从此「哪怕中途崩溃也不留垃圾」。掌握 trap,你的脚本就从「能跑」升级到「能被信任」。

内容来源:综合 Shell 脚本知识与原项目「Linux 命令大全」整理,套用体系化模板。

学习目标

阅读完本节,你应当能够:

  1. trap 'commands' SIGNAL 注册信号处理函数,说清「信号」和「退出」的区别。
  2. 写出 trap 'cleanup' EXIT 模式,保证脚本无论怎么退出都执行清理。
  3. 在脚本开头建临时目录或锁文件,并挂上对应的清理 trap
  4. 区分 EXITINT(Ctrl+C)、TERM(kill)、HUP(终端断开)几种常见信号。
  5. 解释「为什么单靠 EXIT 不够,还要配 INT/TERM」的历史原因(POSIX 细节)。
  6. 写一个「即使被 Ctrl+C 也能删除临时文件、释放锁」的健壮脚本。

一、设计动机(为什么这样设计)

问题:脚本中途死了,垃圾谁来清?

看一个典型场景:

#!/usr/bin/env bash set -euo pipefail tmpdir=$(mktemp -d) # 建临时目录 do_work # 这里如果失败,set -e 让脚本立刻停 # ……临时目录永远不会被删 rm -rf "$tmpdir" # 这行到不了

set -e 在第 05 节是好事,但它带来的副作用是「脚本可能在任何一行中断」,于是结尾的清理代码(rm -rf $tmpdir)经常到不了。结果就是 /tmp 里堆满 tmp.XXXXXX 残留文件。

更糟的场景:用户在脚本跑到一半时按了 Ctrl+C,进程收到 SIGINT 直接退出,根本不会执行后面的任何代码。临时文件、锁文件、半成品数据库全留下。

trap 的本质:在退出前插入一段代码

trap 是 Shell 的「退出钩子」。它的语法是:

trap '要执行的命令' 触发的信号

当脚本收到指定信号、或正常退出时,Shell 会先执行你 trap 注册的命令,然后再真正退出。

trap 'echo "再见!"' EXIT # 脚本退出时打印"再见"

EXIT 是最常用的「伪信号」

EXIT 不是真实的 Unix 信号(不是 Ctrl+C 那种),而是 Shell 自己造的「伪信号」——它在脚本以任何方式退出时都会触发:正常执行到底、exitset -e 触发的停止、被信号杀死……都算。

#!/usr/bin/env bash tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT # 关键一行:退出时删临时目录 # 之后无论脚本怎么结束(正常/出错/Ctrl+C),tmpdir 都会被清理 do_work

关键概念trap '...' EXIT 是「保证清理」的万能钥匙。把所有需要收尾的动作(删临时文件、释放锁、关闭连接)写进 EXIT 的处理函数,你就再也不用担心「脚本中途死了留垃圾」。

真实信号:INTTERMHUP

除了 EXIT 这个伪信号,trap 还能响应真实的 Unix 信号:

信号 触发场景 默认行为
INT 用户按 Ctrl+C 终止进程
TERM kill 命令默认发的信号 终止进程
HUP 终端关闭、SSH 断开 终止进程
EXIT 脚本退出(伪信号) ——
trap 'cleanup; exit' INT TERM # 收到 Ctrl+C 或 kill 时清理后退出

💡 技巧:现代 Bash 里,只用 trap '...' EXIT 通常就够了——因为 Bash 在收到 INT/TERM 时会退出,而退出会触发 EXIT 钩子。所以把清理挂在 EXIT 上,能覆盖绝大多数场景。只有老式 POSIX Shell 或某些边缘情况才需要显式写 INT TERM。但为了保险,很多生产脚本会同时写:trap 'cleanup' EXIT INT TERM HUP

二、高频组合与实战

1. 临时目录的标准化清理(最经典模式)

#!/usr/bin/env bash set -euo pipefail tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT # 保证退出时清理 # 用临时目录干活 echo "数据" > "$tmpdir/data.txt" sort "$tmpdir/data.txt" > result.txt # 脚本结束时(无论成败),tmpdir 自动删除

这是 Shell 脚本里最该形成肌肉记忆的模式之一:先建临时目录、立刻挂 trap、之后随便用

2. 锁文件:防止脚本并发执行

#!/usr/bin/env bash set -euo pipefail lockfile=/tmp/myapp.lock if [[ -e $lockfile ]]; then echo "另一个实例正在运行" >&2 exit 1 fi echo $$ > "$lockfile" trap 'rm -f "$lockfile"' EXIT # 退出时释放锁 # 即使脚本中途崩溃,锁也会被释放(这正是 trap 的价值) do_long_work

没有 trap 的话,脚本一旦异常退出,锁文件会留下,下次就永远启动不了。trap 保证「哪怕死掉也释放锁」。

3. 把清理封装成函数

cleanup() { rm -rf "$tmpdir" rm -f "$lockfile" echo "[$(date +%F\ %T)] 清理完成" >> /var/log/myapp.log } tmpdir=$(mktemp -d) echo $$ > /tmp/myapp.lock trap cleanup EXIT # 函数名作为 trap 的参数

注意:trap 接受的是字符串,所以 trap cleanup EXIT 等价于 trap 'cleanup' EXIT——前者不带引号,后者带。推荐带引号,避免变量被提前展开。

4. 收到 Ctrl+C 时优雅退出

cleanup() { echo echo "正在清理,请稍候..." rm -rf "$tmpdir" exit 130 # 130 是「被 Ctrl+C 终止」的惯用退出码 } trap cleanup INT tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT # 双重保险 # 长时间任务,可被 Ctrl+C 中断并清理 while true; do do_step sleep 1 done

5. 在脚本不同阶段更新 trap

trap 可以随时被覆盖:

tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT # 阶段1: 只删临时目录 prepare_data trap 'rm -rf "$tmpdir"; stop_service' EXIT # 阶段2: 还要停服务 start_service serve

每进入新阶段,重新 trap 覆盖旧的清理逻辑。

6. 子进程的清理

worker_pid= cleanup() { [[ -n $worker_pid ]] && kill "$worker_pid" 2>/dev/null rm -rf "$tmpdir" } trap cleanup EXIT tmpdir=$(mktemp -d) python worker.py & worker_pid=$! wait "$worker_pid"

如果脚本启动了后台子进程,trap 里要记得把它一起 kill 掉,否则 Ctrl+C 后子进程可能继续跑。

三、踩坑与排错

坑 1:清理函数里用了未定义的变量

trap 'rm -rf "$tmpdir"' EXIT # 这里 tmpdir 加了引号,正确 trap "rm -rf $tmpdir" EXIT # 错!双引号会让 $tmpdir 在 trap 注册时就被展开

记住:trap 的命令必须用单引号,让变量在「信号真正触发时」才展开。如果用双引号,变量会在注册时就被固定成当时的值,后续修改无效。

tmpdir=/old trap "rm -rf $tmpdir" EXIT # 固定成 rm -rf /old tmpdir=/new # 改了也没用,trap 里的 tmpdir 还是 /old # 正确写法 trap 'rm -rf "$tmpdir"' EXIT # 单引号,运行时才展开 tmpdir

坑 2:忘了 trap 必须在变量赋值之后注册

trap 'rm -rf "$tmpdir"' EXIT # 此时 tmpdir 还没定义 tmpdir=$(mktemp -d) # 之后才赋值

虽然单引号版能在触发时展开(理论上能拿到值),但顺序最好反过来——先赋值、再 trap,更稳妥,也方便阅读。

坑 3:trap 在子 Shell 里失效

trap 'cleanup' EXIT echo hello | while read line; do # 这里是子 Shell,trap 不会继承 exit 1 done

管道右侧、命令替换、( ... ) 都在子 Shell 里,父脚本的 trap 不会传过去。如果子 Shell 里也需要清理,要单独 trap

坑 4:在 EXIT 钩子里调用 exit 导致死循环

trap 'exit 1' EXIT # 危险!EXIT 触发 → exit 1 → 又触发 EXIT → ……

EXIT 钩子里不要再调 exit,否则可能递归。直接让钩子结束(自然返回)即可。

坑 5:信号处理函数里执行耗时操作,导致用户感觉「卡住」

trap 'sleep 10; cleanup' INT # Ctrl+C 后要等 10 秒才退,用户体验差

清理函数应该尽量快(删文件、kill 进程),不要做耗时操作。如果有大量清理,可以打印一句「清理中...」让用户知道在干活。

本节要点回顾

  1. trap 'commands' SIGNAL 注册信号/退出钩子,脚本退出或收到信号时先执行注册的命令。
  2. EXIT 是最常用的「伪信号」,在脚本以任何方式退出(正常/出错/被杀)时都触发——这是「保证清理」的万能钥匙。
  3. 真实信号 INT(Ctrl+C)、TERM(kill)、HUP(终端断开)——现代 Bash 里只用 EXIT 往往就够(因为收到这些信号会先退出,从而触发 EXIT),保险起见可写 trap 'cleanup' EXIT INT TERM HUP
  4. 经典模式:开头建临时目录 / 锁文件 → 立刻挂 trap '...' EXIT → 之后随便用——保证「哪怕崩溃也不留垃圾、不锁死」。
  5. trap 的命令必须用单引号,让变量在触发时才展开;用双引号会让变量在注册时被固定。
  6. EXIT 钩子里不要再调 exit,避免递归;清理函数要快,别让用户等。
  7. trap 在子 Shell(管道右侧、命令替换、( ))里失效,子 Shell 需要单独注册。

至此你有了「遇错即停」(set -euo pipefail)和「退出清理」(trap)两件法宝。下一节讲健壮性第三件:getopts——给你的脚本配上规范的参数解析,让用户能用 -h 看帮助、-v 看版本、-o output 指定输出。一个能被别人(或一周后的自己)正确使用的脚本,才算真正可上线的工具。


发布者: 作者: 灏天文库 转发
评论区 (0)
U