如何在 shell 中实现并行执行
今天,有个同事问我,他的 shell 中要执行一个迭代200次的循环,因为每一次循环都需要消耗一定的时间,执行起来比较慢,问我可不可以改成并行执行,循环一次执行10个任务,循环20次来完成所有的任务。
什么是并行?并行,是一种常见的任务执行过程模式,指可以同时执行两个或多个程序,与之相对的则是串行。还应该注意,并行不是并发,两者之间是有明显区别的,有些开发者经常搞混。并发是指服务系统支持两个或多个任务同时存在,同时存在并不意味着同时执行,因为服务系统单位时间上只执行一个任务,其它的任务以等待的形式存在。
下面就同事的问题,介绍如何在 shell 中解决并行控制的方法。
串行改为并行
首先,先来看一个串行的例子:
> for i in `seq 1 10` do sleep 1; echo $i done
这是一个迭代次数为10的循环,每一个循环都会等待 1 秒,执行总时长约等于 10 秒。sleep 1
会阻塞循环,只有 sleep 1
执行结果,才会进入下一循环,这是典型的串行模式。
shell 提供了一种把命令提交到后台任务队列的机制,即使用 命令 &
将命令控制权交到后台并立即返回执行下个任务。
> for i in `seq 1 10` do sleep 1 &; echo $i done
还是这个例子,多了个 &
符,其作用是将命令 sleep 1
提交到后台去执行,而 for 无须等待就可进入下一次循环。所以上面的 for 循环在 1 秒未到的时间内就执行完毕,然后系统会逐个执行 sleep 1
并向终端报告命令执行结束。
并行-等待模式
上面将串行循环改为并行循环的例子,并没考虑这样的情况。
> for i in `seq 1 10` do sleep 1 &; echo $i done echo "all weakup"
这个例子要求在 for 循环中的所有命令(sleep 1)都执行完之后,打印 “all weakup”。如果按照这段脚本,发现情况并不是这样的,因为 for 循环不会等待 sleep
命令执行结束后才结束,而是把命令提交给系统后自己就退出了,进而还没有1个 sleep 执行完毕之前,“all weakup” 就已经打印了。
为了达到题目要求,需要在 echo "all weakup"
命令之前,加上 wait
命令,意为等待上面所有 &
作用过的后台任务执行结束后才继续往下。
> for i in `seq 1 10` do sleep 1 &; echo $i done wait echo "all weakup"
并行度控制
下面列举的方案并不包括所有可能的实现方案。
方案1-控制一次性提交的后台任务数量
上面的示例中,for 循环会将所有命令转为后台执行。显然,如果每个命令需要比较大的开销,并且循环次数太多,这个方法并不可取。那么,要求 for 循环有部分执行或循环达到一定次数后就要 wait
,等待前一批次的所提交的任务执行完之后,再提交一定数量命令(再循环一定次数)后再继续 wait
。虽然这种方案并不优雅,但至少不会导致一次性向系统提交过多后台任务。
看看下面的例子:
degree=4 for i in `seq 1 10` do sleep 1 & # 提交到后台的任务 echo $i [ `expr $i % $degree` -eq 0 ] && wait done
上面示例,设置了一个变量 degree
,用来表示并行度,在整个循环中控制阻塞的关键就是于语句 [ ``expr $i % $degree`` -eq 0 ] && wait
,即在第 n 次循环时,如果 n 恰好对 degree 求模等于0时,那么循环先阻塞,等待前面 n 个后台任务执行完毕后再继续,以此类推。
方案2-利用队列来控制提交的任务数量
并行度控制,原理还不算复杂,但因为 shell 的原生数据结构支持较弱,使用 shell 来实现并行度控制就比较麻烦。
与 c、java、python 等语言实现并行度的原理基本一致,都是设置一个类似线程池或者工作池的数组,
方案3-利用命名管道来做任务队列
大致原理是创建一个 FIFO 命名管道来做为队列,先放进一定量的字符到这个管道做为信号。然后在一个 for 循环中,每循环一次,从管道中读取一个字符信号,提交一个后台任务,并往这个管道中追加一个字符信号,保持管道中的字符信号数量。
是不是很像当 java 中的线程池、golang 中的 chan。没有接触过命名管理、文件描述符等概念的,可能会比较难以理解下面示例中的部分细节。
_fifofile="$$.fifo" mkfifo $_fifofile # 创建一个FIFO类型的文件 exec 6<>$_fifofile # 将文件描述符6写入 FIFO 管道, 这里6也可以是其它数字 rm $_fifofile # 删也可以, degree=4 # 定义并行度 #根据并行度设置信号个数 #事实上是在fd6中放置了$degree个回车符 for ((i=0;i<${degree};i++));do echo done >&6 for i in `seq 1 20` # 循环20次 do # 从管道中读取(消费掉)一个字符信号 # 当FD6中没有回车符时,停止,实现并行度控制 read -u6 { sleep 1 # 实际任务的命令 echo $i echo >&6 # 当进程结束以后,再向管道追加一个信号,保持管道中的信号总数量 } & done wait # 等待所有任务结束 exec 6>&- # 关闭管道
方案1-控制一次性提交的后台任务数量 是采用分批次提交的思路,
而 方案2 则是利用消费命名管道时read
会出现等待的特性,因为是每执行完一个任务后就追加一个信号,等同释放一个任务执行名额,总是保持任务并行执行数为 degree。
因为采用队列模型,当执行完一个任务,马上就会有另一个任务被启动,在服务系统设计时,这种模式的任务控制是系统资源利用率最高的。
shell 脚本中$, ,,#,$?含义
$0 这个程式的执行名字 $n 这个程式的第n个参数值,n=1…9 $* 这个程式的所有参数,此选项参数可超过9个。 $# 这个程式的参数个数 $$ 这个程式的PID(脚本运行的当前进程ID号) $! 执行上一个背景指令的PID(后台运行的最后一个进程的进程ID号) $? 执行上一个指令的返回值 (显示最后命令的退出状态。0表示没有错误,其他任何值表明有错误) $- 显示shell使用的当前选项,与set命令功能相同 @ 跟 @ 跟@跟*类似,但是可以当作数组用
命名管道
命名管道处理的思路:
就相当于此时有10个开水房间,配有10把钥匙,此时有100个人要打开水,那么前10个人抢到钥匙,就先进去打开水,后面的90个人就需要等前面一个人出来后还回钥匙,在拿着钥匙进去打开水,这样就能控制100个人打开水的任务,同时不会将系统的资源一次性耗用太多,增加压力,减小处理速度。
知识点:
1、命名管道的特性
如果管道内容为空,则阻塞
管道具有读一个少一个,存一个读一个的性质,放回去的可以重复取
可以实现队列控制
2、如果管道放一段内容没有人取,则会阻塞
解决上述问题,可以通过文件描述符
文件描述符具有管道的所有特性,同时还具有一个管道不具有的特性:无限存不阻塞,无限取不阻塞,无需关注管道内容
命名管道创建方式
# 1、创建命名管道 mkfifo /tmp/fl #2、创建文件描述符100,并关联到管道文件 exec 100<>/tmp/fl #3、调用文件描述符,向管道里存放内容,同时也表示将用完的管道内容在放回管道 echo >&100 #4、读取文件描述符关联管道中的内容 `read -u100`` #5、关闭文件描述符的读和写 exec 100<&- exec 100>&-
方案4-使用 xargs 命令的控制参数
我是在写本文的时候,才发现原来 xargs
有一个控制并行的参数。
> seq 20 | xargs -I % -P4 sh -c 'echo %; sleep 1s'
关键在于 xargs
的 -P
参数,指一次性接收多少个参数,默认为1。使用 -I %
指定在命令中可以使用 %
符来表示接收到的参数
方案5-使用 parallel 命令行工具
在 gnu linux 的生态中,有一个专门用来处理本文所述并行控制场景的工具,名字叫 parallel。
下面还是使用打印序列号的例子来演示如果控制并行:
> seq 20 | parallel -j 4 "echo {}; sleep 1"
命令格式与 xargs
类似,使用 -j
来指定并行度;使用 {}
来表示参数。