最近写一个shell脚本,遇到了一个奇怪的现象,经过一番查询发现之前没有注意过的陷阱:Shell(至少是Bash)会将管道运算符的前后命令放到子shell里执行。这将导致:看似在一个脚本的变量,出现改动后没效果的现象。下面是问题背景,关于此问题可以直接跳到“问题”一节查看。

背景

用nmon监控机器的CPU等数据,若要转换成可视化图表,可以使用IBM提供的nmon Excel宏来生成。但要借助Excel生成,不方便批量处理,太麻烦了。若是能用命令行生成网页,直接在服务器上看就好了。

正好nmon for linux本身就提供了生成网页版的工具nmonchartnmonchart的原理是通过shell生成HTML,然后调用google的图表库渲染。

我将70台服务器上的nmon输出文件下载到某台机器上,然后准备用将其转换成HTML文件,并通过通过PythonSimpleHTTPServer模块启动一个简单的HTTP服务器,大家就可以方便的通过浏览器查看每台机器的nmon的可视化结果了。

任务本身很简单,只需循环将文件名传给nmonchart即可,假如nmon的输出文件都已下载到/home/maoshuai/nmon_files下面,则做如下处理:

cd home/maoshuai/nmon_files
ls -1 *.nmon | while read fileName;do
  nmonchart $fileName ${fileName}.html

然后发现速度太慢,每个nmon文件要30多秒才能转换完毕,算下来要30多分钟才能处理完毕。top查看nmonchart调用的sed进程已经100%的CPU,但只能运行在一个CPU上,这是因为shell只能跑在一个CPU上。而当前机器有30个cpu都是空闲,显然是浪费。

接下来需要优化,即将任务分配到30个cpu上并行执行。思路是,先将nmon文件按照CPU的个数拆分为多个组,比如有60个nmon文件,则每个组分2个nmon文件。然后根据CPU的个数起多个后台任务。具体如下:

首先编写一个函数,接受以空格为分隔的文件名,用于处理多个nmon的转换

gen(){
    cd home/maoshuai/nmon_files
    for fileName in $1;do
      nmonchart $fileName ${fileName}.html
}

然后将所有nmon文件,根据CPU个数拆分为多个组:

# get total cpu number
cpuNum=$(cat /proc/cpuinfo | grep "physical id" | wc -l)

cpuIndex=0
cd home/maoshuai/nmon_files

# divide files into serveral groups by cpuIndex
ls -1 *.nmon | while read fileName;do
    # append new file by space
    nmon_files_arr[$cupIndex]="${nmon_files_arr[$cpuIndex]} $fileName" 
    # update cpuIndex
    let "cpuIndex=(cpuIndex+1)%$cpuNum"

最后,按组执行多个后台任务

# launch multi backgroud tasks

for i in $(seq 0 ${#nmon_files_arr[*]};do
    groupFiles=${nmon_files_arr[i]}
    # backgroud running
    gen "$groupFiles" &
done
# wait all backgroup task
wait
echo "All done!"

一切看起来没问题,但第二部的拆分出了问题,我发现最后nmon_files_arr是空,但在循环内echo出来并不是空。

问题

将上面的问题做个简化。发现确实是这样,比如下面的代码,将/tmp下的文件编号输出,并最后输出总文件数:

i=0
ls -1 /tmp | while read fileName;do
    let "i=i+1";
    echo "$i $fileName";
done
echo "total file num: $i"

但得到的结果如下: Screen-Shot-2019-01-20-at-11.30.39

虽然while循环内变量i在递增,但while之外变量i还是保持为0,似乎while循环内的改变不起作用。

进一步简化:

name="shuai"
name="jack" | echo $name
echo $name

发现竟然两次输出的内容为还是shuai。这下看来蹊跷了,经过一番Google,确实有人也遇到过,根本原因是:管道运算符会将命令放到子shell中执行,子shell自然无法修改父shell的变量,而这一点在Bash的man文档里有一句话轻描淡写的一带而过:

Each command in a pipeline is executed as a separate process (i.e., in a subshell).

注意是each这个关键字,意思是管道符左右都是在subshell里执行。这就是为什么最后一个例子两次都输出shuai的原因。

解决办法

针对上面的问题,可以通过消除管道的办法来解决,比如使用for代替:

i=0
for fileName in $(ls -1 /tmp);do
    let "i=i+1";
    echo "$i $fileName";
done
echo "total file num: $i"

或者干脆把后面的语句,整体通过大括号放到一个子shell里:

ls -1 /tmp |{
i=0
while read fileName;do
    let "i=i+1";
    echo "$i $fileName";
done
echo "total file num: $i"
}

或者使用Here string

i=0
while read fileName;do
    let "i=i+1";
    echo "$i $fileName";
done<<<$(ls -1 /tmp)
echo "total file num: $i"

总结

  1. Shell中(至少是bash),管道符左右的命令都是在子shell中执行的。
  2. 由于子shell无法修改父shell的变量,因此管道符两边的变量互相不影响,并且无法修改父shell的变量。
  3. 可以通过for或here string等方法规避这个问题,解决变量不通的问题。

-------------------------

本文采用 知识共享署名 4.0 国际许可协议(CC-BY 4.0)进行许可。转载请注明来源:https://imshuai.com/pipeline-subshell 欢迎指正或在下方评论。