trap 让 Shell 为某个信号或自身事件安排动作,适合删除临时文件、协调退出,但不会让中断变成原子操作,也无法保证任何终止方式都能完成清理。在 Bash 中,trap 是内建命令,EXIT 是 Shell 事件,而不是操作系统信号。可靠的设计需要明确处理哪些出口,并保留执行为什么结束的证据,不能仅凭注册了一次处理器就断言资源一定会被回收。
信号处理与退出清理分开
trap 'cleanup' INT TERM 会替换这些信号的默认动作。如果处理器只是返回,Shell 可能继续执行;安装处理器并不天然等于启动关停。一种明确的 Bash 写法是在信号处理器中主动退出,再让统一的退出处理器负责清理。正常执行到末尾、显式调用退出命令,也会经过这个出口,从而减少各业务分支之间重复的资源回收逻辑。
从函数或被点命令加载的文件返回,不等于结束整个 Shell。普通执行脚本里的顶层 return 也不是通用退出方式。此外,不能跨 Shell 假定未处理的致命信号一定会触发退出清理。应明确处理准备支持的终止信号,并使用服务真正采用的解释器测试,而不是在交互终端里试过一次便推广到所有启动环境。
trap - INT 恢复默认动作,trap '' INT 则忽略信号,两者策略不同。非交互 Shell 还可能从启动者继承被忽略的信号,而且无法重新捕获或重置,所以启动路径也是行为的一部分。需要确认实际安装的动作时,可以检查 trap -p 输出;配置中写过处理器,与当前 Shell 最终持有什么处理器,仍然需要区分。
保留最初的退出原因
进入退出处理器后,应先保存 $?,避免后面的命令覆盖它。清理末尾固定执行 exit 1,会把成功改成失败,也会抹掉原来不同的错误码。使用保存的状态退出,才能保留触发收尾的原始结果。清理本身失败时仍应输出可见诊断,并制定明确策略,尤其要说明主工作成功而清理失败时最终如何报告,不能把这两种结果混成一句“脚本结束”。
示例为已处理的中断选择一百三十,为终止请求选择一百四十三,沿用 Bash 常见的信号状态约定。但这是处理器主动执行的普通退出,不证明进程真的被操作系统信号直接杀死;使用进程接口的监督程序能够区分两者。短暂清理期间关闭退出陷阱、忽略中断和终止请求,是为了避免这些处理器重入清理逻辑,并不是无限期屏蔽关停的建议。
这段清理代码有意优先保留最初的退出状态。删除命令即使报告错误,也不会改变已保存的结果,因此零退出码不是清理完成的独立证明。若残留资源会产生后果,需要另外测试清理失败,并决定是否报告不同的失败、进行安全重试,或保留恢复所需证据。
坏掉的样子
强制杀死信号无法捕获,停止信号也不能捕获,更不能把停止视作清理机会。断电、崩溃和强制终止都可能留下资源。因此,陷阱不能替代崩溃安全写入、事务更新或单独定义的恢复流程。删除目标必须限制在脚本拥有的路径内;一个尚未初始化完成的变量,绝不能意外变成宽泛删除范围。清理动作的权限边界与正常业务操作同样需要检查。
执行时机也有差别。Bash 等待同步前台命令结束时,可以推迟执行已经捕获的信号处理器;通过内建等待命令等待异步任务时,被捕获的信号则会先打断等待,随后进入处理器。因此,注册陷阱并不意味着任意阻塞操作都能立即响应关停,更不能据此承诺固定的退出时限。长时间外部工作需要结合真实等待方式验证。
终端中的 Ctrl-C 会把中断信号发给前台进程组,而向单个进程编号发送信号只针对那个进程。于是,同一个子进程可能在一种测试中收到信号,在另一种测试中没有收到。管理工作进程的脚本,应分别定义信号转发、等待结束和资源归属;不能杀掉不相关进程,也不能把 Shell 已经退出当作所有后代进程都已停止的证据。
快速验证
使用 Python 3、Bash 及常见 Unix 工具运行下面的示例。每个分支启动一个独立 Bash,并在私有临时目录中创建一个文件;信号只发给这层新 Shell 自身。外层临时目录由 Python 管理,强制终止分支故意留下的文件,最后也由这个外层管理器删除,不会把残留留在用户工作区。
import os
import signal
import subprocess
import tempfile
from pathlib import Path
script = r'''
demo_file=$1
cleanup() {
demo_status=$?
trap - EXIT
trap '' INT TERM
rm -f -- "$demo_file"
printf 'cleanup=%s\n' "$demo_status"
exit "$demo_status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
: > "$demo_file" || exit 1
case "$2" in
normal) exit 7 ;;
*) kill "-$2" "$$" ;;
esac
exit 99
'''
with tempfile.TemporaryDirectory(prefix="bash-trap-demo-") as directory:
for mode, expected in [("normal", 7), ("TERM", 143),
("INT", 130), ("KILL", -signal.SIGKILL)]:
target = Path(directory) / mode
result = subprocess.run(
["bash", "--noprofile", "--norc", "-c", script,
"trap-demo", str(target), mode],
env=dict(os.environ, BASH_ENV=""),
text=True, capture_output=True, timeout=5,
)
cleaned = result.stdout == f"cleanup={expected}\n"
remains = target.exists()
assert result.returncode == expected, result
assert (cleaned, remains) == (mode != "KILL", mode == "KILL"), result
assert not result.stderr, result
print(f"{mode}: rc={result.returncode} cleaned={cleaned} remains={remains}")
前三种情况应分别返回七、一百四十三和一百三十,观察到清理输出,且文件已经不存在。强制杀死分支应得到 Python 表示信号终止的负数返回码,通常为负九,没有清理输出,同时文件仍存在;若由 Shell 调用,通常会把同一终止表示为一百三十七。这种差异来自结果表示方式,不能误判成实验不一致。
这组结果验证了原退出状态得到保留,也验证了无法捕获的终止确实绕过清理。它没有测试向工作进程转发信号、终端前台进程组,或长时间同步命令造成的处理延迟。真实脚本涉及这些行为时,需要补上对应环境中的观察,而不是把这个单进程实验当作整套进程管理机制已经验收。
来源
- Bash 信号,等待期间的信号处理及前台进程组,已核验 2026-09-07。
- POSIX trap,退出事件、继承的信号处置及陷阱动作,已核验 2026-09-07。