一、为什么要控制Shell脚本的并发进程数

很多人用Shell脚本干活时,会遇到一个头疼的问题:比如批量处理1000个文件、同时给100台服务器发请求,要是直接把所有任务一股脑丢出去,系统会瞬间冒出几百上千个进程,要么把CPU占满导致所有任务都慢得像蜗牛,要么把内存撑爆直接报错,甚至搞崩整个服务器。

举个最常见的反面例子:假设你要给100台服务器批量检查连通性,直接写个循环执行ping的脚本:

# 反面示例:无限制并发的错误写法
for ip in $(cat ip_list.txt); do
    # 后台执行ping命令,同时启动100个进程
    ping -c 2 $ip &
done
# 等待所有后台任务完成
wait

这个脚本会瞬间启动100个ping进程,如果ip_list.txt里有1000个IP,那就是1000个进程,系统直接被压垮。所以我们必须给并发加个“闸门”——控制同时跑的最大进程数,既能榨干系统性能,又不会搞崩环境。

二、用xargs -P实现简单并发控制

xargs是Shell里专门用来批量处理参数的工具,它自带的-P参数(大写P)就是用来控制最大并发进程数的,这是目前最常用的简单方案,适合快速搞定批量任务。

2.1 xargs -P的核心用法

xargs的逻辑是:把输入的内容拆分成一个个“任务”,然后按-P指定的数量同时跑这些任务,跑完成一个就补一个新的,直到所有任务跑完。

我们先看一个最基础的例子:批量检查IP连通性,限制同时跑3个进程(也就是最多3个IP同时ping)。

# 技术栈:Bash 4.0+(支持所有主流Linux发行版)
# 功能:批量检查IP连通性,限制最大并发数为3
cat ip_list.txt | xargs -I {} -P 3 ping -c 2 {}

这里解释一下几个关键参数:

  • -I {}:把xargs读到的每个IP,替换成命令里的{}占位符,相当于把每个IP作为ping命令的参数。
  • -P 3:指定最大同时启动3个进程,也就是最多3个任务并发执行。

2.2 更灵活的xargs -P示例

有时候我们的任务不是简单的命令,而是一个自定义的Shell函数,比如批量下载文件,每个任务是一个wget命令,限制同时下载5个:

# 技术栈:Bash 4.0+
# 功能:批量下载文件,限制最大并发数为5
# 先把要下载的URL存到url_list.txt,每行一个URL
cat url_list.txt | xargs -I {} -P 5 wget -q {}

如果需要给每个任务加更多逻辑,比如下载后校验文件大小,可以把任务写成一个Shell脚本,再用xargs调用:

# 技术栈:Bash 4.0+
# 1. 先写一个自定义任务脚本download_task.sh,每行一个URL
# 内容如下:
#!/bin/bash
# 任务逻辑:下载URL并校验文件大小
url=$1
output_file=$(basename $url)
# 下载文件
wget -q $url -O $output_file
# 校验文件大小(假设正常大小为1MB,即1048576字节)
if [ $(stat -c %s $output_file) -eq 1048576 ]; then
    echo "$url 下载成功"
else
    echo "$url 下载失败"
fi

然后给脚本加执行权限,再用xargs调用:

# 技术栈:Bash 4.0+
# 给任务脚本加执行权限
chmod +x download_task.sh
# 批量执行任务,限制最大并发数为5
cat url_list.txt | xargs -I {} -P 5 ./download_task.sh {}

2.3 xargs -P的优缺点

优点:

  1. 写法极其简单,几行命令就能搞定,不用自己写复杂逻辑。
  2. 系统自带,不需要额外安装任何工具,兼容性好。
  3. 适合简单的批量任务,比如批量处理文件、批量执行命令。

缺点:

  1. 灵活性差:如果任务需要更复杂的逻辑,比如动态调整并发数、任务优先级、任务失败重试,xargs很难实现。
  2. 任务结果不好处理:xargs默认把所有任务的输出混在一起,很难单独拿到某个任务的结果做后续处理。
  3. 不适合长任务:如果任务是长时间运行的(比如一个任务要跑几小时),xargs还是会严格按-P的数量控制,但如果任务突然失败,xargs不会自动重新调度任务。

三、用命名管道实现自定义线程池模式

如果xargs满足不了需求,我们可以自己写一个“线程池”模式的并发控制,核心用Shell的命名管道(FIFO)来实现。命名管道是一种特殊的文件,它的特点是:写进去的内容必须被读出来,否则写操作会被阻塞,这个特性刚好可以用来控制并发数。

3.1 命名管道的核心原理

我们先简单了解命名管道:

  1. 创建命名管道:用mkfifo命令,比如mkfifo pool.fifo
  2. 写管道:用echo 1 > pool.fifo,如果管道里已经有内容,写操作会等待,直到有内容被读出来。
  3. 读管道:用read -r token < pool.fifo,如果管道里没有内容,读操作会等待,直到有内容被写进去。

我们可以把管道当成一个“令牌池”:

  • 提前往管道里写N个令牌(N就是最大并发数)。
  • 每个任务开始前,先从管道里拿一个令牌(读管道),拿到才能跑。
  • 任务跑完后,再把令牌放回管道(写管道)。
  • 如果同时跑的任务已经到N个,管道里没有令牌了,新任务就会卡在拿令牌的步骤,直到有任务跑完放回令牌。

这个逻辑就是自定义线程池的核心。

3.2 完整的自定义线程池示例

