ARTICLE DETAIL

资讯详情

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

Bash并行for循环:7个实用示例与避坑指南

Bash并行for循环:7个实用示例与避坑指南 写了这么多年脚本我现在看到遍历几十上百个元素的 for 循环第一反应就是给它加一个然后在循环结束处补一句wait。这个习惯帮我省下大量等待时间。今天把“Bash 并行 for 循环”这组技能系统整理一遍给出 7 个能直接抄走改改就用的例子包含常见坑和排查思路。适合正在写批量压缩、批量拉数据、批量渲染、批量测试脚本却觉得一个个跑太慢的朋友。你不需要提前掌握什么复杂工具只要机器上有 Bash 环境后半部分再补一个 GNU parallel 的外部工具例子其余代码都是原生能力开箱即用。1. 为什么推荐在 Bash 中编写并行 for 循环1.1 串行循环的瓶颈到底在哪很多人第一版脚本是从串行 for 开始的逻辑很简单遍历一个列表每次循环里执行几个命令等命令结束进入下一个。看起来没毛病但一旦列表长度上百或者循环体内的命令耗时明显问题就暴露了。第一个瓶颈是单核空转。如果循环体里做的是压缩、图片处理、转码这类 CPU 密集型操作无论你机器有多少核串行循环都只用一个核剩下核心在旁边看热闹。第二个瓶颈是等待型任务。最典型的是网络请求、数据库查询、睡眠延时。这类操作几乎不占 CPU大量的时间花在等待远程返回上串行循环会让整条链路变成“发一个请求、等一个响应、再发下一个”完全浪费了网络和磁盘的吞吐能力。所以并行循环的核心诉求只有一句话把互相之间没有依赖的独立任务从排队执行改成同时执行。判断依赖关系是起步动作循环体内部如果读写同一个文件、递增同一个全局变量、往同一个日志文件追加内容这类情况必须先解决冲突否则并行之后错误率比串行还高。1.2 Bash 并行循环的两个核心原语 与 wait在 Bash 里做并行不需要引入复杂的线程库或进程池组件借助的是最朴素的“后台任务”机制。你在命令末尾加一个Shell 会把这条命令放到后台执行然后立即返回控制权继续跑下一条命令。于是一个 for 循环原本只能一次执行一个循环体现在可以快速把多个循环体都“丢”到后台让它们同时运行。但后台任务不能不管否则脚本可能在任务完成前就结束退出。这时用到wait。不带参数执行wait就是当前 Shell 等待所有后台任务结束等全部结束再继续后面代码。这个组合构成了 Bash 并行 for 循环最基础的形态。可以用一个生活类比理解串行循环像只有一个售票窗口大家都得排队后台加 wait 就像开了 N 个窗口客户全部取号进场工作人员在门口等最后一个人办完再关门。是放客户进去wait是关门等最后一人就这么简单。2. 七个可直接复制的 Bash 并行 for 循环示例2.1 示例一基础后缀后台任务加 wait快速无脑并行这是最原始的并行写法也是理解其他所有方案的地基。#!/usr/bin/env bash for i in {1..10}; do ( echo 任务 $i 开始 sleep $((RANDOM % 3 1)) echo 任务 $i 完成 ) done wait echo 所有后台任务结束我把循环体用圆括号包起来让里面命令跑在一个子 Shell 中。这么做的原因是避免循环里的变量修改污染当前 Shell 环境也避免 CD 目录切换影响后续处理。如果你循环体里只是简单执行一个外部命令不加括号也行但只要涉及变量赋值、临时目录跳转加上括号更干净。( ... ) 这个形态里加在整个复合命令末尾等效于把整个子 Shell 放入后台。执行脚本后你会发现打印结果顺序不再是从 1 到 10而是根据RANDOM决定的完成顺序输出这说明并行确实生效了。这段代码有两个地方值得记住。第一等待语句必须放在循环之后位置错了就前功尽弃。第二如果循环内需要捕获进程 ID 来做更精细的控制可以用$!它记录了最近一个后台任务的 PID后面接wait $pid可以单独等某个具体任务。2.2 示例二用 xargs -P 控制并发数量不玩脱基础写法有一个明显缺陷如果你的列表有 1000 项脚本会一次性创建 1000 个后台进程轻则拖垮内存重则把系统资源耗尽。真实生产环境我们通常想限制同时运行的任务数量比如最多 5 个、8 个或 16 个。xargs命令自带的-P参数就是为此设计的。seq 1 30 | xargs -P 6 -I {} bash -c echo 正在处理项目 {} sleep 1 echo 项目 {} 处理结束 这个命令的意思很简单把seq生成的 30 个数字作为输入列表-P 6表示让 6 个进程同时干活-I {}指定后面用{}占位符接收输入值。bash -c后面跟着的就是你的循环体内容。为什么我推荐在 for 循环之外记住这个 xargs 写法因为它用一条命令同时解决了“遍历”和“限流”代码更短。如果你的循环体不止一行可以写到独立脚本函数里然后用xargs反复调用。实测场景里我需要批量下载 800 个附件串行要跑 20 分钟用-P 10两分钟就结束关键就是并发数可调不会把带宽打满导致公司同事办公网络卡死。补充一个细节-I {}会替换输入中的{}但如果文件名或参数里本身包含大括号字符占位符最好换成一个不冲突的符号比如-I 。另外输入的分隔符可以通过-d指定换行符或空字符处理文件路径时常用-d \n避免空格路径被拆开。2.3 示例三用 GNU parallel 处理重量级并行任务如果你的环境允许安装一个外部工具GNU parallel 是处理并行的重型武器。它不只是替代 xargs还额外提供了任务分配、进度条、任务日志、失败重试、远程分发等一堆功能。Debian 系的 Linux 可以用apt install parallel安装macOS 上用brew install parallel装好后命令名就是parallel。parallel -j $(nproc) --progress echo 任务 {} 开始; sleep 1 ::: {1..20}这里-j指定并发数$(nproc)会自动获取 CPU 逻辑核心数--progress会在终端显示进度:::后面跟的是任务参数列表。GNU parallel 还可以从标准输入接收任务参数比如配合find命令批量处理文件。我更喜欢的是--joblog参数。它会生成一个任务日志文件记录每项任务的起止时间、退出码和是否成功。举个例子parallel -j 8 --joblog run.log bash process.sh {} ::: {1..100}跑完之后打开run.log可以清楚看到哪些任务退出码非零失败的任务还能重新只跑那几条不需要翻终端半天。批量数据迁移、批量渲染、批量回归测试这一类需要“跑完回头看明细”的场景GNU parallel 是首选。但要注意GNU parallel 虽然叫 parallel它和发明人并列命令关键词没有关系不要在不知名的服务器上默认存在生产部署前先检查工具是否安装。2.4 示例四自建并发池用 wait -n 精准控制同时任务数如果不想依赖外部工具又不想一次性创建过多后台进程可以在脚本里自己实现一个简单的并发池。核心是 Bash 4.3 版本起提供的wait -n参数它的作用是“等待任意一个后台任务结束然后返回”。#!/usr/bin/env bash max_jobs4 running0 do_work() { local id$1 echo 任务 $id 处理中 sleep $((RANDOM % 3 1)) echo 任务 $id 完成 } for i in {1..20}; do if (( running max_jobs )); then wait -n (( running-- )) fi do_work $i (( running )) done wait echo 全部任务结束逻辑拆开看很容易理解running变量记录当前启动了多少个后台任务。每次循环先检查是不是已经达到上限如果达到上限就调用wait -n等待其中任意一个结束然后把running减 1腾出配额再启动新任务。整个过程保持最多只有 4 个任务同时存在。我把do_work写成函数这样循环体结构清晰后面扩展也方便。这段代码的调度逻辑就是迷你版的线程池只是用进程实现的。实际使用中我把它封装成模板只要改max_jobs和do_work函数本体就能复用到各种批处理脚本。需要说明的是wait -n在极端情况下可能会因为信号或者作业控制问题报错所以我在前面的脚本里加了一个最后兜底的wait确保即使调度漏掉了某个子进程脚本也不会提前退出。2.5 示例五从文件列表并行处理不怕文件路径带空格很多时候任务列表不是几个数字而是一个文件路径清单可能长达几千行。从文件里逐行读取再并行处理比在命令行里传入参数更实用。#!/usr/bin/env bash task_count0 batch_limit20 while IFS read -r file; do [ -z $file ] continue cp $file /backup/ ((task_count)) if (( task_count % batch_limit 0 )); then wait fi done filelist.txt wait echo 分批并行复制完成读文件用的是while read配合重定向IFS保持行首行尾空格不丢失-r防止反斜杠转义。看到[ -z $file ]跳过空行这个习惯建议保留因为空行经常出现在手动维护的清单里。这个写法相当于每攒满 20 个任务就等待这一批完成再进行下一批。和两个例子相比它的优势是内存友好不需要一次性读取几千行到数组而且每批之间的等待给了系统喘息的空当。处理 CSV 导出、日志分析、批量备份这类文件型任务我常直接用这个模板。有一个容易踩的坑不要写成cat filelist.txt | while read ...因为管道符号会创建子 Shell循环内部修改的变量比如task_count在循环结束之后会丢失你会在统计计数时发现一直是 0。用while ... done filelist.txt能避开这个坑因为重定向发生在当前 Shell 进程内。2.6 示例六并行任务的日志隔离和状态捕获并行任务最难看的往往不是速度而是输出。多个进程同时往同一个终端或者同一个文件里打印日志结果就是内容互相穿插你再也分不清哪行是哪条任务的。解决办法是给每个任务分配独立的日志文件跑完后再合并。#!/usr/bin/env bash tmp_dir$(mktemp -d) for id in {1..10}; do ( process_data $id $tmp_dir/out_$id.log 21 echo $? $tmp_dir/status_$id ) done wait cat $tmp_dir/out_*.log all_result.log每条任务把标准输出和错误输出都重定向到自己的日志文件再单独记录退出码到一个 status 文件。等到所有任务结束再统一合并日志。这样既保住了每个任务自己的完整输出又能检查任务是否失败。mktemp -d在这里很关键它生成一个随机临时目录避免多个实例运行同一个脚本时互相覆盖临时文件。这个函数我在并行脚本里几乎每次都用比手动写固定文件名安全得多。如果你不习惯多文件合并还有另一个办法给每个任务设置一个前缀标记然后打印到同一个日志文件。不过实测下来如果你用echo Task $id: ...拼接字符串想要防止交错是个伪命题因为多个进程同时echo到同一条管道时输出仍然可能交错最稳妥的路径还是“一任务一日志文件”。2.7 示例七在 xargs 子 Shell 里调用自定义函数到了第七个例子解决一个问题脚本里写好的函数在 xargs 创建的bash -c子 Shell 里默认是找不到的。因为子 Shell 是全新的环境父 Shell 里定义的函数不会自动传递进去。解决办法是用export -f把函数导出到环境变量。#!/usr/bin/env bash worker() { local item$1 echo worker 开始处理 $item sleep 1 echo worker 完成了 $item } export -f worker seq 1 30 | xargs -P 8 -I {} bash -c worker $1 _ {}关键在最后一行bash -c worker $1 _ {}里_被当作$0传入而{}这个占位符对应$1。这样worker函数就能接收到正确的参数。这个模式在需要并行调用带参数脚本时非常常用。比如我要处理 100 个不同的测试用例每个用例需要调用一个封装好的函数内部有公共的日志函数、配置读取函数、超时控制逻辑。用export -f能把所有这些函数管线完整带进子 Shell。还有一个小技巧如果自定义函数依赖一些环境变量比如DATA_DIR、BASE_URL这些变量也可以通过export导出子 Shell 就能继承。我习惯在脚本开头集中定义一个“环境变量专区”需要传递给并行的变量全部放那里。这样每种子任务都拿到一致配置排查问题时也能快速确定变量来源。3. 并行循环实战中遇到的问题与排查实录3.1 常见症状和解决方案速查表我把这么多年跑并行脚本踩过的坑整理成一张表可能帮助你少走弯路。症状表现常见原因解决办法输出日志内容互相穿插多个进程同时写入同一文件或管道每个任务独立日志文件结束时统一合并脚本提前结束后台任务被杀父 Shell 退出导致后台任务终止用wait等待所有子进程或使用nohup避免挂断任务数量太多系统卡死并发数没有限制用xargs -P、wait -n并发池或调整示例二到四批量任务报错但脚本返回成功只调用wait没有检查子进程退出码逐个收集 PID 后wait $pid并记录退出码循环内变量计数永远是 0管道创建子 Shell变量在子 Shell 中修改使用重定向代替管道如done filelist.txt文件名带空格被拆成两个参数xargs 默认按空格拆分指定-d \n或使用while read逐行传递部分任务明明失败但没看到报错错误输出被丢弃到/dev/null或未重定向使用21保存到日志失败后单独重跑这张表里最需要重视的是第二行和第四行。很多人写脚本时任务跑完看了一眼“好像全完成了”实际上中间失败一大半就是因为既没检查输出文件也没检查退出码。3.2 退出状态码收集与 wait 的细节盘问wait不带参数是等所有后台任务但它的返回状态是最后一个被等待任务的退出状态。如果你有 10 个任务第一个失败了最后一个成功了wait返回 0你的整个脚本看起来就是成功的。这个坑非常隐蔽。要准确收集每个任务的状态建议这样写#!/usr/bin/env bash pids() for i in {1..10}; do sleep 1 pids($!) done failed0 for pid in ${pids[]}; do if ! wait $pid; then (( failed )) echo PID $pid 执行失败 fi done echo 失败任务数量: $failed把每个后台任务的 PID 收集到数组循环逐个wait $pid通过返回值判断是否正常结束。这样你不仅知道有没有失败还能知道具体哪个失败。这个模式适合对准确性要求高的场景比如批量上传文件必须保证每一份都成功才算本次结束。如果你确实是在使用wait -n的并发池场景想收集单条任务的退出码又不想破坏并发调度可以在某个任务结束后保存当时的$?。但并行进程的返回码和子进程 ID 需要通过数组映射才能对上建议直接使用每个任务输出目录的 status 文件方案我前面示例六就是这么设计简单可靠。3.3 set -e 给的意外惊喜很多脚本开头会写set -e表示任何命令失败就退出脚本。但在并行脚本里set -e往往和苏格拉而不是朋友。因为wait返回非零、某个后台命令自然失败都会让脚本瞬间终止后面未启动的任务直接消失。我踩过的最深刻一次一个批量数据清洗脚本加载千万行数据处理到第 1000 条时其中一条数据格式异常导致命令返回非零set -e直接终止了整个脚本前面半天白跑。后来我再写并行脚本如果不是绝对必要的确会给每个任务执行位置维护if ! ...; then echo 失败; fi而不在脚本全局开set -e。如果一定要开启也要在wait、命令替换和子 Shell 调用点加上|| true或者set e/set -e的局部切换。3.4 关于“并行”本质的提醒进程还是线程最后强调一个重要认知这些例子用的都是多进程并行而不是多线程。Bash 本身不提供线程每条后台任务本质上是一个独立进程。这意味着它们拥有独立的环境空间能够改善程序之间的文件锁问题但同时也意味着每个进程都要重新读取库文件、环境变量启动开销比线程略大。所以任务本身体积太小比如只执行echo hi并行的收益反而不明显因为进程创建的开销超过了并行省下来的时间。这个认知也解释了一个实际观察如果循环体内是“轻量命令一大堆”可以把多条命令合并到一个后台任务里执行减少进程创建次数如果是“重量级脚本”一个任务一个进程就很合理。我通常在脚本里用time跑一遍串行再跑一遍并行对比结果后才决定是否保留并行。4. 如何把并行循环真正落进你的工作流4.1 意识顺序先串行验证再并行提速最后限流别一开始就给一个没验证过的循环体加并行。我的工作习惯是三步走第一步循环体写成函数随便取一个参数跑通确认逻辑和输出没问题。第二步写一个很少并发数比如 2跑几个样例确认结果和串行一致。第三步真正调高并发数加到目标值后再观察系统负载和任务错误率。尤其是带有后续数据处理的循环并行会改变输入顺序如果程序对顺序敏感结果就会和串行版本不同。先验证再提速能省下大量排查时间。4.2 并发数不是越大越好资源要算我见过新手直接-P 100把服务器打挂的。讨论并发上限要看任务类型。CPU 密集型任务并发数通常取$(nproc)的 0.75 到 1 倍左右因为你需要留一个核跑系统服务和脚本本体。磁盘 I/O 密集型任务比如压缩解压、文件拷贝并发数可以适当提高但要注意磁盘带宽太高的并发会导致大量磁盘频繁寻道反而变慢。网络 I/O 型任务下载、接口调用并发上限更高通常 10 到 50 都是合理范围但前提是网卡和远端服务允许。想要快速测试合适并发数可以用time记录不同-P数下脚本耗时。比如-P 2跑一次 60 秒-P 4跑一次 35 秒-P 8跑一次 38 秒说明 4 就是当前环境的最佳值8 开始下降是因为资源竞争。4.3 并行脚本工程化的三个自检问题每次脚本交付给别人之前我习惯过一遍三个问题。第一日志能不能定位问题每一行日志里有没有任务 ID、时间戳、当前步骤如果没有出问题后你会对着几兆日志无从下手。建议至少每个任务带一个编号前缀这只需要示例六那种日志文件方式就能自动实现。第二中间文件有没有清理机制临时目录、临时文件用完是否删除并行任务多的时候一个失败的脚本可能残留几百个临时文件。习惯性使用mktemp -d并把目录放到trap里面清理是一个很好的工程素养。第三任务失败之后能否重跑如果脚本是分段批量处理建议给每个任务输出一个状态标记文件下次运行时跳过已经成功的任务。这个设计在长时间批处理中价值极大即使脚本中途崩溃重跑时也不会浪费时间重复已完成的任务。把这三件事做进模板我几乎所有的批处理脚本都保持了稳定的质量不管任务规模是几十还是十几万量级。最后再分享一个细节。我在实际使用中把并发控制参数几乎总是放到脚本最顶部的变量里MAX_JOBS4而不是直接写死在命令里。这样换一台性能更强的机器或者跑在负载更低的时间段只用改顶上一行。最近一次 8000 个增量数据包的合并任务我把MAX_JOBS从 4 调到 16整体耗时从两小时压到不到二十分钟期间只看了一眼htop确认 CPU 没有爆表。调参能力和写循环代码一样重要这大概就是并行脚本最值得花时间沉淀的地方。
返回列表