0Pricing
Linux Command Line & Bash Scripting Mastery · レッスン

スクリプトのプロファイリングと不要なサブシェルの回避

スクリプトの実行時間を測定し、catとgrepの連鎖のようなforkの多い処理を組み込み機能に置き換えます。

「スクリプトのプロファイリングと不要なサブシェルの回避」はCoddyKit上の無料Linux Command Line & Bash Scripting Masteryレッスンです。 これはレッスン1/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはLinux Command Line & Bash Scripting Mastery学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 Linux Command Line & Bash Scripting Masteryコースには全4レッスンが含まれています。

スクリプトのパフォーマンスが重要な理由

実行速度の遅いBashスクリプトは、CIの時間を浪費し、cronジョブを妨げ、ユーザーを困らせます。処理が遅くなる原因の多くは複雑なロジックではなく、不要なプロセスフォークです。外部コマンドを呼び出すたびに、新しい子プロセスが起動します。

このレッスンでは、次の方法を学びます。

  • timeとbash -xを使って、実際に時間がかかっている箇所を測定する
  • catの無駄な使用のような、フォークが多く発生するアンチパターンを見つける
  • 外部コマンドをより高速なシェル組み込みコマンドに置き換える
  • サブシェルを意図的に使用し、価値を生まない場合は避ける

目標は、より少ない子プロセスと短い経過時間で同じ処理を行うスクリプトを書くことです。

time組み込みコマンドでスクリプトの時間を測定する

最も簡単なプロファイリングツールは、シェル組み込みコマンドのtimeです。任意のコマンドやパイプラインの前に置くと、次の3つの測定値が得られます。

  • real — 経過した実時間(実際に待つ時間)
  • user — ユーザー空間のコードに費やしたCPU時間
  • sys — カーネル(システムコール、I/O)に費やしたCPU時間

realとuser+sysの差が大きい場合、通常はスクリプトがI/Oを待っているか、多数の子プロセスを起動していることを意味します。まずスクリプト全体をtimeで測定し、最適化の前に問題があることを確認してください。

#!/usr/bin/env bash
# Time a whole script block
time {
  for i in $(seq 1 1000); do
    echo "line $i"
  done | grep -c "5"
}
# Output example:
# 271
# real  0m0.045s
# user  0m0.038s
# sys   0m0.012s

bash -xとPS4で実行をトレースする

bash -xは、実行前にすべてのコマンドを表示します。これは実行トレースです。どの行が最も頻繁に実行されているか、また外部プログラムが想定以上に呼び出されていないかを確認できます。

デフォルトでは、トレースされた各行に+が付きます。PS4を使ってプレフィックスにタイムスタンプなどを追加すると、軽量なプロファイラーとして利用できます。

  • PS4は、トレース対象の各コマンドの前に展開されます
  • $EPOCHREALTIME(bash 5以降)または$(date +%s%N)を含めると、ナノ秒単位の分解能が得られます
  • 標準エラー出力をファイルにリダイレクトし、後処理して時間のかかる部分を見つけます
#!/usr/bin/env bash
# Run with:  bash -x ./myscript.sh  2>trace.log
# Or embed tracing inside the script:
export PS4='+ [${EPOCHREALTIME}] ${BASH_SOURCE}:${LINENO}: '
set -x

slow_function() {
  local result
  result=$(cat /etc/hostname)   # fork — slow
  echo "host: $result"
}

slow_function
set +x

# trace.log now contains timestamps so you can diff
# adjacent lines to find which step took longest.

不要なサブシェルとは

サブシェルは、現在のシェルプロセスの子コピーです。次の操作によって作成されます。

  • コマンド置換:$(command)
  • 括弧によるグループ化:( commands )
  • シェル構造へのパイプ:cmd | while read ...

分離やパイプラインが本当に必要な場合、サブシェルは必要です。一方、シェル自体で処理できる外部プログラムを呼び出すだけの場合や、理由なく組み込みコマンドを余分なフォークで包む場合は不要になります。

