外部コマンドの習得:Bashスクリプトのパフォーマンス最適化

Bashスクリプトの隠れたパフォーマンス向上を、外部コマンドの使い方をマスターすることで実現します。このガイドでは、`grep`や`sed`などのプロセスを繰り返し生成することによる大きなオーバーヘッドについて解説します。効率的なBash組み込み機能への置き換え、強力なユーティリティを使ったバッチ処理、ファイル読み取りループの最適化など、実践的で即効性のあるテクニックを学び、高スループットの自動化タスクにおける実行時間を劇的に短縮します。

外部コマンドの習得:Bashスクリプトのパフォーマンス最適化

最速のBashスクリプトは、往々にして起動するプログラムが最も少ないものです。

Bashは糊付け作業に優れています。ファイルを読み込み、何をするか判断し、別のツールを起動し、終了ステータスを確認し、次に進む。しかし、高性能なデータ処理言語ではありません。ありがちな落とし穴は、ちょっとした文字列操作にsed、比較にexpr、ファイルループに毎回grepが必要だとBashを使ってしまうことです。そのスタイルは10行程度なら機能します。しかし、200,000行になると苦痛になります。

コストはプロセスの起動です。スクリプトがgrepsedawkcuttrdatebasenameを実行するたびに、シェルは別のプロセスを作成し、その終了を待つ必要があります。1回の呼び出しは問題になりません。しかし、大きなループ内での1回の呼び出しは、修正する価値のあるパターンです。

まず、ループ内のコマンドを探すことから始めましょう:

grep -nE 'for |while ' script.sh
grep -nE 'grep|sed|awk|cut|tr|expr|basename|dirname|cat' script.sh

これは、すべての一致が悪いという意味ではありません。ファイル全体に対する1回のawkは通常問題ありません。1行につき1回起動されるsedは、メンテナンススクリプトをデプロイ中の謎の障害に変えてしまう類のものです。

小さな外部呼び出しをBash自身で置き換える

最も簡単に効果が出るのは、算術演算、文字列長、接頭辞、接尾辞、単純な置換です。Bashはこれらの処理方法を既に知っています。

外部算術演算:

# 外部の 'expr' ユーティリティを使用
RESULT=$(expr $A + $B)

組み込み算術演算:

RESULT=$((A + B))

外部文字列置換:

MY_STRING="hello world"
NEW_STRING=$(echo "$MY_STRING" | sed 's/world/universe/')

パラメータ展開:

