写Shell脚本这些年,我最怕碰到的不是语法报错,而是脚本运行到一半"不动了"。尤其那些挂着while循环的脚本,一旦循环体里有个命令在等网络、等文件、等用户输入,整个任务就像被按了暂停键,日志停在最后一行,进程占着CPU不放,你甚至分不清它是在慢速处理还是彻底卡死。这个问题的本质是:Shell脚本里的循环天然缺少"时间概念",只要循环条件没有被打破,它会一直转下去,直到机器资源耗尽或者被外部kill。这一章就专注解决两件事——给循环加超时控制,以及让脚本在异常情况下能体面退出。
对于做自动化运维、数据处理、定时任务的朋友来说,这套防死循环的思路属于必须掌握的保命技能。后面讲的内容不涉及高深技巧,大多数是命令行的常规武器,但组合起来会非常有效。我会把实际踩过的坑和排查路径一并放进来,确保你看完能直接对号入座,把脚本改安全。
1. 死循环是怎么来的:常见触发场景与根因定位思路
想防死循环,先得搞清楚死循环到底是怎么出现的。很多时候,程序员写循环时的逻辑本身没错,是循环依赖的外部状态出了问题。
1.1 等待类命令把循环体卡死
最常见的死循环元凶,是循环体内部执行了一个"永远等下去"的命令。比如下面这段:
while read line; do process "$line" done < <(tail -f /app/log/access.log)tail -f会持续监听文件追加内容,永远不会主动结束。while read会一直从中读取,于是这个循环的退出条件——文件读到EOF——永远无法满足。这不是逻辑错误,是你把两个"无限"语义叠加在了一起。
类似的场景还有:
while :; do ssh "$host" "uptime" done如果某个主机的SSH连接挂起,ssh命令本身不会超时退出,循环体就被它卡住,后面的命令全都执行不到。这类问题的特点是:循环不是"飞快地空转",而是"停在一个点不动"。排查时很容易误判成网络慢,实际上就是循环体里缺了超时参数。
1.2 轮询条件永远不满足:最坑的sleep补偿
另一类死循环是轮询型,比如等一个服务端口起来、等一个文件出现、等一个进程退出。典型的写法是:
while ! nc -z 127.0.0.1 3306; do sleep 1 done这段逻辑没毛病,MySQL起来之后循环自然退出。但假如MySQL配置错误、端口被防火墙挡住、或者服务进程崩溃后没被拉起,nc -z会一直返回非0,循环就无限执行下去了。
这里有个陷阱:很多人在循环体加了sleep 1就以为"有间隔的循环不会打死机器"。实际上,sleep只降低循环频率,不提供退出条件。哪怕每30秒轮询一次,脚本照样能挂一整天,只是看起来没那么凶而已。问题的核心不是频率,而是"循环次数有没有上限"。
1.3 文件遍历中的"幽灵更新":for循环为何会越跑越多
for循环表面上是遍历固定集合,但如果你在循环体内修改了集合本身,就会出问题。比如批量重命名文件的场景:
for file in *.txt; do mv "$file" "${file%.txt}.bak" doneShell在执行for file in *.txt时,会先把匹配到的文件列表缓存起来,所以这个循环本身是安全的。但如果你写的是$(ls *.txt)这种命令替换形式,或者循环体在子目录中切换后再用通配符重新展开,行为就会变得不可控。更隐蔽的是下面这种:
while [[ -n $(ls /data/in/*.csv 2>/dev/null) ]]; do process_one_file done如果process_one_file处理失败,没有把CSV文件移走或删除,那么/data/in/目录下的文件永远存在,ls永远有输出,-n永远为真,循环永远不会退出。这类死循环的特点是"空转但无害",CPU占用不高,但日志会以恐怖速度增长,磁盘很快被撑爆。
我把这三种情况总结成一张判断表,方便你在面对"脚本卡住"时快速定位:
| 现象 | 可能成因 | 定位手段 |
|---|---|---|
| 进程停在一个点,CPU很低 | 循环体内有等待类命令 | strace 看阻塞在哪个系统调用 |
| 进程高速运转,CPU拉满 | 循环条件永不为假 | bash -x 看最后重复执行的命令 |
| 日志疯狂增长,CPU不高 | 空转型死循环 | 检查循环条件依赖的文件/命令输出 |
2. 超时控制三板斧:timeout、read -t与自旋计数器
防死循环最直接的手段就是加超时,给循环一个"到点就收手"的机制。Shell脚本里能做超时控制的方案不少,关键是选对场景。
2.1 timeout命令的正确姿势:不止包一层那么简单
GNU coreutils 自带的timeout命令是首选武器,用法非常简单:
timeout 10 tail -f /app/log/access.log它会在10秒后向目标进程发送TERM信号,命令没结束也强制终止。用在循环里就是给每个可能卡住的命令设上限:
while :; do # 这条SSH命令最多只给它15秒 timeout 15 ssh "$host" "uptime" || { echo "[$(date)] $host 连接超时" >> /var/log/check.log break } done这里有两个细节容易踩坑。第一,timeout需要放在命令的最前面,而不是中间。ssh timeout 15 "$host"会把timeout当成远程命令传给远端,效果完全不一样。第二,timeout默认只发送TERM信号,如果目标进程不响应TERM,它会继续挂在那里。这时需要配合-k参数指定"宽限期":
# 10秒后发TERM,若还不退,再过5秒发KILL timeout -k 5 10 ssh "$host" "uptime"给timeout加--preserve-status也值得留意。默认情况下,timeout会把目标命令被杀时的退出码改成124。如果你对外层脚本做了退出码约定,这个特殊码反而会干扰判断。加上--preserve-status后,timeout会如实传递原命令的退出码,外层逻辑更干净。
2.2 read -t:交互式脚本的保命符
如果你的循环涉及用户输入,read命令自带超时功能,这是很多人没注意到的。
while :; do read -t 5 -p "请输入操作指令: " cmd if [[ -z "$cmd" ]]; then echo "等待输入超时,退出" exit 1 fi execute "$cmd" done-t 5表示等待输入最多5秒。如果用户在5秒内没有输入,read返回非0,cmd变量为空,脚本可以据此做出"退出"或"继续"的决定。
还有-N参数控制读取的字符数,-s隐藏输入内容。对需要密码确认的交互式循环,这三个参数组合起来就能防止"提示符出现但没人应答"的挂死。
2.3 自旋等待场景下的退出计数器
对于轮询型循环,最朴素的方案反而是计数器——允许重试N次,超过次数就放弃。不要迷信什么花哨机制,计数器在长时间任务中的可靠性最高:
MAX_RETRY=30 count=0 until nc -z 127.0.0.1 3306; do count=$((count + 1)) if [[ $count -ge $MAX_RETRY ]]; then echo "等待MySQL启动超时,已重试 $count 次" exit 1 fi sleep 2 done这里的count变量每次循环加1,达到30次立即退出。加上sleep 2,整个等待窗口是60秒,非常可控。还有一个进阶写法:用$((count += 1))代替count=$((count + 1)),少敲几个字。
计数器方案的最大好处是"退出原因可追溯"。你可以在超时时把count值、最后一次的状态都写进日志,后面排查时能知道循环卡了多久、卡在哪个阶段。我在生产环境里基本都是计数器方案,因为它一眼就能看出循环允许的最大运行时间。
下面把三种方案适用的场景小结一下:
| 方案 | 适用场景 | 优点 | 局限 |
|---|---|---|---|
| timeout 命令 | 单个命令可能挂起 | 精确控制单命令耗时 | 无法直接控制整个循环 |
| read -t | 交互式输入循环 | 天然等待用户输入 | 只对read生效 |
| 计数器 | 轮询、重试型循环 | 退出原因可追溯 | 需要手动维护计数变量 |
3. 异常退出处理:trap、set -e与退出码的三层防线
超时控制解决的是"循环停不下来",异常退出处理解决的是"循环出错了怎么收场"。一个健壮的脚本,必须在异常发生后能清理临时文件、恢复环境变量、并向外层返回正确的退出码。
3.1 set -e的误区和正确打开方式
很多人一上来就给脚本加set -e,指望"出错就停"。但set -e不是银弹,它有几个容易误判的规则:
- 管道命令中,只检查最后一个命令的退出码。
cmd1 | cmd2中 cmd1 失败但 cmd2 成功,set -e不会退出。 if、while、until的测试条件命令,即使返回非0也不会触发退出。- 被
!取反的命令,不会触发退出。
这就导致一种尴尬情况:循环体内某个关键命令失败了,脚本却还在继续跑。比如:
set -e while :; do curl -s http://example.com/api # 假设curl返回非0,但由于它在普通语句位置,set -e生效应该退出 process_result donecurl返回非0时set -e能让脚本退出,这没问题。但如果curl的请求失败被set -e杀掉了,循环没跑完,临时文件没人清理。所以set -e的正确打开方式是配合trap一起用,一个负责"死",一个负责"死后料理"。
3.2 trap EXIT/ERR/信号:清理战场的关键
trap命令能捕获脚本退出、错误、信号三类事件。最常用的是捕获EXIT,保证脚本无论正常结束还是异常退出,都会执行清理函数:
cleanup() { rm -f /tmp/my_script_*.tmp echo "[$(date)] 脚本退出,临时文件已清理" >> /var/log/my_script.log } trap cleanup EXIT while :; do tmp_file=$(mktemp /tmp/my_script_XXXXXX.tmp) process_data "$tmp_file" # 这里如果执行 exit 1,cleanup 依然会被调用 done只要是脚本进程结束,无论是自然结束、exit、还是被信号杀死(在允许捕获的情况下),EXITtrap都会执行。这让"清理"变成了一个确定性行为,而不是靠程序员在每一条出错路径上手动写清理代码。
再往下细化,ERRtrap可以捕获任何命令返回非0的情况,常用于记录失败现场:
err_report() { echo "命令 $BASH_COMMAND 执行失败,行号 $LINENO" exit 1 } trap err_report ERR配上BASH_COMMAND和LINENO,能自动打印出出错的那条命令和行号。这个信息在定位死循环时尤其宝贵——你会知道循环体里到底是哪一步先爆炸的。
还有一类情况是信号处理。长时间运行的脚本,运维可能用kill -9强杀,强杀无法捕获;但如果用kill发送TERM信号,脚本就能捕获并执行善后:
handle_term() { echo "[$(date)] 收到TERM信号,停止任务..." # 标记循环需要退出 STOP_FLAG=1 } trap handle_term TERM配合一个全局变量,让TERM信号不直接杀死进程,而是改变循环状态的判断条件。这种"软停止"在需要优雅关闭的场景下非常实用。
3.3 退出码设计:给外层调用者一个"可判断的死亡原因"
防死循环的最后一步,是让脚本死得"明明白白"。如果你只是exit 1,外层脚本只知道失败了,不知道为什么失败。我建议按以下规程设计退出码:
EXIT_OK=0 EXIT_TIMEOUT=1 EXIT_GENERAL=2 EXIT_INPUT_ERR=3 EXIT_DEPENDENCY_MISSING=4 if [[ $count -ge $MAX_RETRY ]]; then echo "等待端口超时,退出码 $EXIT_TIMEOUT" exit $EXIT_TIMEOUT fi外层调用方就可以精确判断:
if check_service.sh; then echo "检查通过" elif [[ $? -eq $EXIT_TIMEOUT ]]; then echo "服务启动超时,切备用节点" elif [[ $? -eq $EXIT_DEPENDENCY_MISSING ]]; then echo "缺少依赖环境,先安装依赖" fi尽量做标准化的退出码映射。把每个退出码的含义写进脚本头部的注释块,后续维护的人看到exit $EXIT_TIMEOUT就知道发生了什么,不用翻看错误日志。
4. 实战场景拆解:监控脚本、服务探测与批量任务
理论讲完,进入实操。我挑三个最常见的场景,把完整脚本和设计思路放出来。
4.1 场景一:等待服务启动——带超时的健康检查
部署服务后,脚本需要等服务进程真正监听到端口,才能继续下一步。单纯用nc -z轮询会存在服务假死的风险——端口开了,但进程还不可用。更保险的做法是同步去请求一个实际的健康检查接口:
#!/bin/bash # 等待 HTTP 服务就绪,支持超时和中止 MAX_RETRY=30 RETRY_INTERVAL=2 HEALTH_URL="http://127.0.0.1:8080/healthz" check_health() { local code code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 2 --max-time 3 "$HEALTH_URL") [[ "$code" -eq 200 ]] } echo "[$(date)] 开始等待服务就绪..." count=0 until check_health; do count=$((count + 1)) if [[ $count -ge $MAX_RETRY ]]; then echo "[$(date)] 服务在 $((MAX_RETRY * RETRY_INTERVAL)) 秒内未就绪,退出" exit 1 fi echo "[$(date)] 第 ${count} 次探测失败,${RETRY_INTERVAL}秒后重试" sleep "$RETRY_INTERVAL" done echo "[$(date)] 服务已就绪"curl本身带了--connect-timeout和--max-time,保证单次探测不会因为网络黑洞而无限等待。外层计数器保证整体等待时间有限。这里的双重超时设计,是我在线上看了太多次"单层超时被绕过"之后养成的习惯。
4.2 场景二:批量重命名——先快照后遍历
批量处理文件时防止死循环的核心思路,是"先快照,再遍历",绝不在遍历的同时依赖实时目录状态。用数组把文件名一次性存起来,切断了遍历集合与文件系统状态之间的耦合:
#!/bin/bash # 批量重命名照片文件,处理失败不阻塞,最后输出汇总 cd /data/photos || exit 1 # 先做快照 mapfile -t files < <(find . -maxdepth 1 -type f -name "*.jpg" | sort) total=${#files[@]} echo "[$(date)] 共发现 $total 个jpg文件" processed=0 failed=0 for file in "${files[@]}"; do new_name="${file%.jpg}_$(date +%Y%m%d).jpg" if mv "$file" "$new_name" 2>/dev/null; then processed=$((processed + 1)) else failed=$((failed + 1)) echo "[$(date)] 重命名失败: $file" fi done echo "[$(date)] 处理完成,成功 $processed 个,失败 $failed 个" [[ $failed -eq 0 ]]mapfile是readarray的别名,把命令输出读到数组里非常方便。快照先被完整保存,后续文件系统怎么变化都不影响遍历的集合。这样即使mv因为文件被占用而失败,也只是影响单条记录,不会让循环进入"文件永远处理不完"的怪圈。
4.3 场景三:日志监控——双保险退出机制
监控日志文件时,常见的坑是tail -f无限等待,以及监控目标日志文件被轮转(logrotate)之后找不到内容。我给脚本加上一个"最大读取行数"和一个"文件存在性检查",形成双保险:
#!/bin/bash # 监控日志并触发后续动作,单次运行最多处理 N 行,持续 M 秒 LOG_FILE="${1:-/var/log/app.log}" MAX_LINES=1000 MAX_SECONDS=60 start_time=$(date +%s) line_count=0 if [[ ! -f "$LOG_FILE" ]]; then echo "日志文件不存在: $LOG_FILE" exit 1 fi # 预设超时时间:循环到点强制退出 while [[ $(( $(date +%s) - start_time )) -lt $MAX_SECONDS ]]; do # 用 read -t 保证单次读取也有超时 if read -t 5 -r line; then process_line "$line" line_count=$((line_count + 1)) if [[ $line_count -ge $MAX_LINES ]]; then echo "已达最大行数 $MAX_LINES,停止处理" exit 0 fi fi done < "$LOG_FILE" echo "已达到最大运行时间 $MAX_SECONDS 秒,停止处理" exit 0这个脚本即使日志文件是tail -f的替代品,也能做到"最多跑60秒"或"最多处理1000行",两个条件任何一个先满足就先退出。read -t 5作为单行读取的最后防线,防止某一行内容让process_line卡住。
5. 脚本卡死后怎么排查:从bash -x到strace的完整链路
就算做了超时控制,还是会有漏网之鱼。脚本卡死后不要急着kill,先按下面这条链路排查一遍,往往能直接揪出根因。
5.1 bash -x与PS4:快速定位死循环现场
最基础的手段是用bash -x调试模式运行脚本,它会把每条执行的命令打印出来。配合PS4变量加时间戳,能看出命令执行的节奏:
PS4='+ $(date "+%s.%N") $LINENO: ' bash -x ./monitor.sh输出会变成:
+ 1694356789.123456789 28: count=0 + 1694356789.123456789 30: check_health + 1694356789.123456789 31: curl -s -o /dev/null -w %{http_code} --connect-timeout 2 --max-time 3 http://127.0.0.1:8080/healthz如果时间戳完全不变,说明命令卡在某个系统调用里,比如curl在等待响应。如果时间戳在不断变化,但打印的总是同一行,比如check_health的调用行,说明循环在空转,条件判断一直不满足。这两种情况导致死循环的机理完全不同,处理方式也不一样——前者需要给命令加超时,后者需要修正循环条件。
5.2 strace:从系统层面看清阻塞点
当bash -x显示命令卡在某一行,但又看不出具体卡在哪个阶段时,就要上strace了。strace跟踪系统调用,能告诉你进程到底在等什么:
# 附加到卡死的进程 strace -p 12345 -t -f常见的阻塞输出有几种:
# 阻塞在网络连接 connect(3, {sa_family=AF_INET, sin_port=htons(3306), sin_addr=inet_addr("10.0.0.5")}, 16) # 阻塞在读取管道,等待输入数据 read(0, <unfinished ...> # 反复执行同一命令序列,没有阻塞 stat("/data/in/", 0x7ffd...) = -1 ENOENT (No such file or directory)如果是connect卡住,说明是网络层面没有超时控制,应回到timeout或nc -z的参数配置。如果是read(0)卡住,说明循环在等待输入或管道数据,应检查是否有tail -f之类不会关闭的流。如果反复stat循环,那基本就是逻辑层面的条件永不满足,和等待无关。
5.3 日志切割与看守进程的配合
排查结束后,还要考虑"以后怎么避免"。我的习惯是给所有允许长时间运行的脚本配两个东西:日志切割和时间线记录。
日志切割不复杂,就是每次循环写入日志时带上日期,日志单独存放,按天滚动,避免一个死循环脚本撑爆/var/log:
LOG_PATH="/var/log/monitor/$(date +%Y%m%d).log" echo "[$(date +%H:%M:%S)] 第 $count 次探测失败" >> "$LOG_PATH"时间线记录则是为了事后回溯。脚本每完成一个重要阶段或者每次循环迭代,就打出当前时间。死循环发生时,最后两条日志之间的间隔就能告诉你卡在哪儿了。
如果脚本的稳定性要求非常高,还可以在外面套一个"看门狗"——用另一个脚本定时检查目标进程是否存在,并通过日志文件的更新时间判断它是否还在干活。日志文件超过5分钟没更新,就用kill重启目标脚本。这个方案虽土,但在生产环境中救过我好几次。
写在最后的经验
我在真实服务器上踩过的死循环事故,基本都逃不出本章讲的这几种成因:等待类命令卡住、轮询条件永不满足、遍历集合被动态修改。解决的手段归根结底就三条——给单个命令加超时,给整个循环加上限,以及用trap保证异常退出时能清理战场。你可以在写脚本时把这些手段当成默认标配,而不是出了问题再补。
另外分享一个我个人的习惯:所有可能长时间运行的循环脚本,第一行注释一定写明"最大运行时间"和"设计退出条件"。比如# 最多运行60秒,条件check_health成功。这行注释在三个月后你回来维护脚本时,比任何文档都好用。工具是死的,防死循环的意识才是活的。如果你的脚本还没做过超时和异常退出处理,现在就可以打开手头那个while循环补上计数器,成本很低,收益却可能是一次大事故免于发生。