はじめに
シェルスクリプトを遅くする原因は「外部コマンドの呼び出し」と「サブシェルの生成」です。
シェル自身が持っている機能、if
や case
と言った条件分岐、for
や while
や until
などのループ、関数呼び出し、変数展開 (${var...}
)、算術式展開($((...))
)、 [ ... ]
や read
や echo
や eval
などのシェルビルトインコマンド、そのような部分がシェルスクリプトの遅い原因となることはほとんどありません。
シェルスクリプトはインタプリタ言語だから遅いという指摘がありますが、それは正しい認識ではありません。確かにコンパイラ言語より遅いのは事実ですが、それでもシェル自身が持っている機能だけを使っていれば、シェルスクリプトが実用にならないレベルで遅くなるようなことはありません。「外部コマンドの呼び出し」と「サブシェルの生成」を減らせば、シェルスクリプトは十分な速度で動作します。
問題「次のシェルスクリプトが遅い本当の理由を答えよ」
次のシェルスクリプトは 10 万個のファイルを作成するシェルスクリプトです。(正確にはタイムスタンプの更新ですが計測は全てファイルを削除してから行っています)
#!/bin/sh
i=0
while [ "$i" -lt 100000 ]; do
touch "file${i}"
i=$((i + 1))
done
このスクリプトを実行すると約 145 秒かかりました。
real 2m25.592s
user 1m47.318s
sys 0m41.253s
このシェルスクリプトが遅い理由を答えなさい。また高速化する方法を答えなさい。
【ヒント】ファイル書き込みは、遅さの直接の原因ではありません
#!/bin/sh
i=0
while [ "$i" -lt 100000 ]; do
awk 'BEGIN { exit }' # ファイル書き込みを行わない
i=$((i + 1))
done
real 2m8.121s
user 1m35.151s
sys 0m36.112s
答え「外部コマンドの呼び出しが多いから」
答えはこの記事のタイトルのとおりです。touch
コマンドの呼び出し回数が多いからです。高速化する方法も簡単です。外部コマンドの呼び出し回数を減らせば良いだけです。これにより 100 〜 150 倍以上、高速化します。
回答例 1
外部コマンドの呼び出し回数を減らすようにすると、劇的に速度が上がることがわかります。ちなみに実行シェルは dash です。
#!/bin/sh
files() {
i=0
while [ "$i" -lt 100000 ]; do
echo "file${i}"
i=$((i + 1))
done
}
touch $(files)
real 0m1.255s
user 0m0.358s
sys 0m1.087s
およそ 116 倍に高速化されました。
回答例 2
先ほどとは違い bash で実行しているので直接比べることはできませんが、ブレース展開を利用するとさらに速くなり、元の速度の 150 倍になりました。引数の生成をシェルが効率よく行っているからだと考えられます。
#!/bin/bash
touch file{0..100000}
real 0m0.965s
user 0m0.185s
sys 0m0.785s
回答例 3
引数としてシェルで展開するのではなく xargs
を使ったパターンです。xargs
はパイプで渡されたデータを複数の引数に変換して指定したコマンドを呼び出すコマンドです。したがってこれも touch
コマンドの呼び出し回数は 1 回(引数が多い場合は複数回になる場合もある)になります。元の速さの 150 倍の速度になりました。ここからシェルスクリプトで手続き型的に処理すること自体は遅くないという事がわかります。
#!/bin/sh
i=0
while [ "$i" -lt 100000 ]; do
echo "file${i}"
i=$((i + 1))
done | xargs touch
real 0m0.971s
user 0m0.366s
sys 0m1.048s
回答例 4
引数リストの生成を seq
コマンドに行わせたパターンです。元の速度の 156 倍となり微増しました。引数リストの生成をシェルスクリプトで生成するよりも seq
コマンドを使ったほうが速いということがわかります。しかしながらシェルで実行した回答例 3 に比べて大きな差があるわけではなく、ここからもシェルスクリプト自身はそれほど遅くないということがわかります。
#!/bin/sh
seq -f file%.0f 100000 | xargs touch
real 0m0.931s
user 0m0.188s
sys 0m0.811s
外部コマンドの呼び出し回数が原因であることの再確認
回答例 4 とほぼ同じ書き方で「パイプでデータを xargs
に渡すという形は同じ」ですが、xargs
に -n 1
をつけてファイルごとに touch
コマンドを呼び出しています。つまり10 万回 touch コマンドが呼び出されるということです。
「問題」の結果よりわずかに遅いですが、ほとんど同じ速度であり、シェルスクリプトを遅くしていた原因のほとんどが「外部コマンドの呼び出し回数」 であったことが証明されています。なお外部コマンドの呼び出しにどれくらいかかるかはコマンドによって異なります。遅いコマンドはもっと遅いです。
#!/bin/sh
seq -f file%.0f 100000 | xargs -n 1 touch
real 2m32.405s
user 1m50.580s
sys 0m50.497s
ちなみに最も速いであろう思われる true
コマンドを実行した結果も同じぐらい遅いです。とどのつまり「外部コマンドの呼び出し回数」が遅さの原因です。
#!/bin/sh
seq -f file%.0f 100000 | xargs -n 1 /bin/true
real 2m24.596s
user 1m46.465s
sys 0m46.805s
サブシェル(コマンド置換やパイプ)も遅さの原因
外部コマンドを呼び出さなくても、ループの中でサブシェルが引き起こされる機能(コマンド置換やパイプ)を使うとこれらも劇的に遅くなる原因となります。サブシェルは一般的に新しいプロセス(子プロセス)を生成するため、このような大きな速度低下が発生します。
検証項目 | 時間 | 倍率 |
---|---|---|
サブシェルなし | 0.763s | 1.000 |
サブシェル | 26.756s | 35.067 |
コマンド置換 | 25.431s | 33.330 |
パイプ | 34.684s | 45.457 |
外部コマンド | 138.524s | 181.552 |
補足 サブシェルなしを 1 とした場合の倍率
サブシェルなしは速い
i=0
while [ "$i" -lt 100000 ]; do
echo "$i" > /dev/null # echo はシェルビルトインコマンドなので速い
i=$((i + 1)) # 算術式展開も当然シェルビルトインコマンド
done
real 0m0.763s
user 0m0.465s
sys 0m0.295s
サブシェル
i=0
while [ "$i" -lt 100000 ]; do
( echo "$i" >/dev/null )
i=$((i + 1))
done
real 0m26.756s
user 0m18.035s
sys 0m10.039s
コマンド置換
i=0
while [ "$i" -lt 100000 ]; do
v=$(echo "$i")
i=$((i + 1))
done
real 0m25.431s
user 0m17.649s
sys 0m10.584s
パイプ
i=0
while [ "$i" -lt 100000 ]; do
echo "$i" | true # echo も true もシェルビルトインコマンドだが
i=$((i + 1)) # パイプの両端はそれぞれサブシェルになる
done
real 0m34.684s
user 0m36.166s
sys 0m19.148s
以下のように二つに分けた場合は、遅くならないので echo
コマンドまたは true
コマンドが遅いわけではありません。パイプを用いることでサブシェルが生成されるから遅いのです。
i=0
while [ "$i" -lt 100000 ]; do
echo "$i" >/dev/null
true
i=$((i + 1))
done
real 0m0.782s
user 0m0.461s
sys 0m0.318s
外部コマンド
すでに検証済みですが参考まで
i=0
while [ "$i" -lt 100000 ]; do
/bin/echo "$i" >/dev/null # 外部コマンド版の echo は遅い
i=$((i + 1))
done
real 2m18.524s
user 1m42.325s
sys 0m39.380s
まとめ
シェルスクリプトは他の言語に比べて遅い言語です。しかし劇的に遅い場合、その原因はシェルスクリプトそのものの遅さではなく「外部コマンドの呼び出し」や「サブシェルの生成(コマンド置換やパイプ)」にあります。これらの回数さえ減らしてやればシェルスクリプト自身で処理しても遅くはありません。
多くの場合、文字列編集やループはシェルの機能を使うだけで十分事足ります。変数展開で十分なのに sed
を使ったり、while read ... done < file.txt
で十分なのに cat file.txt | while read ... done
とパイプを使ったり、シェルの機能だけで出来ることに無駄に外部コマンドやサブシェルを使うことの積み重ねでシェルスクリプトは遅くなります。そしてループの中でそれをやってしまうと絶望的に遅くなります。シェルスクリプトに限りませんが、無駄なことをしないことが遅くしないための方法です。
基本的にシェルスクリプト自身の遅さがボトルネックになることはありませんが、もし本当に速度が重要な場合は他の言語で作ったプログラムに処理を任せなければいけません。しかしそのプログラムの呼び出しには時間がかかるというジレンマがシェルスクリプトにはあります。したがって多数の小さなコマンドを何度も呼び出すような処理(例えばアクセス数の多い CGI など)をシェルスクリプトで実装すると、他の言語で実装するよりも大幅にパフォーマンスが低下してしまいます。
シェルスクリプトが遅くなる理由を正しく知っておくことは重要です。間違った理解は間違った結論を導き出します。まずはどこで遅くなっているのかを検証することが重要です。検証なしでは「理由はよくわらんけど、パイプでつないだら速くなったし、xargs
を使ったら速くなったし、なんか知らんけど、とりあえず使っとこ」というような、いい加減な結論になってしまいます。