批量改文件名、批量压缩日志、批量探测端口这些活儿你只要干过一次就不会想再手动一条条敲命令了。想把这类需求自动化绕不开shell编程里的循环语句。我见过不少同事写if判断、变量替换都很利索一到循环就卡壳——要么不知道该用for还是while要么写出来的循环一跑就陷入死循环要么循环体里的变量打印出来是对的循环一结束就变成空值。这篇文章我围绕shell循环语句的三种基本形态——for、while、until——把语法差异、选型场景、常见陷阱一次讲透。内容面向已经会写基础shell命令、但批量处理经验不足的人也适合脚本写完但调试循环时被各种隐性规则坑过的朋友。我不打算只罗列语法因为我一直觉得循环语句本身不难难的是理解它背后的执行规则和边界问题。1. 先想清楚三种循环到底在解决什么不同的问题很多教程一上来就甩语法结果读者背了半天真到写脚本时还是不知道用哪个。我在实际工作中的经验是三种循环各自服务的场景完全不同先理解它适合解决哪类问题语法自然就好记了。1.1 for循环先有清单再逐个处理for循环的本质是遍历一个已经存在的列表。这个列表可以是空格分隔的字符串、一个数组、一个文件通配符展开的结果甚至是一段命令的输出。因为列表在循环开始前就已经确定了循环体里不需要手动维护计数器也不会因为边界条件写错而出现无限循环——列表总共就那么长遍历完自然结束。我做批量操作时最先考虑的就是for。比如要给一批日志文件统一加上备份后缀思路就是先把文件名列表展开再逐个改名。这种场景的关键在于你完全知道要处理什么只需要循环体里拿到当前这一项然后做同样的操作。用for写出来的脚本读起来也像一张任务清单对着清单一项项打钩清单打完工作结束。1.2 while循环边判断边处理while就完全不一样了。while在循环开始前判断一个条件是否成立成立则继续不成立则退出。它适合处理那些你事先不知道次数、也不知道长度的场景。最典型的就是逐行读文件——你只知道有文件要读但不知道里面多少行也不需要事先统计一边读一边处理。另一个经典场景是轮询启动服务后每隔几秒检查端口有没有起来起没起来是个条件次数不可预知这就是while的舞台。用生活化类比来讲for像一份已经打印好的待办清单你按顺序打钩清单打完收工while像一个水龙头你一边开着一边观察水位没到预期就继续放水。二者没有谁更高明只有选得对不对。我见过有人非要用while去遍历一个固定列表结果手动维护自增变量、判断越界写得又长又容易错其实改用for一行就完了。1.3 until循环等一个条件从假变真until和while唯一的区别是语义反转while是条件为真时继续until是条件为真时停止。也就是说until要的是条件还没成立的这段区间。说实话until在实际脚本里用得最少但某些场景它就是最自然的写法。最典型的是等待文件出现。要等某个标志文件生成后再继续用while要写while [ ! -f flag ]; do sleep 2; done这里的逻辑是只要文件不存在就一直等稍微要转个弯。用until写是until [ -f flag ]; do sleep 2; done直接就是直到文件存在为止非常符合人类直觉。在部署脚本、监控脚本里用到这种等待条件达成的逻辑时我会优先用until读代码的人不用停下来想那个取反符号。2. for循环的四种常见写法与选型逻辑for循环虽然概念简单但具体写法有讲究。我梳理了四种高频写法以及每种适合什么场景这部分建议直接收藏。2.1 列表遍历最直观的for用法for循环最通用、最朴素的形态是这样for item in item1 item2 item3; do echo $item done这里的列表并不是严格意义上的数组而是由空格、制表符、换行符切分出来的一个个词。新手特别容易忽略这一点循环体里拿到的每一项怎么切分完全取决于shell的字段分隔规则。举个常见例子namesalice bob carol for name in $names; do echo $name done这段代码能正常输出三个名字。但如果写成for name in $names输出就只剩一行alice bob carol。区别就在于双引号让shell把整个变量当成一个整体循环只遍历了一个项。这是我评审同事脚本时见到的最高频问题之一很多莫名其妙的循环异常根子都在这里。2.2 C风格for需要精确控制步长和变量时需要数值从1到N逐步迭代并且对步长有精确控制时bash提供了C风格的for写法for ((i1; i10; i)); do echo $i done注意这里的方块是双层圆括号不是中括号。循环变量不需要加$前缀条件判断直接用这样的数学运算符而不是test命令的-le。这种写法跟C语言几乎一样适合做数值累加、倒计时、分页循环。我有时候用它生成过去24小时每隔半小时的时间戳配合date命令定位日志一次循环出来24个时间点比手写方便太多。C风格for还有一个好处可以在循环体内直接修改变量值而不影响循环控制逻辑。比如跳过一个特殊值用continue配合if判断就够了。如果你只是遍历一串数字没有修改变量的需求那用下面的花括号写法更简洁。2.3 花括号展开与seq生成数值序列的两种姿势除了C风格写法bash还提供了花括号展开for i in {1..10}; do echo $i done{1..10}会展开成1 2 3 4 5 6 7 8 9 10这一串列表。它比seq命令轻量因为花括号展开是shell自己的机制不启动外部进程。你可以写{01..10}得到前导零也可以写{a..z}遍历字母还可以写{1..10..2}以步长2生成奇数。不过要提醒一句步长参数在bash 4及以上才支持脚本如果要在老式/bin/sh环境下跑别依赖它。那什么时候用seq需要根据变量动态生成范围或者需要带步长且要兼容旧环境时就得用它start5 end20 for i in $(seq $start 2 $end); do echo $i done我的选择原则很简单纯bash脚本里能写{1..10}就写{1..10}干净利落一旦涉及可移植性或者范围来自变量且需步长直接换seq。不用担心seq多一次外部进程开销它只在循环开始前执行一次对整个脚本的性能影响微乎其微。2.4 遍历通配符文件与命令行参数for循环最实用的一类场景是配合通配符遍历文件名for file in /var/log/myapp/*.log; do echo 处理 $file done这里的*由bash展开成匹配到的所有文件名循环体里拿到的是完整路径。这个写法有一个非常关键的边界问题如果没有任何匹配文件通配符会原样保留循环体里就会跑一个不存在路径的迭代。后面我会专门讲处理办法这里先记住这个坑。另一个高频场景是遍历脚本参数for arg in $是标准写法双引号保证参数里的空格不被错误拆分。如果你要同时拿到参数序号就改成for arg in $自己的索引版本或者在循环外先把位置参数数组展开。这两个场景覆盖了绝大多数日常脚本需求。3. while读文件的三类写法与子shell陷阱while在脚本里出现频率最高的场景就是逐行读文件。而这个场景也恰恰是shell脚本里最著名的坑之一我单独用一章来讲。3.1 为什么管道右侧的while会丢失变量先看这段代码count0 cat /etc/passwd | while read line; do count$((count 1)) done echo 总行数: $count很多人第一次跑的时候会惊讶循环明明执行了最后count还是0。问题出在管道上。管道两边的命令默认在独立的子shell中执行while被放在管道右侧它就运行在子shell里。循环体内对count做的任何修改只存在于那个子shell里。子shell结束修改也就一起消失了。打个比方主shell是厨房子shell是送餐员。送餐员可以帮你把菜送到客户手里但他不可能替你把厨房收银台的钱重新点一遍——收银台根本不属于他管理的范围。你指望他回来后告诉你钱数变了当然不可能。要规避这个问题最简单的思路是别让while变成管道右侧的接收端而是用输入重定向把文件直接交给while下面就是具体做法。3.2 用输入重定向喂while read line推荐写法一count0 while read line; do count$((count 1)) done /etc/passwd echo 总行数: $count这里的 /etc/passwd意思是while循环整体的标准输入来自文件。因为while不再处于管道右侧整个循环都运行在当前shell进程里循环体里修改的count在循环结束后依然有效。这是我在生产脚本里最常使用的形态简单、可靠、不绕弯。推荐写法二用进程替换count0 while read line; do count$((count 1)) done (cat /etc/passwd) echo 总行数: $count注意这里的(...)是进程替换的语法它看起来跟管道很像但bash处理它的方式不同循环本身依旧运行在当前shell不会钻进子shell黑洞。这种写法在处理先对输出做某种加工再逐行读取时特别顺手。至于cat file | while read line这种管道写法我建议无论什么情况都不要在生产脚本里用。就算你确认不需要保留循环内变量可读性和排查成本也更高没必要为了少敲一个字符埋下隐患。3.3 用进程替换兼顾命令输出和保留变量进程替换的典型应用是从一条命令的输出里逐行读取同时还要把循环内的统计结果留在循环外。我举个日志统计的例子error_count0 while IFS read -r line; do case $line in *ERROR*) error_count$((error_count 1));; esac done (zgrep -E ERROR /var/log/myapp/app.log.gz) echo 错误次数: $error_count这个脚本直接在压缩日志里搜关键字不需要解压到磁盘zgrep的输出通过进程替换进入while循环循环内统计的error_count在循环结束后依然有效。我在日志分析脚本里用过很多次可靠而且高效。这里的IFS read -r line也是逐行读取的固定搭档IFS防止行首尾空格被吃掉-r防止反斜杠被转义这两个细节能让read处理内容更准确。如果再进阶一点需要同时读两个文件时还可以用自定义文件描述符while IFS read -r line1 IFS read -r line2 3; do echo $line1 | $line2 done file1 3 file2这种写法属于偏门技巧我在这儿提一句主要目的是让你知道while读文件的路子非常宽遇到复杂需求时别被常规写法限制住。4. break、continue、exit循环控制与防死循环循环一旦嵌套或带条件复杂化靠break、continue、exit三个关键字来收放。很多人只会最简单的那层用法遇到实际问题就会绕远路。4.1 多层嵌套里break和continue的层级参数break默认只跳出最内层循环continue默认只跳过当前最内层循环的本次迭代。但bash支持在break和continue后面跟一个数字表示跳出的层数。比如break 2就是从两层嵌套里直接跳出continue 2则跳到外层循环的下一轮。看一个具体场景for host in server01 server02; do for port in 80 443; do if timeout 1 nc -z $host $port /dev/null 21; then echo $host:$port 开放 else echo $host:$port 关闭 break 2 fi done done这里用break 2是因为某个主机第一个端口就不通再继续探测第二个端口已经没意义了直接跳两层退出整个探测任务。如果只用break脚本还会继续跑外层的下一个主机很可能不是我们想要的行为。这种场景下break加数字比设置flag再逐层判断要清晰得多。不过我的建议是嵌套超过两层就尽量别这么用了后面维护代码的人读起来太费劲三个月后的你自己也一样。4.2 exit用在什么位置才算止损exit是直接结束整个脚本它不是专为循环设计的但经常出现在循环的错误处理里。关键问题在于什么时候该用exit我的判断标准很明确——看错误的影响范围。如果错误只是单条数据的异常比如一行日志格式不对、某个文件权限有问题优先continue跳过同时把异常记录到日志如果连续失败次数达到阈值说明当前整个批次可能都有问题用break退出循环如果遇到资源不可用、磁盘满、配置缺失这类全局性错误继续跑下去只会污染后续全部结果甚至让数据越改越乱这时候才用exit快速止损。很多新手喜欢在循环里一遇到非零返回码就exit结果批处理任务跑到一半就停后面正常的数据全没处理。我在评审脚本时经常建议把单条失败和全局失败分开处理用fail_count控制退出时机而不是一棍子打死。4.3 防死循环的几个固定套路死循环最常见的原因是条件永远成立。比如while true; do ... done本身没问题问题往往是循环体里没有退出机制或者退出机制的条件写错了。我见过一个脚本原本是重试次数超过10就break结果计数器放在循环体最后一行而循环体中间有个continue把计数器跳过了导致计数永远卡在1循环永远跑不完。我处理这类问题有几个固定套路也分享给你凡是等待类的while循环都加最大循环次数限制比如count -lt 100作为条件之一涉及命令执行结果判断的先确认命令在异常情况下到底返回什么别凭感觉写条件循环变量如果你需要在continue之前更新就把更新放到continue之前而不是放在循环体尾部调试阶段在循环入口加一行echo当前迭代次数跑两三遍就能看出逻辑有没有问题比瞪眼读代码快得多。这些套路不复杂但能省下大量排错时间。很多死循环事故其实不是条件写错而是变量更新的位置不对记住continue会跳过循环体尾部代码这个规则能避开一大半问题。5. 嵌套循环、数组组合与批量任务的性能取舍循环写出来能跑只是第一步写得高效、可维护是另一回事。这章聊聊嵌套、数组配合以及最常见的性能杀手。5.1 用两层循环生成服务器×端口组合嵌套循环最典型的场景是生成服务器-端口的组合清单。比如我有一组主机名、一组端口要生成所有探测目标for host in server01 server02 server03; do for port in 80 443; do echo $host:$port done done这个脚本跑完会输出每个主机和每个端口的全部组合。批量探测、批量配置下发、批量数据采集里经常能看到这种模式。写这种嵌套循环时循环变量的命名一定要有意义外层用host内层用port一眼就懂。我最怕看到全用i、j、k嵌套的脚本层数一多别人根本不敢维护。如果需要控制嵌套顺序或者在内层循环里依据外层数据做不同处理把变量传递搞清楚就行。如果两层循环的数据量很大比如几百个主机乘几百个端口要考虑是不是改造成并发任务而不是线性傻跑。5.2 循环和数组/关联数组的配合bash的数组是循环的绝佳搭档。遍历数组时用下标方式最稳妥fruits(apple banana water melon) for i in ${!fruits[]}; do echo ${fruits[$i]} done这里用${!fruits[]}拿到的是下标数组然后通过下标去取值。为什么不用for fruit in ${fruits[]}遍历因为如果数组某个元素本身带了空格比如元素water melon直接用${fruits[]}展开会被当作两个元素处理循环逻辑就出问题了。用下标取值无论元素里带什么特殊字符都不会被二次切分。关联数组字典在bash 4及以上也很常用。遍历键、遍历值、按键查找操作起来思路跟索引数组一样只是下标从数字变成了字符串。循环配合关联数组做统计聚合是脚本里替代awk的轻量方案之一。5.3 循环变慢的三个隐形原因shell循环最大的性能短板是每次迭代都启动外部命令。每次外部命令都要fork一个新进程1000次循环就是1000个进程速度会被拖得很慢。有人喜欢在循环里反复调用date、sed、grep功能是实现了但效率惨不忍睹。解决办法有几类能在循环外算好的提前算好比如日期字符串能用bash内置的参数展开替代sed的尽量用实在需要外部命令的考虑改成批量处理而不是循环处理。另一个拖慢因素是循环体里频繁用管道。一层管道就多一个子shell三层管道叠加资源消耗会被放大。数据能在循环外整理好就不要每轮迭代都从头加工。最后是文本路径的选择。用while read逐行处理大文件read本身效率不算高。如果数据规模很大比如几万行、几十万行循环可能不是最优方案awk、sed一次搞定往往快一个量级如果只是需要并发执行一批任务用xargs的-P参数替代bash循环性能提升非常明显。6. 实战阶段最常栽的四个跟头最后这部分我把这些年实际踩过的坑集中列一下。这些坑在教科书里很少专门讲但几乎每个写过循环的人都会撞上。6.1 文件名带空格时for循环直接炸开假设目录里有文件a b.txt和c.txt你写for f in *.txt; do ... done通配符展开后a b.txt会被当成两个项循环体里的处理逻辑全乱了。这是for遍历文件名最经典的坑。我现在的处理方案是成对使用find -print0和read -d find /data/logs -name *.log -print0 | while IFS read -r -d file; do echo 处理: $file done-print0让find用空字符分隔文件名read的-d 也按空字符读取文件名里再奇葩的字符都不会被错误切分。代价是这种写法是管道形式循环内的变量带不出来。如果必须保留变量就用进程替换配合-print0把管道换成 (find ... -print0)。6.2 空目录、空匹配时循环仍然执行一次这是另一个高频坑。对于for f in /data/backup/*.tar.gz如果目录里一个匹配文件都没有通配符会原样保留循环体依然执行一次f的值就是字面量的星号表达式。在脚本里表现为多做了一次无意义操作某些情况下还可能因为把星号当成真实文件名而出错。标准解法是开启nullglobshopt -s nullglob files(/data/backup/*.tar.gz) for f in ${files[]}; do ... donenullglob让没有匹配时通配符展开为空数组就变成空数组for一次都不会执行。脚本开头加上这个选项就能从根上规避空匹配问题。要注意nullglob只在当前shell有效如果脚本里调用了子shell子shell里的设置要单独再开一遍。6.3 循环变量和外部变量互相串味我见过一个脚本循环变量叫i脚本后面有个全局变量也叫i循环还没结束后面原本要用的计数就被循环覆盖了排查了半天。循环变量是最容易被命名冲突连累的因为i、j、k这类短名字太常见。我的建议是养成用有语义的变量名的习惯。遍历文件用file遍历主机用host遍历端口用port逐行读取用line。这样循环体内部可读性高跟外部变量冲突的概率也大大降低。虽然多敲几个字符但换来的是脚本的健壮性绝对值。6.4 不要在循环里反复计算同一个结果这句是我最后想强调的实战心得。很多人写循环时会把外部信息在循环体内反复获取比如每个文件都执行一次date %Y%m%d取当前日期其实这个值在整个脚本运行期间几乎不会变。正确的做法是在循环外先算好存成变量循环里直接引用。这个习惯不仅能提速还能让日志输出的时间戳统一不会因为某次迭代跨过午夜出现前后不一致的情况。批量处理脚本一旦跑起来就是几百上千次迭代把循环外能算的提前算好性能提升肉眼可见。我在写这类脚本时还有一个习惯所有循环体都有缩进所有do和done都对齐条件判断尽量写成易读的形式而不是把一堆逻辑塞在一行里。循环本身就是流程控制里最容易被改乱的地方缩进和命名规范了后面排查才会轻松很多。循环语句的技巧就这些剩下的就是多写多练遇到问题回来翻翻这几章的思路基本都能找到方向。