0

wait -n实现Shell并发数控制任务池:3种写法让批量任务跑满不超载

2026.08.01 | youres | 91次围观

批量任务跑得慢,第一反应是加并发;结果一梭子把几百个后台进程全放出去,机器直接卡死、连接被对端限流、日志乱成一锅粥。真正需要的不是"无限并发",而是并发数可控的任务池——池子里始终跑 N 个,结束一个补一个。bash 从 4.3 起提供的 wait -n 就是干这个的,配合几行循环就能搭出一个够用的调度器,不用装 GNU Parallel,也不用被 xargs 的参数拼接折腾。

下面把我实际在巡检脚本里跑过的几种写法、踩过的坑和兜底方案一次讲清楚。

一、为什么"全放后台再 wait"会翻车

最常见的错误写法是这样:

while IFS= read -r url; do
    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 的语义是:等待任意一个后台作业结束,并返回它的退出码。注意是"任意一个"而不是"全部",这正好是任务池调度需要的原语——它一返回,就说明池子里空出了一个位置。

写法一:计数器控制(开销最小)

#!/usr/bin/env bash
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 实时统计(最不容易算错)

MAX=8

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 写进变量:

declare -A TASK_OF  # 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,最稳的兜底是让每个子任务自己把结果写到文件里,主循环完全不管归属:

RESULT=$(mktemp)

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 做令牌桶,效果完全等价,而且兼容到远古版本:

MAX=8
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 里留垃圾。

五、完整可用脚本:并发巡检 + 结果汇总

#!/usr/bin/env bash
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 的函数和变量;
  • 不需要按任务粒度做重试和结果归属。
xargs -P 8 -I{} -a urls.txt curl -sS -o /dev/null --max-time 10 {}

反过来,只要出现"任务之间有状态共享""要按批次限速""失败的要单独进重试队列""要用 shell 函数",wait -n 任务池的可控性就远胜 xargs——毕竟调度逻辑完全握在自己手里。

小结:五条落地清单

  1. 任务池的核心就一句:池满就 wait -n 阻塞,返回即补位
  2. set -ewait -n 天生冲突,一律写成 wait -n || true 再自己判断。
  3. 退出码归属别靠主进程记账,让子任务把 $? 和标识一起 printf 追加到临时文件,单行原子写入最省心。
  4. 看到 127 先查是不是池子已经空了还在等;看到 128+N 换算成信号名再定位。
  5. bash 3.2 环境用 FIFO 令牌桶顶上,行为等价且不用升级系统。

并发控制这事儿没什么玄学,把"谁在跑、跑完没、跑成什么样"这三个问题记清楚,脚本就稳了。

相关阅读

版权声明

本文仅代表个人观点。
本文系AI辅助作者原创,未经许可,转载请保留原文链接。

发表评论