【注意】最后更新于 November 18, 2021,文中内容可能已过时,请谨慎使用。
最近工作中遇到一个比较有意思的小问题,我在解决的过程中顺便又学到了一些bash的小知识。真希望能有一个给外行的bash的教材,这可比python java这些简明易懂的现代语言什么的有用多了。
这个问题是我用loop-level parallelism来同时后台运行多个进程,怎么样可以只在所有进程都成功的情况下才会 exit 0
。
使用wait命令来获取进程状态
一般来说,bash中后台运行可以通过在末尾添加 &
来在后台运行子进程,但是缺点是一旦一个进程处于后台,那么shell也就不关心它的exit code。这样不管子进程运行多久,主进程都会开心地迅速 exit 0
。
1
2
3
4
|
for((i=0;i<3;i++))
do
./run_something.sh -p a=$i &
done
|
如果想让主进程等一等子进程,那则需要增加 wait
命令。但是这样只会等最后一个子进程的状态,如果前几个子进程运行失败了,主进程还是会欢快地显示运行成功。
1
2
3
4
5
|
for((i=0;i<3;i++))
do
./run_something.sh -p a=$i &
done
wait
|
那有没有办法检查每个进程的状态呢? $!
可以得到上一个命令的进程id,即pid,那我就可以将每个子进程的pid都存起来,再分别等每个子进程的状态,一旦失败就将失败结果传给主进程。但是这样就会遇到pid被回收的问题。每个子进程运行的时间都可能不一样,可能第二个早早地运行成功了,pid被回收给了不属于这个主进程的新进程,所以当查看其状态时,会因为拿不到状态(只能拿到子进程的状态)而报错。
1
2
3
4
5
6
7
8
9
10
|
for((i=0;i<3;i++))
do
./run_something.sh -p a=$i &
pids[${i}]=$!
done
for pid in ${pids[@]}
do
wait $pid || exit
done || exit
|
不过这个wait看起来非常promising,所以我还去研究了文档看看还有什么办法。果然, wait -f
看起来可以等所有的子进程运行完毕,似乎正是我所需要的。但是这个功能要在 bash 4.3 才引入,而我的工作环境默认使用 bash 4.2。
另一个可能有用的是 wait -n
,只是它也只有在bash 4.3才被支持。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
for((i=0;i<3;i++))
do
./run_something.sh -p a=$i &
pidlist="$pidlist $!"
done
for i in $pidlist
do
if ! wait -n $pidlist
then
kill $pidlist 2>/dev/null
exit 1
fi
done || exit
|
时时检查任务状态(check heartbeat)
进程状态行不通,我的另一个想法是通过检查任务状态。以下是相应的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
run_ids=()
for((i=0;i<3;i++))
do
run_ids <- ./run_something.sh -p a=$i &
done
while((${#run_ids[@]}==0))
do
echo "Wait 1 more minutes."
sleep 1m
done
echo "${#run_ids[@]} jobs are executed:"
for id in "${run_ids[@]}";do echo $id;done
while((${#run_ids[@]}>0))
do
echo "Checking heartbeats of the remaining ${#run_ids[@]} jobs."
for i in ${!run_ids[@]}
do
job_run_status=$(./check_run_id.sh -c ${run_ids[$i]})
echo "${run_ids[$i]} is in state $job_run_status"
if [ "$job_run_status" == "SUCCESS" ]
then
unset run_ids[$i]
echo "${#run_ids[@]} jobs remain."
elif [ "$job_run_status" == "FAILURE" ]
then
failures=$[$failures+1]
unset run_ids[$i]
echo "${#run_ids[@]} jobs remain."
exit 1
elif [ "$job_run_status" == "" ]
then
unset run_ids[$i]
fi
done || exit
sleep 1m
done || exit
echo "$failures jobs failed"
|
不过比较tricky的一点是每次执行新的任务,都会生成一个相应的任务run_id。而我需要获取这个run_id才能用其检查状态。所以这一步 run_ids <- ./run_something.sh -p a=$i &
具体怎么实现,让我犯了难。
我试了几种方式将任务产生的run_id存起来,却都不成功。究其原因,则是主进程与子进程之间只能单向传递参数,所以虽然任务会产生run_id,但由于是在子进程中产生的,没有办法向主进程传递。
1
2
3
4
5
|
for((i=0;i<3;i++)); do run_ids+=($(./run_something.sh -p a=$i)) &; done
for((i=0;i<3;i++)); do run_ids[${#run_ids[@]}+1]=$(./run_something.sh -p a=$i) &; done
while read -r line; do run_ids+=("$line"); done < <(for((i=0;i<3;i++)); do ./run_something.sh -p a=$i & done)
|
这个的解决方法则是将参数存在一个文件里,有些复杂了。
xargs
所幸经过一番查找,我在GNU的查找工具组里找到了一个解决方案:xargs。其实我一开始并没明白xargs是什么,只是因为它有多进程参数且可以通过子进程成功与否返回不同的exit code而觉得它会有用,便写了类似下面有问题的代码,并花了很多时间研究如何让xargs接受多个参数。
1
2
3
4
5
|
for((i=0;i<3;i++));
do
((j=i+1))
echo $i $j
done | xargs -I{} -n2 -P 0 bash -c "./run_something.sh -p a={} -p b={}"
|
但实际上,当我研读文档,搞明白xargs真正在干什么的时候,就证明了我的方向错了:我们根本不需要让xargs接受多个参数就行了,一个参数就可以,因为一个参数可以是多条命令。
那么xargs是什么呢,xargs可以将标准输入作为参数传递给命令。一条xargs命令,当该是这样的格式:
1
|
命令1 | xargs [-xargs参数] 命令2
|
|
是管道,命令1的标准输出可以通过管道变成命令2的标准输入。但是,有一些命令比如echo并不接受管道传来的标准输入作为其参数,这个时候就需要xargs作为一个中间商,将标准输入转化成参数。
对于xargs,它会默认命令2是echo,如果命令1也是echo的话,则会默认换行符和空格是分隔符。所以,xargs最常用的方式就是快速进行如下查找,新建,删除等文件操作:
1
2
3
|
find . -name "*.txt" | xargs grep "abcd" # 在所有txt文件中查找含有abcd的文件
echo "a b c d" | xargs -t touch # 直接新建 a、b、c、d四个文件
echo "a b c d" | xargs -p rm # 确认后删除 a、b、c、d四个文件
|
但是,xargs真正性感的是它所支持的命令不仅限于简单的文件操作,它还支持多进程处理。所以,我可已将整个命令构建出来后,传递给bash来运行。
1
2
3
4
|
for((i=0;i<3;i++));
do
echo "./run_something.sh -p a=$i -p b=$[$i+1]"
done | xargs -I{} -P 0 bash -c "{}"
|
这里xargs的参数 ~-I
是用来声明我想替换命令2中的"{}",而 -P
则是 --max-procs
,用来声明最大进程数。如果所有进程都成功执行,则会返回0,否则会返回123。另外,如果我不想等所有进程都执行完便报错,则可以将子进程的报错值改为255。
1
2
3
4
|
for((i=0;i<3;i++));
do
echo "./run_something.sh -p a=$i -p b=$[$i+1] || exit 255"
done | xargs -I{} -P 0 bash -c "{}"
|
我的问题便迎刃而解了。
此外,我还查到可以用gnu parallel去更好地管理多进程,但是由于并非所有系统都自带gnu parallel,我便没有在这上面花太多时间。
彩蛋
因为我对bash很不熟练,在调试第二种方案的时候,有段时间不管怎么回事都显示任务成功完成。后来加上调试模式(见下)仔细研究一番,才发现bash中一对小括号,两对小括号,一对中括号,两对中括号和一对大括号都不能乱用。
1
2
3
|
set -e;
set -o pipefail;
set -x;
|
一对小括号一般用于声明命令或是数组,两对小括号一般则用于变量计算和比较。一对中括号也会用于比较,但是一般是针对字符串的,也只能比较是或者不是。两对中括号则是用于通配符的匹配和替换。而大括号,则一般用于扩展与几种特殊模式的匹配替换。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
# ()
$(cmd) # cmd
array=(a b c d)
# (())
((exp))
((a++))
for i in $(seq 0 4);do echo $i;done
for i in `seq 0 4`;do echo $i;done
for((i=0;i<5;i++));do echo $i;done
for i in {0..4});do echo $i;done
if (($i<5))
if [ $i -lt 5]
# []
[ $string == $a ]
[ $string != $b ]
# [[]]
[[ hello == hell? ]]
if [[ $a != 1 && $a != 2 ]]
# {}
${var%pattern} # 去掉右边,最小匹配
${var%%pattern} # 去掉右边,最大匹配
${var#pattern} # 去掉左边,最小匹配
${var##pattern} # 去掉右边,最大匹配
${var:(-2)} # 右数第二个到最右
${var:3:2} # 左数第三个起长度为2
${var/pattern/pattern} # 替换首个
${var//pattern/pattern} # 替换所有
|