拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Shell循环详解:for、while与until的自动化脚本实战

Shell循环详解:for、while与until的自动化脚本实战 1. 循环语句在Shell里到底解决什么问题我第一次正经考虑学Shell编程是因为连着加班两个晚上都在手动处理同一批日志文件。那时候我还在用最笨的办法打开一个目录逐个文件grep关键字记下结果再打开下一个文件。连续重复了二十多次之后我停下来问自己为什么不让计算机替我把这件事重复二十次循环语句就是干这个的。Shell脚本里的for、while、until三种循环配合break、continue这类控制指令能把你平时手动敲到吐的重复性操作全部自动化。这篇文章适合刚接触Shell脚本的初学者也适合那些已经能写简单脚本、但总在循环细节上栽跟头的朋友。我尽量把原理、用法、坑都摊开来说你看完可以直接抄进自己的脚本里用。1.1 Shell循环和C、Python里的循环差别在哪很多写过程序的人刚接触Shell循环时会有一个错觉觉得它就是另一种语言的for循环。但实际上Shell脚本的循环更偏命令流而不是数据流。C语言的for (i0; i10; 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、until2.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 21 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 21; 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只会跳出forwhile该怎么跑还怎么跑。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脚本会在i2时直接终止后面的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里执行的你在循环内修改的任何变量在循环结束后全部丢失。比如这个脚本count0 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兼容的话老老实实写i1; while [ $i -le 10 ]; do ...; i$((i2)); 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会出现只要是管道 循环的组合都要小心。比如total0 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 在循环体里数数却不生效的经典翻车这类问题多到可以单开一篇这里只提最典型的一个。看这段代码num0 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!取反后变成0while判断0为假循环退出。那这个脚本就是一旦匹配到关键字就退出也不对grep -q匹配到时返回0!变成1为真循环继续……这样理解就错了。我把正确逻辑重新说清楚如果我想等待某一行出现出现后继续正确写法应该是until grep -q 退房 record.txt; do sleep 5 done因为grep -q匹配时返回0until退出条件是命令返回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_THRESHOLD50 today$(date %Y-%m-%d) total_errors0 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 Todays 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循环是强大的工具但真正的进阶不是循环写得越花越好而是知道什么时候该用循环什么时候该用别的工具。
返回列表