2026年9月1日

2026年9月1日

シェルスクリプトのパフォーマンス最適化(計測の仕方と外部コマンドを減らす書き方)

シェルスクリプトが遅い原因の多くは、ループの中で echo | awk、cut、date、expr などの外部コマンドを何度も起動していることです。まず time などで実際に時間がかかっている箇所を計測し、そのうえで bash の組み込み機能(パラメータ展開、[[ ]]、$(( ))、printf)に置き換えるか、ファイル全体を awk などに1回で処理させると、多くの場合は大きく速くなります。この記事では、計測の方法、効果が大きい順の改善ポイント、速さと引き換えに注意すべき点を説明します。

複数の処理を同時に走らせて全体の時間を短くする方法は、別記事の「シェルスクリプトで並列処理を実装する方法」で扱っています。この記事は、1本のスクリプトそのものを無駄なく動かすことに絞っています。

まず計測する:どこが遅いかを確かめる

勘で書き換えると、時間のかからない部分を最適化して終わることがよくあります。手を付ける前に、全体と部分の所要時間を測りましょう。

time でスクリプト全体を測る

time ./report.sh

表示される real は実際の経過時間、user と sys は CPU を使った時間です。real が user + sys より大幅に長いなら、ネットワークやディスクの待ち時間が大半を占めています。この場合、コードの書き方を変えても効果は小さく、待ち時間そのものを減らすか、並列化を検討するほうが有効です。逆に sys が大きい場合は、プロセスの起動(fork と exec)が多すぎることを疑います。

行ごとの時間を見る

bash 5.0 以降では、EPOCHREALTIME という変数でマイクロ秒単位の現在時刻が取れます。set -x のトレース表示に時刻を入れると、どの行で時間がかかっているかが分かります。

PS4='+ ${EPOCHREALTIME} ${LINENO}: ' bash -x ./report.sh 2> trace.log

trace.log の時刻の差が大きい行が、重点的に見直すべき箇所です。主要な Linux ディストリビューションの現行版には bash 5 系が入っていることが多いものの(2026年10月現在)、古いサーバーや macOS の標準 bash(3.2)では使えないため、bash --version で確認してください。2つの書き方を比べたい場合は、hyperfine というベンチマークツールを使うと、複数回実行した平均やばらつきを表示してくれます。

hyperfine --warmup 3 './report_old.sh' './report_new.sh'

1回だけの計測は、ディスクのキャッシュの有無などで大きくぶれます。比較するときは同じ入力データで複数回実行してください。

改善1:ループの中で外部コマンドを起動しない

最も効果が大きいのがこの改善です。grep、awk、sed、cut などは独立したプログラムなので、呼び出すたびに新しいプロセスが作られます。1回あたりはわずかな時間でも、1万行のループの中で2回ずつ呼べば2万回の起動になり、処理の大半がプロセスの起動に費やされます。

Web サーバーのアクセスログから、ステータスが 500 以上のリクエストを IP アドレスごとに数える例で比べてみます。

# 遅い書き方: 1行ごとに awk を2回起動している
while IFS= read -r line; do
  ip=$(echo "$line" | awk '{print $1}')
  status=$(echo "$line" | awk '{print $9}')
  if [ "$status" -ge 500 ]; then
    echo "$ip"
  fi
done < access.log | sort | uniq -c | sort -rn
# 速い書き方1: read で列を分け、bash の中だけで判定する
while read -r ip _ _ _ _ _ _ _ status _; do
  [[ "$status" =~ ^[0-9]+$ ]] && (( status >= 500 )) && printf '%s\n' "$ip"
done < access.log | sort | uniq -c | sort -rn
# 速い書き方2: ファイル全体を awk に1回で処理させる
awk '$9 >= 500 { count[$1]++ } END { for (ip in count) print count[ip], ip }' access.log \
  | sort -rn

read は空白区切りで変数に割り当てるので、9番目の列(Combined 形式のログではステータスコード)を直接取り出せます。使わない列は _ で受けて捨てています。行数が多いファイルでは、2番目の書き方でも十分に速くなることが多く、3番目の awk に任せる書き方はさらに速くなるのが一般的です。差は環境やデータによって変わるため、手元で計測して確かめてください。

目安として、「1行ずつ読んで、1行ごとに外部コマンドを呼ぶ」形が見つかったら、まず「ファイル全体を1つのコマンドに渡せないか」を考えると、改善の方向を見つけやすくなります。

改善2:bash の組み込み機能に置き換える

外部コマンドで書いている処理の多くは、bash の組み込み機能で書き換えられます。