最新のLinuxシステムでは、サブシェルを1つフォークするたびに約1~5ミリ秒かかります。10,000回実行するループで不要なサブシェルを1,000個作成すると、純粋なオーバーヘッドだけで1~5秒追加されます。

典型的なアンチパターン:catの無駄な使用

cat file | grep patternは、フォークが多く発生する最も有名なアンチパターンです。grepだけでファイルを直接読み取れるにもかかわらず、パイプで接続された2つのプロセス(catとgrep)を起動します。

修正方法は簡単です。ファイルを扱えるコマンドにファイル名を直接渡します。ツールがファイル名を受け取れない場合は入力リダイレクトと呼ばれ、受け取れる場合は単にcatを省略します。

  • 遅い: cat file | grep pattern — 2プロセス、1パイプ
  • 速い: grep pattern file — 1プロセス、パイプなし
  • これも速い: grep pattern < file — 1プロセス、標準入力のリダイレクト(パイプバッファーなし)
#!/usr/bin/env bash
# Create a sample file
seq 1 10000 > /tmp/numbers.txt

# --- Slow: useless cat ---
time cat /tmp/numbers.txt | grep -c "^5"

# --- Fast: grep reads the file directly ---
time grep -c "^5" /tmp/numbers.txt

# Both print the same count; the second is measurably faster
# because it skips the cat process and the inter-process pipe.

外部コマンドをシェル組み込みコマンドに置き換える

