ARTICLE DETAIL

资讯详情

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

Shell循环详解:for、while与until的实战应用与避坑指南

Shell循环详解:for、while与until的实战应用与避坑指南 写脚本的人早晚会遇到这么一幕要批量重命名一批日志文件要逐个检查几十台主机的端口状态要把一整个目录里的图片按日期归档。第一回你老老实实复制粘贴命令改个文件名跑一次第二回你发现只是换了个日期而已第三回你开始认真考虑一个问题——能不能让电脑自己把这些重复动作做完。答案就是shell里的循环语句。我最开始接触shell循环时以为它就是个多做几遍的语法糖后来写多了才发现循环其实是脚本的骨架。没有循环的脚本是一次性命令清单有了循环脚本才真正具备处理批量任务、监听状态变化、遍历各类输入的能力。无论是刚准备入门shell脚本的新手还是已经写了不少脚本但总被各种坑绊住的老手这篇文章都值得你花几分钟读完。我会从三种循环的根本区别讲起再结合文件遍历、读文件、参数解析、后台任务这些真实场景把循环的写法、原理、坑和性能问题一次说清楚。1. 循环解决了什么问题从手工重复到批量自动化1.1 什么时候该用循环判断信号与典型场景判断自己需不需要用循环我有个非常简单粗暴的标准同一串命令你手工执行了两遍以上就该停下来想循环了。第三遍还在复制粘贴改参数那就是在浪费生命。拿我自己经历过的一个例子来说有一段时间每天要清理一批旧备份文件规则是保留最近7天的备份其他删掉。手动操作就是先ls看日期再按文件名删。文件少的时候还行文件一旦多了眼睛会花手会抖还可能误删。后来我写了一个三行的for循环把目录遍历、日期比较、删除动作全部包进去每天一条命令搞定。适合用循环的典型场景其实很好识别批量处理类批量重命名、批量压缩、批量转换格式、批量修改权限批量检查类批量ping主机、批量检查端口、批量查看磁盘使用率、批量验证日志关键字遍历类逐个读取文件内容、逐行处理CSV、遍历目录结构等待监听类等待服务启动、轮询任务完成状态、持续监控某个条件反过来有些情况是不适合硬套循环的。比如你只是删除一个固定文件写for i in 1; do rm -f $i; done就纯粹是脱裤子放屁。循环的价值在于批量和动态如果集合是固定、静态且永远不会变的直接写命令反而更清晰。1.2 三种循环指令的本质分工for、while、untilShell里有三套循环指令for、while、until。很多人把它们当成同一个东西的三种写法其实它们的分工完全不同。我给它们一个容易记的类比for循环像点名册。你已经知道要处理的对象集合——不管是文件名、数字范围还是命令行参数拿着清单逐个处理就行了。它的核心是遍历一个已知集合。while循环像听命令。它不关心要跑多少遍只关心条件成立不成立。条件为真就跑一次跑完回来再看条件直到条件变假为止。它的核心是重复执行直到条件不再满足。until循环像倒计时。它和while正好反过来条件是假的时候才继续跑条件变真就停。听起来有点反直觉但在某些特定场景比如等一个服务状态变为可用反而更顺口。它们的语法结构都差不多我们直接看一个对照示例# for遍历数字范围打印1到5 for i in 1 2 3 4 5; do echo 第 $i 次 done # while当计数小于5时继续 count0 while [ $count -lt 5 ]; do echo 当前值: $count count$((count 1)) done # until直到计数达到5才停止 count0 until [ $count -eq 5 ]; do echo 还没到5: $count count$((count 1)) donefor循环体里i是循环变量每次从清单里取一个值往里塞。while后面跟的是条件表达式[ $count -lt 5 ]本质上是调用test命令做判断返回值是0真继续循环非0假就退出。until的逻辑正好相反。选择哪种取决于你先知道什么先知道处理清单用for先知道停止条件用while先知道什么时候才算完事用until。2. for循环的实战变体文件遍历、命令输出与C风格写法2.1 遍历文件名与路径最常用的for写法For循环在脚本里出现频率最高的用途就是遍历文件夹里的文件。最简单的形式是配合通配符glob使用# 遍历当前目录下所有 .log 文件 for f in *.log; do echo 处理文件: $f done # 遍历 /var/log 下所有文件 for f in /var/log/*; do echo 路径: $f done这里有个新手极容易忽略的坑如果没有匹配到文件通配符不会变成空而是保持字面量*.log原样传进来。也就是说当前目录一个log文件都没有的时候循环变量f会变成字符串*.log本身然后循环体还真的会执行一次。我在生产脚本里见过这种bug导致的诡异行为。解决方式是在脚本开头设置nullglob选项shopt -s nullglob for f in *.log; do # 没有匹配文件时循环体一次都不会执行 echo 处理文件: $f donefor后面不一定要跟通配符直接跟变量展开也行。最经典的坑在这里很多人写for f in $来遍历所有命令行参数但$不加引号的话遇到带空格的参数会被拆成多个词最终处理的对象不是一个参数而是拆碎后的片段。正确写法是用$保留每个参数作为独立整体# 错误示范参数带空格时会拆碎 for arg in $; do echo $arg done # 正确写法每个参数原样保留 for arg in $; do echo $arg done顺便说一句要遍历连续数字for i in {1..10}和for i in $(seq 1 10)都可以。前者是bash的花括号展开更快更直接后者依赖外部命令seq还会经过命令替换和分词性能差一些。除非你有特殊理由比如seq支持seq 1 2 10这种带步进的写法否则优先用花括号展开。2.2 用for消费命令输出时的分词陷阱遍历文件用通配符确实顺手但很多时候你的处理清单不是现成的文件而是某条命令的输出结果。比如要找出所有包含特定关键字的文件再逐个处理。最直觉的写法是这样for f in $(grep -l ERROR *.log); do echo 含有ERROR的文件: $f done这个写法能用但你必须清楚它背后发生了什么$(...)命令替换得到一串文本然后shell会对这段文本做词分割word splitting和路径名展开pathname expansion。也就是说输出结果里的空格、制表符、换行都会成为切分符。假如你的日志文件名本身带空格比如app log 2024.log它会被拆成app和log和2024.log三个词循环就炸了。我的建议是能用通配符就用通配符别用命令替换。真要消费命令输出优先考虑改成while循环读行后面会详细讲。如果非要用for可以调整内部字段分隔符IFS让shell只在换行处切分# 仅按换行切分避免空格/制表符把文件名拆碎 IFS$\n for f in $(grep -l ERROR *.log); do echo 含有ERROR的文件: $f done不要忘了循环结束后把IFS恢复成默认值。我在实际项目里习惯在一个子shell中临时改IFS避免污染后续代码( IFS$\n for f in $(grep -l ERROR *.log); do process_file $f done )用括号包起来会启动子shell里面的所有变量修改都不会影响外面算是一种干净的隔离方式。2.3 C风格for循环当Shell遇见算术逻辑除了遍历集合for循环还有一种写法经常被忽略——C语言风格。它的形式是双括号加两个分号for ((i 0; i 10; i)); do echo i 的值: $i done和传统for最大的区别在于它处理的是算术逻辑而不是集合遍历。你可以控制计数器的初始值、步长、终止条件全都自己定。这在累加求和、倒计时、按索引遍历数组时特别好用。举个实际的例子假设你有一个数组想从索引5开始隔一个取一个arr(apple banana cherry orange mango grape) for ((i 0; i ${#arr[]}; i 2)); do echo 索引 $i - ${arr[$i]} done注意这里循环条件是真实的算术比较不是字符串比较所以i 10不需要写成i 10那种方括号test形式。整个循环体的性能也要比for i in $(seq ...)好得多因为不涉及外部命令调用。有一个容易和C风格for混淆的点for ((...))的双括号和算术展开$((...))长得像但用途不同。一个是循环语法一个是数值计算。如果你在一个普通for里想计算变量别把两者搞混。结合((count))这种运算在内层操作计数器是非常常见的模式写法上多留个心眼就好。3. while循环与break/continue读文件、监听变化与流程控制3.1 while read line到底怎么读文件才安全要逐行处理文件最稳妥、最高频的写法就是while read配合重定向。但这里面的细节非常多我先把标准范式给你while IFS read -r line; do echo 读取到: $line done input.txt这行代码每个部分都有讲究IFS等号后面为空表示将字段分隔符设置为空防止read把行首行尾的空白修剪掉。read -r中-r告诉read不要把行尾的反斜杠当作续行符处理保证内容是原样读入。done input.txt是关键中的关键这确保了read从文件取数据而不是从标准输入。很多人会用cat input.txt | while read line; do ...; done这个写法能跑但有一个非常阴损的副作用管道会让while跑在子shell里循环内修改变量改完就丢。我在第4节会专门展开讲这个问题这里先记住结论——用重定向别用管道。对了while read不只是读文件读命令输出也常用记得借助进程替换process substitutionwhile IFS read -r -d file_path; do echo 处理: $file_path done (find /data -name *.tmp -print0)这里-print0用空字符分隔文件名read -d 也用空字符作为行分隔符彻底避开文件名里换行、空格造成的错乱。这一招用于处理带空格甚至带换行的文件名是无敌的。3.2 break和continue的层级控制与配套技巧循环的核心不只是怎么进去还有怎么出来。break用于跳出循环continue用于跳过当前这一轮直接进入下一轮。它们俩都能带一个数字参数表示跳几层for i in 1 2 3; do for j in a b c; do if [ $j b ]; then break 2 # 直接结束内外两层循环 fi echo $i$j done done # 输出只有 1a这个例子可能有点极端但break 2这个写法在处理多层嵌套中遇到异常直接整体退出时是救命稻草。比如你两层循环遍历品牌和机型突然发现一个无效参数不想一层一层break上去直接用break 2干净利落。continue同理continue 2会跳到第二层循环的下一轮。比起用一个标志位在各种if里判断来判断去层级控制更直观。还有一个配合while的经典组合无限循环加case菜单。这种模式在交互式脚本里非常多见while :; do echo 请选择操作: 1) 备份 2) 清理 3) 退出 read -r choice case $choice in 1) do_backup ;; 2) do_clean ;; 3) break ;; *) echo 无效输入请重新选择 ;; esac done注意while :里的冒号在shell语法里它是一个永远返回真退出码0的内建命令相当于无条件死循环。你也可以写while true效果一样。区别是:更快一点少了调用外部命令的步骤。3.3 while在持续等待场景中的真实用法While循环在处理等待某个条件时是无可替代的。比如启动一个服务后你不能立刻访问它得等端口起来。最直觉的写法是sleep 10硬等但10秒不够就继续报错10秒够了又觉得浪费。用while轮询就优雅得多# 最多等待30秒每秒检查一次端口 for ((i 0; i 30; i)); do if nc -z 127.0.0.1 8080; then echo 服务已就绪 break fi sleep 1 done这里用for做超时控制配合nc探测端口。想换成while也行关键是让脚本具备可终止的等待而不是傻等。生产环境里我发现一个经验几乎所有轮询等待的循环都必须有超时上限。没有上限的等待循环在服务永不就绪时会变成僵尸脚本挂在那里每天消耗你的心智。再看一个until的经典应用场景——等文件出现until [ -f /tmp/deploy.done ]; do echo 部署还没完成5秒后再检查... sleep 5 done语义直白直到标志文件出现前一直重复检查。这种写法比while [ ! -f /tmp/deploy.done ]读起来顺畅得多。你可以在部署脚本末尾touch /tmp/deploy.done监控脚本这边配合until整个交互逻辑就从判断条件变成了等待结果代码可读性直接上一个台阶。4. shell循环中的高频坑性能、变量作用域与空循环4.1 管道子shell问题为什么循环里的变量带不出来这个坑我相信所有写shell脚本的人都踩过而且踩完第一次还会踩第二次。看下面这段代码count0 cat numbers.txt | while read -r line; do count$((count 1)) done echo 总行数: $count # 输出总是0输出毫无悬念是0因为管道后面的命令包括while循环整体运行在子shell里子shell修改的变量不会传回父shell。这就是为什么前面我强调读文件一定要用重定向而不是管道count0 while read -r line; do count$((count 1)) done numbers.txt echo 总行数: $count # 正确输出实际行数如果是命令输出就用进程替换count0 while read -r line; do count$((count 1)) done (cat numbers.txt) (...)的进程替换在当前shell里执行不产生子shell边界变量自然能保留。我见过不少脚本因为这个问题在统计计数、汇总结果时得到空值排查半天发现是管道在捣鬼。写循环的第一步想清楚这个循环里有没有需要带出来的变量有的话就远离管道。4.2 性能对比while慢在哪find和xargs为什么绕不开Shell循环的性能问题是当我们处理大量文件时绕不开的话题。很多人写过这种代码# 处理10万个文件用for逐个执行命令 for f in /data/files/*; do grep -l 关键字 $f done这个写法语法没错但它每处理一个文件就要fork一次外部命令进程。10万个文件意味着10万次进程创建系统开销极大实测下来能跑几分钟甚至更久。究其根源是shell本身不擅长为每条数据启动一个程序这种模式。对比之下find加-exec或者xargs之所以高效是因为它们会批量传递参数把几百上千个文件名一次性作为参数传给命令大幅减少进程启动次数# 用xargs批量处理参数一次给一批 find /data/files -type f -print0 | xargs -0 grep -l 关键字 # 不想写xargs时用find -exec加号也能批量 find /data/files -type f -exec grep -l 关键字 {} 这里的-print0和-0都是为了处理文件名带空格的情况用空字符分隔。数据量大了方括号test的每轮开销也会累积。一个经验数值文件数超过几千就开始值得考虑用find/xargs几十万级别的文件用while循环配合read或者用for直接遍历性能都会让人抓狂。但这不意味着shell循环一无是处。当你的处理逻辑本身就是逐行判断、逐个维护状态这种没办法批量参数化的任务时循环仍然是唯一选择。用循环之前先问问自己这条命令能接受多个文件名参数吗能就把参数批量喂给它而不是用循环一个接一个喂。4.3 空变量与特殊字符引号规范与文件名健壮性循环里踩过最多的坑我总结为三类空变量导致的意外循环、特殊字符导致的切分错误、以及引号缺失导致的参数错位。空变量问题非常隐蔽。假设你写targets for f in $targets; do echo 处理 $f done你心里想的是targets是空的循环体不该执行实际结果却会执行一次而且$f的值为空字符串。因为shell在做词分割时空字符串被分出了0个词但这取决于你用的shell版本和上下文。某些情况下未加引号的空变量展开后仍然会作为参数传递。更常见的坑是filesa.log b.log for f in $files; do # a.log和b.log被正确切开但如果你期望a.log b.log作为一个整体就错了 process $f done再叠加文件名带空格file_namemy app.log for f in $file_name; do # 实际处理的是 my 和 app.log 两个文件 process $f done这不是写错语法的问题而是没有意识到$var不加引号时shell会做词分割。统一策略就是我反复强调的凡是变量的值表示一个整体一个文件名、一个路径、一个参数就必须加双引号$var凡是明确想分割的比如把一个空格分隔的字符串拆成多个词才允许裸展开$var。还有一个保护性选项值得推荐bash里设置set -u遇到未定义变量会直接报错退出避免空变量参与循环引发重复或漏处理。我是强烈建议所有脚本开头都写上set -u配合set -e遇到命令失败就退出一起用能挡住大量低级错误。不过set -e在循环里要小心因为循环条件判断失败也会触发退出多写一段时间后你会找到适合自己的组合。5. 循环与真实脚本需求的组合实战参数遍历、后台任务与交互5.1 用shift和getopts在循环里解析位置参数写工具类脚本时第一个要面对的问题就是解析命令行参数。最常见的做法是while加shift配合case。shift命令的作用是让位置参数整体左移$1变成原来的$2$#减一所以它天然适合放在while里挨个消费参数while [ $# -gt 0 ]; do case $1 in -h|--help) show_usage exit 0 ;; -f|--file) shift # 跳过选项本身 input_file$1 # 选项参数值 ;; -v|--verbose) verbosetrue ;; *) echo 未知参数: $1 exit 1 ;; esac shift done这里两次shift的逻辑要想清楚消费到-f时它的下一个参数是文件路径所以要把$1移到文件路径上取完值后循环末尾再移一次。$在循环里逐项移动等所有参数处理完$#变成0循环结束。这套模式是shell脚本解析参数的地基。如果你不想手搓shiftbash自带getopts可以处理简单选项。它和while天然是一对while getopts f:v opt; do case $opt in f) input_file$OPTARG ;; v) verbosetrue ;; ?) echo 非法选项; exit 1 ;; esac donegetopts的字符串f:v里f:表示f选项必带参数v表示布尔开关。它自动帮你处理-f xxx和-fxxx两种写法$OPTARG拿到参数值$OPTIND记录下一轮处理位置。稍微大型一点的脚本我会优先用getopts因为它处理-f缺参、非法选项这类边界情况更省心。shift方案则适合参数结构复杂、需要自定义解析规则的场景。5.2 与grep、find联动在循环中筛选和处理文件循环不能孤立存在它必须和文件查找、内容过滤这些命令联动才有实战意义。最常见的模式是先用grep找到目标文件再进循环处理。# 找出 /var/log 下包含ERROR的所有日志逐个统计行数 for f in $(grep -l ERROR /var/log/*.log); do total_lines$(wc -l $f) echo $f 共 $total_lines 行 done但要提醒你循环里如果还要再一次次检查内容不要用grep输出匹配行直接用grep -q判断退出码for f in /var/log/*.log; do if grep -q ERROR $f; then echo 发现错误的文件: $f # 执行后续修复动作 fi donegrep -q的好处是找到一个匹配就立刻返回不输出任何内容不占用IO带宽大量文件时性能差异巨大。你写循环前先冷静一秒我的每一步到底是在获取数据还是判断布尔条件判断就用-q获取数据就老实捕获输出。handle奇怪文件名的终极搭配自然是find配合-print0和 whilewhile IFS read -r -d f; do # 这里的文件路径无论带多少空格、换行都是安全的 echo $f done (find /data -type f -name *.log -print0)5.3 循环中跑后台任务与交互式输入wait、expect与超时控制循环的进阶玩法是在循环体里启动后台任务用把子进程放到后台然后用wait等待所有后台任务结束。这在做并发批量任务时非常管用# 并发压缩多个文件 for f in /data/files/*.tar; do gzip $f done wait echo 所有压缩任务已完成注意循环体里挂会立刻把任务丢到后台循环本身不会等它。如果任务很多一次性全丢后台可能把系统资源占满常见的控制办法是限制并发数max_jobs5 running0 for f in /data/files/*.tar; do gzip $f running$((running 1)) if [ $running -ge $max_jobs ]; then wait running0 fi done wait这个攒够一批就统一wait的思路虽然简陋但在小范围并发够了。更严谨的并发控制可以用队列和jobs命令逐步收尾不过原理是一样的。至于交互式输入要分两种情况。第一种是脚本启动时向用户要密码等输入用read -s隐藏回显read -s -p 请输入密码: password然后循环里要用这个密码去调用需要交互认证的命令。第二种是脚本已经在后台跑比如nohup或没有终端可交互此时命令一执行就会卡在等待输入上。这种情况我建议两条路一是命令本身支持密码参数如带--password选项的CLI二是用expect脚本模拟交互。前者优先因为更可控后者如果遇到死命令只能靠超时兜底# 给交互命令设置30秒超时避免后台任务永久卡死 timeout 30 interactive_command --option即便设了超时日志里也要把退出码捕获到方便排查到底是正常退出还是超时被杀。循环里跑交互命令属于高风险操作建议你先在单条命令上验证稳定再放进循环批量执行不要一条命令没测通就套循环否则会一次性产生几十个卡死进程。最后说一个我自己的习惯写循环时顺手加一段进度输出尤其是处理大量文件时。echo [$i/$total] 正在处理: $f这种行级别的日志看着朴素真出问题时定位效率比闷头跑强十倍。用循环跑批量任务这么多年我最大的体会是循环本身不是难点难的是想清楚每个循环里有没有需要保留的状态每次迭代之间有没有依赖批量参数能不能替代逐条调用这三个问题。每次写循环前先在脑子里过一遍这几个问题99%的坑都能提前避开。如果你现在正要写一个循环不妨把这篇文章里的代码范式直接抄过去改一改踩过的坑多了自然会形成肌肉记忆。
返回列表