天天在Linux命令行里摸爬滚打的人,大概都有过这么一段经历:写Shell脚本时,凡是遇到重复操作就复制粘贴,几十台服务器要检查就贴几十遍命令,最后脚本比裹脚布还长。直到你真正把循环语句用起来,才算是从"命令搬运工"变成了"脚本作者"。Shell编程里的循环语句就是干这个的——用最小代码量完成批量重复任务,把时间留给真正需要思考的事情。这篇文章不搞虚的,直接把for、while、until三种循环语句的语法、执行逻辑、与break/continue/exit的控制组合全部掰开揉碎,再把循环体常见的几个大坑逐一分析,最后给几个可以直接抄的实战模板。不管你是刚开始学Shell脚本的新手,还是已经写过一阵子、但循环老写着别扭的兄弟,这篇都值得看完。
1. 为什么循环写不好,Shell脚本就跪了一半
1.1 循环的本质:把"重复"交给解释器
循环这个词听起来有点学院派,但说白了特别简单:Shell解释器按照你给出的列表或条件,反复执行同一段命令序列。比如要给一百个配置代码文件加备份,手写一百行cp命令显然不现实。写一个for循环,把这一百个文件名逐个赋给变量,循环体里只写一次cp命令,问题瞬间解决:
for i in *.conf; do cp "$i" "${i}.bak" done这就是循环最基本的价值。它的执行模型也不复杂:Shell先对in后面的部分做分词和路径展开,得到一个元素列表,然后依次取出每个元素赋给变量i,执行do和done之间的命令,处理完最后一个元素就结束。把握住"先展开、再遍历、后执行"这个顺序非常关键,因为后面要说的很多坑,都是从这一步埋下的。
1.2 判断"该不该用循环",比会写循环更值钱
我见过不少朋友把循环当万能药,不管什么场景都套一层for。比如要查文本中匹配某个模式的行,明明grep一条命令就能搞定,他偏要while read line,然后在循环体里再嵌套case判断,结果又慢又难维护。反过来,在文件数量极大、命令行参数直接超过系统上限的时候,固执地不用循环,又会遇到"Argument list too long"的报错,脚本当场翻车。
我自己长期用下来,总结了一套判断标准:找内容、查字段、做统计,优先用grep、awk、sed这类文本工具;只有确实需要对一批对象执行"同一组复合命令"时,才动用循环。数量小、文件名规整的场景用通配符;文件数量大,或者文件名包含空格、换行等特殊字符,才需要循环配合更稳的读取方式。交互式读取用while,固定列表遍历用for。想清楚这些再用循环,脚本会清爽很多。
1.3 do...done不是函数体,变量作用域要提前明白
很多从C语言转过来的人,第一反应会把do...done当成函数体,以为内部的变量只在循环里有效。Shell不是这样。do和done只是循环体的边界标记,里面的变量在循环结束后依然存在,会直接覆盖同名变量。这个特性有好处,比如统计执行次数,直接定义count,循环体里count=$((count+1)),循环结束后count就是总次数,不用设计什么返回值机制。
但这个特性也隐藏风险:一旦循环出现在管道、命令替换里,作用域规则又会变。管道会为命令创建子Shell,循环跑到子Shell里,循环结束后变量修改全部丢失。这一点我会在第4节用一个专门案例讲透,这里先记结论——Shell的变量作用域和主流高级语言差异很大,别用老经验想当然。
2. for、while、until三兄弟的语法与适用场景
2.1 for循环:列表驱动,适合"我知道要遍历什么"
for循环是Shell里出镜率最高的循环语句,因为它最贴合人的直觉:你手里有一批文件、若干台服务器、一组端口号,把它们列出来,逐个处理就好。基本写法是这样:
for ip in 192.168.1.1 192.168.1.2 192.168.1.3; do ping -c1 -W1 "$ip" >/dev/null 2>&1 && echo "$ip 通畅" || echo "$ip 不通" done如果遍历的是一串连续数字,可以用大括号展开:
for i in {1..100}; do echo "$i" done想要带步长的序列,则可以用seq命令替换:
for i in $(seq 1 2 20); do echo "$i" # 输出 1 3 5 7 ... 19 done这里有个容易被忽略的细节:大括号展开发生在变量替换之前,所以写成{1..$n}是无效的,如果循环次数由变量决定,用seq比大括号更靠谱。另外还有C风格的for循环,适合需要"初始化、终止条件、步进"三段式控制的场景:
for ((i=0; i<10; i++)); do echo "$i" done这种写法从C语言转移过来的人会觉得格外亲切,处理"保留最后N个备份文件""尝试重试N次"这类计次逻辑时特别顺手。
2.2 while循环:条件驱动,天生适合"边读边处理"
while循环的核心是"只要条件成立就反复执行",最常见的应用场景就是逐行读取文件:
while IFS= read -r line; do echo "当前行: $line" done < /etc/hosts这里我刻意写了IFS= read -r line,两个参数都很重要。IFS=表示清空字段分隔符,避免行首行尾的空格被吞掉;-r参数让反斜杠不被当作转义符处理,路径里含\时不会静默出错。很多老脚本只写read line,遇到带空格的配置行或者带反斜杠的Windows风格路径就悄悄出问题,排查起来特别费劲。
while也经常用来写"死循环"。运维场景里等端口就绪、等进程退出,都可以用while实现。下面是一个等待服务健康检查通过的典型写法:
while true; do if curl -s http://127.0.0.1:8080/health >/dev/null 2>&1; then break fi sleep 1 done2.3 until循环:条件反向,用得少但偶尔很对口
until和while的语法几乎一样,区别只在判断方向上:while是条件为真时继续,until是条件为真时终止。所以until [ 条件 ]很多时候可以理解成while [ ! 条件 ]。实际项目里我用到until的频率远低于for和while,但在"等待某个条件消失"的场景特别好用,比如这个等待部署锁文件被释放的写法:
until [ ! -f /tmp/install.lock ]; do sleep 2 done这种写法比while [ -f /tmp/install.lock ]加一层取反逻辑读起来自然得多,语义一目了然:"直到锁文件没了,才往下走"。脚本的可读性有时候就体现在这种细微差别上。
为了看得更直观,我把三种语句的差异整理成一张表:
| 循环语句 | 驱动方式 | 进入循环条件 | 典型使用场景 |
|---|---|---|---|
| for | 列表/序列 | 列表非空即遍历 | 文件遍历、枚举计算、固定次数重试 |
| while | 条件判断 | 条件为真继续执行 | 逐行读文件、死循环监控、等待就绪 |
| until | 反向条件判断 | 条件为假继续执行 | 等待条件从真变为假、等待锁释放 |
3. break、continue、exit:循环控制比想象中重要
3.1 三个命令各管一段
写得多了就会发现,光会"让循环跑起来"远远不够,还得能控制循环"什么时候停、怎么停"。Shell提供了三个和循环强相关的控制命令。
break:终止当前这一层循环,跳到循环后面的命令继续执行。continue:跳过本次迭代中continue语句后面的命令,直接进入下一次迭代。exit:不是循环专用命令,它终止整个脚本进程,不管当前在循环的哪一层。
用一个实际例子展示三者的差异。假设要遍历当前目录的所有文件,遇到.bak备份文件就跳过,遇到.conf配置文件就处理并中断整个脚本:
for f in *; do case "$f" in *.bak) continue ;; *.conf) echo "找到配置文件: $f"; exit 0 ;; esac done这里continue把无关的备份文件过滤掉,exit则让脚本在找到目标后立即停止,不会继续做无意义的遍历。
3.2 嵌套循环里的break到底断哪一层
默认情况下,break和continue都只作用于当前所在的那一层循环,想从内层直接跳出外层时,必须给命令加数字参数。看这个双层循环例子:
for i in {1..3}; do for j in {1..5}; do if [ "$j" -eq 3 ]; then break 2 fi echo "$i-$j" done done脚本执行到i=1、j=3时,break 2会同时退出内外两层循环,最终输出结果只有1-1和1-2两行。如果不加数字2,break只会跳出内层循环,外层继续正常迭代,输出结果会完全不同。同理,continue 2表示"跳过外层循环的本次迭代"。
3.3 实战:健康检查里的重试循环
循环控制在脚本里最典型的应用就是重试机制。比如服务重启后,从进程启动到端口真正开始监听往往需要几秒钟,这时候不能只检查一次就放弃。用for循环写一个带次数的重试逻辑:
for ((attempt=1; attempt<=5; attempt++)); do if nc -z 127.0.0.1 8080; then echo "服务已就绪" break fi echo "第 $attempt 次检查未通过,2秒后重试" sleep 2 done这段脚本里break承担"提前成功退出"的任务,防止服务明明已经起来了,还白白跑完剩余次数。如果不加break,只是循环次数被消耗完,功能上勉强能接受;但生产环境里一旦碰上慢启动服务,这种冗余等待会被放大很多倍。
4. 循环体里的坑,我替你们踩过一遍
4.1 管道后循环变量会丢失
这是Shell循环里最容易踩的坑,没有之一。来看看这个统计文件行数的场景:
count=0 cat /var/log/syslog | while read line; do count=$((count+1)) done echo "总行数: $count"我遇到过不少朋友,写完这个脚本后echo出来的count永远是0,怎么检查逻辑都看不出问题。原因在于管道会创建一个子Shell进程,while循环是在这个子Shell里执行的,count=$((count+1))只修改了子Shell里的变量副本,管道结束后子Shell退出,所有修改全部丢失。
解决办法有三种,按推荐程度排序:
# 方案1:直接统计,根本不需要循环 count=$(wc -l < /var/log/syslog) # 方案2:用进程替换,让循环留在当前Shell执行 while read line; do count=$((count+1)) done < <(cat /var/log/syslog) # 方案3:最常规,用输入重定向,同样留在当前Shell while read line; do count=$((count+1)) done < /var/log/syslog方案3是我日常写脚本最推荐的,既没有子Shell问题,又保留了逐行处理的能力,可读性也最好。
4.2 文件名带空格,for会被拆得七零八落
for循环默认是用IFS做分词的,IFS的默认值是空格、制表符和换行。所以当一个目录下的文件名是"my document.pdf"这种带空格的,不能用for f in $(ls *.pdf)这种写法。ls的输出经过命令替换后再分词,空格会把文件名拆成两截,脚本拿到的是"my"和"document.pdf"两个独立元素,后面处理全乱套。
正确的做法是直接用通配符,让路径展开结果不参与IFS分词:
for f in *.pdf; do echo "处理文件: $f" done这个规则最好烂熟于心:遍历文件,优先用通配符,不要套命令替换,不要解析ls输出。遇到更极端的包含换行符的文件名,常规办法都不太稳,得用find配合-print0,再搭配while循环按空字符读取:
find . -name "*.pdf" -print0 | while IFS= read -r -d '' file; do echo "处理文件: $file" done4.3 逐行读取大文件时的性能优化
read逐行循环处理大文件确实很慢,因为read本身虽然是内置命令,但每次读取一行都涉及缓冲区管理,在几万行文件上跑复杂逻辑,性能会明显拉胯。如果只是做简单统计,grep、awk这类字符流工具比while read快十倍以上,完全没必要写循环。
如果必须逐行处理且要做复杂业务逻辑,我的优化思路是先用awk把每行需要的关键字段做预提取和规整,去掉无关内容,再交给循环做后续处理。这样循环要处理的数据量会小很多,整体性能提升明显。这个思路在日志分析脚本里尤其好用。
4.4 循环体内避免频繁调用外部命令
每次在循环体里调用一次外部命令,Shell都要fork一个子进程来执行。循环跑100次,就是100次进程创建,开销非常大。之前见过一个脚本,循环里对每个文件执行三次sed、两次awk,处理几百个文件时卡到令人绝望。优化方式是能一次处理的就不要循环,循环里尽量只用内置功能;确实需要多次处理时,考虑把多个sed表达式合并成一条命令,减少进程数量。
5. 直接能抄的实战:批量重命名、配置遍历与并发控制
5.1 批量重命名文件模板
把当前目录下所有.txt文件改名为.bak,最朴素的写法:
for f in *.txt; do mv "$f" "${f%.txt}.bak" done这里用到了Shell的字符串截取语法${f%.txt},作用是去掉变量f末尾的.txt后缀,再拼上.bak。这套%、#、%%、##的字符串处理在循环里非常实用,比调用sed再做变量替换干净得多。
如果需要给文件名增加日期前缀,可以配合date命令:
prefix=$(date +%Y%m%d) for f in *.log; do mv "$f" "${prefix}_${f}" done5.2 遍历服务器列表执行远程命令
运维场景里最常见的一个需求:有一台跳板机,要去几十台服务器上执行同一段命令。我习惯先把服务器IP或主机名写进一个iplist.txt文件,然后配合while逐行读取:
while read -r hostname; do [ -z "$hostname" ] && continue echo "正在处理: $hostname" ssh -o ConnectTimeout=5 "$hostname" 'df -h /' done < iplist.txt文件里如果有空行,[ -z "$hostname" ] && continue可以快速跳过,避免ssh拿到空参数后报错。这是读配置类文件时很值得保留的习惯。
5.3 并发循环:后台加wait控制
循环体里的命令默认是串行执行的,一个跑完才跑下一个。处理几百个文件、几十台服务器时,串行会非常浪费时间。一个简单的提速方案是把命令放到后台执行,循环结束后用wait等待所有任务完成:
for ip in $(cat server.list); do ( ping -c1 -W1 "$ip" >/dev/null 2>&1 && echo "$ip 通畅" || echo "$ip 不通" ) & done wait注意,我用了子Shell的括号把批量命令包起来,这样循环的每次迭代都能在独立环境里执行,不会有变量互相污染。这个方案在需要并发执行但又不想引入xargs或GNU Parallel的时候特别实用。如果担心并发数量太多压垮机器,还可以在循环里维护一个计数器,每启动几个任务就wait一次,做简单限流。
5.4 日志文件按天清理
生产环境里日志清理是每天都要面对的事,保留最近7天日志的脚本可以这样写:
log_dir=/var/log/myapp days=7 find "$log_dir" -name "*.log" -mtime +$days -print | while read -r logfile; do echo "清理过期日志: $logfile" rm "$logfile" done这里用find按修改时间筛选出超过7天的日志文件,再交给while逐行处理。如果确认筛选结果没问题,可以把echo行删掉,直接用rm "$logfile"。第一次跑的时候我强烈建议保留echo先试运行一遍,确认没有误删再开真正的清理,这个习惯能省下很多麻烦。
6. 调试与防护:让循环脚本在生产环境敢跑
6.1 bash -x 逐条跟踪循环执行
循环脚本一旦出错,定位难度比普通脚本高很多,因为同样的命令要跑几十上百遍,你不知道是哪一遍、哪个变量出了问题。我调试任何带循环的脚本,首选就是bash -x方式执行:
bash -x myscript.sh执行时Shell会把每个命令展开后的真实内容都打印到终端,变量被替换成什么值一目了然。比如for i in {1..3},你能直接看到i依次变成1、2、3,命令也逐条展开。加上-x之后的输出量确实大,但排查循环问题,这份输出就是最好的线索。
6.2 set -eu与循环的共处
很多讲究的脚本开头都会写set -eu,意思是遇到未定义变量直接报错、命令返回非零立刻退出。这两个设置在循环场景下要特别注意。set -e会让循环体内任何一条命令执行失败时整个脚本退出,好处是无法掩盖错误,坏处是有时循环体里某些命令本来就可能返回非零,一旦出现整个脚本就中断了。
我的处理方式是:不是每条命令都需要全局生效的set -e,可以用在具体命令后加|| true的方式,明确告诉Shell这条命令允许失败,不影响循环继续。比如ping -c1 "$ip" || true,这样既保持脚本整体对错误敏感,又不会因为个别正常波动就全盘退出。
6.3 用dry-run模式先跑一遍
循环脚本最怕的不是运行慢,而是破坏性操作批量误执行。清理文件、删除日志、批量改配置这类脚本,我几乎都会内置一个dry-run开关:
dry_run=1 for f in *.log; do if [ "$dry_run" -eq 1 ]; then echo "将删除: $f" else rm "$f" fi done只要把dry_run=1改成dry_run=0,脚本就变成真实执行。这个模式的精髓在于:让脚本在正式执行前把"将要做什么"完整打印出来,人工确认无误后再放行。老话讲"先看一遍再动手",在这种批量操作场景里,这个习惯比任何防护都管用。
写到这里,我想起自己早期写循环脚本时的狼狈样——一边跑一边盯着终端,生怕哪个变量没展开、哪条命令写错,把线上文件删掉一片。后来我把上面这套"通配符遍历、IFS处理、管道换重定向、dry-run先行、bash -x定位"的组合练成肌肉记忆后,循环脚本就从一个天天提心吊胆的环节,变成了最可以放心交给别人维护的部分。Shell循环语句值得花时间彻底吃透,它几乎贯穿所有运维脚本、部署脚本和数据处理脚本,用熟练之后,你写脚本的速度和信心都会上一个大台阶。