批量任务跑得慢,第一反应是加并发;结果一梭子把几百个后台进程全放出去,机器直接卡死、连接被对端限流、日志乱成一锅粥。真正需要的不是"无限并发",而是并发数可控的任务池——池子里始终跑 N 个,结束一个补一个。bash 从 4.3 起提供的 wait -n 就是干这个的,配合几行循环就能搭出一个够用的调度器,不用装 GNU Parallel,也不用被 xargs 的参数拼接折腾。
下面把我实际在巡检脚本里跑过的几种写法、踩过的坑和兜底方案一次讲清楚。
一、为什么"全放后台再 wait"会翻车
最常见的错误写法是这样:
curl -s -o /dev/null "$url" &
done < urls.txt
wait
URL 列表 20 条时它工作得很好,2000 条时就会出事:
- 进程数失控:瞬间 fork 出上千个 curl,内存和上下文切换直接把负载顶上天。
- 文件描述符耗尽:每个 curl 至少占一个 socket,撞上
ulimit -n后开始报 "Too many open files",失败的还没法区分是网络问题还是资源问题。 - 对端限流:目标站点看到同源 IP 瞬时上千并发,轻则 429 重则封 IP,拿回来的数据全是脏的。
- 退出码全丢:末尾那个裸
wait只返回最后一个作业的状态,前面失败了多少个完全不知道。
所以核心诉求很明确:限流 + 结束一个立刻补一个 + 退出码可回收。
二、wait -n 任务池的两种基本写法
wait -n 的语义是:等待任意一个后台作业结束,并返回它的退出码。注意是"任意一个"而不是"全部",这正好是任务池调度需要的原语——它一返回,就说明池子里空出了一个位置。
写法一:计数器控制(开销最小)
MAX=8
running=0
while IFS= read -r url; do
if (( running >= MAX )); then
wait -n
(( running-- ))
fi
curl -sS -o /dev/null --max-time 10 "$url" &
(( running++ ))
done < urls.txt
wait
逻辑很直白:池子满了就阻塞在 wait -n 上,有作业结束才继续投递。每轮循环不 fork 额外进程,1 万条任务也不会有额外开销,是我默认用的版本。
写法二:jobs -rp 实时统计(最不容易算错)
while IFS= read -r url; do
while (( $(jobs -rp | wc -l) >= MAX )); do
wait -n
done
curl -sS -o /dev/null --max-time 10 "$url" &
done < urls.txt
wait
jobs -rp 只列出正在运行的作业 PID,每轮都以作业表的真实状态为准,不依赖自己维护的计数。代价是每轮循环要起一个命令替换子 shell,任务量特别大时有可感知的开销。
怎么选:子任务是单条命令、结构简单,用写法一;子任务内部还会派生别的后台进程、或者你自己也说不准计数准不准,用写法二。
三、三个必踩的坑
坑 1:set -e 会被 wait -n 的非零退出码直接干掉
脚本头上写了 set -e,任务池跑到第一个失败的子任务时,wait -n 返回非零,整个脚本当场退出——后面几百条任务一条没跑。正确写法是显式吞掉或显式处理:
wait -n || true
# 关心失败,但不想中断池子
if ! wait -n; then
rc=$?
(( fail_count++ ))
echo "[warn] 一个子任务失败 rc=$rc" >&2
fi
坑 2:计数器会漂移
wait -n 一次只回收一个作业的状态。如果同一瞬间有 3 个作业结束,你也只减了 1,剩下 2 个已经结束的作业还挂在作业表里——下次 wait -n 会立刻返回,不阻塞,逻辑上不会死锁,但"池子里到底跑着几个"这个数会短暂偏大,实际并发反而低于设定值。追求精确限流就上写法二。
另一种漂移来源更隐蔽:子任务里自己又 & 了一层后台进程,孙子进程不在当前 shell 的作业表里,wait 等不到它,主脚本退出时它还在跑。遇到这种情况,把清理逻辑写进 trap 'kill 0' EXIT 或者让子任务自己 wait 干净再退出。
坑 3:退出码分不清是谁的
wait -n 只告诉你"有个作业挂了、退出码是 X",不告诉你是哪个。bash 5.1 起加了 -p 选项可以把结束作业的 PID 写进变量:
curl -sS -o /dev/null "$url" &
TASK_OF[$!]=$url
wait -n -p done_pid
rc=$?
echo "${TASK_OF[$done_pid]} 结束,退出码 $rc"
低于 5.1 的 bash 没有 -p,最稳的兜底是让每个子任务自己把结果写到文件里,主循环完全不管归属:
run_one() {
local url=$1
curl -sf -o /dev/null --max-time 10 "$url"
printf '%s\t%s\n' "$?" "$url" >> "$RESULT"
}
单行 printf 小于 PIPE_BUF(Linux 上 4096 字节)时写入是原子的,并发追加不会串行,这比在主进程里维护映射表可靠得多。
退出码速查:0 正常;127 表示当前没有可等待的后台作业(池子空了还在 wait,是逻辑 bug);124 是 timeout 命令判定超时;128+N 表示被信号 N 杀掉,其中 143 = SIGTERM,137 = SIGKILL,130 = Ctrl+C。
四、macOS / bash 3.2 没有 wait -n 怎么办
系统自带 bash 是 3.2 的机器(典型就是 macOS)根本没有 wait -n,报 wait: -n: invalid option。不想强制升级 bash 的话,用 FIFO 做令牌桶,效果完全等价,而且兼容到远古版本:
FIFO=$(mktemp -u)
mkfifo "$FIFO"
exec 9<>"$FIFO" # 读写方式打开,避免阻塞
rm -f "$FIFO"
# 预放 MAX 个令牌
for ((i=0; i<MAX; i++)); do echo >&9; done
while IFS= read -r url; do
read -r -u 9 # 取令牌,取不到就阻塞
{
curl -sS -o /dev/null --max-time 10 "$url"
echo >&9 # 还令牌
} &
done < urls.txt
wait
exec 9>&-
两个细节别省:exec 9<> 必须是读写模式打开,只读或只写都会在没有对端时卡住;mkfifo 之后立刻 rm,文件描述符已经持有 inode,进程退出自动清理,不会在 /tmp 里留垃圾。
五、完整可用脚本:并发巡检 + 结果汇总
set -uo pipefail # 故意不加 -e
LIST=${1:?用法: $0 urls.txt [并发数]}
MAX=${2:-8}
RESULT=$(mktemp)
trap 'rm -f "$RESULT"' EXIT
check_one() {
local url=$1 code
code=$(curl -sS -o /dev/null -w '%{http_code}' \
--max-time 10 --retry 2 "$url" 2>/dev/null)
printf '%s\t%s\t%s\n' "$?" "${code:-000}" "$url" >> "$RESULT"
}
running=0
while IFS= read -r url; do
[[ -z $url || $url == \#* ]] && continue
if (( running >= MAX )); then
wait -n || true
(( running-- ))
fi
check_one "$url" &
(( running++ ))
done < "$LIST"
wait
echo "===== 巡检汇总(并发 $MAX)====="
awk -F'\t' '{ total++ }
$1 != 0 { curl_fail++; print " [curl失败 rc=" $1 "] " $3 }
$1 == 0 && $2 !~ /^[23]/ { http_bad++; print " [HTTP " $2 "] " $3 }
END { printf "总计 %d,curl异常 %d,状态码异常 %d\n",
total, curl_fail+0, http_bad+0 }' "$RESULT"
这个脚本我在几千条 URL 的站点巡检里跑过:并发数设成 CPU 核心数的 2 到 4 倍比较合适(IO 密集型任务瓶颈在网络不在 CPU),超过 32 之后收益基本没有,失败率反而上升。
六、什么时候别自己写,直接上 xargs -P
说句老实话,如果你的任务满足下面三条,xargs -P 一行就够了,没必要写任务池:
- 输入是纯粹的一行一条,不需要复杂解析;
- 子任务是单条命令,不依赖当前 shell 的函数和变量;
- 不需要按任务粒度做重试和结果归属。
反过来,只要出现"任务之间有状态共享""要按批次限速""失败的要单独进重试队列""要用 shell 函数",wait -n 任务池的可控性就远胜 xargs——毕竟调度逻辑完全握在自己手里。
小结:五条落地清单
- 任务池的核心就一句:池满就
wait -n阻塞,返回即补位。 set -e和wait -n天生冲突,一律写成wait -n || true再自己判断。- 退出码归属别靠主进程记账,让子任务把
$?和标识一起printf追加到临时文件,单行原子写入最省心。 - 看到
127先查是不是池子已经空了还在等;看到128+N换算成信号名再定位。 - bash 3.2 环境用 FIFO 令牌桶顶上,行为等价且不用升级系统。
并发控制这事儿没什么玄学,把"谁在跑、跑完没、跑成什么样"这三个问题记清楚,脚本就稳了。
相关阅读
版权声明
本文仅代表个人观点。
本文系AI辅助作者原创,未经许可,转载请保留原文链接。

发表评论