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

Backgrounded subshells use incrementally more memory

我在后台循环启动 1000 个子 shell。我假设它们使用的内存量大致相同。

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

最小的 subshel​​l 使用 340KB,而最大的需要 624KB。

这是怎么回事?有没有办法避免这种情况?我很难过,因为我的并行计算的组织方式需要数千个后台子 shell,而且我 运行 内存不足。

这里的本质问题是,当 bash 启动子 shell 时,它只是克隆自己,而不是从头开始执行新的 shell。这意味着 subshell 与 parent shell.

中分配的所有临时数据结构一起诞生

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

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

减少内存消耗的一个简单方法是将 for 循环更改为计算 for,它看起来很像 C for 和额外的 parent这些:

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 GB。并且二次方增长使其快速增长。

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

这是事半功倍的结果,但并没有真正解决根本问题。 parent shell 还需要跟踪它生成的所有 children,它通过保留每个 child 的一些数据来做到这一点。每次生成新的 child 时都会修改该内存结构,因此每个新的 child 都具有不同的数据结构。在这种情况下,COW 根本无济于事,总内存消耗将是严格的二次方。

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

正如 Charles Duffy 在 (now-deleted) 评论中所建议的那样,一个简单的解决方法是使用 disown 命令从作业 table 中删除并行任务:

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

另一方面,如果您所做的只是启动一个外部命令——或者即使这是您做的最后一件事并且其他一切都非常快——您可以使用 exec 来用外部命令替换 subshell 内存映像:

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

您甚至可以使用完整的脚本执行 exec,但调用较少的 memory-intensive 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  in 
  o*)
    printf "Original: " >> /dev/stderr
    for i in $(seq ); do ( echo $i; sleep 10; )& done
    ps -osize=,cmd= | grep '[s]ubshell' | awk '{s+=}END{print NR, s}' 1>&2
    sleep 15
    ;;
  f*)
    printf "Fix for loop: " >> /dev/stderr
    for ((i = 0; i < ; ++i)); do ( echo $i; sleep 10; )& done
    ps -osize=,cmd= | grep '[s]ubshell' | awk '{s+=}END{print NR, s}' 1>&2
    sleep 15
    ;;
  d*)
    printf "Also disown: " >> /dev/stderr
    for ((i = 0; i < ; ++i)); do ( echo $i; sleep 10; )& disown; done
    ps -osize=,cmd= | grep '[s]ubshell' | awk '{s+=}END{print NR, s}' 1>&2
    sleep 15
    ;;
  e*)
    printf "Exec external: " >> /dev/stderr
    for ((i = 0; i < ; ++i)); do ( echo $i; exec sleep 10; )& done
    ps -p$$ -Csleep -osize= | awk '{s+=}END{print NR, s}' 1>&2
    sleep 15
    ;;
  a*)
    printf "Exec dash: " >> /dev/stderr
    for ((i = 0; i < ; ++i)); do ( exec /bin/dash -c "echo $i; sleep 10"; )& done
    ps -p$$ -Cdash -osize= | awk '{s+=}END{print NR, s}' 1>&2
    sleep 15
    ;;
  *)
    echo "First argument should be original, forloop, disown, exec or ash."
    ;;
esac