1. 循环语句在Shell里到底解决什么问题
我第一次正经考虑学Shell编程,是因为连着加班两个晚上,都在手动处理同一批日志文件。那时候我还在用最笨的办法:打开一个目录,逐个文件grep关键字,记下结果,再打开下一个文件。连续重复了二十多次之后,我停下来问自己:为什么不让计算机替我把这件事重复二十次?
循环语句就是干这个的。Shell脚本里的for、while、until三种循环,配合break、continue这类控制指令,能把你平时手动敲到吐的重复性操作全部自动化。这篇文章适合刚接触Shell脚本的初学者,也适合那些已经能写简单脚本、但总在循环细节上栽跟头的朋友。我尽量把原理、用法、坑都摊开来说,你看完可以直接抄进自己的脚本里用。
1.1 Shell循环和C、Python里的循环差别在哪
很多写过程序的人刚接触Shell循环时会有一个错觉,觉得它就是"另一种语言的for循环"。但实际上Shell脚本的循环更偏"命令流",而不是"数据流"。
C语言的for (i=0; i<10; i++)是在操作内存里的变量,Python的for i in range(10)也是在操作迭代器。但Shell里最常见的循环写法是:
for i in *.log; do echo "$i" done这种写法本质上是在遍历文件系统的路径名,或者说是Shell把通配符展开后的结果逐一交给循环体处理。再加上Shell是一门"每个命令都是进程"的解释型语言,循环体里每跑一次命令都可能有额外的进程开销,所以用Shell处理大批量任务时,性能优化思路和常规编程语言完全不同。
还有一个关键差异:Shell变量默认是全局的、弱类型的,循环里如果不小心改了某个变量,循环结束之后它的值还会残留。这在写长脚本时容易埋雷,我后面专门有一节讲这个坑。
1.2 循环解决的三类典型问题
我自己把Shell循环的用途归纳成三类,这也是平时写脚本遇到最多的情况。
第一类是批量操作。比如给一批文件重命名、批量压缩日志、批量启动服务。这类场景的典型特征是有明确的"输入列表",适合用for循环处理。
第二类是条件驱动。比如等待某个服务启动完成、不断检查某个端口是否监听、每五秒拉一次系统负载。这类场景的典型特征是你不知道要循环多少次,只能靠条件判断是否退出,最适合while循环。
第三类是逐行处理文本。比如读取配置文件里的每一行、解析命令输出的每一条记录、处理CSV文件。这类场景在Shell里非常常见,正确姿势是while read配合文件重定向,但也有不少新手用错方式,导致变量丢失或者性能奇差。
你可以先把这三类套到自己的需求上,再决定用哪个循环,比一开始就纠结语法要高效得多。
2. 三种基础循环的用法拆解:for、while、until
2.1 for循环:遍历列表的万金油
for循环是Shell里出场率最高的循环语句,基本语法就两种写法。
第一种是遍历一个显式列表:
for ip in 192.168.1.1 192.168.1.2 192.168.1.3; do ping -c 1 -W 1 "$ip" >/dev/null 2>&1 && echo "$ip is up" || echo "$ip is down" done第二种是遍历命令展开的结果,这是我在脚本里用的最多的:
for logfile in $(ls /var/log/*.log 2>/dev/null); do echo "Processing $logfile" tail -n 5 "$logfile" done第二种写法里有两个容易翻车的点。第一个是如果文件名包含空格,Shell默认会按空格切分,导致一个文件名被拆成多个。解决方案是使用while read配合进程替换,或者临时改IFS,后面有详细说明。第二个是ls输出里如果包含错误信息(比如没有匹配项时输出到stderr),处理起来也很麻烦,我通常直接改用通配符展开:
for logfile in /var/log/*.log; do [ -f "$logfile" ] || continue echo "Processing $logfile" done加一行[ -f "$logfile" ] || continue是为了防"没有匹配文件时通配符原样返回"的情况,这个习惯我建议所有写Shell的钱都要养成。
2.2 while循环:条件为真就继续跑
while循环和for正好互补,它是"先判断条件,条件为真就执行循环体",适合那些不知道循环多少次、只知道什么时候该停的场景。
最经典的例子是读文件逐行处理:
while read -r line; do echo "Line: $line" done < input.txt这个写法是Shell逐行处理文本的黄金姿势。read -r里的-r参数表示不处理反斜杠转义,我一开始经常漏掉,结果遇到Windows换行符或者路径里的反斜杠时全乱了。< input.txt是整个循环体的输入重定向,注意它写在done后面,不是写在while那一行——这个位置我见过无数人放错。
另一个常见用法是死循环加手动退出:
while true; do read -p "Enter command (q to quit): " cmd [ "$cmd" = "q" ] && break eval "$cmd" done还有监控类脚本里等待条件满足的写法:
while ! ping -c 1 -W 1 192.168.1.1 >/dev/null 2>&1; do echo "$(date +%H:%M:%S) waiting for host..." sleep 2 done这个while !的写法很精妙:ping成功时返回0,!取反后条件为假,循环退出;ping失败时非0,!取反后为真,继续重试。用一句话概括就是"直到某个命令成功才算完"。
2.3 until循环:条件为真就停下来
until和while是亲兄弟,只是逻辑完全相反:until是"条件为假时继续执行,为真时退出"。很多人会忽略它,但只要理解了"等到某个条件出现就停止"这个语义,用起来其实非常顺手。
until [ -f /tmp/flag.txt ]; do echo "Waiting for flag file..." sleep 3 done echo "Flag file created, proceeding."这个脚本的含义是"我不知道/tmp/flag.txt什么时候会出现,反正它不出现我就一直等"。用while ! [ -f /tmp/flag.txt ]也能达到一样的效果,但until表达这种语义更直接,读脚本的人一眼就能明白意图。
实际项目里我常用它做服务启动等待:
until curl -s http://localhost:8080/healthz >/dev/null; do echo "Service not ready, retrying..." sleep 2 done echo "Application health check passed."一句话总结三兄弟的选型:有明确列表用for,等条件不满足时退出用while,等条件满足时退出用until。只要这个判断口诀,90%的场景不会选错。
3. 循环体的控制指令:break、continue与退出边界
循环不只靠条件结束,很多时候需要在循环体里主动打断流程。这就轮到break和continue登场了。
3.1 break:跳出循环的正确姿势
break是"中断整个循环",不管循环条件还成不成立,直接跳到done后面继续执行。
最常见的场景是提前结束搜索类任务。比如在文件列表里找一个包含"error"的文件,找到第一个就不继续找了:
for file in *.log; do if grep -q "error" "$file"; then echo "Found error in $file" break fi donebreak还支持数字参数,比如break 2是跳出两层循环。我在双层嵌套里做深搜时用过,但说句实话,能用到break 2的脚本已经比较绕了,如果写的时候自己都觉得晕,那最好的做法是重构循环逻辑,而不是强行用多层break。
有一个细节值得注意:break只能中断当前的循环层级,不能中断外部无关的循环。比如你先用while包了一个外层循环,内层又有个for,内层里的break只会跳出for,while该怎么跑还怎么跑。
3.2 continue:跳过本轮,继续下一轮
continue和break的区别是"跳过本轮循环体剩余部分,直接进入下一轮"。它适合配合条件做白名单/黑名单过滤。
for server in server1 server2 server3; do [ "$server" = "server2" ] && continue echo "Deploying to $server" done这个脚本会部署server1和server3,但跳过server2。我在批量服务器执行任务时经常用这个方法做"排除单点",比如某台机器临时维护,就先跳过。
continue同样支持数字参数指定跳过第几层循环。但这个用法比break 2更容易让人困惑,我强烈建议你在脚本里只用单层continue,如果真的需要多层,优先考虑拆函数。
3.3 break、continue和exit的边界
新手最容易混淆的是exit和break。exit是终止整个脚本进程,而break只是结束当前循环。差别在下面的示例里很直观:
#!/bin/bash for i in 1 2 3; do [ "$i" = "2" ] && break echo "Loop $i" done echo "Still alive after break"这段会输出Loop 1和Still alive after break。但如果把break换成exit,脚本会在i=2时直接终止,后面的echo永远不会执行。
还有一个容易被忽略的continue细节:如果你用了while read读取文件,在循环体里执行continue或break之前,一定要想清楚有没有关闭文件重定向。实际上continue不会关闭重定向,文件还是会继续往下读,这点没有问题,真正值得注意的是如果你在循环体内重定向了其他文件,continue时会不会残留句柄——我遇到过几次排查很久才发现是文件描述符没关干净的,这个在容器内长驻脚本里特别明显。
4. 文件处理级的循环实战:重命名、逐行读取与性能红线
Shell循环的看家本领是处理文件。这里我分享三个我日常工作最常用的场景,每个都有真实踩坑的经验。
4.1 批量重命名文件的完整踩坑链路
先说背景:我有一批日志文件,命名格式是app-20240101.log、app-20240102.log,我想统一改成app_20240101.log格式,把横杠换成下划线。
第一版脚本我写得很天真:
for file in app-*.log; do newname="${file//-/_}" mv "$file" "$newname" done打开目录一看,改名确实成功了,但多了个隐患:如果已经存在目标文件,mv会直接覆盖,连提示都不给。第二次跑这个脚本时,我发现app-20240101.log被改名成了app_20240101.log,但如果这个文件本来就有同名文件,旧文件就没了。加个安全判断是必须的:
for file in app-*.log; do newname="${file//-/_}" if [ -e "$newname" ]; then echo "Skip $file, $newname already exists" else mv "$file" "$newname" echo "Renamed $file to $newname" fi done这个教训适用于所有批量操作类脚本:改文件之前,先检查目标是否存在。删文件之前,更要先备份。我见过不少人在生产环境跑一条find ... -exec rm,误删了不该删的东西,就是因为少了这层防护。
另外推荐一个更现代的做法:用rename命令配合Perl正则表达式,能处理更复杂的批量重命名。但如果你不想依赖rename是否有装,for + mv永远是Shell脚本里最可移植的方案。
4.2 逐行读取文件的正确姿势与IFS陷阱
读取配置文件并逐行处理是Shell脚本里出现频率最高、坑也最多的操作。我讲一个最常见的错误写法:
cat /etc/passwd | while read line; do echo "User: $line" done这个写法能用,但有个隐性问题:cat和while处于管道两端,while循环体是在一个子Shell里执行的,你在循环内修改的任何变量,在循环结束后全部丢失。
比如这个脚本:
count=0 cat /etc/passwd | while read line; do count=$((count + 1)) done echo "Total: $count" # 输出 Total: 0这个问题极其隐蔽,很多新手写脚本时发现"变量传不出来",百思不得其解。解决方案有两个:一是用进程替换替代管道while read line < <(cat /etc/passwd),二是用重定向while read line < /etc/passwd。我用重定向写法最多,因为它最直观、可读性最好。
正确处理配置文件的完整例子:
> awk -F: '{print $1" -> "$7}' /etc/passwd 2>/dev/null || : # 或者用纯Shell while IFS=: read -r user pass uid gid info home shell; do echo "User $user uses shell: $shell" done < /etc/passwd这里把IFS=:加在read命令前面,表示这一条read命令使用冒号作为字段分隔符,而不会污染后面其他命令的IFS全局值。这种"临时设置IFS"的写法是while read处理CSV/日志文件的利器。注意IFS设置在read前,它只对这行命令生效,出了循环体就还原,否则脚本后面乱七八糟的语句都会被冒号分隔影响。
我在处理日志时也常这么干:
while IFS=' ' read -r timestamp level message; do if [ "$level" = "ERROR" ]; then echo "[$timestamp] $message" fi done < app.log4.3 循环内的性能红线:别在循环里反复启动重量级命令
Shell的循环体每跑一轮就是一个子进程上下文。如果你在循环里做大量操作,性能会成倍下降。我最常看到的问题是这两类。
第一类是在循环里多次查同一个命令结果。比如:
for user in alice bob carol; do uid=$(grep "^$user:" /etc/passwd | cut -d: -f3) echo "$user has uid $uid" done这个写法没问题,但如果循环次数上千,每次都启动两个外部命令,就会有明显的性能损失。可以把/etc/passwd整个读进变量后再用Shell内置的字符串匹配处理,或者一条awk直接搞定全部用户:
awk -F: '/^(alice|bob|carol):/ {print $1" has uid "$3}' /etc/passwd思路是:能用一条命令批量完成的,就不要在Shell循环里逐条执行。
第二类是在循环体里使用管道开销较大的命令。比如处理大文件时逐行用grep过滤再sed替换,远不如一个完整的sed -i或awk方案高效。具体场景我的建议是:循环适合做"每一个文件都要做一套操作",命令本身比较轻量;如果是"一个文件里的每一行做操作",能用awk就用awk,能用一个进程完成的就别用一万个进程。
还有一个性能优化细节是避免在循环体内频繁触摸文件系统。比如stat每个文件、ls -l每个文件,这类操作很慢。如果能一次find拿到所有需要的元信息,就先用find把它一次性输出,再交给循环处理。
5. 进阶玩法:数组遍历、select菜单与C风格for循环
基础循环学会以后,下面这三个进阶玩法能帮你应对更复杂的需求。
5.1 数组下标遍历与元素遍历
Shell支持数组,循环和数组是一对黄金搭档。遍历数组有两种方式。
方式一:遍历数组元素本身:
servers=("web01" "web02" "db01") for server in "${servers[@]}"; do echo "Server: $server" done方式二:通过下标遍历,适用于需要知道"当前是第几个"的场景:
servers=("web01" "web02" "db01") for i in "${!servers[@]}"; do echo "Index $i: ${servers[$i]}" done"${!servers[@]}"这个写法的意思是"展开所有数组下标"。我第一次看到也愣了半天,但用熟之后非常方便。注意一定要写成"${servers[@]}"带引号,否则数组元素里的空格又会被拆开,这个坑我在前面反复强调了。
5.2 select循环:用三行代码做交互式菜单
select是Bash特有的循环结构,专门用来生成交互式菜单。我第一次用的时候感觉"太香了",原来做一个带选择框的脚本只需要这么几行:
select action in "查看磁盘" "查看内存" "备份数据" "退出"; do case $action in "查看磁盘") df -h ;; "查看内存") free -h ;; "备份数据") tar -czf backup.tar.gz /home ;; "退出") break ;; *) echo "无效选择";; esac done运行后会显示编号菜单,用户输入编号后select自动把选中的文本放进$action变量。这个结构适合写运维工具的小入口,但不适合无人值守的自动化场景,因为select必须有终端交互。
5.3 C风格for循环:需要计数时别硬凑
Bash里的for ((...))语法基本就是把C语言的for搬家过来了,适合需要精确控制起始、结束和步长的场景:
for ((i = 1; i <= 10; i += 2)); do echo "Count: $i" done这个写法在普通Shell(比如sh)里不一定支持,所以我只在确认使用Bash的脚本里用它。需要纯POSIX兼容的话,老老实实写i=1; while [ "$i" -le 10 ]; do ...; i=$((i+2)); done。
5.4 双层嵌套循环的一个实用案例
嵌套循环最怕的是逻辑混乱和性能爆炸。我的建议是外层循环控制"较少数量的维度",内层循环控制"较多数量的维度",比如按月份和类型组合执行清理任务:
months=(01 02 03) types=("backup" "archive") for month in "${months[@]}"; do for type in "${types[@]}"; do pattern="*.log.${month}.${type}" echo "Cleaning $pattern" rm -f "$pattern" done done这个脚本里嵌套顺序很重要。外层月份只有3个,内层类型只有2个,总共遍历6次,非常轻量。如果你反过来把月放在内层而类型在外层,逻辑上一样,但可读性会变差。
我用echo写在了真正的rm前,也就是"干跑一遍"先看看匹配到什么。生产环境的批量删除脚本,我永远建议先带着echo跑一遍,确认无误后再去掉。
6. 循环里的隐性Bug:变量子进程、grep误判与引号细节
这个部分是我最想分享的。很多Shell代码翻车,不是循环语法不熟,而是这几个隐性坑。
6.1 子Shell和变量作用域问题
前面提到了管道会开启子Shell导致变量丢失。这个问题不仅在while read会出现,只要是"管道 + 循环"的组合都要小心。比如:
total=0 echo "1 2 3 4 5" | while read -r -a nums; do for n in "${nums[@]}"; do total=$((total + n)) done done echo "Total: $total"这里的total还是在子Shell里累加的,循环体外读取仍然是0。解决办法是改用进程替换,或者把计算逻辑搬进循环体里面直接输出,再或者把管道后的循环用一种不用管道的方式重写。
我的经验法则很简单:如果你的循环要给外面的变量赋值,就别用管道。写成done < file、done <<< "$var"或done < <(command)任意一种都比管道安全。
6.2 在循环体里数数却不生效的经典翻车
这类问题多到可以单开一篇,这里只提最典型的一个。看这段代码:
num=0 ps aux | grep nginx | while read row; do num=$((num + 1)) done echo "nginx processes: $num"输出永远是0,原因还是子Shell。我知道很多人的第一反应是"难道num没加进去?",实际上加进去了,但加的是子Shell里的副本。这种场景下改成:
num=$(ps aux | grep nginx | wc -l)一行命令就解决,千万不要硬撑着用循环去数数。
另一个相关陷阱是在while read里做变量递增,循环结束后需要用到增量结果。从实践角度说,能用wc -l、grep -c、awk计数就用它们,别用Shell循环计数,Shell的循环计数在性能和正确性上都容易出问题。
6.3 grep、test的退出码在循环里怎么用
grep和test命令的退出码(0表示成功/匹配,非0表示失败/不匹配)在循环条件里非常有用。但新手最容易踩的坑是grep找不到匹配时返回1,而while是"为真才继续",会导致一匹配不到就退出循环。看这个例子:
while ! grep -q "退房" record.txt; do sleep 5 done这个脚本的意思是"等record.txt里出现'退房'关键字再继续",因为grep -q在没有匹配时返回1,!把它反转成0也就是条件为假,循环退出——不对,我写反了,让我仔细捋一下。
实际上如果grep -q没匹配到,它返回1,!取反后变成0,while判断0为假,循环退出。那这个脚本就是"一旦匹配到关键字就退出"?也不对,grep -q匹配到时返回0,!变成1为真,循环继续……这样理解就错了。
我把正确逻辑重新说清楚:如果我想"等待某一行出现,出现后继续",正确写法应该是:
until grep -q "退房" record.txt; do sleep 5 done因为grep -q匹配时返回0,until退出条件是"命令返回0",所以匹配到就退出,继续执行脚本。用while的话会写成while ! grep -q ...,含义是"只要没有匹配就一直循环",匹配到后!把0变成1的真值,while就退出了,逻辑上也对,但用until语义更直白。
这两个方向实在太容易混了,我靠口诀记忆:while是"条件成立就跑",until是"条件成立就停"。如果你想要的是"等到某个条件成立就往下继续",用until;如果你想要的是"只要某个条件成立就一直跑下去",用while。
6.4 引号、空格与IFS修改的连锁反应
我一直强调引号,因为Shell循环里引号错了,轻则数据不对,重则误删文件。
看一个危险的例子:
for file in $(ls); do rm "$file" done如果目录里有个文件叫my data.txt,$(ls)展开后会被拆成两个词:my和data.txt。前者删不到文件(出错),后者可能删掉一个本来就存在的data.txt文件。这就是"误删"的典型来源。
更安全的做法是直接用通配符:
for file in *; do rm -- "$file" done通配符展开时,Shell会保留完整文件名,不会按空格拆成多个词。
至于IFS的修改,我只提醒一点:不要在脚本里全局改IFS。你不在循环开头恢复,脚本后面所有按字符切分的地方都可能乱掉。我通常在while read前面单独设置,比如:
while IFS='|' read -r name age city; do echo "$name lives in $city" done < data.txt这种写法的好处是IFS只对这一条read生效,不影响后续命令。
7. 一个完整的日志分析脚本:从需求到落地的全过程
前面讲了那么多零碎的用法和避坑,最后我拿一个真实小脚本走通全流程:每天凌晨检查应用日志,找出当天的ERROR、统计数量,并且超过阈值就发告警。
这个需求用循环来做很自然,逻辑拆成四步:先定位需要检查的日志文件,再逐行读内容统计ERROR,然后判断是否超过阈值,最后输出结果或告警。脚本如下:
#!/bin/bash LOG_DIR="/var/log/myapp" DAILY_THRESHOLD=50 today=$(date +%Y-%m-%d) total_errors=0 for logfile in "$LOG_DIR"/*.log; do [ -f "$logfile" ] || continue while IFS= read -r line; do if [[ "$line" == *"ERROR"* ]]; then total_errors=$((total_errors + 1)) fi done < "$logfile" done echo "Today's total ERRORs: $total_errors" if [ "$total_errors" -gt "$DAILY_THRESHOLD" ]; then echo "WARNING: ERROR count exceeded threshold." fi这里我刻意用了一个嵌套循环:外层for遍历日志文件,内层while read逐行检查。有人会问为什么不用grep直接数?可以,但循环版本的好处是后续可以扩展更多逻辑,比如统计每个文件的错误数、判断错误类型、切换不同处理方式,这些用纯命令组合会越来越绕。
如果需要更快速的处理方式,用grep -c "ERROR" "$logfile"一条命令就能拿到计数:
for logfile in "$LOG_DIR"/*.log; do count=$(grep -c "ERROR" "$logfile") total_errors=$((total_errors + count)) done这个写法简单、快,但如果我想对每个ERROR行做更深入的解析(提取时间戳、提取模块名、按类型分类),就必须走逐行循环了。所以我的经验是:只做计数器用grep,要做字段级分析就用while read循环。
我在实际项目里后来还扩展了这四个点,供你参考:过滤掉历史上的ERROR重放(判断时间戳范围);用mail或curl发送告警;对ERROR类型做去重统计;把统计结果追加写入数据库做趋势报表。这套"循环剥离法"(外层遍历实体、内层逐行处理、累加时注意变量作用域)是我目前处理类似需求的基础框架。
写循环脚本最需要磨炼的能力,不是把语法背下来,而是把一个任务拆成"哪个层级需要遍历、哪个层级需要条件控制、哪个层级需要避免子Shell"这样的结构化思维。
一些属于我个人的体会
脚本写了这么多年,循环语句始终是使用频率最高的功能模块,没有之一。如果你刚入门Shell编程,我的建议是不要试图一次记住所有语法细节,而是先拿"批量重命名文件""批量ping一堆IP""逐行扫描日志统计关键字"这三个小需求练手,把for、while、until和break、continue用熟,再去看数组、select、C风格for这些进阶特性。
踩过的坑方面,最深刻的一条经验是:Shell循环的变量作用域和引号问题远比其他语言容易埋雷,写完后一定要用set -u(变量未定义时报错)和bash -n script.sh(语法检查)过一遍,有条件的话再用shellcheck扫一遍。这个习惯帮我省下了无数个排查时间。
最后分享一个小技巧:当你发现循环里反复出现同样的命令时,先停下来想想它是不是能用awk、find、xargs一条命令代替。Shell循环是强大的工具,但真正的进阶不是"循环写得越花越好",而是"知道什么时候该用循环,什么时候该用别的工具"。