Shell编程里,循环语句往往是最不起眼,却又是最见功力的一块。很多人刚学 Shell 时,先背会了if,再学会变量和函数,等到真正把一堆重复操作写进脚本,才发现循环才是那个真正干活的引擎。不管是批量改名、遍历日志、轮询服务状态,还是生成一堆配置文件,循环都用得上。这篇文章不打算绕过坑只讲语法,我会把日常工作里最常写的几种循环、选型思路、以及那些只有踩过才会注意的边界情况,一次性说清楚。只要你已经在写脚本,或者正准备把手工重复命令改成脚本,这篇都值得留着对照。
1. 循环不是语法喜好,是脚本的骨架
1.1 重复的事,为什么要交给循环
假设你手工执行过这样一串命令:
cd /data/images cp a.jpg a_backup.jpg cp b.jpg b_backup.jpg cp c.jpg c_backup.jpg当时只有三个文件,命令还能忍受。但换成 300 个文件呢?你的手指会先抗议。更麻烦的是修改逻辑:如果不只是cp,而是要在复制后压缩、加时间戳、校验大小,你要改的就不是一行,而是三百行。循环的核心价值,是把“针对一批对象的同一套操作”抽象成一次描述。将来需求变化,只改循环体里的几行,脚本就会把新规则应用到每一个对象上。这才符合脚本的初衷:把重复的体力劳动交给机器,把判断和决策留给人。
很多人总觉得循环写法太灵活、记不住,反而宁愿复制粘贴。真到线上环境出了问题,需要整批调整时,复制粘贴的脚本就是一颗定时炸弹,而循环脚本可能只要改一个参数。所以我一直建议:凡是看到脚本里连续出现三次以上相同操作,第一反应就应该是“这个地方能不能收敛成一个循环”。
1.2 三种循环类型怎么分
Shell 里的循环大体可以分成三类:for、while、until,另外还有一个容易被忽略的交互循环select。它们解决的问题有重叠,但思考角度不太一样。
| 类型 | 适用场景 | 退出方式 |
|---|---|---|
for | 已知要处理的一组对象,比如文件列表、参数列表、数组 | 遍历完列表后自动退出 |
while | 依赖条件判断是否继续,比如轮询服务、逐行读取文件 | 条件为假时退出 |
until | 一直等到某个条件变真,比如等待端口就绪 | 条件为真时退出 |
select | 交互菜单,让用户从若干选项中选择 | 循环体内手动break |
for是“我知道要处理哪些东西,挨个处理”;while是“我不知道要跑多久,只知道什么时候该停”;until则正好相反,是“我不知道要等多久,只知道什么时候该继续”。理解了这个思想,你就不容易把三类循环用混。接下来我会按实际使用频率,从for开始拆开讲。
2. for 循环的花样多,怎么选才不踩坑
2.1 最常用的 in 列表写法
for最常见的形态是这样:
for ip in 192.0.2.1 192.0.2.2 192.0.2.3; do if ping -c 1 -W 1 "$ip" >/dev/null 2>&1; then echo "$ip 可达" else echo "$ip 不可达" fi done这里的in后面跟的是一个“词表”,Shell 会用空白字符把整句话拆成多个词,然后每次取一个词赋给变量ip,再执行循环体。注意我给"$ip"加了双引号,这是必须养成的习惯。如果某个元素本身包含空格,不引号的话会被再次拆开,循环里拿到的就是错误的数据。变量名也不一定非叫i,在真实业务里建议用有含义的名字,比如server、file、user,三个月后再回看脚本,你绝对会感谢自己。
如果不在in后面写任何列表,for会默认遍历脚本的位置参数。这是写通用工具脚本时非常好用的特性:
#!/bin/bash for file in "$@"; do echo "处理参数: $file" done"$@"加引号后,每个参数会保持独立,即使参数里有空格也不会被错误拆分。这条规则几乎适用于所有批量处理脚本。
2.2 位置参数与数组展开
数组也是for最爱处理的数据来源:
apps=("nginx" "redis" "mysql") for app in "${apps[@]}"; do systemctl status "$app" >/dev/null 2>&1 \ && echo "$app 正常运行" \ || echo "$app 状态异常" done"${apps[@]}"表示把数组里每个元素当成独立个体展开。很多人第一次写数组循环时,会漏掉双引号,写成${apps[*]},结果是整个数组被压成一个字符串,循环只执行一次,非常容易踩坑。使用[@]而不是[*],是数组遍历的通用原则。
还有一种写法经常在脚本里出现:大括号展开加for。
for n in {1..10}; do echo "$n" done大括号展开在循环展开之前就完成了,所以{1..10}不会因为里面是一个变量就直接报语法错误,但它也不太能吃变量。如果需要动态范围,优先考虑下面的 C 风格写法。
2.3 C 风格 for 数值循环
C 风格for是 Bash 提供的,语法结构更像常见的编程语言:
for ((i = 1; i <= 10; i++)); do echo "第 $i 次" done这种写法的好处是三个表达式都可以用变量参与运算。比如分块处理一批数据,每隔 100 行处理一次:
start=1 end=500 step=100 for ((i = start; i <= end; i += step)); do echo "处理区间 $i 到 $((i + step - 1))" done用纯for i in $(seq 1 500)也能实现,但会多启动一个seq外部命令,而且如果是几百万元素,命令替换会把全部结果一次性塞进内存。相比之下,for ((...))是纯算术循环,开销小得多,也更容易表达“按步长递增”这类逻辑。
2.4 什么时候别用 for i in $(...)
for i in $(cat file)是我见过最常见的错误写法,没有之一。这句话看起来没问题:读文件,把每一行当成一个元素,循环处理。但它的问题在于,命令替换$(cat file)的结果会被 Shell 按照“空白”重新拆分,空格、制表符、换行都会被当作分隔符。假如文件里有一行是hello world,你以为循环拿到hello world,实际拿到的是hello和world两个独立元素。
处理文件的正确姿势通常是while read,这个我们下一节会专门讲。这里先记住一条经验:当你要按“行”处理文本时,优先想while read;当你要按“文件名”处理时,优先想for加通配符。例如:
for log in /var/log/*.log; do echo "$log" done这种写法天然按文件名分割,即使是包含空格的文件名也不会被拆开,因为通配符展开的结果是完整文件名。如果目录下没有匹配文件,默认情况下*.log会原样保留字符串,循环体仍会执行一次。处理这种情况的通用守卫是:
for log in /var/log/*.log; do [ -e "$log" ] || continue echo "$log" done[ -e "$log" ] || continue确保“没有文件时不瞎处理”。
3. while 与 until:条件循环的两副面孔
3.1 while read 逐行处理:为什么必须加 IFS= 和 -r
按行读取文件,几乎是日志脚本里最核心的动作。推荐写法是这样的:
while IFS= read -r line; do echo "读取到的行: $line" done < /etc/hosts这里有三个细节值得多说。第一,< /etc/hosts放在done后面,这叫输入重定向,意思是整个循环的输入都来自该文件。第二,IFS=把字段分隔符设为空,防止read自动去掉行首行尾的空格,如果你希望保留原始内容的空格,这个必须加上。第三,-r表示“反斜杠不转义”,也就是说如果某一行以反斜杠结尾,read不会自作聪明地把它和下一行拼起来。
还有一个老手才会注意的坑:当文件的最后一行没有换行符时,read会返回非零状态,循环可能少处理最后一行。更健壮的写法是:
while IFS= read -r line || [ -n "$line" ]; do echo "$line" done < input.txt|| [ -n "$line" ]保证“即使最后一行没有换行符,也能被处理到”。现实里的导出文件、Windows 生成的文本,经常缺这最后一脚,你没踩过不代表它不存在。
3.2 管道造成的子 Shell 陷阱
我见过不少脚本这样写:
count=0 cat input.txt | while read -r line; do count=$((count + 1)) done echo "总行数: $count"执行结果会让你怀疑人生:count永远是 0。原因在于管道两边的命令在 Bash 里是子 Shell,子 Shell 里的变量修改不会传回父 Shell。管道右侧的while是在一个独立进程里跑的,循环结束之后,这个进程也就结束了,你修改的count自然跟着一起消失。
解决办法是把管道换成输入重定向,让循环留在当前 Shell 中:
count=0 while read -r line; do count=$((count + 1)) done < input.txt echo "总行数: $count"如果必须读取另一个命令的输出,也可以用进程替换:
while read -r line; do echo "$line" done < <(grep "ERROR" /var/log/app.log)这样既保留了“从命令输出中逐行读取”的能力,又不会因为管道丢变量。这个细节在写“循环结束后还要使用统计结果”的脚本时非常致命。
3.3 until:等到某个条件成立
until的语义是“条件为假时继续循环,条件为真时退出”。它最典型的应用是等一个服务起来:
until curl -s http://127.0.0.1:8080/health >/dev/null; do echo "服务未就绪,3 秒后重试..." sleep 3 done echo "服务已就绪"有人会问,这用while ! curl ...也能写。没错,效果一样。但until在语言表达上更贴近“我要等到什么”,读代码的人一眼就能明白意图。如果你的逻辑是“直到某件事发生才继续”,就用until;如果是“当某条件存在就继续,不存在就停”,就用while。这属于代码可读性的选择,没有绝对对错。
实际使用时,无限等待会卡住整个脚本,建议加一个超时计数器:
tries=0 until curl -s http://127.0.0.1:8080/health >/dev/null; do tries=$((tries + 1)) if [ "$tries" -ge 30 ]; then echo "等待超时,退出" exit 1 fi sleep 2 done3.4 break、continue、exit 和死循环
循环体里的控制语句,决定了循环什么时候提前结束,什么时候跳过当前项。break用于跳出当前循环,continue用于跳过本次循环剩下的语句直接进入下一次,exit则直接退出整个脚本。
for server in 192.0.2.1 192.0.2.2 192.0.2.3; do if [ "$server" = "192.0.2.2" ]; then continue fi if ! ping -c 1 -W 1 "$server" >/dev/null 2>&1; then echo "$server 不通,终止本轮部署" break fi echo "部署到 $server" done这段逻辑是:跳过保留地址,遇到不通的主机就停止整个循环。break后面也可以跟数字,比如break 2表示跳出两层嵌套循环,但可读性很差,我不建议写嵌套太深的循环去依赖这个功能。如果逻辑复杂到需要多层跳出,多半应该拆成函数,用return返回状态码来管理流程,比break n清晰得多。
死循环也是一种合法模式,后台监控脚本里很常见:
while true; do echo "心跳 $(date '+%F %T')" sleep 5 done注意一定要有sleep之类让出 CPU 的操作。裸奔的死循环会把 CPU 拖到满负荷,尤其在容器里会造成整台宿主机发热。另一种写法是for ((;;)),效果等同但可读性略差。
4. 循环里的性能杀手,比语法错误更隐蔽
4.1 大量文本别用 Shell 循环
Shell 循环天生适合处理“对象少、每个对象操作重”的任务,不适合处理“海量文本、逻辑全是行内字符串操作”的任务。比如要统计一个日志文件里各状态码出现的次数,你完全可以一行awk解决:
awk '{count[$9]++} END {for (code in count) print code, count[code]}' access.log有些新手会写一个while read,然后在循环体里用一堆grep、sed、case去解析,最后输出统计结果。这样做不是不行,但每处理一行就可能派生两三个子进程,处理一万行就是两三万个进程,慢得让人怀疑机器是不是坏了。awk本身就是一个循环引擎,内部把这些事情在单次进程里完成,效率高出一个量级。
什么时候该用 Shell 循环?我的经验是:当“每个对象都需要调用多个命令、判断多个条件,并且对象个数是几百到几千”的时候,Shell 循环是合适且清晰的。如果是处理几万行文本的解析与统计,先想想awk、sed、sort、uniq能不能直接解决。能用标准工具解决的,就不要发明自己的循环。
4.2 每轮循环重复调用外部命令
循环体里尽量不要每轮都启动无谓的外部命令。举个最常见的例子:
for n in {1..100}; do echo "$(date '+%F %T') 第 $n 次" done$(date ...)每轮都会启动一个date进程,100 次就是 100 个子进程。如果时间精度要求没那么细,可以在循环外先取一次:
now=$(date '+%F %T') for n in {1..100}; do echo "$now 第 $n 次" done同样的道理适用于uuidgen、whoami、hostname这类“结果相对固定”的命令。先在循环外把值算好,循环内只管使用,是提高脚本性能最直接的手段。当然,如果每个文件都需要单独取时间戳、单独算大小,那该在循环里做的事也别硬移出去,性能和可读性要平衡。
4.3 文件名带空格、换行怎么处理
前面说了,for file in *.log能正确处理带空格的文件名,因为通配符展开结果不会二次拆分。但如果你从命令输出里获得文件名,比如find,就危险了:
for file in $(find . -name "*.log"); do echo "$file" donefind输出的每一行会被按空白拆成多个元素。假如有个文件名是my notes.log,这个循环会得到两个错误的文件路径。安全的做法是用find -print0输出空字符分隔,再用while read -d ''读取:
find . -name "*.log" -print0 | while IFS= read -r -d '' file; do echo "$file" done这里的-d ''相当于指定分隔符为空字符,正是find -print0输出的分隔方式。同理,xargs -0也是一样的思路:
find . -name "*.log" -print0 | xargs -0 rm -f这种“空字符分隔”的写法在处理任意文件名时是唯一稳的方式,尤其当文件名可能来自用户上传、包含空格甚至换行时,千万别存侥幸心理。
4.4 临时文件、退出状态与信号
循环跑起来之后,如果用户按Ctrl+C中断,脚本默认会直接退出,已经创建的临时文件可能残留在系统里。完善的脚本应该在循环开始前注册一个清理钩子:
tmp_file=$(mktemp) trap 'rm -f "$tmp_file"' EXIT while read -r line; do echo "$line" >> "$tmp_file" done < input.txttrap ... EXIT会在脚本退出时执行清理动作,不管是从循环里正常退出,还是被信号打断退出。这里还有个细节:如果脚本里某条命令失败会导致退出,需要先决定“循环中的重要命令失败了,是继续还是终止”。常见的做法是配合set -e,但set -e在管道、命令替换等场景下的行为容易有边界问题,我建议在循环体内主动检查返回值,而不是完全依赖set -e。比如:
if ! cp "$src" "$dest"; then echo "复制失败: $src" continue fi5. 实战应用:日志统计、批量处理与交互菜单
5.1 写一个状态码统计片段
日志统计这个需求太常见了,几乎每周都会用到。假设 access.log 的第九列是状态码,你想看哪些状态码出现得最多:
awk '{count[$9]++} END { for (code in count) { print code, count[code] } }' access.log | sort -k2 -nr | head -20注意这里awk内部的for (code in count)也是一种循环,它遍历的是关联数组的键。sort -k2 -nr按第二列数字逆序排序,head -20取前 20。整个命令没有写显式循环,但执行效率远高于 Shell 循环。如果你还需要对状态码做更复杂的判断,比如 5xx 单独统计,可以再套一层awk:
awk '{if ($9 >= 500) {f++} else {o++}} END {print "5xx:", f, "其他:", o}' access.log这种组合方式,比“逐行读文件再手工加计数器”要简洁得多。
5.2 批量生成缩略图
图片处理的批量循环是for的最佳展示场景。假设目录下有一批.jpg原图,要给每张生成一张宽 800 的缩略图:
for img in *.jpg; do [ -e "$img" ] || continue convert "$img" -resize 800x "${img%.jpg}_thumb.jpg" done"${img%.jpg}"去掉文件名的.jpg后缀,再拼上_thumb.jpg,这是 Shell 的字符串截断语法,比basename外加字符串操作更干净。[ -e "$img" ] || continue负责应对目录里没有图片时*.jpg保留字面量的问题。如果图片数量多到上千张,还想提高速度,可以用多进程并行:
find . -name "*.jpg" -print0 | xargs -0 -n1 -P4 -I{} convert {} -resize 800x {}-P4表示四个进程并行,-I{}让convert可以灵活指定输入输出位置。注意这只是流程示意,实际覆盖写原图前一定要先备份,或者在-I里配置新的输出名。并行能带来明显提速,但也会放大资源占用,线上操作前先ulimit看一眼没问题。
5.3 select 交互菜单
select是很多人不知道的好东西。它把循环和菜单合二为一,适合写运维小工具。示例:
#!/bin/bash PS3="请选择操作(输入编号): " select opt in 启动服务 停止服务 重启服务 退出; do case "$opt" in 启动服务) echo "执行启动逻辑" ;; 停止服务) echo "执行停止逻辑" ;; 重启服务) echo "执行重启逻辑" ;; 退出) echo "退出菜单" break ;; *) echo "无效选项:$REPLY" ;; esac done运行后,Shell 会列出 1 到 4 的选项,等待用户输入编号。用户输入的数字会保存在REPLY,选中的文本会保存在变量opt。如果没选对,就走到*)分支重新提示。select本身就是一个无限循环,直到break才会退出。它尤其适合那种不想记参数、希望交互式操作的脚本,比如给不太熟悉命令行的同事用。
6. 综合案例:日志归档脚本、练习与经验汇总
6.1 带安全守卫的日志归档循环
把前面这些知识串起来,写一个带安全守卫的日志归档脚本。目标:把/var/log/myapp下所有.log文件移动到当天日期目录,文件名加上小时分钟秒,避免重名。
#!/bin/bash set -euo pipefail log_dir="/var/log/myapp" archive_dir="/backup/logs/$(date +%F)" mkdir -p "$archive_dir" for log in "$log_dir"/*.log; do [ -e "$log" ] || continue base=$(basename "$log") ts=$(date +%H%M%S) dest="$archive_dir/${base}.${ts}" if mv "$log" "$dest"; then echo "已归档: $log -> $dest" else echo "归档失败: $log" exit 1 fi done几处关键点:set -euo pipefail让脚本对未定义变量和命令失败更敏感;[ -e "$log" ] || continue避免通配符没匹配到文件时执行奇怪操作;mv后检查返回值,失败就退出。这里的date在循环外取了一次日期,循环内取的是时分秒,很适合高频归档场景。如果同一秒内出现同名文件,mv会覆盖,实际业务里可以根据需要改成timestamp=$(date +%s%N)提高精度。
6.2 CSV 批量导入
处理 CSV 文件也是循环的常见场景。假设有这样一个scores.csv:
张三,北京,88 李四,上海,92 王五,广州,75导入脚本可以写成:
while IFS=',' read -r name city score; do score=$((score + 1)) echo "$name 城市 $city 调整后得分 $score" done < scores.csv注意IFS=','后,read按逗号分隔字段;name、city、score分别由三列填充。如果是 Windows 生成的 CSV,行尾还带着\r,需要先把格式清一下:
while IFS=',' read -r name city score; do city=${city%$'\r'} echo "$name $city $score" done < scores.csv这里用$'\r'表示一个回车符,${city%$'\r'}去掉字符串末尾的回车。CRLF 问题在跨平台脚本里非常常见,症状就是变量看起来正常,实际末尾藏着一个不可见字符,比对字符串时永远不相等。
6.3 三分钟自测题
想验证自己是否真的掌握了,可以试这三题:
- 用
for循环批量创建test_1.txt到test_10.txt十个文件。 - 用
while read按行读取/etc/passwd,只打印文件系统用户(UID 小于 1000 的按实际环境调整)。 - 写一个
select菜单,包含“显示当前时间、显示磁盘使用、退出”三个选项,选中后执行对应命令。
第一题考的是数值循环和字符串拼接;第二题考的是按行读取和字段拆分;第三题考的是交互菜单和break。每道题都在 20 行以内能完成,做不出来就回头看看前面对应的章节。
6.4 经验问题汇总表
我把日常踩坑频率最高的问题汇总成一张表,方便你排查脚本时直接对照:
| 问题现象 | 常见原因 | 修正方式 |
|---|---|---|
| 循环只执行了一次 | 数组用了${arr[*]}而不是"${arr[@]}" | 使用带引号的"${arr[@]}" |
| 循环结束后变量丢失 | 管道右侧的while在子 Shell 中执行 | 改用done < file或< <(cmd) |
| 文件名带空格时处理出错 | for i in $(find ...)按空白拆分 | 用find -print0搭配while read -d '' |
| 文件按行读取少最后一行 | 最后一行没有换行符 | while read -r line || [ -n "$line" ] |
| Windows 脚本执行异常 | CRLF 导致\r残留 | sed -i 's/\r$//' script.sh |
| 通配符无匹配时循环还执行 | glob 原样保留模式字符串 | [ -e "$file" ] || continue |
| 循环处理几万行日志很慢 | 每行启动了多个外部命令 | 改用awk或sed批量处理 |
这张表是我多年写脚本积累下来最实用的部分。每次看到循环不按预期走,先对照表格检查一遍,很多问题当场就水落石出。Shell 循环本身不难,难的是永远记住:它处理的不是一个抽象概念,而是一批带空格、带换行、可能缺失结尾符的真实文件和数据。养成边界思考的习惯,你的脚本就不会再莫名其妙翻车。