Shell脚本三种循环全解析:for、while、until实战避坑指南

发布时间:2026/10/23 7:19:10

Shell脚本三种循环全解析:for、while、until实战避坑指南 如果你和我一样每天都在和各种运维脚本、部署脚本打交道你会发现一件事真正让脚本从“能跑”变成“靠谱”的往往不是某个冷门命令而是循环。for、while、until 这三个循环结构几乎撑起了 Shell 自动化的大半边天。它们的语法都不难但我在实际维护脚本、帮同事排查问题的过程中见过太多“一天崩三次”的脚本原因不在语法而在循环理解得不够透。这篇东西不讲那些一眼就能看懂的入门语法书内容而是从实际使用场景出发把三种循环怎么选、怎么写、怎么躲坑一次说清楚。适合刚接触 Shell 没多久、想把脚本写得更稳的同学也适合写了一阵子但总在循环上被绊一脚的人。看完之后你至少能解决一个具体问题下次写脚本时不再靠“哪个顺眼用哪个”来选循环。1. 循环在自动化脚本里的真实分量1.1 一个反直觉的现状语法都认识脚本却难维护Shell 循环的语法确实不难三行就能背下来。但“认识”和“会用”之间差距极大。我见过很多同事写的脚本逻辑上完全正确可一旦要改需求比如把“处理 10 个文件”改成“处理 100 个文件”或者把“等到服务起来”变成“最多等 10 次”整个循环就得推倒重写。原因往往只有一个当初选循环的时候根本没想清楚条件和次数之间的关系。另外一个更隐蔽的问题是不少人把循环当“模板”用习惯性地打开旧脚本复制一段 for 循环改个变量名就开始跑。这样做在简单场景下没问题可一旦遇到环境变化、空文件、没匹配到文件这类边界情况脚本就可能直接“炸”掉。循环结构本身不背这个锅背锅的是使用者的思维惯性。1.2 统一认知循环只是一个“框架”真正见功夫的是循环体我在很多次分享里都提过一句话Shell 脚本的功力一半在循环选择另一半在循环体内部怎么处理变量和命令状态。循环体里面干的活比如读文件、调用外部命令、判断上一条命令的成功失败才是决定脚本健壮性的关键。举个例子同样是用 for 循环遍历一批日志文件有人写出来的循环体会判断文件是否为空、有没有权限、上一条命令是否失败然后拿着错误信息继续下一次处理有人写出来的循环体就是一条裸的cp或者mv一旦碰到文件名里带空格脚本直接就停在那里了。循环结构本身没错但花同样的时间写出来的脚本稳定性可以差好几倍。这也是为什么我会建议初学 Shell 的人别急着去看 awk、sed 的高级技巧先把这三种循环在真实场景下的“性格差异”摸透。2. for 循环把“已知清单”变成“执行清单”2.1 从最常见的几种写法开始for 循环的核心思想是“遍历一个已知清单”。这个清单可以是一个列表、一批文件、一组数字甚至是一段命令的输出。最常见的写法是下面这种for i in 1 2 3 4 5; do echo 第 $i 次执行 done这种写法适合次数固定、范围明确的场景。比如给 5 台机器依次发指令就可以直接写死。但实际中我很少写死数字更多是用命令替换生成清单比如遍历某个目录下的文件for file in /data/logs/*.log; do echo 处理日志$file done注意这种写法有一个比较隐蔽的坑如果/data/logs/目录下根本没有.log文件for循环会把*.log当成一个普通字符串去遍历循环体里拿到的就是一个不存在的文件名。这个坑我后面专门再讲。还有一类就是 C 风格的 for 循环。Shell 虽然不像 C 语言那样强类型但也可以用(( ))做算术运算控制循环for ((i1; i10; i)); do echo 序号: $i done这类写法适合需要精确控制步长、索引的场景。比如批量创建编号目录、做分页请求都非常顺手。2.2 命令替换式遍历能力强代价也大先看一个实际场景需要读取某个文件里的 IP 列表逐个 ping 一遍。新手最容易写成的样子是for ip in $(cat ip_list.txt); do ping -c 1 $ip /dev/null echo $ip 通了 done这条命令本身没毛病但你要清楚它背后的开销$(cat ip_list.txt)会先把整个文件读进内存再按空白字符拆分成多个字段循环体每次迭代都要启动一个ping外部进程。文件小、次数少还好说如果 IP 列表有几千条脚本跑起来会非常慢。这里不是说不能用命令替换式 for 循环而是要意识到两件事第一如果清单来自外部命令输出且输出量巨大就要考虑是不是要改用 while 逐行处理避免一次性把全部数据塞进内存第二如果循环体内部频繁调用外部命令机器性能压力会成倍增加这时候更合理的做法是考虑能不能把循环体里的逻辑合并成一次调用比如用 awk 或者 xargs 来做并行处理。我在真实项目里见过一个处理 5 万行数据的脚本for 循环里每次都调grep和sed结果跑了半个多小时。后来我把循环体里那串逻辑改成一段 awk跑了不到半分钟。这不是循环本身的错而是“用循环处理大规模外部命令调用”这个策略本身就不划算。for 循环真正适合的是遍历一批已知的、可控数量的对象而不是拿着它当数据库去枯燥地扫海量数据。3. while 循环真正贴近业务状态的变化条件3.1 逐行读取文件这个姿势一定要稳while 循环和 for 循环最大的区别在于for 循环关心的是“我要遍历什么”while 循环关心的是“什么时候停下来”。最常见的 while 场景是逐行读文件while IFS read -r line; do echo 处理$line done /data/config/hosts.txt这个写法里有两个细节值得注意。第一个是IFS它把字段分隔符设为空避免行首行尾的空格被read自动吃掉第二个是-r它不让反斜杠被转义处理。这两个参数组合起来才是逐行读取最稳的姿势。很多人写while read line一旦文件里出现特殊字符行内容就会变得不可控。我还见过有人写cat file | while read line这看起来没啥问题但其实埋了一个更大的雷管道会让while跑在子 Shell 里循环体里所有对变量的修改出了循环就全丢了。比如你想统计总行数count0 cat file.txt | while read -r line; do count$((count 1)) done echo 总行数: $count这段代码执行完count几乎永远是 0。原因就是管道右边的while处于子 Shell变量副本的修改传不回父 Shell。改成while ... done file.txt这种输入重定向的形式就不会有这个问题。这是我在帮同事排查脚本时遇到频率最高的“灵异事件”之一。3.2 无限循环 条件退出最实用的守护模式while 另一个经常上场的场景是“无限循环”。很多人一听到无限循环就觉得可怕担心脚本卡死。但配合break它反而是做这类任务最好的结构等到某个条件满足就退出或者重试到某个次数上限就退出。retry0 while true; do if curl -s http://127.0.0.1:8080/health /dev/null 21; then echo 服务已就绪 break fi retry$((retry 1)) if [ $retry -ge 10 ]; then echo 等待超时退出 exit 1 fi sleep 2 done这个模式在服务依赖场景里非常常见。部署脚本启动一个新的服务进程后不能假设它立刻就能接受请求必须反复检查健康状态。用 while true 配合计数器和条件退出比用 for 循环固定次数灵活得多因为停止条件不是“次数到了”而是“业务状态就绪了”。还有一种是处理用户输入和交互比如写一个菜单while true; do read -p 请输入选项start/stop/quit: action case $action in start) service_start ;; stop) service_stop ;; quit) break ;; *) echo 未知选项 ;; esac done这种写法读起来非常直观而且逻辑容易扩展。我在写部署工具、初始化脚本的时候经常把主菜单设计成一个while true循环内部用case分发请求。4. until 循环一直被人忽略的“反条件”神器4.1 until 与 while 的本质区别如果说 while 是“条件为真就跑”那 until 就是“条件为假就跑直到条件变真才停”。语法上两者几乎镜像until [ $ready yes ]; do echo 还没准备好继续等 sleep 1 done注意until循环的循环体在条件成立前会一直执行一旦条件成立循环立刻退出。这和 while 正好相反while 的条件成立时进入循环体until 的条件成立时离开循环体。用逻辑术语讲until 就是 while 的否命题版本。很多人不习惯用 until因为心里默认“不就是要用 while 加感叹号吗”。确实while [ ! condition ]和until [ condition ]语义上几乎等价但可读性完全不一样。我自己的体会是当脚本的核心诉求是“等某件事发生”而不是“在条件满足期间做某事”时用 until 能让意图一眼亮出来。别人接手你的脚本看到 until 就知道“这脚本在等某个状态”。看到 while 里套一串复杂判断还得一路读到底才能弄明白。4.2 典型应用服务健康检查与重试等待until 最适合的场景就是轮询等待。比如部署一个内网服务需要等端口能连通了再继续往下走until nc -z 127.0.0.1 8080; do echo 端口 8080 还没起来2 秒后重试... sleep 2 done又比如等一个标志文件出现这个在某些同步流程里很有用until [ -f /data/sync/.done ]; do echo 等待同步完成标志文件... sleep 5 done还有重试连接远程数据库的场景until mysqladmin -h 127.0.0.1 -u root ping /dev/null 21; do echo 数据库暂不可用正在重试... sleep 3 done这些场景用 until 写出来几乎不用加注释谁看谁知道这个脚本在干嘛。而且 until 天然会先执行一次条件检查也就是说如果条件一开始就满足它一次都不会走循环体直接继续往下。这一点在“永久性任务”里特别重要因为可以避免不必要的空等。5. 循环选择的实战决策逻辑别再凭感觉5.1 一张对照表解决大部分选择困难经常有小伙伴问我到底什么时候用 for什么时候用 while什么时候用 until我把自己的判断逻辑总结成了一张表场景特征适合的循环原因遍历次数或元素范围已知for天然适合已知清单和确定次数需要按索引精确控制步长for (C 风格)写法直观步长可控逐行处理文件或命令输出while流式处理不一次性全部加载条件随时变化依赖运行状态while每个循环周期根据状态重新判断等待外部状态变为真until语义正好匹配“等到某天”需要定时重试直到成功until条件满足即退出次数可控无限循环并在某个地方跳出while true break灵活可自由编排退出逻辑这张表不是教条而是一个起点。我见过有人非要用 for 循环去轮询服务状态也能写出来但代码里必须塞一堆条件判断和临时变量看起来特别别扭。反过来有人用 while 遍历固定列表写起来也不是不行但可读性和维护性都会打折扣。5.2 嵌套循环、函数调用和可读性规范掌握了基础选型另一种常见需求就是嵌套循环。比如要遍历多个目录每个目录下又要遍历多个文件for dir in /data/project/*; do [ -d $dir ] || continue for file in $dir/*.conf; do [ -f $file ] || continue echo 处理配置$file done done注意这里我用了一个很关键的习惯[ -d $dir ] || continue先判断目录是否存在不存在就用continue跳过。这个习惯能避免很多因为路径不存在、权限不足导致的脚本报错。嵌套循环里break和continue默认只作用于最内层循环如果你需要提前跳出外层循环得用带标签的写法outer_loop: for f in /data/input/*; do for g in /data/input/*.lock; do [ -e $g ] break out done done不过说实话我遇到嵌套循环需要跳外层的情况时第一反应不是用标签而是重新审视一下这个循环结构是不是设计得太复杂了。绝大多数情况下把外层逻辑抽到一个函数里用返回值来控制流程比在多层循环里跳来跳去清晰得多。循环体里调用函数也是常有的事。要注意的是函数内部如果有return只是退出函数不会退出循环。如果需要根据函数结果决定是否继续循环建议做成返回值判断check_config() { [ -f $1 ] || return 1 return 0 } for file in /data/conf/*.ini; do if ! check_config $file; then echo 配置缺失跳过 $file continue fi apply_config $file done这样可以保持逻辑裸露在外层整个脚本读起来像步骤说明一样后期维护成本低很多。6. 我在真实脚本里踩过的循环坑6.1 管道子 Shell 丢变量排查一小时崩溃一秒这是我想放到最前面讲的坑因为它太隐蔽了。前面已经简单提过cat file | while read line的问题这里再展开一下。假设我写了一个统计脚本total0 cat access.log | while read -r line; do total$((total 1)) done echo 总请求数: $total第一次跑的时候total输出 0我以为是日志文件没内容还特意去tail看了一下。后来又换了好几种排查方式最后才意识到是子 Shell 的变量隔离问题。管道后面的while运行在子 Shell 里循环里改的total只是一个副本循环结束副本消亡父 Shell 里的total纹丝不动。解决方式很简单把管道改成输入重定向total0 while read -r line; do total$((total 1)) done access.log echo 总请求数: $total如果你确实需要从管道里读别的命令输出可以用进程替换total0 while read -r line; do total$((total 1)) done (grep 2024-01-01 access.log) echo 总请求数: $total这个写法和管道一样能读取命令输出但避免了子 Shell 问题。我用这种方式处理过很多“从一堆进程信息里统计行数”的场合都非常稳。6.2 通配符没匹配到文件for 循环的隐形炸弹for f in *.log这种写法如果目录里真的没有.log文件循环体会拿着*.log这个字面量进去执行。你在循环体里写rm $f那就等于执行了rm *.log虽然不会真的删掉不存在的文件但如果后续有cp $f /backup/就会直接报错甚至在某些粗心的写法里可能把*.log当成一个实际参数传给某个命令后果很难预料。我现在的习惯是for 遍历文件前先做一次匹配检查。用nullglob选项可以解决空匹配问题shopt -s nullglob files(*.log) for f in ${files[]}; do echo 处理文件$f done shopt -u nullglob或者更保守一点直接用前面提过的判断加 continue 的方式for f in *.log; do [ -f $f ] || continue echo 处理文件$f done这两种写法都能让脚本在“空目录”场景下依然保持正常。说实话我在大量生产环境脚本里看到的问题一半以上都和环境里的“某个文件不存在”有关提前处理好空匹配能省下很多半夜被叫醒的麻烦。6.3 循环里条件判断太随性continue 和 break 用得乱continue 和 break 用起来不难但真的有人在循环里副带令人看不懂的条件。比如下面这种每个简单的事情都要绕一下for i in {1..20}; do if [ $((i % 2)) -eq 0 ]; then continue else echo 处理 $i fi done这里的else其实是多余的continue 之后根本不会走到 else。写成这样读代码的人得多看两眼才能确定逻辑。我一般会写成for i in {1..20}; do [ $((i % 2)) -eq 0 ] continue echo 处理 $i done同理break 的时机也要清晰。我见过有人在多层嵌套里用多个 break结果只看代码根本不知道是跳出哪一层。遇到这种情况我会先重构拆分函数而不是靠 break 标签去硬撑。还有一个容易踩的坑是break 之前忘记处理本轮循环的“最后状态”。比如你有一个循环在批量上传文件某次上传失败后你直接 break结果前面的文件已经传上去了后面的还没传。下次脚本再跑要么重复传要么漏传。正确做法是break 前先记录已处理进度脚本支持续跑。保持这种意识比循环写得多花哨重要得多。6.4 算术运算和变量自增别被整数陷阱带偏Shell 循环里变量自增是一个高频操作。写成i$((i1))、((i))都可以但千万要注意Shell 的算术运算只支持整数如果你在循环里做除法或者取小数结果会被直接截断。for ((i1; i5; i)); do percent$((i * 100 / 5)) echo 进度: $percent% done这段没问题因为 100 和 5 都是整数。但如果你要算 1/3想得到 0.333就必须借助awk或bc了。我见过有人在循环里用$((1/3))输出 0还找半天找不到原因。另外i和i在 Shell 的(( ))里都能自增但作为循环条件时要注意是否被当成返回值判断。写while (( count 10 ))之类的玩容易把自己绕晕不如老老实实while [ $count -lt 10 ]; do count$((count1)); done。6.5 循环里频繁调用外部命令导致脚本慢到怀疑人生最后讲一个性能相关的坑。Shell 循环本身并不算慢慢的是在循环体里反复启动外部进程。每调一次外部命令就要创建新进程、加载程序、执行、再退出这个开销在循环次数达到几千甚至几万时会变得非常明显。我处理过一个实际案例脚本要遍历一个文件列表对每个文件执行sed替换再执行grep校验。文件有近万个脚本跑了几个小时。后来我把这段逻辑改成了先合并成一个临时文件再用一次sed -i处理最后用一次grep校验整个脚本跑完不到半分钟。所以如果你发现自己的脚本越来越慢先别急着加sleep或者上定时任务而是数一数循环体里有没有高频的外部命令。如果只是做文本处理、字段切割尽量让 awk、sed 一次性搞定如果需要并行处理一批任务可以用xargs -P拉起多个进程。这些技巧比单纯换一个循环结构对性能的提升要明显得多。7. 几个可以直接抄的循环实战模板7.1 批量重命名文件并记录日志shopt -s nullglob mkdir -p /data/backup/rename_log for f in /data/input/*.txt; do base${f%.txt} new${base}_$(date %Y%m%d).bak cp $f $new echo $(date): $f - $new /data/backup/rename_log/rename.log done这里我用了${f%.txt}做后缀替换比用sed再组装路径安全得多因为文件名里可能包含各种特殊字符用参数扩展能最大程度保留原名。7.2 轮询等待文件生成并触发处理wait_file() { local target$1 local max_retry${2:-30} local retry0 until [ -f $target ]; do retry$((retry 1)) if [ $retry -ge $max_retry ]; then echo 等待超时$target return 1 fi sleep 2 done echo 检测到文件$target return 0 } if wait_file /data/sync/import.done 30; then echo 开始导入数据... else echo 暂停处理稍后重试 fi这个函数用了local声明局部变量避免影响外层脚本的变量空间。每次重试都有次数上限不会无限卡死。7.3 按行读取 CSV 并跳过表头while IFS, read -r name city age; do if [ $name 姓名 ]; then continue fi echo 用户$name城市$city done /data/export/users.csvCSV 文件如果格式简单用IFS, read拆字段非常方便。要留意的是如果字段本身包含转义的逗号这种写法就不适用了那时要直接用awk -F更稳。8. 用一个小练习检验你的循环理解如果你看完前面这些内容不确定自己是不是真的理解了三种循环的区别可以试着做这个小练习写一个脚本每隔 5 秒检测一次某个目录里的.ready文件是否存在10 次之后还没出现就给用户提示并退出如果出现了创建一个.processed标志文件并遍历该目录下所有.txt文件逐行统计行数最后把所有行数累加起来输出。这个练习里会用到until或者 while 取反用来等待.readyfor用来遍历.txt文件while read用来逐行统计能把三个循环结构都用上并且注意好空文件、变量作用域、退出时机这些细节那你这篇关于循环的功课就算过关了。我在练习自己的脚本能力时经常干这种“一个场景里强逼自己用三种结构”的事比翻来覆去看语法文档管用得多。Shell 循环看起来简单真正用得顺手靠的是对业务条件的理解和边界情况的敏感。希望这篇东西能帮你少踩几个我当年的坑。
延伸阅读

更多相关文章

2026/10/23 7:19:10

TCP粘包彻底解决:双缓冲区拆包器设计与C语言工程实现

粘包这个话题,前一篇里我聊过成因,也对比过定长包、分隔符、长度域几种常见拆包思路。今天这篇专门讲双缓冲区解决粘包——名字听起来基础,但实际工程里反而是最容易出bug、也最值得掰开揉碎讲透的部分。简单说,TCP给应用层的只有…

2026/10/23 7:14:10

内网离线安装deb包:APT本地仓库构建与依赖排错

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/23 7:14:10

Win10安装.NET Framework 3.5失败的根源与四阶排错法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/23 9:24:36

声音肌肉训练:8类演播场景的声学参数化训练法

简介:本资源是面向声音创作者、配音初学者及演播进阶学习者的专业练习素材合集,源自喜马拉雅大学体系,聚焦朗读表达、角色塑造与情绪张力等核心能力训练。PDF文档整合了历史、悬疑、恐怖、奇幻、经管、都市、言情、儿童共八大类演播练习文本链…

2026/10/23 9:24:36

Redis集群搭建实战:Ubuntu 16.04单机与集群环境配置及投票后端验证

简介:本资源为南京邮电大学大数据存储与管理课程实验一的完整报告文档,面向大数据、云计算方向的学生及Redis初学者,帮助读者在Ubuntu环境下完成Redis单机与集群的安装配置,并动手实现一个文章投票网站后端。压缩包内仅含1个doc文…

2026/10/23 9:24:36

发卡网源码核心逻辑与自动发货部署实战解析

简介:最新发卡系统网站源码是一套修复版发卡网完整源代码,面向需要搭建虚拟商品自动售卖平台的运营者,以及想学习PHP商城开发的工程师。系统覆盖商品管理、订单处理、支付集成、用户管理和安全防护等核心模块,并通过定时脚本清理过…

2026/10/23 9:24:36

VMware 虚拟机安装 Ubuntu 搭建 ROS 开发环境完整教程与避坑指南

简介:这份PDF教程面向ROS机器人开发的入门学习者,聚焦机器人学习环境搭建中最基础也最容易踩坑的一环——Ubuntu系统的安装。内容从Ubuntu镜像的下载与位数选择讲起,逐步延伸到虚拟机软件的安装配置、虚拟机创建与参数设置、Ubuntu镜像加载与…

2026/10/23 9:24:36

PLC标准化编程实战:从架构设计到IO映射的八步法

简介:这份PDF资料面向工业自动化工程师与PLC编程学习者,聚焦S7-1200/S7-1500平台的标准化编程方法与设计,帮助解决程序严谨性差、可复制性低、技术提升难等常见痛点。内容围绕程序结构规划、变量命名规则、设备对象分解与状态模型构建展开&am…

2026/10/23 9:19:34

RISC-V AI全栈软件生态构建与优化实践

1. RISC-V与AI全栈软件生态的现状与挑战RISC-V作为开放指令集架构,近年来在AI领域展现出独特优势。与传统x86和ARM架构相比,RISC-V的模块化设计允许开发者根据AI工作负载特点定制指令集,这种灵活性为AI加速提供了新的可能性。但在实际落地过程…

2026/10/22 11:29:28

金融AI落地指南:从调研报告到技术选型与工程实践

简介:这份PDF报告聚焦2025年金融服务业中国AI现状与趋势,面向金融科技从业者、AI战略规划人员及关注行业数字化转型的研究者,帮助读者把握生成式AI在银行、支付、交易等场景中的落地路径与投资方向。资源包内含1个PDF文件,大小约1…

2026/10/23 0:02:26

Verilog实现FPGA密码锁:矩阵键盘与状态机设计实战

说实话,这几年接触了不少想入门FPGA的朋友,如果你问哪个项目能一口气把Verilog的核心基本功串起来,我通常会推荐电子密码锁。这个项目看着不起眼,却把矩阵键盘扫描、按键消抖、状态机设计、动态显示刷新、外设控制这些数字逻辑里的…

2026/10/23 0:02:26

数据服务监控运维实战:从指标体系到告警治理与故障排查

周末凌晨两点,手机震了。第一反应是“数据服务挂了”。打开电脑看了下,果然,某张核心报表的数据产出时间已经超过SLA两小时,下游三个业务方连着发来问询。这种场景,做过大数据运维的人都不会陌生。数据服务的监控与运维…

2026/10/23 0:02:26

FreeRTOS任务通知实战详解:内核原理、API与避坑指南

裸机编程切到RTOS之后,任务之间的通信和同步永远是躲不开的坎。前面聊过队列、信号量、互斥锁、事件组,这次把FreeRTOS任务通知(Task Notification)单独拎出来讲透。原因很简单:任务通知是FreeRTOS里最容易用错、也最容…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