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

## はじめに

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

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

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

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

次のシェルスクリプトは 10 万個のファイルを作成するシェルスクリプトです。（正確にはタイムスタンプの更新ですが計測は全てファイルを削除してから行っています）

```sh
#!/bin/sh

i=0
while [ "$i" -lt 100000 ]; do
  touch "file${i}"
  i=$((i + 1))
done
```

このスクリプトを実行すると約 145 秒かかりました。

```console
real	2m25.592s
user	1m47.318s
sys 	0m41.253s
```

**このシェルスクリプトが遅い理由を答えなさい。また高速化する方法を答えなさい。**

【ヒント】ファイル書き込みは、遅さの直接の原因ではありません

```sh
#!/bin/sh

i=0
while [ "$i" -lt 100000 ]; do
  awk 'BEGIN { exit }' # ファイル書き込みを行わない
  i=$((i + 1))
done
```

```console
real	2m8.121s
user	1m35.151s
sys 	0m36.112s
```

## 答え「外部コマンドの呼び出しが多いから」

答えはこの記事のタイトルのとおりです。`touch` コマンドの呼び出し回数が多いからです。高速化する方法も簡単です。外部コマンドの呼び出し回数を減らせば良いだけです。これにより 100 〜 150 倍以上、高速化します。

### 回答例 1

外部コマンドの呼び出し回数を減らすようにすると、劇的に速度が上がることがわかります。ちなみに実行シェルは dash です。

```sh
#!/bin/sh

files() {
  i=0
  while [ "$i" -lt 100000 ]; do
    echo "file${i}"
    i=$((i + 1))
  done
}

touch $(files)
```

```console
real	0m1.255s
user	0m0.358s
sys 	0m1.087s
```

およそ 116 倍に高速化されました。

### 回答例 2

先ほどとは違い bash で実行しているので直接比べることはできませんが、ブレース展開を利用するとさらに速くなり、元の速度の 150 倍になりました。引数の生成をシェルが効率よく行っているからだと考えられます。

```sh
#!/bin/bash
touch file{0..100000}
```

```console
real	0m0.965s
user	0m0.185s
sys 	0m0.785s
```

### 回答例 3

引数としてシェルで展開するのではなく `xargs` を使ったパターンです。`xargs` はパイプで渡されたデータを複数の引数に変換して指定したコマンドを呼び出すコマンドです。したがってこれも `touch` コマンドの呼び出し回数は 1 回（引数が多い場合は複数回になる場合もある）になります。元の速さの 150 倍の速度になりました。ここから**シェルスクリプトで手続き型的に処理すること自体は遅くない**という事がわかります。

```sh
#!/bin/sh

i=0
while [ "$i" -lt 100000 ]; do
  echo "file${i}"
  i=$((i + 1))
done | xargs touch
```

```console
real	0m0.971s
user	0m0.366s
sys 	0m1.048s
```

### 回答例 4

引数リストの生成を `seq` コマンドに行わせたパターンです。元の速度の 156 倍となり微増しました。引数リストの生成をシェルスクリプトで生成するよりも `seq` コマンドを使ったほうが速いということがわかります。しかしながらシェルで実行した回答例 3 に比べて大きな差があるわけではなく、ここからも**シェルスクリプト自身はそれほど遅くない**ということがわかります。

```sh
#!/bin/sh
seq -f file%.0f 100000 | xargs touch
```

```console
real	0m0.931s
user	0m0.188s
sys 	0m0.811s
```

## 外部コマンドの呼び出し回数が原因であることの再確認

回答例 4 とほぼ同じ書き方で「パイプでデータを `xargs` に渡すという形は同じ」ですが、`xargs` に `-n 1` をつけてファイルごとに `touch` コマンドを呼び出しています。つまり**10 万回 touch コマンドが呼び出される**ということです。

「問題」の結果よりわずかに遅いですが、ほとんど同じ速度であり、**シェルスクリプトを遅くしていた原因のほとんどが「外部コマンドの呼び出し回数」** であったことが証明されています。なお外部コマンドの呼び出しにどれくらいかかるかはコマンドによって異なります。遅いコマンドはもっと遅いです。

```sh
#!/bin/sh
seq -f file%.0f 100000 | xargs -n 1 touch
```

