如果你已经能写几行Shell脚本,大概率会有这种感觉:单条命令都懂,但真要批量处理文件、批量检查服务、批量整理日志时,就只会复制粘贴。在服务器上待久了,你会发现Shell脚本里百分之七八十的代码,本质都是在做“同一件事做N遍”——而这正是循环语句要解决的核心问题。
Shell脚本的循环体系并不复杂,主线就三条:for负责遍历,while负责条件驱动,until负责“死磕到成功为止”。三条线各司其职,选对了循环类型脚本读起来顺,选错了代码要么绕来绕去,要么变量边界全是坑。我会把三种循环的语法、适用场景、控制语句和生产环境里常见的坑都过一遍,适合刚入门想系统梳理循环用法的同学,也适合已经写过不少脚本、想查漏补缺的朋友。
1. 循环解决的是脚本里最常见的问题:重复劳动
1.1 服务器上的日常:同一件事做N遍
我做过无数次的例子:线上有10台业务机,要检查每台的某个端口通不通;某个目录下躺着上百个日志文件,要按照日期把它们重新命名归档;配置文件里分了几十个区块,要把每个区块的key-value读出来。这些需求的单次操作命令都不难,难点在于怎么让命令“自己重复”。
没有循环的初级方案是复制粘贴。前几次还行,到了第5次、第10次,不但看着难受,还容易改漏一行。写成循环之后,核心逻辑仍然只有几行,数据变了、机器列表变了,改一下列表就行,代码主体不用动。这也是为什么几乎所有脚本教程都绕不开循环的原因:你看懂了循环语法,才真正有了“写脚本处理批量问题”的底气。
1.2 for、while、until到底怎么选
不少初学者最大的困惑不是某个循环不会写,而是不知道该用哪个。我自己的选择标准很简单,记三句话就行:
- 如果循环次数已知,或者要遍历固定的列表/数组,用for。
- 如果循环次数未知,只根据某个条件判断是否继续,用while。
- 如果希望“条件不满足时反复尝试,直到成功为止”,用until。
遍历100个IP是for;读配置文件直到文件末尾是while;等待某个服务端口起来是until。下面用表格直接对照:
| 场景 | 推荐循环 | 原因 |
|---|---|---|
| 遍历固定列表或数组 | for | 天然适应“一次取一个元素” |
| 逐行处理文件 | while | 行数未知,读完即止 |
| 轮询等待某个状态 | until | 条件为假时继续,语义更自然 |
| 死循环做守护任务 | while true | 不知道何时结束,想停再break |
我理解循环的关键切入点是:循环的本质是选择“循环的量”从哪里来。for的量来自列表,while和until的量来自不断变化的判断条件。把这个问题想清楚了,选型就顺理成章。
2. for循环的三种打开方式:列表、数组、C风格
for循环是Shell里最常用的循环,原因很简单:它几乎覆盖了“处理一批东西”的所有日常工作。下面拆开讲。
2.1 列表式for:最直白的写法
for最基础的写法就是遍历一个列表:
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 通了" || echo "$ip 挂了" done每次循环,ip变量依次被赋值为列表里的一个元素,每赋值一次就执行一遍循环体。列表可以是显式写的,也可以是大括号展开:
for i in {1..10}; do echo "第 $i 次" done带步长的写法也很实用,比如只处理奇数:
for i in {1..10..2}; do echo "$i" done这段会输出1、3、5、7、9。{1..10..2}语法在bash 3.0及以上都支持,绝大多数Linux发行版默认的/bin/bash都没问题。但要注意:大括号展开不支持变量,比如{1..$n}是不能用的。想根据变量的长度循环,可以用C风格for或者seq,我后面会补充。
列表式for最怕列表里有特殊字符。for f in *.jpg是安全的,因为通配符展开后每个文件名还是一个独立词;但for f in $(find . -name "*.jpg")就不一定了,文件名里一旦有空格就会被拆成多个词。这个坑我在第6章展开讲。
2.2 数组迭代:记得用${arr[@]}
工作中更常用的是遍历数组。比如把所有服务器的hostname放到一个数组里:
servers=("web01" "web02" "db01") for server in "${servers[@]}"; do echo "正在部署到 $server ..." ssh "$server" "deploy.sh" done很多初学者会问,为什么是"${servers[@]}"而不是"${servers[*]}"或者裸写$servers。区别很好理解:
"${servers[@]}":数组每个元素作为独立的词,即使元素里有空格也不会被拆分。"${servers[*]}":所有元素合并成整体字符串,用IFS的第一个字符连接,默认是空格。- 裸写
$servers:等于"${servers[0]}",只取第一个元素,这基本不是你想要的效果。
如果你的数组元素是路径或包含空格的字符串,用"${arr[@]}"基本是标准答案。另外老版本 /bin/sh 并不支持数组,如果脚本shebang写的是#!/bin/sh,记得改成#!/bin/bash再使用数组功能。
2.3 C风格for:需要下标和步长时的逃生通道
有时候你想用下标遍历,或者循环次数来自变量,C风格的for就很好用:
n=10 for ((i=1; i<=n; i++)); do echo "$i" done注意这里for ((...))用的是双括号,循环变量i可以直接用变量名,不需要$n这种写法。C风格for最大的价值有三个:
- 循环次数可以用变量。
- 可以精确控制步长,比如
i+=2。 - 退出条件可以是一个表达式,不止是简单的比大小。
比如想每隔2秒检查一次某个进程,最多检查30次:
for ((count=0; count<30; count++)); do if pgrep -f "myapp" >/dev/null; then echo "myapp 已启动" break fi sleep 2 done这个场景同时用到了C风格for和break的提前退出,非常典型。实际生产环境里,C风格for一般用在“需要计数、需要精确控制迭代次数”的地方,比如重试N次、生成N个序号。
3. while与until:读文件、轮询服务时的正确姿势
for偏向“有一个明确的清单要遍历”,while和until则偏向“根据条件决定要不要继续”。这一组的循环次数不是写死的,而是运行时判断的,逻辑更灵活,坑也更隐蔽。
3.1 逐行读取文件的完整写法
while最常见的场景是逐行读文件。这里必须强调一个完整写法,很多人写简化版写习惯了,处理真实数据时就会踩坑:
while IFS= read -r line; do echo "$line" done < config.txt每一次循环,read从文件读取一行存入line变量;读到文件末尾,read返回非零,循环自然结束。问题在于简化版省略了太多细节:
read默认会处理反斜杠转义,如果日志里包含类似\n这种字符串,不加-r会被悄悄吃掉,数据就变了。- 默认IFS会去掉行首和行尾的空格。如果你在读配置文件,缩进是有意义的,少个空格整个解析逻辑就错了。写
IFS=能保证读取到的内容保持原样。 - 前面的
IFS=只是临时给read设置一个空IFS,不影响循环外的IFS,这个写法是“保持数据原样”的最小改动。
我见过不少脚本在循环里做line=$(echo $line | tr -s ' ')这类清理,如果源头就是while IFS= read -r,很多这类操作根本不需要。
读文件时还有一个容易忽略的点:如果文件最后没有换行符,while read也能正常读完,兼容性不用担心。真正需要担心的是文件编码,比如带BOM或CRLF换行的文件,读出来的行尾会带着\r,导致后续比较全部失败,这是另一个话题,这里先提醒一句。
3.2 管道与子Shell:循环里赋的变量为什么消失了
这个坑我敢说每个写过Shell的人都遇到过。看下面的代码:
total=0 cat access.log | while read -r line; do total=$((total+1)) done echo "总行数:$total"你直观地以为total最后会变成文件的行数,实际输出往往是0。原因不是循环没执行,而是管道左边的cat和右边的while会被放在同一个子Shell里执行。子Shell有自己的变量副本,循环里累加的是副本,循环结束后副本销毁,外层total还是原来的0。
正确写法是把文件用重定向喂给while,而不是通过管道:
total=0 while read -r line; do total=$((total+1)) done < access.log echo "总行数:$total"这样while和脚本主体是同一个进程,变量修改就能保留。如果确实要从某个命令的输出里读,可以用进程替换:
total=0 while read -r line; do total=$((total+1)) done < <(some_command)<(some_command)会生成一个临时文件描述符,让读文件的循环拿到命令输出,同时又不进入子Shell。这个写法在bash里非常实用。理解子Shell是Shell脚本进阶的一道坎:管道、命令替换$(...)、括号子Shell( ... )都会创建子Shell,只有重定向不会。写循环时想保留循环内的变量变化,就尽量用重定向或进程替换,别用管道。
3.3 无限循环与until:轮询和守护任务的标准姿势
定时检查服务状态、等待某个端口从不可用到可用,这类任务用while true加sleep,或者用until,语义上很清晰。守护任务的常见模板:
while true; do if ! pgrep -f "myservice" >/dev/null; then echo "$(date): myservice 挂了,尝试重启" systemctl restart myservice fi sleep 5 done这个循环一旦启动就一直运行,每5秒检查一次myservice进程。要不要退出由break决定,比如加一个最大循环次数防止死循环:
count=0 while true; do # 检查逻辑 count=$((count+1)) if (( count >= 60 )); then echo "1分钟内未恢复,退出" break fi sleep 1 doneuntil和while正相反。while是条件为真继续跑,until是条件为假继续跑,直到条件为真才停。比如等待服务健康检查接口:
until curl -sf http://localhost:8080/health >/dev/null 2>&1; do echo "服务还没准备好,3秒后再试..." sleep 3 done echo "服务已启动"如果这里写while,就得写while ! curl ...; do,可读性明显差一截。所以遇到“反复试直到成功”的场景,我优先用until,它就是在描述这个语义。while true加break适合更复杂的守护逻辑,until适合单条件的等待,两者从使用场景上就不会打架。
4. 循环控制三件套:break、continue、exit
循环写多了自然要面对“什么时候提前结束”“什么时候跳过某一次”的问题。Shell在这方面的控制语句就是大家熟悉的break、continue,以及特殊位置的exit。先贴个结论:break提前结束整个循环,continue跳过当前这一次迭代,exit直接终止整个脚本。这三者的边界搞清楚了,循环就能变成可控的。
4.1 break:不只是跳出当前循环,还能指定跳出N层
break直接终止当前循环。比如在有序列里找第一个满足条件的元素:
for i in {1..20}; do if (( i > 10 )); then echo "找到第一个大于10的数是 $i" break fi done嵌套循环时,break默认只跳出最内层。要跳出外层循环,break后面可以跟数字,比如break 2表示跳出两层:
for i in {1..5}; do for j in {1..5}; do if (( i * j >= 12 )); then echo "在 i=$i, j=$j 时达到阈值" break 2 fi done donebreak 2在双重循环里跳出两层,三重循环里break 3同理。需要注意的是,数字超过实际嵌套层数时,break会直接导致脚本退出。我习惯在脚本里写注释明确标注break N的含义,避免过几天回来看代码时还要数循环层数。
4.2 continue:只跳过当前迭代
continue用于跳过当前这一次循环体,直接进入下一次迭代。比如打印除3以外的数字:
for i in {1..5}; do (( i == 3 )) && continue echo "$i" done输出1、2、4、5。continue同样支持continue N,但continue经常出现在分支里,数字语义不如break直观,我在实际项目里很少用continue N,需要跳过多层时宁可重构循环结构,让逻辑更平直。
4.3 set -e与循环:命令非零退出会静默炸掉整个脚本
这是生产环境里特别容易踩的坑。很多脚本开头会写set -e,表示任何命令返回非零就退出。这个设置和循环一起用时,问题就来了:
set -e for file in *.txt; do grep "error" "$file" >/dev/null done如果某个txt里没有error,grep返回1,整个脚本立刻退出,后面文件都不处理了。这显然不是想要的效果。
我自己常用的处理方式有两种。一种是临时关闭errexit:
set -e for file in *.txt; do set +e grep "error" "$file" >/dev/null set -e done另一种是把可能失败的命令放进if判断里:
for file in *.txt; do if grep "error" "$file" >/dev/null; then echo "$file 包含error" fi doneif条件里的命令即使失败也不会触发脚本退出,这个特性是set -e约定俗成的例外。理解了“set -e下循环里的失败命令具有杀伤力”之后,你写脚本时会下意识给可能失败的命令加个保护,不会再莫名其妙发现脚本跑着跑着就没了,这算是写生产脚本的第一个实用经验。
5. 生产环境实战:批量改名、日志归类、并发下载
前面语法讲了不少,这一节用三个我实际写过的场景把循环串起来。这三个场景覆盖了for、while、数组、关联数组和后台任务控制,基本就是日常脚本的主旋律。
5.1 批量重命名与按日期归档
运维最常干的活儿之一是整理日志。比如某个目录下每天产生一堆*.log,想改名为带日期的归档文件并压缩:
for f in *.log; do base="${f%.log}" new_name="${base}_$(date +%Y%m%d).log.bak" mv "$f" "$new_name" gzip "$new_name" done这里用了参数展开${f%.log},去掉结尾的.log子串。${f%.log}是bash最常用的字符串操作之一,配合循环批量改名非常顺手。如果文件名里恰好有空格,mv "$f" "$new_name"加了双引号仍然安全。这个写法是Linux GNU stat的语法,macOS的stat参数不同,跨平台使用时要注意。
把文件按日期放进不同目录也类似:
for f in *.log; do day="$(stat -c %y "$f" | cut -d' ' -f1)" mkdir -p "$day" mv "$f" "$day/" done这个例子从stat输出里提取日期,再建目录、移动文件。循环体的核心逻辑可以任意复杂,但每条命令都要处理“当前这一个元素”,这是for循环的心智模型:一次只处理一个,处理完自动换下一个。这也是为什么我常说for循环是“批量操作的标准载体”。
5.2 日志分类与错误统计
日志分析是while的经典场景。假设有个大日志文件,想把ERROR级别的行单独存到一个文件,WARN级别的存到另一个:
while IFS= read -r line; do case "$line" in *"ERROR"*) echo "$line" >> /var/log/errors_$(date +%F).log ;; *"WARN"*) echo "$line" >> /var/log/warnings_$(date +%F).log ;; esac done < app_$(date +%F).log注意<后面的路径用了命令替代去取当天的日期,循环里的输出路径也用了日期。整个脚本虽然短,但从“读一行”到“判断级别”再到“追加到对应文件”是一个完整的数据流,如果不用while做这个活,你就得先把整个文件按级别拆开再人工处理,复杂度高不少。
统计访问日志状态码也可以循环加关联数组,比如统计HTTP状态码分布:
declare -A status_count while read -r line; do code=$(echo "$line" | awk '{print $9}') (( status_count[$code]++ )) done < access.log for code in "${!status_count[@]}"; do echo "状态码 $code: ${status_count[$code]} 次" done这里第一个for遍历的是关联数组的所有key,"${!status_count[@]}"里的感叹号就是“取所有key”的语法。bash 4及以上才支持关联数组,主流Linux发行版都满足。如果你在macOS上使用自带的bash 3.2,关联数组会报错,要么升级bash,要么改用临时文件加sort统计。需要注意的是,遍历关联数组的顺序是不保证的,想按访问量排序还得再套一层sort,生产环境里我一般直接用awk统计,循环在这里主要是用来演示数组遍历的写法。
5.3 并发下载与wait
循环不一定非要串行执行。当任务互相独立时,可以用后台执行加wait把串行变成并发。比如批量下载多个文件:
urls=( "https://example.com/data/a.gz" "https://example.com/data/b.gz" "https://example.com/data/c.gz" ) for url in "${urls[@]}"; do curl -s -O "$url" & done wait echo "全部下载完成"&让每个curl在后台跑,循环体立刻进入下一次迭代;wait等待所有后台任务结束。这个模板在批量拉取数据、批量压测时都很实用。不过要克制:并发数量不是越大越好,机器CPU、带宽有限的场景下并发太多反而拖垮整个系统。如果需要限制并发数,可以引入计数变量或者用xargs -P。wait还有wait -n版本,可以等最先结束的那个后台任务,适合“任何一个任务完成了就继续下一步”的场景,不过这是bash 5.1+才支持的特性,生产环境使用前要确认版本。
6. 那些让我浪费过时间的坑
循环语法本身不难,难的是运行时我随手写出来的代码,偶尔会在特殊数据面前翻车。这一节记录几个真实踩过、而且概率不低的坑。
6.1 文件名带空格时for瞬间翻车
最典型的是用命令替换生成for的列表:
for f in $(find . -name "*.txt"); do echo "$f" done如果目录下有个叫my notes.txt的文件,find输出的是./my notes.txt,赋值给f时会按空格拆开,一次迭代拿到./my,下一次拿到notes.txt,文件不存在,逻辑全部错乱。
安全做法是让for直接利用通配符展开,或者用read处理以空字符结尾的路径。通配符方案最简单:
for f in *.txt; do echo "$f" done如果目录层级不确定,想递归处理,bash 4+开启globstar后可以用**:
shopt -s globstar for f in **/*.txt; do echo "$f" done处理任意深度的目录,最稳的方案是find配合read -d '':
while IFS= read -r -d '' f; do echo "$f" done < <(find . -name "*.txt" -print0)-d ''指定read的分隔符是空字符,-print0让find输出以空字符结尾的路径。空字符不会出现在文件名里,所以这是最稳妥的处理方式,专门用来兜底文件名包含空格、换行等特殊情况。顺带说一句,这里用的是进程替换而不是管道,原因在第3章已经讲过:管道会把while放进子Shell,导致循环里的变量改动全部丢失。
6.2 read不加-r的麻烦与IFS被空格污染
读文件时如果写成while read line,而不是while IFS= read -r line,下面的数据会让你怀疑人生:
# 文件内容: C:\Windows\new while read line; do echo "$line" done < fileread默认会把反斜杠当成转义符,\W会被吃掉反斜杠,输出变成C:Windowsnew。如果配置里包含Windows路径、正则表达式这类带反斜杠的字符串,不加-r取出来的就是坏的。
另一个容易忽略的问题是IFS。默认IFS包含空格、制表符、换行,read默认会按这些字符把一行拆成多个字段,同时剥掉行首行尾的空格。如果你只是为了逐行原样读取,一定要写:
while IFS= read -r line; do这里的IFS=是给这条read命令临时设置空IFS,不影响后续其他命令。这个写法一开始觉得啰嗦,但我后来养成了习惯:只要read的目的不是“按分隔符取多个列”,我一律用IFS= read -r。显式、保守、不猜,在Shell脚本里是极好的策略。如果确实想按分隔符拆列,那就明确写IFS,比如读取CSV时经常看到:
while IFS=, read -r col1 col2 col3; do echo "第一列=$col1 第二列=$col2 第三列=$col3" done < data.csv这种方式读CSV也常常被引号、逗号内容搞乱,遇到复杂CSV,我的建议是直接用专门的csv工具或Python处理,Shell的read不是万能的。
6.3 循环里疯狂开销导致慢到怀疑人生
另一种翻车叫“循环太慢”。典型代码:
for i in {1..1000}; do echo "$i" >> result.txt done每条echo都打开文件一次,1000次就是1000次打开关闭,比实际需要的慢很多。正确方式是把重定向放到整个循环外面:
for i in {1..1000}; do echo "$i" done > result.txt这样echo全程写同一个文件描述符,速度翻几倍起步。同类问题还有:循环里反复调用$(date)、$(grep)、$(awk)等外部命令,每一次命令替换都要启动新进程,开销不小。如果能在循环外先把日期存进变量:
today=$(date +%F) for f in *.log; do echo "$today $f" done就比循环里每次执行$(date)省下一大笔进程创建开销。循环内部尽量用Shell内建语法:字符串比较用[[ ... ]]而不是grep -q,取子串用参数展开而不是cut。原则是:循环体外能算好的别放到循环里,循环体能内建解决的别启动外部进程。这个习惯在处理上万条数据时是质的差别,值得刻意练习。
7. select循环:被低估的交互菜单工具
7.1 select的基本用法
说完了主要循环,还有一个容易被忽略的循环相关语法:select。它不是普通“重复执行”的循环,而是专门做交互菜单的。语法很简洁:
select choice in "安装" "卸载" "退出"; do case "$choice" in "安装") echo "开始安装..."; break ;; "卸载") echo "开始卸载..."; break ;; "退出") break ;; *) echo "请输入1到3之间的数字" ;; esac done运行后,终端会显示编号菜单,用户输入数字,select自动把对应的列表项赋值给choice变量。每次选择后循环体会执行一遍,遇到break或exit才会结束。菜单界面完全由Shell自己生成,不需要用echo手打编号。
select配套的变量还有REPLY,保存用户输入的那个数字。用REPLY可以做数字层面的判断,比如判断用户是否输入了合法序号:
select choice in "启动" "停止" "重启"; do if [[ -z "$choice" ]]; then echo "非法输入,请输1-3" continue fi echo "你选择了:$choice" break done当用户输入一个不存在的编号比如6时,choice是空字符串,REPLY才是数字6。判断[[ -z "$choice" ]]就能捕获非法输入。需要注意的是,select循环在非交互环境(比如通过管道喂入stdin)下会表现异常或直接失败,因为菜单选择依赖终端输入。写自动化测试脚本或定时任务时不要用select,它只适合人工操作场景。另外,菜单选项如果超过10个,显示和选择体验都不好,这种时候自己用echo加read画一个定制菜单反而更灵活。
7.2 用select写一个部署菜单
实际项目里,我常把select放在部署脚本开头,让操作者选择环境或动作,逻辑马上清晰起来:
PS3="请选择部署环境:" select env in "开发环境" "测试环境" "生产环境" "退出"; do case "$env" in "开发环境") echo "目标:dev,开始部署..." # deploy_to_dev break ;; "测试环境") echo "目标:test,开始部署..." break ;; "生产环境") echo "目标:prod,这步要谨慎..." break ;; "退出") echo "bye" exit 0 ;; *) echo "请输入1-4" ;; esac donePS3是select菜单的提示符,默认是#?,设成有含义的提示能让使用者少犯错。select循环体的注意事项和前面几个循环一样,case分支里记得处理缺省项,并且一定要有退出途径,不然用户选错了就永远卡在菜单里。如果想做出“选完一个动作之后还能继续选下一个”的多级菜单,把case里的break换成continue或者干脆不写break,循环就会一直停留在菜单中,配合退出项就能实现比较完整的人机交互。我做过不少部署助手脚本,select配case是最快的实现方式,没有之一。
最后说一点个人习惯:凡是超过三次以上的重复命令,我会条件反射地先想想是不是该写成循环;凡是循环里要执行的命令超过两三条,我会先打个草稿想清楚退出条件和数据边界。踩过几次坑之后你会发现,循环写得好不好,直接决定了脚本的稳定性和可读性。希望这篇关于Shell脚本循环语句的梳理,能帮你少走我走过的弯路。