
提到 shell 编程循环几乎是绕不开的一道坎。不管你是运维、后端还是天天跟服务器打交道的开发只要有批量操作、日志处理、重试等待这类需求最后都会落到 for、while、until 这几条循环语句上。这个标题看起来基础但实际写起来坑不少很多脚本跑得不对查到最后往往就是循环写歪了要么该遍历的文件没遍历全要么循环里变量死活不更新要么照着网上的命令抄下来却因为空格、换行的问题挂掉。这篇就把 shell 循环从语法到实战完整拆一遍整理成可以直接抄作业的写法也会把我在实际脚本里踩过的坑讲清楚。适合刚学 shell 的读者也适合写过一阵子但总被边界问题搞到头疼的老手。1. 先从整体上理解 shell 循环1.1 循环到底在解决什么问题循环本质上是“重复”的自动化。你在终端里一条命令一条命令地敲十次、一百次也许还能忍但如果是批量改一千个文件名、读一个几万行的日志、或者每隔几秒探测一次服务状态手工就完全不现实了。shell 循环就是帮我们把“对一组对象做同一件事”这个动作压缩成几行脚本。举一个最朴素的需求把当前目录下所有 .log 文件按日期重命名。没有循环的时候你得一个个 mv有循环之后就是遍历一遍 *.log对每个文件执行 mv。这就是 for 循环最典型的场景。还有一类场景是“等到某个条件成立”。比如服务刚重启端口还没起来你需要每隔 2 秒探测一次最多等 30 秒。这种“反复检查、直到成功或超时”的逻辑用 while 或者 until 来表达就非常自然。所以循环的思维模型其实就两种第一种是“我有一批东西挨个处理”第二种是“我有一个条件反复尝试直到满足”。理解了这两种模型再看语法就不会懵。1.2 三个循环怎么选shell 里最常用的三个循环for、while、until。很多人把 pends 在语法上其实重点是用在哪。循环适合场景执行逻辑for遍历已知列表文件名、参数、数组、命令输出列表里每一项执行一次循环体while条件持续为真时反复执行读文件行、等待端口、计数控制先判断条件为真才进入循环体until条件为假时反复执行直到条件为真才停止先判断条件为假才进入循环体说得直白一点while 是“只要……就一直做”until 是“直到……才不做”。两者正好是互为取反的关系。举个例子你要写一个循环打印 1 到 5for 写法直接遍历 1 2 3 4 5while 写法变量 i1只要 i5 就打印然后 i1until 写法变量 i1直到 i5 才停期间打印同一个需求三个循环都能做但没有最好的循环只有更贴合场景的循环。新手最容易犯的错就是不管三七二十一全用 for结果遇到“读取文件行”这种场景for 默认按空格分词文件名带空格就直接崩了。所以选哪个循环正确答案是先看你要处理的数据是什么形态。2. 三大循环语句的语法与适用场景拆解2.1 for 循环遍历列表的主力for 的语法有几种写法平时最常用的是这种for 变量名 in 列表; do 循环体 done列表可以是多种形态。最常见的几种# 直接枚举 for i in 1 2 3 4 5; do echo number: $i done # 使用花括号展开生成序列 for i in {1..10}; do echo number: $i done # 使用 seq 命令生成序列 for i in $(seq 1 2 10); do echo step 2: $i done # 遍历当前目录下所有 .sh 文件 for f in *.sh; do echo script: $f done # 遍历数组 arr(apple banana cherry) for fruit in ${arr[]}; do echo fruit: $fruit done花括号展开{1..10}和 seq 的区别要说明一下花括号是 shell 自带的展开机制效率高但不能放在变量里比如for i in {$start..$end}这种写法是无效的。需要用变量控制范围时就得上 seq 或者 C 风格的 for。C 风格 for 是另一种常见写法适合有计数逻辑、或者需要同时操作多个变量的场景for ((i1; i100; i)); do echo count: $i done它的结构和 C 语言几乎一样初始化、条件判断、每轮步进。如果你写过程序这种写法会更顺手。要注意i前面是两个左括号不用加$前缀这是 bash 的算术表达式语法不是普通命令。2.2 while 循环面向条件的接力循环while 的语法是while 条件; do 循环体 done条件是命令的退出状态码返回 0 表示真非 0 表示假。只要条件为真循环体就反复执行。最常见的三种用法第一种读取文件内容这个在日志处理里极其常见while read line; do echo line: $line done input.txt注意最后那个 input.txt它把文件作为循环的标准输入read 每次读一行读完自动结束不用手动处理行数。这种写法比for line in $(cat input.txt)安全得多for 会按空格把一行拆成多个单词而且文件里如果有很多行命令替换还会受 IFS 影响搞不好一行变三段。第二种无限循环加条件退出常用于服务状态探测while true; do if curl -s http://localhost:8080/health | grep -q ok; then echo service is ready break fi sleep 2 donetrue是一个永远返回 0 的命令所以这个循环不主动 break 就停不下来。配合 sleep 控制探测频率配合 break 在成功时退出这是运维脚本里最常见的保活、健康检查模板。第三种算术条件控制用双括号写数值判断i1 while ((i 10)); do echo i$i ((i)) done在 (( )) 里可以直接写i不需要$也不需要expr注意别把 ((i)) 里的变量 i 写成$i语法会报错。这是我早期经常踩的坑。2.3 until 循环与 while 正好相反until 的语法结构跟 while 一模一样until 条件; do 循环体 done区别只在于语义while 是“条件为真就执行”until 是“条件为假就执行直到条件为真才停”。实际中 until 用得比 while 少但有的场景写起来比 while 更直白。比如“一直尝试连接数据库直到连接成功为止”until mysqladmin ping -h localhost --silent; do echo waiting for mysql... sleep 2 donemysqladmin ping 成功时返回 0直到返回 0 才退出循环。如果用 while 写就是while ! mysqladmin ping ...需要对返回值取反。两种写法都能跑但直接读 until 那一版意思一目了然。要注意until的退出时机它是在每次循环开始前判断条件所以如果条件一开始就满足循环体一次都不会执行。2.4 三种循环语法速查循环类型格式退出时机for...infor i in list; do ...; done列表遍历完毕C 风格 forfor ((i0; in; i)); do ...; done条件变假whilewhile command; do ...; done命令返回非 0untiluntil command; do ...; done命令返回 0无限循环while true; do ...; break; done手动 break用不用 break、continue见下面的细节控制部分。3. 循环内部的细节控制与边界处理3.1 break、continue、exit循环里的三道闸门写循环只靠条件判断是不够的很多时候要在循环体内部改变流程。break跳出当前这一层循环。continue跳过本次循环的剩余部分直接进入下一轮。exit直接退出整个脚本不管外面还有多少层循环。举例你要找到第一个大于 10 的偶数for i in {1..20}; do if ((i % 2 ! 0)); then continue fi if ((i 10)); then echo found: $i break fi done奇数全被 continue 跳过了找到 12 之后 break 收工。continue 在这里的作用就是“过滤不需要的处理项”。break 还可以带数字参数比如break 2表示跳出两层循环。这个在嵌套循环里会用到。比如遍历二维数组、同时处理多个维度的时候你想在内层满足条件后直接退出外层for i in {1..3}; do for j in {1..3}; do if ((i * j 5)); then echo exit at $i x $j break 2 fi done done不带参数的 break 只会跳出内层 j 的循环外层 i 会继续。如果逻辑上需要彻底收手break 2就省了你再设一个标志位。但也要提醒一句break 层级写多了脚本会变难读。如果嵌套超过两层建议先想想是不是能拆成函数。3.2 子 shell 陷阱为什么循环里的变量会丢失这是 shell 循环里最容易让人抓狂的问题也是网上提问率最高的一个。看这个例子count0 cat input.txt | while read line; do count$((count 1)) done echo total lines: $count运行完你会发现count 打印出来还是 0。原因在于管道符左边的命令和 right 边处于不同的子 shell。cat input.txt | while ...相当于把 while 放到了一个子进程里子进程里的 count 和外面脚本里的 count 是两个独立变量子进程结束变量也跟着销毁外面的 count 根本没被改到。同样的坑也存在于for i in $(cmd)这类命令替换里但核心矛盾集中在管道 while 的组合上。正确的解法有三种第一种把循环放到同一个进程里执行用输入重定向代替管道count0 while read line; do count$((count 1)) done input.txt echo total lines: $count第二种使用进程替换这样可以保留管道带来的流式处理优势又不会开子 shellcount0 while read line; do count$((count 1)) done (cat input.txt) echo total lines: $count注意 (命令)中间有个空格这是 bash 的进程替换语法。它把命令输出作为虚拟文件通过重定向喂给 while。实测下来这是最优雅、也最不容易出问题的方案。第三种如果坚持用管道那就把 while 整个放进一个 subshell再用 echo 把结果输出到 stdout靠命令替换接住结果。但说实话这是绕路不如前面两种直接。还有一个相关坑for i in $(cat file)会把换行符当成普通空白文件里每一行如果有空格就会被拆成多段。所以我的习惯是文本处理一律用 while read不要用 for in 拼文件。3.3 空格、分隔符与空值for 循环最常见的翻车点shell 默认把空格、Tab、换行当成分隔符。这意味着你写for f in $(ls *.mp3); do mv $f /backup/ done如果目录里有一个文件名是my music.mp3它会变成两个文件循环项my和music.mp3。最后 mv 命令会报错或者把文件移动错位置。正确做法是用通配符让 shell 自己展开for f in *.mp3; do mv $f /backup/ done通配符展开的结果是一个完整的文件名不会被二次分词。关键还是变量要加双引号$f这样即使名字里有空格也被当成一个整体。另一个翻车点是列表为空时。假设目录下没有 .mp3 文件for f in *.mp3; do mv $f /backup/ done循环仍会执行一次f的值是字面量*.mp3。如果你没加判断脚本就会尝试把不存在文件移动报错但不会停下。如果你想避免这种“空值执行”可以用 nullglob 选项shopt -s nullglob for f in *.mp3; do mv $f /backup/ done开启 nullglob 之后没有匹配文件时通配符直接展开为空循环体一次都不执行。这是写批量文件处理必备的选项强烈建议养成习惯。3.4 循环体内的数值累计与运算细节循环里经常要计数、累加、算平均值。shell 的算术运算和别的语言不一样要注意写法。基础累加sum0 for i in {1..100}; do sum$((sum i)) done echo sum$sum$(( ))是 bash 的算术展开语法里面可以写sum i但赋值时要带sum和$。另一种等价写法for i in {1..100}; do ((sum i)) done(( )) 直接做运算不输出结果也不需要$。很多人把这两种混在一起写比如sum$(($sum$i))也能跑但看着啰嗦我统一推荐((sum i))这种。C 风格 for 里的步进也一样for ((i0; i100; i2)); do echo even index: $i done要注意一个八进制陷阱如果数值带有前导 0比如08在 (( )) 里会被当成八进制解析然后报错。解决办法是显式声明十进制或者避免用前导 0 的变量。比如从date %H%M直接拿到的0830放进 (( )) 就可能出错需要先处理。4. 几个直接能抄的实战脚本4.1 批量重命名与归档备份批量改文件名是 for 最舒服的舞台。比如把目录下所有 .txt 改成 .mdshopt -s nullglob for f in *.txt; do mv -- $f ${f%.txt}.md done这里${f%.txt}是参数扩展从变量 f 的末尾去掉.txt后缀再加.md实现改名。--的作用是防止文件名以-开头时被 mv 当成选项。如果你还想加日期前缀shopt -s nullglob today$(date %Y%m%d) for f in *.log; do mv -- $f ${today}_${f} done批量备份到目录时要先确认目录存在backup_dir/backup/$(date %Y%m%d) mkdir -p $backup_dir shopt -s nullglob for f in *.conf; do cp -a $f $backup_dir/ done这里mkdir -p是幂等操作目录在就跳过不在就创建不用担心重复执行报错。实测跑定时任务时这个组合非常稳。4.2 逐行读取文件做日志统计日志统计是 while read 的主场。需求统计一个日志文件里每个 IP 出现了多少次。declare -A ip_count while read ip rest; do ((ip_count[$ip])) done access.log for ip in ${!ip_count[]}; do echo $ip ${ip_count[$ip]} done这里 read 一次读两个字段第一个是 IP第二个是剩余内容。关联数组 ip_count 以 IP 为下标累加次数。最后遍历数组键打印结果。更常见的需求是筛选特定行并处理。比如日志里找出包含关键词的行提取时间字段做统计while read line; do case $line in *ERROR*) ts$(echo $line | awk {print $1, $2}) echo error at $ts ;; esac done app.log这段可以套到绝大多数“找出错误日志”的场景里。用 case 匹配关键词比 grep 后再循环更灵活因为你在同一行内还能继续做格式化。4.3 重试机制与健康检查初始化服务或者部署脚本里经常要“等服务起来”。用 until 写最自然attempt0 max_attempt10 until curl -sf http://localhost:8080/health /dev/null; do attempt$((attempt 1)) if ((attempt max_attempt)); then echo service failed to start after ${max_attempt} attempts 2 exit 1 fi echo waiting for service... attempt $attempt sleep 2 done echo service is upcurl 的-s静默、-f失败时不输出页面内容并返回非 0正好用来做条件判断。/dev/null丢弃 stdout 的返回内容。超过最大尝试次数就直接报错退出这是脚本健壮性的关键。如果服务是守护进程你可能想让它保持运行而不是等一次就结束。可以配合 while true 和后台启动while true; do if ! pgrep -f my_worker /dev/null; then echo worker not running, restarting... /path/to/my_worker fi sleep 5 done这种“监控重启”脚本建议配合 nohup 或其他进程守护工具来做而不是单纯靠这个循环撑但逻辑模板可以直接用。4.4 循环里的并发控制 和 wait当循环体里的任务不互相依赖时串行执行很浪费时间。比如要压缩 100 个目录一个个 tar 可能要十几分钟并行可以快好几倍。基础写法是在命令后面加 放入后台最后用 wait 等待所有后台任务完成for dir in */; do tar -czf ${dir%/}.tar.gz $dir done wait这样一个循环会瞬间把所有 tar 任务丢到后台。但有个问题如果目录有 100 个等于同时起 100 个 tar 进程系统负载直接拉满。限制并发数量要用到 xargs 的 -P 参数find . -maxdepth 1 -type d | xargs -P 4 -I {} tar -czf {}.tar.gz {}-P 4 表示最多同时执行 4 个任务xargs 自己维护任务队列。用 shell 纯循环做并发控制也不难但代码会复杂不少一般推荐用 xargs。这个技巧在批量数据处理、日志归档里都很有用实测下来比单线程能省一半以上的时间。5. 常见问题速查与调试习惯5.1 常见问题速查表症状原因解法循环变量在循环结束后没有变化管道 while 造成子 shell变量在子进程中被复制改用重定向done file或进程替换done (cmd)for 遍历文件名带空格被拆成多个命令替换或未加引号导致单词拆分用通配符展开列表变量始终加双引号目录没有匹配文件时循环执行一次通配符不匹配时保持原样开头执行shopt -s nullglob循环里数值带有前导 0 报错bash 把 08 等识别为非法八进制避免前导 0或10#$val强制十进制break 只跳了一层循环嵌套循环中 break 默认只退当前层需要跳多层用break N循环太快导致服务还没起来就判断失败缺少等待时间循环体内加sleep并设置最大尝试次数脚本跑着跑着报 “argument list too long”列表一次性展开过大改用 while read 或 xargs 分片处理5.2 调试循环脚本时的几条个人习惯写循环最容易出问题的地方都在边界空列表、带空格文件名、变量初始值。我个人的调试习惯是先写一个 3 到 5 条数据的测试集跑通再上全量哪怕全量只有 20 个文件也建议先小范围验证。调试状态下一定要用 bash -x 看执行过程bash -x myscript.sh-x 会逐行打印命令和变量展开结果哪个文件被拆错了哪个条件判断没进去一眼就能看出来。配合 set -u 让未定义变量直接报错可以在变量拼写错误时立刻暴露问题#!/bin/bash set -eu不过 set -e 有个副作用循环里某个命令返回非 0 时整个脚本会退出。有时候这不是你想要的比如探测命令失败本来就在预期内。所以用的时候要分清场景或者在某些命令后面加|| true显式忽略。还有一个习惯是循环里尽量少写复杂逻辑。如果循环体超过 10 行我会考虑把它抽成函数。这样做的好处是单独测试函数更容易循环的读文件、遍历逻辑和具体业务解耦。最后一个个人体会shell 循环写得好不好一半看你对数据形态够不够敏感。遇到问题先别急着写代码想想数据是什么——是文件名列表、是文件内容流、还是数值范围想清楚这个for 还是 while、加不加引号、要不要处理空值答案自然就出来了。写了几百个脚本之后你会发现循环语法只占一小部分大部分时间其实花在识别边界条件和防翻车上把上面这些细节都过一遍你的脚本能少踩很多坑。