やりたいこと 外部コマンドを使う書き方 組み込み機能での書き方
拡張子を除く `$(echo "$f" \ sed 's/\.log$//')` ${f%.log}
ディレクトリ部分を除く $(basename "$path") ${path##*/}
文字列の置換 `$(echo "$s" \ sed 's/a/b/g')` ${s//a/b}
文字数 `$(echo -n "$s" \ wc -c)` ${#s}
計算 $(expr $a + 1) $(( a + 1 ))
部分一致の判定 `echo "$s" \ grep -q foo` [[ "$s" == *foo* ]]
正規表現で取り出す `$(echo "$s" \ grep -o '[0-9]*')` [[ "$s" =~ ([0-9]+) ]] の後 ${BASH_REMATCH[1]}
現在時刻 $(date '+%F %T') printf -v now '%(%F %T)T' -1

printf '%(書式)T' は bash 4.2 以降で使える組み込みの日時表示です。printf -v 変数名 とすると、コマンド置換 $( ) を使わずに結果を変数へ入れられます。コマンド置換は外部コマンドを呼ばない場合でもサブシェルを作るため、ループの中では printf -v のほうが軽くなります。

basename を置き換えるときは、末尾に / が付いたパスで結果が異なる点に注意してください。${path##*/} は /var/log/ に対して空文字を返します。入力の形が決まっている場合に使うのが安全です。

改善3:入出力をまとめる

ループの中で毎回ファイルに追記すると、そのたびにファイルを開いて閉じる処理が発生します。

# 毎回ファイルを開き直している
for f in *.csv; do
  echo "$f" >> list.txt
done

# ループ全体の出力を1回だけリダイレクトする
for f in *.csv; do
  printf '%s\n' "$f"
done > list.txt

ファイルを丸ごと配列に読み込むなら、mapfile -t lines < file が while read で1行ずつ追加するより速く、書き方も簡潔です。また、find で見つけたファイルにコマンドを実行するときは、-exec cmd {} \;(1ファイルごとに1回起動)より -exec cmd {} +(まとめて渡す)のほうが起動回数が減ります。

改善4:外部コマンド自体を速く使う

外部コマンドを避けられない場面でも、使い方で速度が変わります。

  • **固定文字列の検索は grep -F**: 正規表現の解釈が不要なぶん速くなります。複数の文字列を探すなら、ループで何度も grep を呼ばず、grep -F -f patterns.txt で1回にまとめます。
  • **LC_ALL=C でロケールの処理を省く**: sort や grep は、ロケールが UTF-8 の場合に文字の比較が重くなることがあります。LC_ALL=C sort とするとバイト単位の比較になり速くなりますが、並び順が変わり、日本語などの多バイト文字を1文字として扱わなくなります。ASCII だけのデータや、並び順を問わない処理に限って使ってください。
  • **sort のメモリと並列**: GNU sort では、-S で使うメモリ量、--parallel で並列数を指定できます。巨大なファイルを並べ替えるときに効果があります。
  • パイプラインを短くする: cat file | grep foo | awk '{print $2}' は、awk '/foo/ {print $2}' file の1プロセスで済みます。

速さより優先すべきこと

最適化の過程で、正しさや読みやすさを損なわないように注意してください。

  • クォートを外さない: "$var" の引用符を外しても速くはならず、空白を含む値で不具合が起きるだけです。
  • パイプと while の組み合わせ: cmd | while read ... のループは別のプロセスで動くため、ループ内で変更した変数がループの後で消えます。while read ...; done < <(cmd) の形にするか、bash の shopt -s lastpipe を使います。
  • 処理の目的に合ったツールを選ぶ: 数百万行の集計や複雑なデータ変換をシェルだけで書くより、awk や Python に任せたほうが速く、保守もしやすい場合があります。
  • 変更後は結果を比較する: 最適化前と後で、同じ入力に対して同じ出力になるかを diff で確認してから入れ替えます。

POSIX の範囲で書かれた短いスクリプトは、起動の速い dash で動かすと速くなる場合があります。ただし、bash 固有の機能(配列や [[ ]] など)を使っていると動かないので、シバンを変える前に動作を確認してください。

まとめ

  • 最適化の前に time、PS4 と EPOCHREALTIME を使ったトレース、hyperfine などで遅い箇所を計測する
  • 最も効くのは、ループの中で外部コマンドを起動しないこと。ファイル全体を awk などに1回で渡せないかを考える
  • 文字列の加工・計算・日時の取得は、パラメータ展開、$(( ))、printf -v などの組み込み機能で書ける
  • 出力のリダイレクトはループ全体でまとめ、find -exec ... + で起動回数を減らす
  • LC_ALL=C は速くなるが、並び順と多バイト文字の扱いが変わる点に注意する
  • 最適化の前後で出力を比較し、正しさを優先する

お気軽にご相談ください

お見積りへ お問い合わせへ
▲