最近工作中遇到一个比较有意思的小问题,我在解决的过程中顺便又学到了一些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} # 替换所有