12
9

More than 1 year has passed since last update.

シェルスクリプトを劇的に遅くしない為には「外部コマンドの呼び出し回数」を減らせ!

Last updated at Posted at 2022-10-24

はじめに

シェルスクリプトを遅くする原因は「外部コマンドの呼び出し」と「サブシェルの生成」です。

シェル自身が持っている機能、ifcase と言った条件分岐、forwhileuntil などのループ、関数呼び出し、変数展開 (${var...})、算術式展開($((...)))、 [ ... ]readechoeval などのシェルビルトインコマンド、そのような部分がシェルスクリプトの遅い原因となることはほとんどありません。

シェルスクリプトはインタプリタ言語だから遅いという指摘がありますが、それは正しい認識ではありません。確かにコンパイラ言語より遅いのは事実ですが、それでもシェル自身が持っている機能だけを使っていれば、シェルスクリプトが実用にならないレベルで遅くなるようなことはありません。「外部コマンドの呼び出し」と「サブシェルの生成」を減らせば、シェルスクリプトは十分な速度で動作します。

問題「次のシェルスクリプトが遅い本当の理由を答えよ」

次のシェルスクリプトは 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 を使ったら速くなったし、なんか知らんけど、とりあえず使っとこ」というような、いい加減な結論になってしまいます。

12
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
9