ARTICLE DETAIL

资讯详情

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

Shell循环语句实战指南:for、while、until与循环控制全解析

Shell循环语句实战指南:for、while、until与循环控制全解析 天天在Linux命令行里摸爬滚打的人大概都有过这么一段经历写Shell脚本时凡是遇到重复操作就复制粘贴几十台服务器要检查就贴几十遍命令最后脚本比裹脚布还长。直到你真正把循环语句用起来才算是从命令搬运工变成了脚本作者。Shell编程里的循环语句就是干这个的——用最小代码量完成批量重复任务把时间留给真正需要思考的事情。这篇文章不搞虚的直接把for、while、until三种循环语句的语法、执行逻辑、与break/continue/exit的控制组合全部掰开揉碎再把循环体常见的几个大坑逐一分析最后给几个可以直接抄的实战模板。不管你是刚开始学Shell脚本的新手还是已经写过一阵子、但循环老写着别扭的兄弟这篇都值得看完。1. 为什么循环写不好Shell脚本就跪了一半1.1 循环的本质把重复交给解释器循环这个词听起来有点学院派但说白了特别简单Shell解释器按照你给出的列表或条件反复执行同一段命令序列。比如要给一百个配置代码文件加备份手写一百行cp命令显然不现实。写一个for循环把这一百个文件名逐个赋给变量循环体里只写一次cp命令问题瞬间解决for i in *.conf; do cp $i ${i}.bak done这就是循环最基本的价值。它的执行模型也不复杂Shell先对in后面的部分做分词和路径展开得到一个元素列表然后依次取出每个元素赋给变量i执行do和done之间的命令处理完最后一个元素就结束。把握住先展开、再遍历、后执行这个顺序非常关键因为后面要说的很多坑都是从这一步埋下的。1.2 判断该不该用循环比会写循环更值钱我见过不少朋友把循环当万能药不管什么场景都套一层for。比如要查文本中匹配某个模式的行明明grep一条命令就能搞定他偏要while read line然后在循环体里再嵌套case判断结果又慢又难维护。反过来在文件数量极大、命令行参数直接超过系统上限的时候固执地不用循环又会遇到Argument list too long的报错脚本当场翻车。我自己长期用下来总结了一套判断标准找内容、查字段、做统计优先用grep、awk、sed这类文本工具只有确实需要对一批对象执行同一组复合命令时才动用循环。数量小、文件名规整的场景用通配符文件数量大或者文件名包含空格、换行等特殊字符才需要循环配合更稳的读取方式。交互式读取用while固定列表遍历用for。想清楚这些再用循环脚本会清爽很多。1.3 do...done不是函数体变量作用域要提前明白很多从C语言转过来的人第一反应会把do...done当成函数体以为内部的变量只在循环里有效。Shell不是这样。do和done只是循环体的边界标记里面的变量在循环结束后依然存在会直接覆盖同名变量。这个特性有好处比如统计执行次数直接定义count循环体里count$((count1))循环结束后count就是总次数不用设计什么返回值机制。但这个特性也隐藏风险一旦循环出现在管道、命令替换里作用域规则又会变。管道会为命令创建子Shell循环跑到子Shell里循环结束后变量修改全部丢失。这一点我会在第4节用一个专门案例讲透这里先记结论——Shell的变量作用域和主流高级语言差异很大别用老经验想当然。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 -c1 -W1 $ip /dev/null 21 echo $ip 通畅 || echo $ip 不通 done如果遍历的是一串连续数字可以用大括号展开for i in {1..100}; do echo $i done想要带步长的序列则可以用seq命令替换for i in $(seq 1 2 20); do echo $i # 输出 1 3 5 7 ... 19 done这里有个容易被忽略的细节大括号展开发生在变量替换之前所以写成{1..$n}是无效的如果循环次数由变量决定用seq比大括号更靠谱。另外还有C风格的for循环适合需要初始化、终止条件、步进三段式控制的场景for ((i0; i10; i)); do echo $i done这种写法从C语言转移过来的人会觉得格外亲切处理保留最后N个备份文件尝试重试N次这类计次逻辑时特别顺手。2.2 while循环条件驱动天生适合边读边处理while循环的核心是只要条件成立就反复执行最常见的应用场景就是逐行读取文件while IFS read -r line; do echo 当前行: $line done /etc/hosts这里我刻意写了IFS read -r line两个参数都很重要。IFS表示清空字段分隔符避免行首行尾的空格被吞掉-r参数让反斜杠不被当作转义符处理路径里含\时不会静默出错。很多老脚本只写read line遇到带空格的配置行或者带反斜杠的Windows风格路径就悄悄出问题排查起来特别费劲。while也经常用来写死循环。运维场景里等端口就绪、等进程退出都可以用while实现。下面是一个等待服务健康检查通过的典型写法while true; do if curl -s http://127.0.0.1:8080/health /dev/null 21; then break fi sleep 1 done2.3 until循环条件反向用得少但偶尔很对口until和while的语法几乎一样区别只在判断方向上while是条件为真时继续until是条件为真时终止。所以until [ 条件 ]很多时候可以理解成while [ ! 条件 ]。实际项目里我用到until的频率远低于for和while但在等待某个条件消失的场景特别好用比如这个等待部署锁文件被释放的写法until [ ! -f /tmp/install.lock ]; do sleep 2 done这种写法比while [ -f /tmp/install.lock ]加一层取反逻辑读起来自然得多语义一目了然直到锁文件没了才往下走。脚本的可读性有时候就体现在这种细微差别上。为了看得更直观我把三种语句的差异整理成一张表循环语句驱动方式进入循环条件典型使用场景for列表/序列列表非空即遍历文件遍历、枚举计算、固定次数重试while条件判断条件为真继续执行逐行读文件、死循环监控、等待就绪until反向条件判断条件为假继续执行等待条件从真变为假、等待锁释放3. break、continue、exit循环控制比想象中重要3.1 三个命令各管一段写得多了就会发现光会让循环跑起来远远不够还得能控制循环什么时候停、怎么停。Shell提供了三个和循环强相关的控制命令。break终止当前这一层循环跳到循环后面的命令继续执行。continue跳过本次迭代中continue语句后面的命令直接进入下一次迭代。exit不是循环专用命令它终止整个脚本进程不管当前在循环的哪一层。用一个实际例子展示三者的差异。假设要遍历当前目录的所有文件遇到.bak备份文件就跳过遇到.conf配置文件就处理并中断整个脚本for f in *; do case $f in *.bak) continue ;; *.conf) echo 找到配置文件: $f; exit 0 ;; esac done这里continue把无关的备份文件过滤掉exit则让脚本在找到目标后立即停止不会继续做无意义的遍历。3.2 嵌套循环里的break到底断哪一层默认情况下break和continue都只作用于当前所在的那一层循环想从内层直接跳出外层时必须给命令加数字参数。看这个双层循环例子for i in {1..3}; do for j in {1..5}; do if [ $j -eq 3 ]; then break 2 fi echo $i-$j done done脚本执行到i1、j3时break 2会同时退出内外两层循环最终输出结果只有1-1和1-2两行。如果不加数字2break只会跳出内层循环外层继续正常迭代输出结果会完全不同。同理continue 2表示跳过外层循环的本次迭代。3.3 实战健康检查里的重试循环循环控制在脚本里最典型的应用就是重试机制。比如服务重启后从进程启动到端口真正开始监听往往需要几秒钟这时候不能只检查一次就放弃。用for循环写一个带次数的重试逻辑for ((attempt1; attempt5; attempt)); do if nc -z 127.0.0.1 8080; then echo 服务已就绪 break fi echo 第 $attempt 次检查未通过2秒后重试 sleep 2 done这段脚本里break承担提前成功退出的任务防止服务明明已经起来了还白白跑完剩余次数。如果不加break只是循环次数被消耗完功能上勉强能接受但生产环境里一旦碰上慢启动服务这种冗余等待会被放大很多倍。4. 循环体里的坑我替你们踩过一遍4.1 管道后循环变量会丢失这是Shell循环里最容易踩的坑没有之一。来看看这个统计文件行数的场景count0 cat /var/log/syslog | while read line; do count$((count1)) done echo 总行数: $count我遇到过不少朋友写完这个脚本后echo出来的count永远是0怎么检查逻辑都看不出问题。原因在于管道会创建一个子Shell进程while循环是在这个子Shell里执行的count$((count1))只修改了子Shell里的变量副本管道结束后子Shell退出所有修改全部丢失。解决办法有三种按推荐程度排序# 方案1直接统计根本不需要循环 count$(wc -l /var/log/syslog) # 方案2用进程替换让循环留在当前Shell执行 while read line; do count$((count1)) done (cat /var/log/syslog) # 方案3最常规用输入重定向同样留在当前Shell while read line; do count$((count1)) done /var/log/syslog方案3是我日常写脚本最推荐的既没有子Shell问题又保留了逐行处理的能力可读性也最好。4.2 文件名带空格for会被拆得七零八落for循环默认是用IFS做分词的IFS的默认值是空格、制表符和换行。所以当一个目录下的文件名是my document.pdf这种带空格的不能用for f in $(ls *.pdf)这种写法。ls的输出经过命令替换后再分词空格会把文件名拆成两截脚本拿到的是my和document.pdf两个独立元素后面处理全乱套。正确的做法是直接用通配符让路径展开结果不参与IFS分词for f in *.pdf; do echo 处理文件: $f done这个规则最好烂熟于心遍历文件优先用通配符不要套命令替换不要解析ls输出。遇到更极端的包含换行符的文件名常规办法都不太稳得用find配合-print0再搭配while循环按空字符读取find . -name *.pdf -print0 | while IFS read -r -d file; do echo 处理文件: $file done4.3 逐行读取大文件时的性能优化read逐行循环处理大文件确实很慢因为read本身虽然是内置命令但每次读取一行都涉及缓冲区管理在几万行文件上跑复杂逻辑性能会明显拉胯。如果只是做简单统计grep、awk这类字符流工具比while read快十倍以上完全没必要写循环。如果必须逐行处理且要做复杂业务逻辑我的优化思路是先用awk把每行需要的关键字段做预提取和规整去掉无关内容再交给循环做后续处理。这样循环要处理的数据量会小很多整体性能提升明显。这个思路在日志分析脚本里尤其好用。4.4 循环体内避免频繁调用外部命令每次在循环体里调用一次外部命令Shell都要fork一个子进程来执行。循环跑100次就是100次进程创建开销非常大。之前见过一个脚本循环里对每个文件执行三次sed、两次awk处理几百个文件时卡到令人绝望。优化方式是能一次处理的就不要循环循环里尽量只用内置功能确实需要多次处理时考虑把多个sed表达式合并成一条命令减少进程数量。5. 直接能抄的实战批量重命名、配置遍历与并发控制5.1 批量重命名文件模板把当前目录下所有.txt文件改名为.bak最朴素的写法for f in *.txt; do mv $f ${f%.txt}.bak done这里用到了Shell的字符串截取语法${f%.txt}作用是去掉变量f末尾的.txt后缀再拼上.bak。这套%、#、%%、##的字符串处理在循环里非常实用比调用sed再做变量替换干净得多。如果需要给文件名增加日期前缀可以配合date命令prefix$(date %Y%m%d) for f in *.log; do mv $f ${prefix}_${f} done5.2 遍历服务器列表执行远程命令运维场景里最常见的一个需求有一台跳板机要去几十台服务器上执行同一段命令。我习惯先把服务器IP或主机名写进一个iplist.txt文件然后配合while逐行读取while read -r hostname; do [ -z $hostname ] continue echo 正在处理: $hostname ssh -o ConnectTimeout5 $hostname df -h / done iplist.txt文件里如果有空行[ -z $hostname ] continue可以快速跳过避免ssh拿到空参数后报错。这是读配置类文件时很值得保留的习惯。5.3 并发循环后台加wait控制循环体里的命令默认是串行执行的一个跑完才跑下一个。处理几百个文件、几十台服务器时串行会非常浪费时间。一个简单的提速方案是把命令放到后台执行循环结束后用wait等待所有任务完成for ip in $(cat server.list); do ( ping -c1 -W1 $ip /dev/null 21 echo $ip 通畅 || echo $ip 不通 ) done wait注意我用了子Shell的括号把批量命令包起来这样循环的每次迭代都能在独立环境里执行不会有变量互相污染。这个方案在需要并发执行但又不想引入xargs或GNU Parallel的时候特别实用。如果担心并发数量太多压垮机器还可以在循环里维护一个计数器每启动几个任务就wait一次做简单限流。5.4 日志文件按天清理生产环境里日志清理是每天都要面对的事保留最近7天日志的脚本可以这样写log_dir/var/log/myapp days7 find $log_dir -name *.log -mtime $days -print | while read -r logfile; do echo 清理过期日志: $logfile rm $logfile done这里用find按修改时间筛选出超过7天的日志文件再交给while逐行处理。如果确认筛选结果没问题可以把echo行删掉直接用rm $logfile。第一次跑的时候我强烈建议保留echo先试运行一遍确认没有误删再开真正的清理这个习惯能省下很多麻烦。6. 调试与防护让循环脚本在生产环境敢跑6.1 bash -x 逐条跟踪循环执行循环脚本一旦出错定位难度比普通脚本高很多因为同样的命令要跑几十上百遍你不知道是哪一遍、哪个变量出了问题。我调试任何带循环的脚本首选就是bash -x方式执行bash -x myscript.sh执行时Shell会把每个命令展开后的真实内容都打印到终端变量被替换成什么值一目了然。比如for i in {1..3}你能直接看到i依次变成1、2、3命令也逐条展开。加上-x之后的输出量确实大但排查循环问题这份输出就是最好的线索。6.2 set -eu与循环的共处很多讲究的脚本开头都会写set -eu意思是遇到未定义变量直接报错、命令返回非零立刻退出。这两个设置在循环场景下要特别注意。set -e会让循环体内任何一条命令执行失败时整个脚本退出好处是无法掩盖错误坏处是有时循环体里某些命令本来就可能返回非零一旦出现整个脚本就中断了。我的处理方式是不是每条命令都需要全局生效的set -e可以用在具体命令后加|| true的方式明确告诉Shell这条命令允许失败不影响循环继续。比如ping -c1 $ip || true这样既保持脚本整体对错误敏感又不会因为个别正常波动就全盘退出。6.3 用dry-run模式先跑一遍循环脚本最怕的不是运行慢而是破坏性操作批量误执行。清理文件、删除日志、批量改配置这类脚本我几乎都会内置一个dry-run开关dry_run1 for f in *.log; do if [ $dry_run -eq 1 ]; then echo 将删除: $f else rm $f fi done只要把dry_run1改成dry_run0脚本就变成真实执行。这个模式的精髓在于让脚本在正式执行前把将要做什么完整打印出来人工确认无误后再放行。老话讲先看一遍再动手在这种批量操作场景里这个习惯比任何防护都管用。写到这里我想起自己早期写循环脚本时的狼狈样——一边跑一边盯着终端生怕哪个变量没展开、哪条命令写错把线上文件删掉一片。后来我把上面这套通配符遍历、IFS处理、管道换重定向、dry-run先行、bash -x定位的组合练成肌肉记忆后循环脚本就从一个天天提心吊胆的环节变成了最可以放心交给别人维护的部分。Shell循环语句值得花时间彻底吃透它几乎贯穿所有运维脚本、部署脚本和数据处理脚本用熟练之后你写脚本的速度和信心都会上一个大台阶。
返回列表