第 9 章 · 06 健壮性第二件:trap 信号与退出清理 本节摘要:上一节的 让脚本「出错就停」,但「停」本身带来了新问题:脚本建了临时目录、写了锁文件、开了子进程,突然中断时这些东西怎么办?答案是 ——Shell 的信号处理与退出钩子。 让脚本「无论以何种方式退出(正常结束、命令失败、Ctrl+C、被 kill)都先执行清理函数」。本节要讲透 的两个核心用法:一是 信号——脚本退出时无条件触发,是「保证清理」的万能钥匙;二是 / 等真实信号——让你能响应 Ctrl+C 和终止请求。最经典的模式是:脚本开头建临时目录、把删除逻辑挂到 上,从此「哪怕中途崩溃也不留垃圾」。掌握 ,你的脚本就从「能跑」升级到「能被信任」。
本节摘要:上一节的
set -euo pipefail让脚本「出错就停」,但「停」本身带来了新问题:脚本建了临时目录、写了锁文件、开了子进程,突然中断时这些东西怎么办?答案是trap——Shell 的信号处理与退出钩子。trap 'cleanup' EXIT INT TERM让脚本「无论以何种方式退出(正常结束、命令失败、Ctrl+C、被 kill)都先执行清理函数」。本节要讲透trap的两个核心用法:一是EXIT信号——脚本退出时无条件触发,是「保证清理」的万能钥匙;二是INT/TERM等真实信号——让你能响应 Ctrl+C 和终止请求。最经典的模式是:脚本开头建临时目录、把删除逻辑挂到trap ... EXIT上,从此「哪怕中途崩溃也不留垃圾」。掌握trap,你的脚本就从「能跑」升级到「能被信任」。
内容来源:综合 Shell 脚本知识与原项目「Linux 命令大全」整理,套用体系化模板。
阅读完本节,你应当能够:
trap 'commands' SIGNAL 注册信号处理函数,说清「信号」和「退出」的区别。trap 'cleanup' EXIT 模式,保证脚本无论怎么退出都执行清理。trap。EXIT、INT(Ctrl+C)、TERM(kill)、HUP(终端断开)几种常见信号。EXIT 不够,还要配 INT/TERM」的历史原因(POSIX 细节)。看一个典型场景:
#!/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 自己造的「伪信号」——它在脚本以任何方式退出时都会触发:正常执行到底、exit、set -e 触发的停止、被信号杀死……都算。
#!/usr/bin/env bash tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT # 关键一行:退出时删临时目录 # 之后无论脚本怎么结束(正常/出错/Ctrl+C),tmpdir 都会被清理 do_work
关键概念:
trap '...' EXIT是「保证清理」的万能钥匙。把所有需要收尾的动作(删临时文件、释放锁、关闭连接)写进EXIT的处理函数,你就再也不用担心「脚本中途死了留垃圾」。
INT、TERM、HUP除了 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。
#!/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、之后随便用。
#!/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 保证「哪怕死掉也释放锁」。
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——前者不带引号,后者带。推荐带引号,避免变量被提前展开。
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
traptrap 可以随时被覆盖:
tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT # 阶段1: 只删临时目录 prepare_data trap 'rm -rf "$tmpdir"; stop_service' EXIT # 阶段2: 还要停服务 start_service serve
每进入新阶段,重新 trap 覆盖旧的清理逻辑。
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 后子进程可能继续跑。
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
trap 必须在变量赋值之后注册trap 'rm -rf "$tmpdir"' EXIT # 此时 tmpdir 还没定义 tmpdir=$(mktemp -d) # 之后才赋值
虽然单引号版能在触发时展开(理论上能拿到值),但顺序最好反过来——先赋值、再 trap,更稳妥,也方便阅读。
trap 在子 Shell 里失效trap 'cleanup' EXIT echo hello | while read line; do # 这里是子 Shell,trap 不会继承 exit 1 done
管道右侧、命令替换、( ... ) 都在子 Shell 里,父脚本的 trap 不会传过去。如果子 Shell 里也需要清理,要单独 trap。
EXIT 钩子里调用 exit 导致死循环trap 'exit 1' EXIT # 危险!EXIT 触发 → exit 1 → 又触发 EXIT → ……
EXIT 钩子里不要再调 exit,否则可能递归。直接让钩子结束(自然返回)即可。
trap 'sleep 10; cleanup' INT # Ctrl+C 后要等 10 秒才退,用户体验差
清理函数应该尽量快(删文件、kill 进程),不要做耗时操作。如果有大量清理,可以打印一句「清理中...」让用户知道在干活。
trap 'commands' SIGNAL 注册信号/退出钩子,脚本退出或收到信号时先执行注册的命令。EXIT 是最常用的「伪信号」,在脚本以任何方式退出(正常/出错/被杀)时都触发——这是「保证清理」的万能钥匙。INT(Ctrl+C)、TERM(kill)、HUP(终端断开)——现代 Bash 里只用 EXIT 往往就够(因为收到这些信号会先退出,从而触发 EXIT),保险起见可写 trap 'cleanup' EXIT INT TERM HUP。trap '...' EXIT → 之后随便用——保证「哪怕崩溃也不留垃圾、不锁死」。trap 的命令必须用单引号,让变量在触发时才展开;用双引号会让变量在注册时被固定。EXIT 钩子里不要再调 exit,避免递归;清理函数要快,别让用户等。trap 在子 Shell(管道右侧、命令替换、( ))里失效,子 Shell 需要单独注册。至此你有了「遇错即停」(set -euo pipefail)和「退出清理」(trap)两件法宝。下一节讲健壮性第三件:getopts——给你的脚本配上规范的参数解析,让用户能用 -h 看帮助、-v 看版本、-o output 指定输出。一个能被别人(或一周后的自己)正确使用的脚本,才算真正可上线的工具。