MY_STRING="hello world"
NEW_STRING=${MY_STRING/world/universe}
printf '%s\n' "$NEW_STRING"
タスク 非効率な方法(外部) 効率的な方法(組み込み)
部分文字列の抽出 `echo "$STR" cut -c 1-5`
長さの確認 expr length "$STR" ${#STR}
接尾辞の削除 basename "$file" .log ${file%.log}
パスの削除 basename "$path" ${path##*/}
ファイル名の削除 dirname "$path" ${path%/*}
最初の一致を置換 sed 's/foo/bar/' ${value/foo/bar}
すべての一致を置換 sed 's/foo/bar/g' ${value//foo/bar}

Bashの条件文では[[ ... ]]を優先しましょう。これはシェルキーワードであり、パターンマッチングをきれいに処理し、[ ... ]で発生する引用符の問題を回避できます。

if [[ $name == *.log && -s $name ]]; then
  printf 'non-empty log: %s\n' "$name"
fi

ただし、これをやりすぎないでください。Bashのパターン置換は完全な正規表現エンジンではありません。ルールが本当に複雑な場合は、1回のawkperlの方が、巧妙なシェル展開よりもクリーンで、通常は高速です。

繰り返し処理ではなくバッチ処理を行う

ツールが1回の実行で多くの入力を処理できる場合は、多くの入力を与えましょう。これは、grepawksedfind、圧縮ツール、アップロードクライアント、ネットワークサービスに接続するものなどで特に重要です。

以下のループはファイルごとに1つのgrepを起動します:

for file in *.log; do
  grep "ERROR" "$file" > "${file}.errors"
done

1つの結合された結果だけが必要な場合は、1つのgrepを使用します:

grep "ERROR" *.log > all_errors.txt

ファイルごとの出力が必要な場合は、分割が本当に必要かどうかを検討してください。場合によっては、後続のツールがgrep -Hからファイル名のプレフィックスを読み取ることができます:

grep -H "ERROR" *.log > errors-with-filenames.txt

行指向の変換では、単純なgrep | awkチェーンを1つのawkプログラムにまとめます:

awk '/data/ {print $1}' input.txt | sort > output.txt

これでもsortは実行されますが、それは問題ありません。ソートはまさに外部ツールが行うべき仕事です。有用な変更は、不要なcatと別個のgrepを削除することです。

catなしでファイルを読み取る

標準的な行読み取りループが使われるのには理由があります:

while IFS= read -r line; do
  printf 'Processing: %s\n' "$line"
done < file.txt

IFS=は先頭と末尾の空白を保持します。-rreadがバックスラッシュをエスケープとして扱うのを防ぎます。リダイレクトによりループは現在のシェルに留まり、これはループ内で後で必要になる変数を更新する場合に重要です。

以下のバージョンは無害に見えますが、通常はより悪い結果をもたらします:

cat file.txt | while read -r line; do
  count=$((count + 1))
done
printf '%s\n' "$count"

Bashでは、パイプラインのセグメントは一般的にサブシェルで実行されるため、countが親シェルで更新されない可能性があります。また、メリットなくcatを起動します。

入力が実際にコマンドによって生成される場合は、プロセス置換を使用します:

while IFS= read -r file; do
  printf 'large file: %s\n' "$file"
done < <(find /var/log -type f -size +100M)

ここではfindが実際の作業を行っています。ループを現在のシェルに維持することは依然として有用です。

find -exec ... +xargs を注意深く使う

ファイルループは、意図せず速度低下を引き起こす一般的な原因です:

for file in $(find . -name '*.tmp'); do
  rm "$file"
done

これはスペースで壊れ、rmを繰り返し起動します。バッチ実行を使用しましょう:

find . -name '*.tmp' -exec rm -f {} +

+形式は、多くのパスを各rm呼び出しに渡します。古い\;形式は、パスごとにコマンドを1回実行します。

並行処理の恩恵を受けるコマンドには、xargs -Pを使用すると実時間を短縮できます:

xargs -n 1 -P 4 curl -fsS -O < urls.txt

ファイル名が関係する場合は-0を使用します:

find uploads -type f -print0 | xargs -0 -n 50 -P 4 ./process-file

並列処理は無料ではありません。4つのcurlジョブは1つより速いかもしれません。40にすると、APIによって制限されたり、小さなホストを飽和させたりする可能性があります。

すべてを書き換える前に測定する

適切な最適化は、時間がどこで費やされているかによって異なります。まずは簡単なタイミング測定から始めましょう:

time ./script.sh

プロセスが多いスクリプトの場合、Linuxのstrace -cは、スクリプトがプロセスの作成、ファイルのオープン、I/Oの待機に時間を費やしているかどうかを示します:

strace -f -c ./script.sh

シェルトレースは繰り返されるコマンドを明らかにします:

PS4='+ $SECONDS ${BASH_SOURCE}:${LINENO}: '
bash -x ./script.sh

スクリプトが95%の時間をデータベースエクスポートの待機に費やしている場合、${value/foo/bar}を置き換えても意味がありません。しかし、sedを300,000回実行している場合は、効果があります。

外部ツールが適している場合を知る

目標 最適なツール(一般的) 備考
フィールド抽出とフィルタリング awk 表形式テキストにはBashループより優れています。
ストリーム編集 sed ファイルに対する1回のパスに適しています。
ファイル探索 find lsの解析よりも安全です。
JSON jq JSONをcutで解析しないでください。
並列ジョブ xargs -P または GNU parallel 制限を追加し、障害を処理します。
大規模テキスト処理 awkperl、Python 無理なBashよりも明確なことが多いです。

Bashの組み込み機能は高速ですが、保守性は依然として重要です。私は、40行の壊れやすく元の作者だけが理解できるパラメータ展開よりも、1つの明確なawkスクリプトを保守したいと思います。

実用的なレビューチェックリスト

Bashスクリプトが遅いと感じたら、次の順序で確認します:

  1. ループ内の外部コマンドを見つける。
  2. 単純な算術演算と文字列操作をBash展開に置き換える。
  3. 不要なcat呼び出しを削除する。
  4. grepawksedfind -exec ... +、またはxargsでファイル引数をバッチ処理する。
  5. 変数をループの後も保持する必要がある場合は、行読み取りループを現在のシェルに維持する。
  6. 再度測定する。

すべてのスクリプトをベンチマーク演習にする必要はありません。大きな成果は通常、いくつかの明白な箇所、つまり1行あたり1コマンド、1ファイルあたり1コマンド、または1APIアイテムあたり1コマンドから得られます。それらを修正し、スクリプトを読みやすく保ち、実行時間が問題でなくなったらそこで止めましょう。