我们还是以批量检查IP连通性为例,自己写一个线程池,限制最大并发数为3,同时支持任务结果的处理、任务失败重试。

# 技术栈:Bash 4.0+
# 功能:自定义线程池批量检查IP连通性,支持最大并发控制、结果处理、失败重试
set -euo pipefail  # 开启严格模式,避免脚本出错
# 配置参数
MAX_CONCURRENT=3    # 最大并发数
RETRY_TIMES=2       # 任务失败重试次数
IP_LIST="ip_list.txt" # IP列表文件

# 1. 创建命名管道
POOL_FIFO="pool.fifo"
rm -f $POOL_FIFO  # 先删除已有的管道,避免冲突
mkfifo $POOL_FIFO

# 2. 往管道里写入令牌,令牌数量等于最大并发数
for ((i=0; i<MAX_CONCURRENT; i++)); do
    echo "token" > $POOL_FIFO
done

# 3. 定义任务函数:检查IP连通性
check_ip() {
    local ip=$1
    local retry=$2
    # 先拿令牌,拿到才能继续
    read -r token < $POOL_FIFO
    echo "开始检查IP: $ip"

    # 任务逻辑:ping两次,超时1秒
    if ping -c 2 -W 1 $ip > /dev/null 2>&1; then
        echo "IP $ip 连通成功"
        # 任务成功,放回令牌
        echo "token" > $POOL_FIFO
        return 0
    else
        # 任务失败,判断是否需要重试
        if [ $retry -gt 0 ]; then
            echo "IP $ip 连通失败,剩余重试次数: $((retry-1))"
            # 放回令牌,重新调度任务
            echo "token" > $POOL_FIFO
            # 递归调用任务,重试次数减1
            check_ip $ip $((retry-1))
            return $?
        else
            echo "IP $ip 连通失败,已达最大重试次数"
            # 任务最终失败,放回令牌
            echo "token" > $POOL_FIFO
            return 1
        fi
    fi
}

# 4. 启动所有任务,每个任务后台运行
while read -r ip; do
    # 后台执行任务,每个任务独立运行
    check_ip $ip $RETRY_TIMES &
done < $IP_LIST

# 5. 等待所有后台任务完成
wait

# 6. 清理临时文件
rm -f $POOL_FIFO
echo "所有任务执行完成"

这个脚本的逻辑非常清晰:

  • 提前往管道里放3个令牌,相当于最多同时跑3个任务。
  • 每个任务开始前必须拿一个令牌,拿到才能跑。
  • 任务跑完不管成功失败,都要把令牌放回管道,让新任务可以拿到。
  • 如果任务失败,只要还有重试次数,就会重新调度任务,直到成功或用完重试次数。

3.3 自定义线程池的优缺点

优点:

  1. 灵活性极高:可以自定义任何逻辑,比如动态调整并发数、任务优先级、任务失败重试、任务结果的单独处理、任务超时控制等。
  2. 任务结果可控:每个任务的结果可以单独处理,比如把成功的任务和失败的任务分别存到不同的文件,方便后续分析。
  3. 适合复杂任务:比如长时间运行的任务、需要复杂逻辑的任务、需要调度管理的任务。

缺点:

  1. 写法复杂:需要自己实现令牌机制、任务调度、错误处理、临时文件清理等逻辑,对Shell的基础要求比较高。
  2. 容易出bug:比如忘记放回令牌,会导致新任务拿不到令牌,整个脚本卡死;或者临时文件没有清理,导致下次运行出错。
  3. 性能开销:自定义的逻辑比xargs的原生实现要慢一点,不过对于大多数场景来说,这个开销可以忽略。

四、两种方案的应用场景和选择建议

4.1 应用场景对比

  • 用xargs -P的场景:

    1. 简单的批量任务,比如批量重命名文件、批量删除文件、批量执行简单命令。
    2. 快速实现,不需要复杂的逻辑,只要控制并发数就行。
    3. 脚本运行环境受限,不能写复杂的Shell逻辑。
  • 用自定义线程池的场景:

    1. 复杂的批量任务,比如批量下载需要校验、批量执行需要重试、批量处理需要结果分类。
    2. 任务需要动态调度,比如根据系统负载调整并发数。
    3. 任务需要优先级控制,比如先处理重要的任务,再处理次要的任务。

4.2 选择建议

如果是简单的批量任务,优先用xargs -P,写法简单,不容易出错;如果是复杂的批量任务,或者需要更多的控制逻辑,就用自定义线程池。

五、注意事项

  1. 最大并发数的设置:不要随便设成很大的数,比如1000,要根据系统的CPU、内存、网络带宽来设置。一般来说,CPU密集型任务(比如批量压缩文件)的最大并发数设成CPU核心数的1-2倍;IO密集型任务(比如批量下载、批量ping)的最大并发数可以设大一点,比如10-50。
  2. 命名管道的清理:自定义线程池里的命名管道是临时文件,一定要在脚本结束前清理,否则下次运行会报错。
  3. 任务的错误处理:不管用哪种方案,都要注意任务的错误处理,比如任务失败不要导致整个脚本崩溃。
  4. 输出的处理:如果任务的输出很多,最好把输出重定向到文件,避免屏幕刷屏。

六、文章总结

Shell脚本的并发控制是很多开发者都会遇到的问题,核心是在系统性能和任务效率之间找到平衡。xargs -P是一种简单快速的方案,适合简单的批量任务;自定义线程池是一种灵活的方案,适合复杂的批量任务。不管用哪种方案,都要注意最大并发数的设置、错误处理、临时文件清理等问题。