后台子shell逐渐使用更多内存

时间:2018-08-17 14:39:10

标签: bash subshell

我正在循环中在后台启动1000个subshel​​l。我假设它们使用的内存量大致相同。

for i in `seq 1000`; do
  (
    echo $i;
    sleep 100;
  )&
done;

但是,他们没有。每个新的子shell比前一个消耗更多的内存。他们的内存使用量正在增加。

$ ps -eo size,command --sort -size | grep subshell | head -n2
  624 /bin/bash /tmp/subshells.sh
  624 /bin/bash /tmp/subshells.sh
$ ps -eo size,command --sort -size | grep subshell | tail -n2
  340 /bin/bash /tmp/subshells.sh
  340 /bin/bash /tmp/subshells.sh

最小的子外壳需要340KB,而最大的子外壳则需要624KB。

这是怎么回事?有办法避免这种情况吗?我很伤心,因为并行计算的组织方式需要成千上万的后台子shell,而我的内存不足。

1 个答案:

答案 0 :(得分:7)

这里的本质问题是,当bash启动子外壳程序时,它只是克隆自己而不是从头开始执行新的外壳程序。这意味着子shell带有在父shell中分配的所有临时数据结构。

这样做是子外壳继承当前执行环境所必需的:外壳函数和变量以及其他外壳设置。通常,它也更有效,因为它避免了相当大的Shell启动成本。

Unix写时复制(COW)语义避免了复制所有这些数据结构的一些内存成本。但是,由于COW可以在完整的页面上工作,而不是在单个分配上工作,因此无法完全避免复制。

要减少内存消耗,您可以做的一件简单的事情就是将for循环更改为计算的for,看起来很像带有附加括号的C for

for ((i=0; i<5000; ++i)); do

您的for循环(for i in $(seq 5000); do)必须首先将seq 5000的输出完全扩展为一个字符串(大约30kb),然后将其拆分为5000个字,每个字都是一个分配以及5000个元素的指针向量。分配开销意味着每个字的开销将超过40个字节,即使每个字符串只有5个字节长。由于这些是单独的分配,因此它们分散了一些,其他分配将在同一VM页面中进行,从而触发COW。

尽管这些数字看起来很小,但是您通过将N个shell克隆与N个单词向量相乘来乘以所有内容,这意味着总内存消耗是N的二次方。如果您有2500万个单词,这甚至会增加很多如果每个单词仅占用几个字节:每个40字节,则为1千兆字节。二次增长使其迅速增长。

当我尝试更改for语句时,它(总共)节省了大约三分之一的已用内存。

这是不费吹灰之力的大胜利,但是它并没有真正解决潜在的问题。父外壳还需要跟踪它产生的所有子代,并且通过保留有关每个子代的少量数据来做到这一点。每次产生一个新孩子时都会修改该内存结构,因此每个新孩子出生时都会具有不同的数据结构。在这种情况下,COW根本无济于事,并且总的内存消耗将严格平方。

修复将取决于您在循环中实际执行的操作。

如查尔斯·达菲(Charles Duffy)在(已删除的)评论中所建议的,一个简单的解决方法是使用disown命令从作业表中删除并行任务:

for ((i=0; i<5000; ++i)); do
  (
    echo $i;
    sleep 100;
  )&
  disown
done;

另一方面,如果您所做的只是启动一个外部命令-或即使这是您要做的最后一件事,而其他一切都非常快-您可以使用exec来替换带有外部命令的subshel​​l内存映像:

for ((i=0; i<5000; ++i)); do
  (
    echo $i;
    exec sleep 100;
  )&
done;

您甚至可以使用完整的脚本来完成exec,但是调用占用较少内存的shell,例如dash

实验结果(总进程大小以千字节为单位):

                             fix for    fix for    fix for
                 Only fix   + disown     + exec     + exec
   N  Original   for loop   children      sleep       dash
4000   4655956    3148792    1601428    1233212    1265224
5000   6768896    4404432    2001428    1541460    1581540
6000   9241116    5837660    2401428    1849692    1897768
7000  12056056    7443052    2801428    2158752    2213992
8000  15235688    9220568    3201428    2466104    2530180

很明显,前两列在N中大致是二次的,后三列是线性的。

我使用以下助手来收集这些统计信息;您可以在各种case子句中看到精确的循环。对于所有测试,其总和为N + 1(因此包括驱动程序)的进程数:

#!/bin/bash

case $1 in 
  o*)
    printf "Original: " >> /dev/stderr
    for i in $(seq $2); do ( echo $i; sleep 10; )& done
    ps -osize=,cmd= | grep '[s]ubshell' | awk '{s+=$1}END{print NR, s}' 1>&2
    sleep 15
    ;;
  f*)
    printf "Fix for loop: " >> /dev/stderr
    for ((i = 0; i < $2; ++i)); do ( echo $i; sleep 10; )& done
    ps -osize=,cmd= | grep '[s]ubshell' | awk '{s+=$1}END{print NR, s}' 1>&2
    sleep 15
    ;;
  d*)
    printf "Also disown: " >> /dev/stderr
    for ((i = 0; i < $2; ++i)); do ( echo $i; sleep 10; )& disown; done
    ps -osize=,cmd= | grep '[s]ubshell' | awk '{s+=$1}END{print NR, s}' 1>&2
    sleep 15
    ;;
  e*)
    printf "Exec external: " >> /dev/stderr
    for ((i = 0; i < $2; ++i)); do ( echo $i; exec sleep 10; )& done
    ps -p$$ -Csleep -osize= | awk '{s+=$1}END{print NR, s}' 1>&2
    sleep 15
    ;;
  a*)
    printf "Exec dash: " >> /dev/stderr
    for ((i = 0; i < $2; ++i)); do ( exec /bin/dash -c "echo $i; sleep 10"; )& done
    ps -p$$ -Cdash -osize= | awk '{s+=$1}END{print NR, s}' 1>&2
    sleep 15
    ;;
  *)
    echo "First argument should be original, forloop, disown, exec or ash."
    ;;
esac