写Shell脚本最烦什么我猜十有八九是改一个文件再改第二个再改第三个这类重复劳动。我第一次被Shell循环打动是当时要给几十个配置文件的同一位置插入一行参数。手动改到第三个文件的时候我停下来想这活儿不该这么干。于是第一次认认真真写了一个for循环三行代码跑完收工。那一刻我才意识到循环不是Shell的一个语法点而是Shell脚本的灵魂。这篇文章就把我这些年写循环的经验整理出来从语法细节到实战用法再到我踩过的坑和性能优化思路一次性说透。写给刚开始接触Shell脚本的同学也写给已经写了一阵子但总觉得循环里有些行为怪怪的朋友。1. 循环的真正价值从重复劳动到自动化1.1 一个真实场景给50台服务器更新配置先说一个最典型的场景。假设我手里有一批服务器IP列表放在server.list文件里每行一个IP。我需要登录每台机器修改/etc/ssh/sshd_config里的某个参数然后重启服务。没有循环的时候我只能一台一台敲50台就是50遍中间还容易漏。用while循环加read整个任务变成这样while read ip; do ssh user$ip sed -i s/#MaxAuthTries 6/MaxAuthTries 3/ /etc/ssh/sshd_config; systemctl restart sshd done server.list这个例子虽然简单但它把两个核心能力组合在了一起一是逐行读取文件二是对每一行执行一组命令。循环在这里做的事情本质上是一个迭代器——把重复的工作抽象成规则剩下的交给机器。类似的应用还有批量创建用户、批量推送公钥、批量备份日志、批量清理临时文件。你会发现只要出现了对一批东西做同一件事的需求循环就是最自然的表达方式。1.2 循环选型第一课什么时候用for什么时候用while很多初学者一上来就问for和while有什么区别我用一句话回答for循环强在遍历已知列表while循环强在等待条件变化或逐行消费输入。这不是教条而是两种循环在设计目标上的本质差异。for循环适合的场景遍历一组文件名、遍历一个数组、遍历一段数字序列。比如for file in *.log; do echo 处理 $file done这里*.log先被Shell展开成具体的文件名列表然后for逐个赋值给变量。这个过程是先有完整列表再依次处理。while循环适合的场景从文件逐行读取、监听某个进程的退出状态、轮询等待某个接口可用、写一个永不退出的守护式循环。它的特点是每次迭代都依赖上一轮的状态变化所以天然适合处理流式输入和条件监控。until循环则是while的镜像while是条件为真就继续until是条件为假才继续。实际用得少但在等待一个动作完成的场景里有奇效。我把三者的选择逻辑总结成一个表格方便对照循环类型适用场景退出时机典型例子for列表已知遍历处理列表耗尽批量重命名文件while条件驱动逐行消费条件变为假读文件、轮询状态until等待条件达成条件变为真等待端口开放2. 三种循环的语法细节与执行逻辑2.1 for循环的四种写法for循环的写法比很多人想象中多。我见过最混乱的脚本一屏里混着三四种写法阅读体验极差。但其实只要掌握下面几种就够了。第一种遍历显式列表for name in alice bob charlie; do echo hello, $name done第二种遍历通配符展开的结果for conf in /etc/nginx/conf.d/*.conf; do echo 检查 $conf done这里有个细节如果/etc/nginx/conf.d/下没有任何.conf文件Shell会把字面字符串/etc/nginx/conf.d/*.conf直接传给for而不是报错。所以严谨的脚本会在前面先判断是否存在匹配shopt -s nullglob for conf in /etc/nginx/conf.d/*.conf; do echo 检查 $conf donenullglob打开之后没有匹配时列表就为空循环体一次都不执行。这个开关很小但能在自动化脚本里救你一命。第三种C语言风格的数字循环for ((i1; i10; i)); do echo 第 $i 次迭代 done这种写法适合明确知道循环次数、并且需要用到递增计数的场景。注意两个括号中间的空格不能随意省略写成for((i1...))在部分Shell版本也能跑但为了可读性我建议保留空格。第四种遍历命令替换的结果for line in $(cat urls.txt); do echo url: $line done这个写法能跑但我要提醒一句$(cat urls.txt)会按空格和换行做单词拆分如果文件里有一行内容包含空格就会被拆成两段。后面章节我会专门展开讲这个问题。2.2 while循环与read组合逐行处理文本的正确姿势逐行读文件是Shell脚本里最高频的需求之一。标准写法是while IFS read -r line; do echo 读到: $line done input.txt这里有几个不起眼但至关重要的细节。IFS的意思是清空字段分隔符。read默认会用空格、制表符、换行符作为分隔符把读到的内容拆成多个字段。如果把IFS清空read就会把整行原样塞进变量行首行尾的空格也得以保留。这在处理带缩进的配置文件时非常有用。-r参数禁止read对反斜杠做转义处理。如果不加输入里的\n字符串会被当成换行符处理配置文件里的路径一旦包含反斜杠就全乱了。-r是逐行读取的标配没有特殊理由不要省。还有一点经常被忽略这个while是在子Shell里执行吗不是的。因为重定向符号 input.txt是加在while关键字后面而不是管道的右侧。如果写成cat input.txt | while ...那这个while就是在子Shell里跑的循环里对变量的修改循环结束后全部丢失。这个坑我后面专门说。2.3 until循环与退出码思维until循环的核心价值在于等待一个预期的结果。比如等一个服务端口从关闭变到开启until nc -z 127.0.0.1 8080; do echo 端口还没开继续等... sleep 2 done echo 服务已就绪nc -z只探测端口通不通成功时返回退出码0失败时返回非0。until的行为就是只要命令返回非0就继续循环直到返回0才停止。所以你要等待的目标本质上就是让某个命令变成成功的状态。这个思维的妙处在于你可以把任何探测动作封装进until里。比如等一个文件出现until [ -f /tmp/ready.flag ]; do sleep 1 done再比如等一个进程退出until ! pgrep -f batch_job.py /dev/null; do echo 任务还在跑等待中... sleep 5 done这里!对退出码取反这样until就变成了当进程不存在时停止循环。写多了你就会发现循环条件并不只能写文件判断、数字比较任何命令都可以作为条件只要它返回值对得上。2.4 break、continue、exit的作用域与循环控制循环控制有三个关键字经常混用break、continue、exit。它们的区别可以直接决定脚本行为尤其要注意嵌套循环时代码跳出到哪里。break跳出当前这一层循环。continue跳过本次迭代进入下一次。exit退出整个脚本进程后续所有代码都不执行。嵌套循环里break只会跳出一层。如果想跳出两层循环可以用break 2。数字表示退出多少层for ((i1; i3; i)); do for ((j1; j3; j)); do if [ $j -eq 2 ]; then break 2 fi echo $i - $j done done上面这段代码只会输出1 - 1然后直接跳出两层循环。continue也有类似用法continue 2但实际场景里用得比break 2少得多。exit虽然不属于循环控制关键字但我在循环里经常用它来发现问题时直接终止整场任务。比如批量处理文件时发现磁盘满了继续跑下去只会制造更多半成品文件这时候exit 1就是最合理的选择。3. 实战案例文件遍历、批量处理与文本解析3.1 批量重命名文件批量重命名是我认为最能体现循环价值的入门例子。假设一个目录里有大量photo_2023_*.jpg文件我要把年份从2023改成2024。直接用mv手动改名显然不现实写个循环就清晰了for file in photo_2023_*.jpg; do newname${file/2023/2024} mv $file $newname echo 重命名: $file - $newname done${file/2023/2024}是Shell的字符串替换语法只替换第一个匹配。循环里每次迭代只处理一个文件逻辑简单出错了也容易定位。需要提醒的是所有变量引用必须加双引号否则文件名带空格时mv会收到被拆开的参数命令直接失败。如果重命名规则更复杂比如把扩展名从.txt改成.md同时把文件名改成小写可以用参数扩展结合trfor file in *.TXT; do basename${file%.TXT} newname$(echo $basename | tr A-Z a-z).md mv $file $newname done循环在这里的价值是让每个文件的处理逻辑互相独立新增规则只要在循环体里加一行。3.2 遍历目录与嵌套循环遍历目录是另一个频繁需求。只处理当前目录下的文件当然简单但如果要递归子目录情况就复杂了。一个朴素的方案是用嵌套forfor dir in */; do echo 进入目录: $dir cd $dir || continue for file in *.conf; do echo 处理: $dir/$file done cd .. done这个方案能跑但cd来cd去容易出错一旦某个目录cd失败后面全乱。所以我会换一种思路用find先把文件列表生成好再交给for循环while IFS read -r -d file; do echo 处理: $file done (find /etc -name *.conf -type f -print0)这里用-print0让find输出以空字符分隔的文件名再配合read -d 逐行读取。这样做的好处是文件名不管带空格、换行还是特殊字符都不会被错误拆分。 (...)的写法叫进程替换它让while在当前的Shell进程中执行循环内的变量修改不会丢失。这个组合我几乎天天用它是处理任意复杂文件名列表的最稳妥姿势。3.3 用循环读文件逐行处理逐行处理文本时最常见的是处理日志、CSV、配置清单。比如一个简单的日志分析需求从access.log中提取所有返回码为500的行并统计次数。count0 while IFS read -r line; do if echo $line | grep -q 500 ; then count$((count1)) echo $line errors.log fi done access.log echo 500错误总数: $count这个例子把读取、过滤、累加、写文件串在一起。每行处理逻辑都写在循环体里方便后续加复杂判断。但要说性能最优同等场景直接用grep加wc -l更快grep 500 access.log | wc -l所以这里有个取舍如果只是找出符合条件的行用管道命令一行搞定如果每一行还需要额外的逻辑处理才用循环。循环的价值不在炫技而在表达逐行执行一系列规则的能力。3.4 循环里跑grep统计日志与提取关键信息把grep放进循环里是Shell脚本里非常常见的组合。比如我要统计一批日志文件中各包含多少条错误信息for logfile in /var/log/app/*.log; do error_count$(grep -c ERROR $logfile) echo $logfile: $error_count 条错误 donegrep -c直接输出匹配行数不需要再套一层wc。这个脚本可以快速对比多份日志的错误密度。还有另一个高频用法循环遍历多个关键词统计每个关键词的出现次数keywords(timeout refused fail) for kw in ${keywords[]}; do cnt$(grep -c $kw /var/log/backend.log) echo 关键词 $kw 出现 $cnt 次 done注意这里数组遍历的写法${keywords[]}一定要把整个数组展开成多个独立的词。如果写成${keywords[*]}所有元素会被合成一个字符串循环只会执行一次。这个区别我在写脚本时栽过跟头后来凡是数组遍历一律用[]。4. 循环中的坑与性能陷阱4.1 for循环通配符不展开的经典坑前面提到过nullglob这里展开说一下。默认情况下如果一个通配符没有匹配到任何文件Shell不会报错而是把通配符本身当作字面字符串。于是你的脚本可能静默处理了一个根本不存在的文件而且完全无感知for f in /tmp/nonexistent/*.txt; do rm $f done如果匹配不到任何文件f的值就是字面字符串/tmp/nonexistent/*.txtrm会收到一个不存在的路径然后报错。表面上是脚本bug实际上是通配符展开的语义问题。对策一是直接判断shopt -s nullglob files(/tmp/nonexistent/*.txt) if ((${#files[]} 0)); then for f in ${files[]}; do rm $f done finullglob让未匹配的列表变成空数组然后通过数组长度判断是否进入循环。这个模式值得记住复杂脚本里先用数组接收列表再遍历数组比直接用for接通配符更容易控制边界情况。4.2 文本文件最后一行没有换行符导致漏读用while read读文件时如果文件的最后一行没有换行符部分环境下会漏读。这个问题的根源在于read是按行分隔符来判定一行的没有换行符的内容读不到完整行。解决的惯用方法是这样while IFS read -r line || [ -n $line ]; do echo 处理: $line done input.txt|| [ -n $line ]的意思是当read因为EOF退出时如果line里还有内容说明这是最后一段没有换行符的数据也要处理。这个细节解决了很多脚本最后一条数据丢失的诡异现象属于那种查了半天查不出来、最后发现是换行符问题的情况。4.3 管道子Shell导致的变量丢失这个坑我会重点提醒因为它太容易踩了。先看一段看起来没问题的代码count0 cat server.list | while read ip; do count$((count1)) done echo 共处理 $count 台服务器很多人以为会输出服务器的数量实际输出是0。原因在于管道两侧是在子Shell里执行的。整个while循环在子进程里跑循环里对count的修改只存在于那个子进程的内存空间中。循环结束子进程销毁count还是原来的0。最常见解法是用进程替换让while在当前Shell里执行count0 while read ip; do count$((count1)) done (cat server.list) echo 共处理 $count 台服务器 (cat server.list)是对 server.list的推广——如果数据来源不是一个文件而是一条命令的输出就用进程替换来避免子Shell问题。这个解法的好处是循环内外的变量数据共享代码修改最小。另一个思路是启用lastpipe选项但这个选项只在作业控制关闭的非交互Shell里生效可移植性不如进程替换。4.4 性能陷阱循环次数多的时候怎么提速Shell循环的处理效率远低于其他语言这几乎是所有Shell脚本性能问题的天花板。如果只是几百上千次迭代体验不明显一旦进入几万、几十万的量级慢得让人抓狂。这时候你要优先考虑的不是优化循环语法而是减少循环本身。能交给awk处理的就交给awk。比如对文件的每一行做字段提取与统计awk一次搞定不要去写while循环# 低效 while read line; do ip$(echo $line | awk {print $1}) cnt$((cnt1)) done file.log # 高效 awk {print $1; count} END {print count} file.log能批量处理的就不要一条条跑命令。比如要复制100个文件与其在循环里跑100次cp不如用一次tar或rsync批量完成。如果你确实需要在循环内部执行不可拆分的命令能减少循环次数的先减少次数。比如先过滤再循环不要让循环负责过滤# 低效每行都跑一次grep while read line; do if echo $line | grep -q ERROR; then ... fi done app.log # 高效先过滤再处理 grep ERROR app.log | while read line; do ... done注意上面第二个写法里的while又在子Shell里了。如果循环体内需要更新外部变量改回进程替换写法即可。逻辑优先还是性能优先由实际需求决定。5. 从会写到用好工程化思维与进阶工具5.1 循环里的错误处理与超时控制循环进入实际问题场景后第一件事就是要处理循环体执行失败的情况。裸跑的循环一旦中间某一步出错可能脚本继续往下跑最终留下残缺的数据。合理的做法是在循环体内检查退出码for dir in */; do cd $dir || { echo 无法进入 $dir跳过 continue } echo 开始处理 $dir cd .. done||后面接continue表示进入不了目录就跳过当前迭代。这是一个很实用的模式任何可能失败的命令都用||接上失败后的处理分支。只有当业务上确实允许失败可忽略时才什么都不写。超时控制也是循环脚本不可忽视的一环。比如用while轮询一个接口必须设一个最大等待时间防止脚本无限空转timeout30 elapsed0 while ! curl -s http://127.0.0.1:8080/health; do if [ $elapsed -ge $timeout ]; then echo 等待超时退出 exit 1 fi sleep 2 elapsed$((elapsed2)) done这里用elapsed变量累加等待时间达到阈值就主动退出。很多线上事故就是没有这类保护脚本挂在某个细节上跑了一整夜。5.2 进度显示与日志记录循环跑很久的时候如果没有输出人根本不知道脚本卡在哪里还是正常运行中。我给循环加进度信息的原则是每处理一个单位输出一行简短状态并写到日志文件。logfilebatch_$(date %Y%m%d).log index0 total$(ls files/*.tar | wc -l) for archive in files/*.tar; do index$((index1)) echo [$index/$total] 处理 $archive ... | tee -a $logfile tar -xf $archive -C /data/unpacked/ || { echo [错误] $archive 解压失败 | tee -a $logfile } done echo 全部完成日志: $logfiletee -a同时把信息打到屏幕和日志文件方便实时观察也留底。我在写超过几分钟的循环任务时一定会加上这个机制。不然一旦脚本半夜报错你两眼一抹黑只能从头再来。5.3 并行化思路让循环利用多核能力Shell循环默认是单线程的处理一批互相独立的任务时浪费了多核处理器的能力。最简单的并行化手段是使用后台任务加waitfor server in $(cat server.list); do ssh user$server uptime done wait echo 所有服务器查询完成把每个任务丢到后台wait等待所有后台任务结束。这样SSH的耗时就不再是累加的而是并发执行。但直接这样写有一个隐患如果任务数量非常大比如几百个同时启动几百个后台进程会导致系统负载飙升。这时候要控制并发数。一个常见的做法是用令牌思想——用一个队列文件控制同时最多跑几个max_jobs10 for task in $(cat jobs.list); do # 当前后台任务数达到上限时等待任意一个完成 while [ $(jobs -r | wc -l) -ge $max_jobs ]; do sleep 1 done do_work $task done wait echo 全部完成jobs -r统计当前正在运行的后台任务数达到阈值就先等待。这个方式很好理解缺点是sleep 1的粒度不够精细。更稳的方案可以用xargs -Pcat jobs.list | xargs -P 10 -I {} do_work {}xargs -P 10直接控制并发10个进程代码更短也没有睡眠轮询的问题。所以我给的建议是小任务量用加wait大任务量优先考虑xargs -P。实际项目里能用xargs -P解决的不要自己造轮子。5.4 从循环到xargs与awk的思维升级到这里你基本已经能写出健壮的循环脚本了。但我建议你也留意一条升级路径能用管道组合命令时优先考虑替代循环的写法。我见过很多脚本明明一行find加xargs解决的事情非要写十行for循环性能和可读性都吃亏。一个典型的对比批量删除.tmp文件。循环写法for f in *.tmp; do rm $f done一行替代find . -name *.tmp -delete另一个例子从文件里提取所有失败的IP并去重。循环写法要好几行用管道则是grep FAIL app.log | awk {print $1} | sort -u这不是说循环没有用而是说循环应该用在真的要处理每一个对象、每一步都有独立逻辑的场景。当任务只是一个过滤变换聚合的流水线时管道本身就是一种更高层的循环。我自己的习惯是写循环之前先停下来想想这个任务能不能用一个管道解决。如果不能再把循环拿出来。这个思考三秒钟的步骤长期来看能帮你省下无数调试时间。对我们这些天天和Shell打交道的人来说循环编程的水平基本决定了脚本的可靠程度。语法本身并不复杂真正拉开差距的是对细节的把控——通配符的展开规则、子Shell的变量隔离、文件名的特殊字符处理、循环退出条件的边界。这些年写脚本踩过的坑几乎都集中在这些细节里。我分享的这些经验如果能帮你在调试时少掉几根头发这篇就没白写。后面你碰到奇怪的循环行为也可以顺着这几种坑的方向去排查多半能定位到问题。