1行で行う多くの変換には、フォークを完全に回避できる組み込みコマンド相当の処理があります。次の一般的な置き換えを比較してください。

  • echo ${#var}はecho "$var" | wc -cの代わりになります — 文字列の長さ
  • ${var^^}と${var,,}はecho "$var" | tr 'a-z' 'A-Z'の代わりになります — 大文字・小文字の変換(bash 4以降)
  • ${var//search/replace}はecho "$var" | sed 's/search/replace/'の代わりになります — 単純な置換
  • [[ "$var" =~ pattern ]]はecho "$var" | grep -q patternの代わりになります — 正規表現による一致判定
  • read -r line < fileはline=$(head -n1 file)の代わりになります — 先頭行の読み取り

これらの組み込みコマンドはいずれも子プロセスをフォークしません。1回あたりの削減量は小さくても、ループ内では大きな差になります。

#!/usr/bin/env bash
sentence="hello world from bash"

# --- Fork-heavy ---
upper_slow=$(echo "$sentence" | tr 'a-z' 'A-Z')
length_slow=$(echo "$sentence" | wc -c)

# --- Builtin equivalents (zero extra processes) ---
upper_fast=${sentence^^}
length_fast=${#sentence}

echo "Slow upper : $upper_slow"
echo "Fast upper : $upper_fast"
echo "Slow length: $length_slow"
echo "Fast length: $length_fast"

ループ内のサブシェルを避ける

ループ内のコマンド置換では、反復回数に応じてフォークのコストが増加します。$(date)を1回呼び出す500回のループでは、タイムスタンプのためだけに500個の子プロセスが起動します。

ループのオーバーヘッドを減らす方法:

  • 不変のコマンドをループの外に移動する(1回だけ計算して再利用)
  • 算術展開 $(( expr ))を優先する — フォークではなく組み込み機能です
  • 書式設定だけが必要な場合は、dateの呼び出しではなくprintfを使用する
  • 外部呼び出しをまとめる:先にデータを収集し、ループの外で1回処理する
#!/usr/bin/env bash
# Demonstrate: compute-once vs fork-per-iteration

# Bad: $(date) forks 1000 times
time (
  for i in $(seq 1 1000); do
    ts=$(date +%s)   # fork each iteration
    echo "$i $ts" > /dev/null
  done
)

# Good: capture once, reuse
time (
  ts=$(date +%s)   # fork exactly once
  for i in $(seq 1 1000); do
    echo "$i $ts" > /dev/null
  done
)

パイプのサブシェルと変数スコープの落とし穴

bashでは(ksh/zshとは異なり)、パイプライン内の各コマンドがそれぞれ独自のサブシェルで実行されます。そのため、パイプ内で設定した変数はパイプの終了後に失われます。

これは正しさに関するバグであると同時に、パフォーマンスの問題でもあります。データを収集しようとしてwhile readにパイプしたのに、後で変数が空になっていることがあります。

解決策は2つあります。

  • プロセス置換 while read line; do ...; done < <(command)を使用する — whileループがサブシェルではなく現在のシェルで実行されます
  • lastpipeオプション(shopt -s lastpipe)を使用する — パイプラインの最後の部分を現在のシェルで実行します(bash 4.2以降)
#!/usr/bin/env bash
count=0

# --- Bug: count is always 0 after pipe (subshell) ---
seq 1 5 | while read -r n; do
  (( count++ ))
done
echo "After pipe   : count=$count"   # prints 0

# --- Fix 1: process substitution (no subshell for while) ---
count=0
while read -r n; do
  (( count++ ))
done < <(seq 1 5)
echo "Process sub  : count=$count"   # prints 5

# --- Fix 2: lastpipe option ---
shopt -s lastpipe
count=0
seq 1 5 | while read -r n; do
  (( count++ ))
done
echo "lastpipe     : count=$count"   # prints 5

マイクロベンチマークでサブシェルのコストを測定する

小さなベンチマークを使えば、サブシェルのオーバーヘッドを簡単に確認できます。$(( ))(組み込み機能)で算術演算を行う場合と、expr(外部プロセス)にパイプして同じ演算を行う場合を比較します。

一般的なLinux環境では、exprを10,000回呼び出すと約5秒かかる一方、$(( ))を同じ回数呼び出しても0.1秒未満です。同じ出力で50倍の差が生じます。

このベンチマークのパターンは、最適化の効果を測定したい場合にも役立ちます。両方のバージョンをループ内でN回実行し、timeで比較してください。

#!/usr/bin/env bash
N=500

# External command (fork per call)
time (
  x=0
  for ((i=0; i<N; i++)); do
    x=$(expr $x + 1)   # forks expr each time
  done
  echo "expr result: $x"
)

# Arithmetic builtin (no fork)
time (
  x=0
  for ((i=0; i<N; i++)); do
    (( x++ ))          # pure builtin
  done
  echo "builtin result: $x"
)

ヒアストリングでechoのパイプを避ける

変数を標準入力として渡すために、echo "$var" | commandというパターンをよく使用します。これは2つのプロセス(echoとcommand)をフォークし、パイプを作成します。ヒアストリング(<<<)を使えば、1つのプロセスだけで同じことを実現できます。外部コマンドはカーネルが管理する一時バッファーから読み取ります。

  • grep pattern <<< "$var" — 1プロセス、パイプなし
  • read -r field1 field2 <<< "$line" — 外部ツールなしで変数を分割
  • wc -w <<< "$sentence" — 変数の単語数をカウント

ヒアストリングは、フォーク1回ごとの差が重要になるタイトなループ内で特に有効です。

#!/usr/bin/env bash
data="The quick brown fox"

# --- Fork-heavy: echo spawns a child ---
word_count_slow=$(echo "$data" | wc -w)
echo "Slow word count: $word_count_slow"

# --- Fast: here-string, only wc spawns ---
word_count_fast=$(wc -w <<< "$data")
echo "Fast word count: $word_count_fast"

# --- Even better: use parameter expansion (zero forks) ---
# Split into array, count elements
read -ra words <<< "$data"
echo "Zero-fork count: ${#words[@]}"

実践的なリファクタリング:変更前と変更後

ログファイルを処理する現実的なスクリプトを例に、ここまでに学んだことを適用してみましょう。元のバージョンでは、cat、grep、awk、trをパイプで連結しています。リファクタリング後のバージョンでは、プロセス数を8個から2個に削減します。

主な変更点:

  • catを削除 — grepがファイルを直接読み取るようにした
  • tr '[:lower:]' '[:upper:]'を${var^^}に置き換えた
  • echo "$line" | grep -qを[[ $line =~ ]]に置き換えた
  • パイプでつないだwhileループの代わりに、プロセス置換とread -rを使用した

リファクタリング後は、もう一度time ./script.shを実行して改善を確認してください。推測で判断せず、必ず測定しましょう。

#!/usr/bin/env bash
# Create a sample log
printf 'ERROR: disk full\nINFO: started\nERROR: timeout\nINFO: done\n' \
  > /tmp/sample.log

# === BEFORE (fork-heavy) ===
time (
  cat /tmp/sample.log \
    | grep 'ERROR' \
    | while read -r line; do
        label=$(echo "$line" | tr '[:lower:]' '[:upper:]')
        echo "[ALERT] $label"
      done
)

# === AFTER (builtin-first) ===
time (
  while IFS= read -r line; do
    echo "[ALERT] ${line^^}"
  done < <(grep 'ERROR' /tmp/sample.log)
)

理解度チェック:サブシェルのスコープ

パイプラインのサブシェルと、パイプ内で行った変数の変更が失われるのを防ぐ方法について、理解度を確認しましょう。

レッスンのまとめ:まずプロファイリング、フォークは少なく

このレッスンでは、bashスクリプトで不要なプロセス生成を引き起こす、最も一般的な原因を特定して排除する方法を学びました。

重要なポイント:

  • 最適化する前に、time と PS4 を付加した bash -x を使って測定します
  • Useless cat は最も広く見られるアンチパターンです。ファイル名を直接受け取れるコマンドには、ファイル名を直接渡します
  • echo "$var" | command は、ヒアストリング(command <<< "$var")またはビルトインに置き換えます
  • パラメータ展開(${var^^}、${var//s/r}、${#var})を使うと、多くの tr、sed、wc の呼び出しを置き換えられます
  • パイプラインのサブシェルでは変数の変更が引き継がれません。プロセス置換または shopt -s lastpipe を使います
  • 不変のコマンド呼び出しはループの外に移し、expr よりも $(( )) 算術を優先します

経験則は次のとおりです:まず測定し、可能な場合は外部コマンドをビルトインに置き換え、2回目の測定で改善を確認します。

よくある質問

「スクリプトのプロファイリングと不要なサブシェルの回避」レッスンは無料ですか?

はい。「スクリプトのプロファイリングと不要なサブシェルの回避」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、Linux Command Line & Bash Scripting Masteryコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 Linux Command Line & Bash Scripting Masteryコースには全4レッスンが含まれています。

「スクリプトのプロファイリングと不要なサブシェルの回避」で何を学びますか?

スクリプトの実行時間を測定し、catとgrepの連鎖のようなforkの多い処理を組み込み機能に置き換えます。 ブラウザで直接実行するハンズオンコードでLinux Command Line & Bash Scripting Masteryを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

Linux Command Line & Bash Scripting Masteryを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのLinux Command Line & Bash Scripting Masteryは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン1/4です。

「スクリプトのプロファイリングと不要なサブシェルの回避」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このLinux Command Line & Bash Scripting Masteryレッスンでコードを書いて実行できますか?

はい。すべてのLinux Command Line & Bash Scripting Masteryレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. スクリプトのプロファイリングと不要なサブシェルの回避
  2. xargs -Pとバックグラウンドジョブによる並列処理
  3. GNU parallelによるワークロードのオーケストレーション
  4. スループット向上のためのストリーミングパイプラインと名前付きパイプ
← Linux Command Line & Bash Scripting Masteryに戻る