Shell多进程并发:从、xargs -P到GNU parallel的提速实战
发布时间:2026/10/5 10:51:51 作者:尧图编辑部 阅读量:1,286

这一章我们专门聊一个特别实际的话题Shell 多进程并发编程。写过批量脚本的人应该都有这种经历——一个for循环老老实实跑一百次任务CPU 明明是八核十六线程但整个脚本跑下来用了大半天。问题不在你写的逻辑上而在于默认串行执行把多核硬件完全浪费了。这一章要解决的就是这件事在不换语言、不引入复杂框架的前提下用 Shell 自身的机制把批量任务并发化把执行时间压下来。内容适合谁呢主要是两类读者一类是刚写完入门教程、正在尝试处理“批量下载”“批量压缩”“批量转码”这类场景的朋友另一类是已经在生产环境里用 Shell 脚本跑批处理任务但发现速度不理想、想找更稳妥方案的运维和开发。我尽量把原理、坑和可直接抄的模板都讲清楚。1. 串行for循环的瓶颈所在为什么任务越跑越慢1.1 一个最容易写出来的循环问题出在哪先看一段最典型的批量处理代码for id in $(cat ids.txt); do wget -q http://192.168.1.100/files/${id}.tar.gz -O /data/backup/${id}.tar.gz done这段代码逻辑没问题但它最大的问题是“一个一个来”。当前一个wget下载完才会启动下一个wget。假如每个文件平均下载需要 2 秒100 个文件就是 200 秒。但如果网络带宽、目标服务器并发能力都允许很多任务其实是可以同时下载的。串行执行意味着单核在干活其余核心在围观。我再拆一下时间构成。一次wget命令的耗时主要分三块DNS 解析和建立连接的时间数据真正在网络上传输的时间写入磁盘的时间。这三块里真正占用 CPU 的时间少得可怜绝大部分时间都在等网络、等磁盘、等外部服务。如果你只有一个进程在等那么这段时间里 CPU 基本上是在空转宝贵的多核资源就这么浪费掉了。1.2 Shell 任务的两大类耗时特征想优化并发先要对任务做分类。我在实际项目里基本把任务分成两类I/O 密集型任务比如下载文件、上传日志、数据库导入导出、视频转码中的磁盘读写。这类任务的特点是等待时间长、CPU 占用低非常适合多进程并发。并发数量可以开得比较大因为它们在等待的时候并不抢 CPU。CPU 密集型任务比如图片压缩、hash 计算、日志解析。这类任务本身把 CPU 跑满了并发数量不必超过物理核心数不然反而会互相抢资源、性能下降。Shell 本身的语法并不区分这两类但你心里要有数。后续配置并发线程数时I/O 密集型开大点、CPU 密集型开小点是个永恒的原则。举个例子视频转码用 FFmpeg每个进程都会把单个核心吃满你开 100 个并发进程结果是 100 个进程抢几个核速度没快多少系统负载却飙升。反过来如果任务是下载文件并发开 100 个、200 个都没问题瓶颈通常在网络带宽而不是 CPU。1.3 进程启动开销也不容忽视还有一个容易被忽略的点Shell 每执行一条外部命令都要fork一个子进程再通过exec加载目标程序。这个过程虽然快但也不是零成本。如果你在一个循环里跑上百次外部命令启动开销会累积成可观的耗时。并发不能治疗这个问题但可以让等待和启动阶段重叠起来。这部分我后面会反复用到time命令来验证优化效果。记住一句话先看任务的耗时构成再决定用什么并发方案不要盲目堆并发数。2. 三种主流 Shell 并发方案、xargs -P、GNU parallel2.1 最原始的后台符与waitShell 自身最简单的并发手段是后台符。命令后面加一个Shell 不会等它执行完而是直接返回并继续执行下一条命令。配合wait命令可以等待所有后台任务结束。直接看示例#!/bin/bash for id in $(cat ids.txt); do wget -q http://192.168.1.100/files/${id}.tar.gz -O /data/backup/${id}.tar.gz done wait这段代码会把ids.txt里所有任务一次性全部丢到后台然后wait等它们全部跑完。只改了一行整个脚本就从“串行”变成了“并发”。但别高兴太早。这种写法有个致命问题它没有并发上限。如果ids.txt里有 2000 个 id你就一次性创建了 2000 个wget子进程。目标服务器扛不住是一回事本机的文件描述符、进程表空间也会瞬间被打满。如果此时还开着很多 IO系统负载会非常难看甚至可能触发ulimit限制导致部分进程启动失败。所以裸用适合“任务量很小、不超过 10 个”的场景。任务量一大必须配合后面的并发控制手段。2.2xargs -P参数按行并发的利器xargs是每个 Linux 发行版都自带的标准工具-P参数用来指定同时运行的进程数。它会把输入按行拆分然后交给后面的命令并行处理。改造一下刚才的场景cat ids.txt | xargs -P 10 -I {} wget -q http://192.168.1.100/files/{}.tar.gz -O /data/backup/{}.tar.gz这里-I {}表示用输入行替换{}-P 10表示最多同时运行 10 个wget进程。xargs会自动维护一个进程池任务开始 10 个跑完一个就补充一个直到所有行都处理完。相比裸xargs -P的优点是简洁、自带并发上限、不需要自己写 wait。它的写法也更适合“从文件读取参数列表逐条执行外部命令”的批处理场景这也是为什么它在批量下载、批量格式转换脚本里如此常见。但是它也有自己的坑-I {}会改变xargs拼接参数的方式而且当命令本身需要多级 shell 调用时引号嵌套会比较费劲。例如cat urls.txt | xargs -P 8 -I {} sh -c curl -sL $1 /data/$(basename $1) _ {}注意sh -c后面那个_ {}_会变成$0{}变成$1。这种写法在复杂命令中很常见但初学者经常漏掉$0占位符导致参数错位。我建议不是特别复杂的场景能不用多级 shell 就不用尽可能让xargs直接拼接命令参数。2.3 GNU parallel功能最全的重型方案xargs -P已经能解决大部分需求但如果你需要更精细的控制比如“每个任务失败自动重试”“输出顺序保持与输入一致”“给任务加进度条”那就得请出 GNU parallel 了。下个例子cat urls.txt | parallel -j 8 --retries 3 --keep-order wget -q {}说明-j 8: 最多同时 8 个任务--retries 3: 任务失败自动重试 3 次--keep-order: 输出结果保持输入行顺序免得日志对不上号{}: 输入行的占位符。GNU parallel 还支持把任务分发到多台机器上执行这个特性在中小集群环境里特别好用。不过它是独立的第三方工具部分最小化安装的服务器上没有需要先安装yum install parallel或apt install parallel都行。对已经能跑xargs的环境来说GNU parallel 是增强选项不是必选项。2.4 把三种方案放在一起看下面这张表是我平时选型时参考的你可以直接存着方案并发控制保序输出失败重试学习成本适用场景wait需自己控制不支持不支持最低任务量小于等于 10 的脚本xargs -P支持-P不支持不支持中批量参数外部命令GNU parallel支持-j--keep-order--retries中高复杂批量任务、多机分发我的建议是日常两三行的批处理脚本用xargs -P就足够了脚本逻辑复杂、对输出顺序和失败重试有明确要求时直接上 GNU parallel省心很多。3. 给并发踩刹车并发数与进程池的精细控制3.1 不控制并发的后果一次把机器搞到卡死的真实经历前年我帮团队写过一个批量转码脚本最初版本就是裸并发。当时files.txt里一共 5000 个音频文件脚本一次性全部丢到后台FFmpeg 进程瞬间起了 5000 个。结果机器直接在几分钟内卡到 SSH 都连不上最后只有重启。重启完我第一件事就是翻/var/log/messages看到了大量fork failed: Resource temporarily unavailable的错误。这就是没控制并发上限的代价。后来我加了两个保险一是脚本开头用ulimit -u设置用户最大进程数防止单用户把系统进程表打爆二是在代码里用信号量方案限制并发数。ulimit只是兜底真正好用的是信号量。3.2 信号量控制并发数的经典写法Shell 里实现信号量最经典的做法是用命名管道FIFO加文件描述符。思路很简单先往管道里塞 N 行数据每个任务执行前读走一行拿走令牌执行完再还回去一行。谁拿到令牌谁才能执行从而限制同时运行的任务数量。看完整示例#!/bin/bash max_jobs6 fifo/tmp/sem_$$ mkfifo $fifo # 打开读写两端避免 read 到 EOF 退出 exec 9$fifo rm -f $fifo # 向管道放入 max_jobs 个令牌 for ((i0; imax_jobs; i)); do printf \n 9 done task() { local id$1 # 这里是真正的任务逻辑 sleep $((RANDOM % 3 1)) echo job $id done } for id in {1..20}; do read -r -u 9 # 读走一个令牌拿不到就等 { task $id printf \n 9 # 任务结束归还令牌 } done wait exec 9-运行效果是同一时刻最多 6 个任务在执行任务完成后新任务自动接上直到 20 个任务全部跑完。这里的核心在于exec 9$fifo它同时打开管道的读端和写端否则当没有写端引用时read会立刻遇到 EOF 退出信号量就失效了。我之前在内网分享这段代码时有同事问为什么不直接用xargs -P 6。确实这个场景xargs更简洁。但做大型脚本时信号量方案可以嵌进循环逻辑里、配合变量传参、动态调整并发数这是只靠xargs不好做到的。另外写轮子本身就是理解原理的过程理解了这个再去看xargs的-P实现原理你会更踏实。3.3 trap 与进程清理并发脚本有个隐蔽问题如果脚本运行到一半被 CtrlC或者某个子进程异常退出后台任务可能被留在那里继续跑导致“僵尸进程”和资源泄漏。所以生产环境脚本最好加上traptrap kill 0; exit 1 INT TERM EXITkill 0会杀死当前进程组里的所有子进程在并发场景下特别管用。建议所有用到的脚本都加上这一句它能帮你少踩很多坑。判断并发数是否合理的经验值I/O 密集型任务并发数 CPU 核心数 x 4 ~ x 8CPU 密集型任务并发数 CPU 核心数。直接套这个起步再用top观察负载如果不高可以再加。4. 并发场景最常见的三个事故输出、文件和退出码4.1 多个进程同时写一个日志文件收到的是什么串行脚本里你写入日志无所谓换个行追加一行就行。但并发场景下多个子进程同时执行echo xxx log会碰到文件描述符竞争。小行还好如果是较长的内容、多行输出两个进程可能互相交错把日志格式撕得稀烂。更严重的是写同一个结果文件。比如批量任务要把结果汇总到一个result.txt如果每个子进程都直接往同一个文件追加你最后得到的一定是缺行、乱序、甚至内容损坏的文件。这个问题有三种常见解法每个任务分开写日志文件例如logs/${id}.log跑完后再合并。这个办法最糙但最稳适合日志本来就不要求实时查看的场景。用flock给文件加锁强制同一时间只有一个进程能写目标文件。例如exec 9/data/result.txt flock -x 9 echo $result /data/result.txt flock -u 9 exec 9-把写文件的重任交给最后的汇总环节子任务只输出到 stdout用 GNU parallel 配合--keep-order统一收集再统一写文件。4.2 终端输出重影和竖行断裂并发脚本最直观的乱象是终端输出多个进程同时打印一行字可能被另一行截断成两半。如果你只想在终端看到大概进度这无所谓但你要是想从输出里解析结果就悲剧了。处理办法是把各任务的 stdout 和 stderr 分别重定向到独立文件cat urls.txt | xargs -P 10 -I {} sh -c curl -sL {} -o /dev/null 2/tmp/err_{}用任务 id 区分错误文件后面排查时能立刻定位是哪个任务出了问题。这里有个小技巧文件名里必须带唯一的任务编号不能所有任务共用一个 stderr 文件否则又变成并发写同一个文件的问题了。4.3 退出码静默丢失并发脚本里最坑的一环后台并行模式下父 Shell 不能像串行那样拿到子进程的退出码。很多人改造完脚本后发现“为什么有的文件没生成脚本却显示成功了”多半就是没检查退出码。你在串行脚本里写的set -e在并发场景下并不好使。后台子进程失败父进程不一定能感知到。我常用的兜底方案是cat ids.txt | xargs -P 10 -I {} bash -c convert src/{}.jpg dst/{}.jpg || echo {} /tmp/failed.txt任务失败时把失败的 id 写入/tmp/failed.txt脚本跑完后再统一看一眼这个文件决定是否重跑。这比单纯依赖退出码靠谱得多。如果是 GNU parallel--joblog参数更好用它会记录每个任务的运行状态和退出码失败重试、失败梳理都很方便。4.4 管道退出码的隐藏问题还有一个坑来自管道本身。cmd1 | cmd2 | xargs -P这个链式结构里最终退出码看的是最后一个命令前面命令的失败会被吞掉。想保留中间失败信息得开set -o pipefailset -o pipefail cat urls.txt | xargs -P 10 -I {} wget -q {} -O /dev/null这样一旦cat读取失败管道整体也会返回非零状态。这个细节很多人不知道等到排查脚本为什么“成功”时才发现源头读取早就断了。5. 实战前后对比批量下载压缩包的效率提升实测5.1 原始串行脚本假设有个批量下载场景需要从内网服务器拉取 500 个压缩包每个约 30MB保存到本地/data/backup/。原始脚本长这样#!/bin/bash for id in $(seq 1 500); do wget -q http://192.168.1.100/packages/${id}.tar.gz -O /data/backup/${id}.tar.gz done我们先用time跑一遍。本地磁盘很快网络局域网延迟也不高单个文件平均耗时约 1.8 秒500 个文件串行总耗时约 900 秒即 15 分钟。5.2 改成并发脚本因为这是典型的 I/O 密集型任务我决定用xargs -P来改造核心思路是并发数开大一些。考虑内网带宽和磁盘写入能力先设 20 并发#!/bin/bash seq 1 500 | xargs -P 20 -I {} wget -q http://192.168.1.100/packages/{}.tar.gz -O /data/backup/{}.tar.gz实测耗时一下子降到 75 秒左右。从 900 秒到 75 秒12 倍的提升本质就是 20 个文件同时在下载等待时间被压缩掉了。5.3 反复调并发数找到最优值我又分别试了-P 10、-P 50、-P 100并发数实测耗时备注1900 秒串行基线10140 秒左右提升明显2075 秒左右收益最大的一段5072 秒左右提升很小10073 秒左右反而有轻微波动这个结果说明当并发数超过某个阈值后带宽和磁盘写入能力已经成为新的瓶颈无脑加并发只会让资源白热化速度不再上升。所以调并发是一个“找拐点”的过程不必一味求大。做这类优化时建议每次都记录耗时和并发数选一个收益最大、稳定性最好的值作为生产配置。5.4 更稳的版本上面脚本虽然快但缺少失败处理。我在生产环境里会再加两行#!/bin/bash set -o pipefail seq 1 500 | xargs -P 20 -I {} bash -c wget -q http://192.168.1.100/packages/{}.tar.gz -O /data/backup/{}.tar.gz || echo {} /tmp/fail.log脚本跑完若是返回非零就去/tmp/fail.log看失败列表。这种方法对付偶发网络抖动非常有效。6. 什么时候不该用 Shell 并发看看你的任务边界在哪里Shell 并发虽然好用但不是所有批量优化都该用它。如果任务之间有数据依赖比如“第二个任务必须等第一个任务生成完文件再开始”那就不要并发老老实实写串行依赖。又或者你要共享大量状态、做复杂的消息通信Shell 的进程边界非常粗糙用起来很别扭这种情况更适合直接用 Python 的多进程库比如multiprocessing或者用专门的任务队列工具。Shell 并发的优势是简单粗暴适合“批量启动一批外部命令并等待结果”的场景。一旦你发现自己开始写复杂的进程间通信、锁管理、状态同步这说明任务复杂度已经超出 Shell 的舒适区了。另外把并发数写进脚本后不要以为万事大吉。上线前要观察机器负载、内存占用、日志输出慢慢调参数。我自己习惯先用 10% 的样本量跑通流程再全量跑。这一步能省下很多半夜被监控告警吵醒的时间。最后分享一个小习惯所有并发脚本我都会在开头加一行注释写明“预计并发数、结合任务类型调整、实测耗时”。这行注释对我自己以及后来接手脚本的同事都有帮助。毕竟脚本能跑只是第一步能稳定、可维护地跑下去才是真正的效率提升。