```console
real	2m32.405s
user	1m50.580s
sys 	0m50.497s
```

ちなみに最も速いであろう思われる `true` コマンドを実行した結果も同じぐらい遅いです。とどのつまり「外部コマンドの呼び出し回数」が遅さの原因です。

```sh
#!/bin/sh
seq -f file%.0f 100000 | xargs -n 1 /bin/true
```

```console
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 とした場合の倍率

### サブシェルなしは速い

```sh
i=0
while [ "$i" -lt 100000 ]; do
  echo "$i" > /dev/null # echo はシェルビルトインコマンドなので速い
  i=$((i + 1))          # 算術式展開も当然シェルビルトインコマンド
done
```

```console
real	0m0.763s
user	0m0.465s
sys 	0m0.295s
```

### サブシェル

```sh
i=0
while [ "$i" -lt 100000 ]; do
  ( echo "$i" >/dev/null )
  i=$((i + 1))
done
```

```console
real	0m26.756s
user	0m18.035s
sys 	0m10.039s
```

### コマンド置換

```sh
i=0
while [ "$i" -lt 100000 ]; do
  v=$(echo "$i")
  i=$((i + 1))
done
```

```console
real	0m25.431s
user	0m17.649s
sys 	0m10.584s
```

### パイプ

```sh
i=0
while [ "$i" -lt 100000 ]; do
  echo "$i" | true # echo も true もシェルビルトインコマンドだが
  i=$((i + 1))     # パイプの両端はそれぞれサブシェルになる
done
```

```console
real	0m34.684s
user	0m36.166s
sys 	0m19.148s
```

以下のように二つに分けた場合は、遅くならないので `echo` コマンドまたは `true` コマンドが遅いわけではありません。**パイプを用いることでサブシェルが生成されるから**遅いのです。

```sh
i=0
while [ "$i" -lt 100000 ]; do
  echo "$i" >/dev/null
  true
  i=$((i + 1))
done
```

```sh
real	0m0.782s
user	0m0.461s
sys 	0m0.318s
```

### 外部コマンド

すでに検証済みですが参考まで

```sh
i=0
while [ "$i" -lt 100000 ]; do
  /bin/echo "$i" >/dev/null # 外部コマンド版の echo は遅い
  i=$((i + 1))
done
```

```console
real	2m18.524s
user	1m42.325s
sys 	0m39.380s
```

## まとめ

シェルスクリプトは他の言語に比べて遅い言語です。しかし劇的に遅い場合、その原因はシェルスクリプトそのものの遅さではなく「**外部コマンドの呼び出し**」や「**サブシェルの生成（コマンド置換やパイプ）**」にあります。これらの回数さえ減らしてやれば**シェルスクリプト自身で処理しても遅くはありません**。

多くの場合、文字列編集やループはシェルの機能を使うだけで十分事足ります。変数展開で十分なのに `sed` を使ったり、`while read ... done < file.txt` で十分なのに `cat file.txt | while read ... done` とパイプを使ったり、シェルの機能だけで出来ることに無駄に外部コマンドやサブシェルを使うことの積み重ねでシェルスクリプトは遅くなります。そしてループの中でそれをやってしまうと絶望的に遅くなります。シェルスクリプトに限りませんが、無駄なことをしないことが遅くしないための方法です。

基本的にシェルスクリプト自身の遅さがボトルネックになることはありませんが、もし本当に速度が重要な場合は他の言語で作ったプログラムに処理を任せなければいけません。しかしそのプログラムの呼び出しには時間がかかるというジレンマがシェルスクリプトにはあります。したがって多数の小さなコマンドを何度も呼び出すような処理（例えばアクセス数の多い CGI など）をシェルスクリプトで実装すると、他の言語で実装するよりも大幅にパフォーマンスが低下してしまいます。

シェルスクリプトが遅くなる理由を正しく知っておくことは重要です。間違った理解は間違った結論を導き出します。まずはどこで遅くなっているのかを検証することが重要です。検証なしでは「理由はよくわらんけど、パイプでつないだら速くなったし、`xargs` を使ったら速くなったし、なんか知らんけど、とりあえず使っとこ」というような、いい加減な結論になってしまいます。