☰
Shell循环编程实战:for、while、until用法与避坑指南
2026/10/20 21:34:30 网站建设 项目流程

写Shell如果只会按顺序敲命令,那顶多算个“打字员”;能熟练用循环把几十台服务器的日志一次性拉下来、把几千个文件批量改名、把一个巡检脚本挂在后台跑一整夜,才算摸到了自动化的门槛。循环语句就是Shell编程里最值得花时间啃透的一块,原因很简单:几乎你写的每一个稍微有点实际价值的脚本,都离不开遍历、轮询和重复处理。这篇内容我会把for、while、until三种循环从语法到实战细节全部过一遍,重点讲文档里不会写的坑和排查思路,适合刚接触Shell想系统补基础的同学,也适合已经写过些脚本但经常被“变量丢了”“死循环了”“文件名字被拆了”这类问题折磨的运维和开发朋友。

1. 为什么循环是Shell编程的分水岭:思路与选型

1.1 循环要解决的四大日常场景

循环的本质就一句话:把同一个操作,作用到一批不同的对象上。这个“对象”可以是文件、目录、主机名、端口号、日志行,也可以是不断变化的状态值。

我平时在服务器上干活,循环最常出现在四类场景里。第一类是批量处理,比如对某个目录下的所有.log文件做压缩,对所有节点执行同一句远程命令,给一批新入职的同事批量创建系统账号。第二类是日志和数据统计,典型写法就是for line in $(cat access.log)这类遍历,把每一行拆出来匹配、计数、汇总。第三类是状态轮询,比如等待某个服务起来、等待某个文件被写入、等待数据库某个表的数据同步完成,这种场景天然就是“每隔几秒检查一次,不满足条件就继续等”的循环逻辑。第四类是重试机制,调用外部接口、执行远端命令、下载资源,失败了一次不代表永久失败,给它自动重试三次才下结论,这种逻辑没有循环根本没法优雅实现。

你会发现,这四类场景没有一类是单条命令能搞定的。它们共同的特点是“数量不确定”或“状态未知”,而循环语句就是为这种不确定性设计的。理解这一点,你就能明白循环选型不是死记语法,而是先想清楚自己要面对的数据是已知集合还是动态条件。

1.2 for、while、until怎么选

很多初学者背着三种循环的语法,却分不清什么时候用哪个。这里我给出一个经过大量实践验证的选型逻辑:

  • for循环:当你知道要处理哪些东西,或者至少知道要循环多少次时,用for。比如遍历目录下全部文件、循环1到100、遍历命令输出的每一行。for的语义是“我有一堆东西,挨个处理”。
  • while循环:当循环次数取决于某个条件是否成立时,用while。比如“只要文件还没有写完就一直读”“只要服务还没起来就一直ping”“只要计数不等于5就继续”。while的语义是“只要条件为真,就一直执行”。
  • until循环:当你想表达“一直做到某个事情发生为止”时,用until。它和while正好相反,条件是假的才进循环体,条件一旦变真就退出。语义是“傻等到那一刻”。

真实项目里,while的使用频率明显高于until,因为大部分状态检查都适合用“条件为真继续执行”来描述。但是until在“等待某个进程退出”“等待某个文件出现”这类场景里,写出来的代码可读性比while更好,因为它的字面意思就是“直到...才停”。

2. for循环的核心实操:从基础语法到C风格

2.1 五种常见写法,一次捋清楚

for循环的语法变化比较多,我按照从常用到少用的顺序列出来,每种都配一个最小可运行示例。

第一种,直接遍历一个列表:

for ip in 192.0.2.10 192.0.2.11 192.0.2.12; do echo "checking $ip" done

这种写法的特点是,列表元素在命令中直接写死,适合数量少、不会变化的场景。注意元素之间用空格分隔,如果元素本身包含空格,就要用引号包起来,这一点后面专门讲。

第二种,借助命令替换生成列表:

