0

timeout与while循环组合重试机制详解:让你的批量任务既不卡死又不放弃

2026.08.08 | youres | 67次围观

写Shell脚本做批量任务的时候,有一个特别常见的头疼问题:某个任务执行着执行着就卡住了,既不成功也不退出。这时候timeout命令可以设一个上限,超时就把进程杀掉;但光杀掉还不够——我们需要的是让它超时后自动重试,而不是直接放弃。

timeout和while循环单独用各有各的局限,把它们组合起来才能实现「既不卡死、又不放弃」的理想重试模式。这篇文章讲清楚这个组合的核心思路、几种常见写法,以及实际用的时候要注意哪些坑。

为什么需要timeout + while的组合

先说清楚两个命令各自能做什么、做不到什么。

timeout:给命令设一个存活时间,超时就发送信号强制终止。适合防止命令永久挂起,但不负责重试逻辑。

while循环:根据条件反复执行命令,适合循环等待某个结果,但本身不内置超时概念,一个死循环可能永远跑下去。

把它们组合起来,while负责控制「重试多少次」,timeout负责控制「每次给多少时间」。两者各司其职,形成一个完整的重试框架。

基础写法:最直接的一种组合

先看一个最简单的模板:

#!/bin/bash max_attempts=5 attempt=0 while [ $attempt -lt $max_attempts ]; do attempt=$((attempt + 1)) echo "第 $attempt 次尝试..." # timeout 30秒执行目标命令,退出码124表示超时 timeout 30s curl -s http://example.com/api/check > /tmp/result.txt if [ $? -eq 0 ]; then echo "成功" break else echo "失败,10秒后重试..." sleep 10 fi done

这个脚本的逻辑很清晰:用while控制总次数,timeout保护每次执行不超30秒。如果curl正常返回(退出码0),退出循环;如果超时或出错(退出码非0),等10秒再试,最多试5次。

区分超时和其他错误

上面这个写法把所有非0退出码都当成「失败」处理了。但实际场景中,超时(退出码124)和命令本身执行失败(其他退出码)应该区别对待。改进一下:

#!/bin/bash max_attempts=5 attempt=0 while [ $attempt -lt $max_attempts ]; do attempt=$((attempt + 1)) timeout 30s curl -s -f http://example.com/api/check -o /tmp/result.txt exit_code=$? if [ $exit_code -eq 0 ]; then echo "第 $attempt 次:请求成功" break elif [ $exit_code -eq 124 ]; then echo "第 $attempt 次:超时(timeout退出码124),10秒后重试" else echo "第 $attempt 次:命令失败(退出码 $exit_code),5秒后重试" fi [ $attempt -lt $max_attempts ] && sleep 10 done

timeout的退出码124是它特有的标识,表示进程被超时机制杀掉了。这个数字在排查脚本问题的时候很有用——看到124就知道是时间到了被强制停的,不是因为命令本身报错了。

带指数退避的写法

每次失败都等固定时间有时候不够优雅。当远程服务压力大的时候,拼命重试反而会把它冲垮。用指数退避让等待时间逐步拉长,效果更好:

#!/bin/bash max_attempts=5 attempt=0 base_delay=5 while [ $attempt -lt $max_attempts ]; do attempt=$((attempt + 1)) timeout 30s curl -s -f http://example.com/api/check -o /tmp/result.txt if [ $? -eq 0 ]; then echo "成功" break fi # 指数退避:5s -> 10s -> 20s -> 40s delay=$(( base_delay * (2 ** (attempt - 1)) )) echo "第 $attempt 次失败,${delay}秒后重试(最多 $max_attempts 次)" [ $attempt -lt $max_attempts ] && sleep $delay done if [ $attempt -eq $max_attempts ]; then echo "达到最大重试次数,任务失败" exit 1 fi

这里用了一个简化的指数公式,每次失败后等待时间是上一次的2倍。如果担心多个客户端同时退避导致「重试风暴」,可以再加上一点随机抖动(jitter)来分散重试时间点。

while配合until:等条件满足为止

有时候重试的目标不是「执行命令」,而是「等某个条件变成真」。比如等远程服务上线、等某个文件出现、等数据库连接建立。这种场景下while和until配合timeout更自然:

#!/bin/bash # 等服务就绪,超时则退出 timeout=120 # 最多等2分钟 elapsed=0 while [ $elapsed -lt $timeout ]; do # 尝试连接一次 timeout 5s curl -s http://localhost:8080/health > /dev/null 2>&1 if [ $? -eq 0 ]; then echo "服务就绪,继续执行后续步骤" break fi echo "服务尚未就绪,等待中...(已等待 ${elapsed}s)" sleep 10 elapsed=$((elapsed + 10)) done if [ $elapsed -ge $timeout ]; then echo "等待超时,服务启动失败" exit 1 fi

这个写法用while控制一个总超时时间,每次循环等待10秒,累计超时2分钟就放弃。适合做启动依赖检查。

trap收尾:防止脚本被中断后留尾巴

重试脚本运行时如果被人按了Ctrl+C中断,子进程可能还挂在后台。要养成在脚本开头加上trap的习惯:

#!/bin/bash trap 'echo "收到中断信号,清理退出"; kill 0 2>/dev/null; exit 1' INT TERM max_attempts=5 attempt=0 while [ $attempt -lt $max_attempts ]; do attempt=$((attempt + 1)) timeout 30s curl -s -f http://example.com/api/check -o /tmp/result.txt [ $? -eq 0 ] && break echo "第 $attempt 次失败,等待后重试..." [ $attempt -lt $max_attempts ] && sleep 10 done

trap设置了INT(Ctrl+C)和TERM(kill命令)两个信号的处理方式,收到任意一个就杀掉整个进程组再退出,防止留下孤儿进程。

几个容易踩的坑

1. timeout和wait的退出码不要混淆。timeout返回124表示超时杀掉,返回137表示被SIGKILL(128+9)杀掉,正常执行完返回命令本身的退出码。while循环里的$?是最近一条命令的退出码,在执行timeout之后立即读取才准确。

2. 后台进程不会被timeout杀掉。如果命令里用了&后台运行,timeout只能管到主进程,子进程会继续跑。需要用进程组的方式处理:timeout 30s bash -c 'command &'

3. sleep在timeout之前还是之后要分清楚。重试循环里如果先timeout再sleep,每次失败后都会等。如果sleep的时候被中断就跳过一次等待。更好的做法是在循环开头就判断是否超过最大次数。

总结

timeout和while循环的组合,本质上是把「单次超时控制」和「多次重试循环」两个职责分开,各干各的。掌握这个思路之后,可以根据实际需求灵活调整:

  • 需要限制总时长 → 在while外层加总超时计数
  • 需要指数退避 → 每次失败后延长等待时间
  • 需要区分错误类型 → 判断退出码124(超时)还是其他
  • 需要安全退出 → 加trap清理子进程

把这几个要素组合起来,就能写出一个在任何批量运维场景下都靠得住的重试脚本。


相关文章:

版权声明

本文仅代表个人观点。
本文系AI辅助作者原创,未经许可,转载请保留原文链接。

发表评论