for file in $(ls /var/log/*.log); do echo "$file" done

或者更常见的是遍历一个文本文件里的主机名:

for host in $(cat hosts.txt); do ping -c 1 "$host" done

这种方式非常灵活,它是把外部命令的标准输出按照空白字符切分后,逐个作为循环变量。但切分逻辑和“空格”有关,一旦文件名或行内容包含空格,就会出现意想不到的拆词问题,这里的坑我们单独一节来说。

第三种,使用大括号展开:

for i in {1..10}; do echo "num: $i" done

大括号展开的语法{1..10}在bash里会被展开成1到10的序列,也可以反向{10..1},还可以带步长,比如{1..10..2},得到1、3、5、7、9。这是需要生成连续数字序列时最快最清晰的方式,但要注意两点:一是它要求bash版本不低于3.0,老旧系统上的sh不支持;二是如果数字范围特别大,比如{1..1000000},展开过程本身会占用较多内存,不如用下面的seq方式。

第四种,使用seq命令:

for i in $(seq 1 2 10); do echo "odd: $i" done

seq 1 2 10输出1、3、5、7、9,适合需要灵活控制步长的场景。seq比大括号展开更通用,在任何Shell环境下都能用,代价是多启动一个外部命令,但对现代服务器来说这点开销完全可以忽略。

第五种,C风格for循环:

for ((i = 1; i <= 10; i++)); do echo "count: $i" done

这种写法从C语言移植过来,最接近传统编程语言的循环习惯。它需要用双括号括起来,循环变量初始化、条件判断、步进修改都写在(( ))内部。它的优势在于可以方便地做复杂的条件控制,比如for ((i = 10; i > 0; i -= 2)),这在处理需要倒序或跳步的逻辑时特别顺手。我自己的习惯是:涉及数字索引且逻辑稍微复杂一点的场景,直接用C风格,读起来直观得多。

2.2 最容易翻车的空格与分隔符问题

for循环最大的一个暗坑,就是对“列表里含有空格或者通配符展开结果中含有空格”的处理。

举一个我踩过的真实例子。某次我需要批量处理服务器上的一批日志目录,目录名长成“app 2024-01-01”这种带空格的格式。我一开始写了:

for dir in $(find /data/logs -type d); do echo "$dir" done

结果一个目录名在循环里被拆成了两个独立的值,后续的压缩命令全部出错。原因就是$(find ...)输出的整行文本,会被Shell按照IFS(内部字段分隔符)切分成多个字段,默认的IFS包含空格、制表符和换行,所以带空格的目录名没法完整保持。

解决办法有三种。第一种,对待处理的元素统一加引号,但问题是循环变量已经被拆开了,引号救不回来已经被切碎的数据。第二种,临时修改IFS,只按换行切分:

IFS=$'\n' for dir in $(find /data/logs -type d); do echo "$dir" done

这里的$'\n'是bash里表示换行符的写法,把IFS改成只包含换行,空格就不会再触发分词了。但要注意,这种改法是全局的,建议在使用前存下原值、用完恢复,否则会影响脚本里后续所有命令的分词逻辑。

第三种更推荐的方案是:凡是涉及文件遍历,优先考虑用find ... -exec或者while read来处理,而不是让for去接find的文本输出。比如上面的场景改成:

find /data/logs -type d -print0 | while IFS= read -r -d '' dir; do echo "$dir" done

-print0让find用空字符而不是换行来分隔结果,read -d ''告诉read也用空字符作为定界符,这样无论文件名里有什么特殊字符都能安全传递。这个模式在处理文件名空间、换行、特殊字符时是真正稳妥的做法。

for循环里另一个常见的坑是,遍历某个目录下的文件时,如果目录里一个文件都没有,通配符*.log不会被展开成空列表,而是被当作字面字符串“*.log”传给循环体。这时候循环还会执行一次,循环变量等于*.log。解决办法是在循环体内加一个存在性判断:

for file in /var/log/*.log; do [ -e "$file" ] || continue echo "process $file" done

[ -e "$file" ]判断文件是否存在,如果通配符没有匹配到任何文件,$file就是那个字面量,-e判断为假,continue跳过,循环体自然一次都不执行。

3. while循环的核心实操:条件驱动与逐行处理

3.1 三大典型用法:计数、轮询、等待

while循环的核心是“条件为真就继续”,所以写while的第一步永远是想清楚“退出条件是什么”。没有退出条件的while,就是死循环。

第一种典型用法是计数器控制。这个和for的C风格类似,只是写法更古典:

i=0 while [ $i -lt 5 ]; do echo "round $i" i=$((i + 1)) done

条件判断用了[ $i -lt 5 ],注意变量最好加双引号写成[ "$i" -lt 5 ],避免变量为空时语法报错。每轮循环末尾必须更新计数器,这一步漏了就会无限循环。

第二种典型用法是死循环配合break退出,这是实现状态轮询的常见结构:

while true; do status=$(curl -s http://localhost/health) if [ "$status" = "ok" ]; then echo "service is up" break fi sleep 3 done

这里的while true是一个永不为假的条件,真正的退出是靠循环体内的break来实现的。好处是逻辑清晰:检查、判断、退出都集中在循环体里,适合“每隔几秒检查一次状态”这种操作。需要注意sleep 3一定要加,否则脚本会疯狂空转,白白占满CPU。

第三种典型用法是把外部命令的执行结果作为循环条件。Shell允许while后面直接跟一条命令,命令退出码为0就认为条件为真。比如数据同步的等待:

while ! pgrep -f "sync_task" > /dev/null; do echo "sync_task not running, wait..." sleep 5 done

这里用!对pgrep的退出码取反,进程不存在时pgrep返回非0,取反后为真,循环继续;进程出现后pgrep返回0,取反后为假,循环退出。这种写法的语义就是“等到进程出现为止”。

3.2 逐行读文件的正确姿势与子Shell陷阱

while在Shell编程中最有名的一个应用,就是逐行读取文件。基础写法是这样的:

while IFS= read -r line; do echo "line: $line" done < /etc/hosts

这里有几个细节值得掰开说。read命令每次从标准输入读取一行,赋值给line变量。-r参数表示禁止反斜杠转义,也就是说如果行里有\n这种字符,不应该被解释成换行而应该保留原样,处理日志和配置文件时这个参数几乎必须加。IFS=放在read命令前面,表示给这条read命令单独设置一个空的IFS,这样行首行尾的空格就不会被read自动去掉,能完整保留原始内容。注意这个IFS=是read命令的前置环境变量赋值,不是while的前置条件,所以不能随手挪到while后面。

文件重定向< /etc/hosts在done的后面,表示整个while循环的标准输入都来自这个文件。这样逐行读取文件的方式,比在for里用$(cat file)要稳妥得多,不仅不会因为空格拆词,还能处理大文件,因为它是流式的,不会一次性把整个文件读进内存。

但这里有一个非常经典的坑:管道会开启子Shell,循环里的变量改动带不出来。举个例子,我想统计一个文件里包含“error”的行数:

count=0 cat app.log | while IFS= read -r line; do if [[ "$line" == *error* ]]; then count=$((count + 1)) fi done echo "error count: $count"

这段代码的输出永远是0。原因是cat app.log | while ...这条管道,会让while在子Shell中执行,子Shell里修改的count变量,在管道结束、回到父Shell后全部丢失。这是初学Shell最容易遇到、也最让人摸不着头脑的问题。

解决办法有两类。第一类是改用进程替换,避免使用管道:

count=0 while IFS= read -r line; do if [[ "$line" == *error* ]]; then count=$((count + 1)) fi done < <(cat app.log) echo "error count: $count"

< <(cat app.log)叫做进程替换,它把命令的输出包装成一个虚拟文件,重定向给while。关键区别是:这里的while不在管道里,没有进入子Shell,所以循环体内对变量的修改在循环结束后依然有效。

第二类是把后续依赖这个变量的逻辑全部放进子Shell里,在循环结束前把结果输出出来。这种方法适合那些不需要把变量带出来、只想拿到最终计算结果的情况:

cat app.log | { count=0 while IFS= read -r line; do if [[ "$line" == *error* ]]; then count=$((count + 1)) fi done echo "error count: $count" }

用花括号把整个处理逻辑括起来,让它在同一个子Shell里连续执行,最后在子Shell内输出结果,这样就不会遇到变量丢失的问题。这两种方案我实际都用过,进程替换更整洁,花括号分组更好移植,看个人喜好。

4. until循环:一次把语义补全

4.1 until的本质与while的对比

until循环在语法上和while几乎一模一样,唯一的区别是判断逻辑正好相反:

until 条件; do 循环体 done

条件为“假”时进入循环体执行,条件为“真”时退出循环。换句话说,while是“条件成立就继续”,until是“条件成立就停”。用逻辑表达式来看,until cond等价于while ! cond。

举个例子,等待一个文件生成:

until [ -f /tmp/ready.txt ]; do echo "ready.txt not found, wait..." sleep 2 done

如果用while写,就要写成while [ ! -f /tmp/ready.txt ]; do ...,虽然只有一个小小的!差别,但读起来确实没有until直观。这种“等待某个外部条件变成真”的场景,我个人认为until是表达最贴切的。它对应到日常语言就是“一直做某件事,直到那件事发生”。

4.2 典型场景:等文件、等进程、等条件就绪

until最适合的三类场景,我分别给出可以直接抄的模板。

第一,等待文件出现。这在处理任务触发信号、同步标记文件时很常见:

until [ -f /data/flag/import.done ]; do echo "$(date +%H:%M:%S) waiting for import.done" sleep 5 done echo "flag file exists, continue"

注意这里的风险:如果外部程序永远不会创建那个文件,脚本就会永远等下去。所以在实际项目中,我会加上超时限制。做法是引入一个计数器,在达到阈值时主动退出:

wait_seconds=0 until [ -f /data/flag/import.done ]; do if [ "$wait_seconds" -ge 60 ]; then echo "timeout waiting for import.done" exit 1 fi echo "waiting... ${wait_seconds}s" sleep 2 wait_seconds=$((wait_seconds + 2)) done

第二,等待进程退出。这常用于脚本里先启动一个任务,等它结束后再执行后续步骤:

until ! pgrep -f "start_batch" > /dev/null; do echo "batch still running, waiting..." sleep 10 done

这里用! pgrep表示“当进程不在时退出循环”,配合until的语义就是“直到进程消失”。同样是等进程,这一段就比while+取反的写法读起来顺畅不少。

第三,等待网络端口就绪。我写部署脚本时经常要等服务端口起来才能继续做健康检查,这种场景用until非常舒服:

until nc -z 127.0.0.1 8080; do echo "port 8080 not ready" sleep 2 done echo "port 8080 is open"

nc -z用来检测端口是否可连接,可连接时退出码为0,until条件变真,循环退出。如果服务器上没有nc命令,可以用bash自带的/dev/tcp特性:

until (echo > /dev/tcp/127.0.0.1/8080) > /dev/null 2>&1; do echo "port 8080 not ready" sleep 2 done

4.3 三种循环的对比与选型速查

用一张表把三种循环的定位、触发逻辑和使用场景收拢起来,方便日常翻看:

循环类型进入条件退出条件典型场景代表写法
for列表中有未处理的元素列表耗尽遍历文件、遍历主机、固定次数循环for i in {1..10}
while条件判断为真条件判断为假状态轮询、读文件、重试、计数while [ "$i" -lt 5 ]
until条件判断为假条件判断为真等待文件出现、等待进程退出until [ -f /tmp/ready ]

选型的时候不一定要死板,同一个需求用for和while都能实现,比如固定次数的循环既可以用for ((i=1;i<=10;i++))也可以用i=1; while [ $i -le 10 ]。我的建议很简单:哪种写法让你明天再看代码时不需要额外思考,就选哪种。脚本是写给下一个维护者看的,而那个维护者大概率就是三个月后的你自己。

5. 循环控制与错误重试:break、continue与退出码的配合

5.1 break和continue的层级参数

循环体里的break和continue是控制循环节奏的两个阀门。break表示立即退出当前整个循环,continue表示跳过本次循环的剩余部分、直接进入下一次迭代。它们都可以接一个数字参数,表示作用到第几层循环,默认是1,也就是当前层。

嵌套循环里这个数字特别有用。举一个实际场景:我要在多个目录里搜索某个配置项,只要在第一个找到的目录里命中就直接停止所有搜索。

for dir in /etc/app /opt/app /data/app; do for conf in "$dir"/*.conf; do if grep -q "ENABLE_DEBUG" "$conf"; then echo "found in: $conf" break 2 fi done done

这里的break 2表示直接跳出两层循环,也就是for和它外层for全部终止。如果只写break,它只跳出内层循环,外层还会继续换下一个目录搜索,不是我们想要的效果。continue同样支持数字参数,continue 2会跳到外层循环的下一轮迭代,这在处理“跳过整个外层项的剩余处理”时很实用。

我见过不少人在嵌套循环里靠标志变量来达到同样效果,比如定义一个found=1,然后每层循环都去检查。这种做法代码冗长还容易错,用break加层级数字反而清爽。前提是你要确认自己写的到底是几层嵌套,数字写大了没有效果,写小了照样停不下来。

5.2 重试机制实战:自动重试3次,间隔递增

循环最经典的一类应用是错误重试。日常运维脚本里,调用外部接口、执行远端命令、拉取资源包都可能偶发失败,一次失败就放弃太脆弱,无限制重试又太危险,所以通常的做法是“固定次数 + 固定间隔 + 超时退出”。

我常用的一个模板是这样:

attempt=0 max_attempts=3 while true; do attempt=$((attempt + 1)) if curl -fsS --connect-timeout 5 "http://api.example.com/health" > /dev/null 2>&1; then echo "request success on attempt ${attempt}" break fi echo "attempt ${attempt} failed" if [ "$attempt" -ge "$max_attempts" ]; then echo "ERROR: failed after ${max_attempts} attempts" exit 1 fi sleep 2 done

拆开看几个关键点。curl -fsS表示静默失败但显示错误、HTTP错误码视为失败,--connect-timeout 5防止连接长时间卡住。if直接跟curl命令,用命令退出码判断成功还是失败。成功时执行break退出循环,失败时先判断是否已经达到最大次数,达到就exit 1退出整个脚本,没达到则sleep 2后继续下一轮。

这里的“间隔递增”需求也很常见,比如第一轮失败等2秒重试,第二轮失败等4秒,第三轮等8秒,给下游系统更多恢复时间。实现方式是把sleep时间也做成变量:

attempt=0 max_attempts=5 wait_time=2 while true; do attempt=$((attempt + 1)) if curl -fsS "http://api.example.com/health" > /dev/null 2>&1; then echo "success on attempt ${attempt}" break fi echo "attempt ${attempt} failed, wait ${wait_time}s" if [ "$attempt" -ge "$max_attempts" ]; then echo "ERROR: gave up after ${max_attempts} attempts" exit 1 fi sleep "$wait_time" wait_time=$((wait_time * 2)) done

这种写法把重试逻辑和业务逻辑放到了同一个循环体里,结构一目了然。有一点要特别提醒:sleep前面的判断不要漏掉,否则脚本会在最后一次失败重试后继续sleep,然后进入不存在的第6轮循环,白白浪费时间。

5.3 exit、break和return在循环中的分工

循环里涉及退出时,有三个关键字很容易弄混:exit、break、return。它们的作用范围完全不同。

  • break只退出循环本身,脚本继续执行循环后面的命令。
  • exit直接终结整个脚本进程,不管你在嵌套多少层循环里,一旦执行exit,整个脚本终止并返回指定退出码。
  • return是用在函数体里的,它退出的是当前函数,回到调用函数的地方。如果return出现在主脚本的循环里,脚本本身没有函数包裹,用return会直接报语法错误。

我自己的使用习惯是:循环体内的正常分流用break或continue,确认整个脚本无法继续时才用exit,函数内的循环需要提前返回结果时用return。有一个典型的误用:有人想在循环里检测到致命错误后退出整个脚本,却写了break,结果脚本继续往下跑,产生了更严重的副作用。反过来,有人只是想在循环里结束一个函数,却写exit,结果整条脚本被掐断,调用方根本拿不到返回值。这三个关键字的区别,值得在写每一个循环前快速过一遍脑子。

6. 常见问题与排查技巧实录

6.1 高频故障速查表

把我在项目里遇到过的、以及帮别人排查过的循环相关问题整理成速查表,这些案例每一个都是真实踩过坑的,建议收藏:

症状常见原因处理建议
管道while里累加的变量,循环结束后变成初始值管道两侧在子Shell执行,变量作用域隔离改用进程替换< <(command),或把逻辑整体放子Shell里
for遍历带空格的目录名,目录被拆成多个值默认IFS包含空格,命令替换结果被分词临时调整IFS为换行;文件遍历场景改用find -print0和while read
文件最后一行没有被处理到文件末尾缺少换行符,read读到残留内容时遇到EOF确认文件格式,必要时在while结束后单独处理最后一行
脚本明明写了循环却只执行了一次条件判断里变量名拼错,条件恒为假用bash -x查看每条命令展开后的值
循环无限跑,停不下来循环体内缺少退出条件,或条件永远为真加入计数器、sleep、超时判断,必要时trap异常信号
从Windows编辑器粘贴的脚本执行报错,提示找不到命令文件是CRLF换行,行尾\r被当成命令一部分执行sed -i 's/\r$//' script.sh或dos2unix script.sh

这里面最值得反复说的是第一行。子Shell变量丢失这个问题,新手遇一次基本都会懵几个小时,因为它不报错、不警告,就是结果不对。你真的想弄明白这个问题,可以写一个最小复现脚本,把cat file | while和< <(cat file)放在同一个脚本里对比输出,跑一次就透彻了。

6.2 我常用的三个排查工具与习惯

写循环脚本时,我的固定动作是先过shellcheck,再用bash -x跑一遍关键路径,最后在循环体里临时加echo输出中间变量。

shellcheck是一个Shell脚本静态检查工具,很多发行版的软件源里直接就能装。它对循环类问题的识别非常敏锐,比如能指出管道里while的SC2030和SC2031问题、for循环里变量没加引号的SC2086问题,还会提醒你某些bash特性在sh环境下不可用。我见过太多“在bash下跑得好好的,换到sh就全线崩溃”的脚本,shellcheck在早期就能把这些坑标出来。

bash -x script.sh是调试运行的利器。它会逐行打印每条命令展开后的实际内容,比如for i in {1..10}会先展开成for i in 1 2 3 4 5 6 7 8 9 10,[ "$i" -lt 5 ]会输出展开后的具体值。配合set -x在脚本内部启用也是一样的效果。看展开后的输出,基本能定位九成以上的逻辑错误,比如变量没赋值、条件写反、循环变量被意外覆盖等。

还有一个习惯是“干跑”。正式执行批量写操作(比如批量改名、批量删除、批量执行远端命令)之前,先用echo把要执行的原命令打印出来,人眼确认一遍无误后,再把echo去掉换成真实操作。这个习惯听着简单,但在循环场景里价值极高,因为循环会把一条错误命令放大几十倍甚至上千倍地执行。

6.3 一个真实重构案例:日志关键字巡检脚本

之前接手过一段同事遗留的巡检脚本,它的任务是扫描某业务集群里所有服务器的应用日志,统计包含“FATAL”的行数,超过阈值就告警。原始实现是这样的:

for server in $(cat servers.txt); do ssh "$server" "grep -c 'FATAL' /var/log/app/app.log" done

表面上看挺简洁,实际用起来却有三个问题:服务器列表里如果某个主机名暂时解析不了,整条命令会卡很久;grep -c只输出计数,没法区分是0个FATAL还是文件不存在;如果日志文件名随日期变化,这段脚本就必须每天改。

我重构后变成了这样:

while IFS= read -r server; do [ -z "$server" ] && continue count=$(ssh -o ConnectTimeout=5 -o BatchMode=yes "$server" \ "grep -c 'FATAL' /var/log/app/app.$(date +%Y-%m-%d).log 2>/dev/null || true") echo "$server: $count" if [ "${count:-0}" -gt 50 ]; then echo "ALERT: $server has too many FATAL lines" fi done < servers.txt

改动点都对应了前面讲过的原则。用while逐行读servers.txt,避免for因空格拆行;加了ConnectTimeout避免连不上的主机拖时间;|| true防止grep找不到文件时因为非零退出码打断脚本;日期用命令替换自动生成每天的文件名。这个脚本后来稳定跑了很久,没有再出过之前那些幺蛾子。

个人经验上,循环脚本里最容易出事的往往不是语法本身,而是“数据里有什么”你根本预料不到。服务器列表里有空行、文件里某一行以特殊字符开头、变量在子Shell里被静默复制——这些问题只有真跑一遍才知道。所以我的习惯是用bash -x盯紧变量展开,用shellcheck做静态扫描,然后在正式任务前干跑一次只打印不执行。这三级防线加下来,循环脚本踩坑的概率能降一个数量级。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询