[Bash] 並列数を制限しつつコマンドを並列実行するパターン

次のスクリプトは1回1秒かかるコマンド(関数)を単純に直列実行する例です。ループで10回実行するため、全て終わるまでに10秒かかります。

sleep_then_echo() {
  sleep 1
  echo $1
}

for i in $(seq 1 10); do
  sleep_then_echo $i
done

これを並列実行して速くする方法を考えてみます。

並列数を無制限にバックグラウンドジョブとして並列実行する場合

これを &wait を使って並列実行すれば、10並列で実行されるため約1秒で終わります。

sleep_then_echo() {
  sleep 1
  echo $1
}

for i in $(seq 1 10); do
  sleep_then_echo $i &
done

wait

時間のかかるコマンドの末尾に & を付けることでバックグラウンドジョブとして非同期に実行し、wait コマンドで実行中の全てのジョブが終了するまで待機しています。

並列数を制限しつつバックグラウンドジョブとして並列実行するパターン

&wait だけで手軽に並列実行の恩恵を受けられますが、もし並列実行する処理が重たいものだった場合、並列数を多くしすぎると実行負荷が上がり、逆に遅くなったり不安定になる場合があります。

こういった場合は次のようにして並列数を制限することで、安定して実行できるようになります。10回のループを5並列に実行するため、約2秒で終わります。

# 同時実行するジョブの最大数
MAX_CONCURRENT_JOBS=5

sleep_then_echo() {
  sleep 1
  echo $1
}

# 実行しているジョブ数を出力する
running_jobs_count() {
  # -r は running のジョブだけを出力するオプション
  jobs -r | wc -l
}

for i in $(seq 1 10); do
  # 実行しているジョブが最大数に達している場合は終了を待機する
  while (( $(running_jobs_count) >= MAX_CONCURRENT_JOBS )); do
    sleep 1
  done

  sleep_then_echo $i &
done

wait

ちなみに各ジョブの成功可否 (exit code) を判定したい場合は、もう少し書き方を工夫する必要があります。

xargs で並列数を制限しつつ並列実行するパターン

for を使わないパターンとして、xargs -P number でも手軽に並列実行ができます。-P オプションで並列実行するプロセス数を指定できます。次のスクリプトは10回のループを5並列に実行するため、約2秒で終わります。

sleep_then_echo() {
  sleep 1
  echo $1
}

# xargs で起動した子プロセスからも関数を使用出来るようにする
# export -f は Bash 固有の機能である点に注意
export -f sleep_then_echo

# sh -c ではなく bash -c としないと環境によって期待通りに動作しない点に注意
seq 1 10 | xargs -I@ -P5 -n1 bash -c "sleep_then_echo @"

また、こちらの方法は並列実行した各コマンドのいずれかが失敗した場合、xargs の exit code も1以上(失敗)になります。

そのため set -eo pipefail しておくだけでお手軽にエラーハンドリングができる点もメリットです。(参考: シェルスクリプトを高級言語のような書き味に近づける Tips 集

並列数に関しての注意

並列数を増やしすぎると CPU やメモリなどのリソースを過剰に消費し、処理速度の低下やシステム不安定化を招くおそれがあります。実際の負荷を見つつ適切な並列数を探りましょう。

並列化の方法まとめ

&wait を組み合わせて並列実行するパターンは説明的なスクリプトを書きやすい点がメリットでしょう。

xargs を使うパターンはオプションで並列数を指定するだけなので簡潔かつエラー検知も容易ですが、xargs の挙動やオプションを理解して使う必要があります。

また、この記事で紹介しなかった方法として GNU parallel を使う方法もあります。GNU parallel は xargs よりも柔軟で強力な並列制御やエラーハンドリング機能を備え、複雑な処理を簡潔かつ安全に並列実行できるツールです。

別途インストールが必要なため今回は割愛しますが、興味があればこちらも調べてみてください。

VSCode に ShellCheck を導入してシェルスクリプトのまずい書き方を検出してみよう

普段の作業の効率化に欠かせないシェルスクリプトですが、変数の扱いや条件式のミスで、思わぬバグや危険な動作が起こることもあります。

そういったトラブルを未然に防ぐために、シェルスクリプトの解析ツール「ShellCheck」を使ってチェックする方法を紹介します。

ShellCheck とは?

ShellCheck とは、シェルスクリプトコードを静的解析によりチェックし、誤りや危険な書き方を教えてくれるツール(Linter)です。構文の問題点やバグだけでなく、推奨されない書き方も教えてくれます。

初心者はもちろん、普段からシェルスクリプトを書く開発者にも非常に役立ちます。

VSCode と連携させることで、スクリプトを書きながらリアルタイムにチェックすることもできます。

安心して品質の高いスクリプトを書くために、ほぼ必須なツールと言えるでしょう。

ShellCheck がチェックしてくれること

ShellCheck がチェックしてくれる内容は とても多岐に渡ります。例えば…

  • クォーテーションの取り扱いに関する指摘:
    • ダブルクォーテーションで変数を囲わずに展開している場合の指摘など。
    • 予期せぬ単語分割やパス名展開による事故を抑止する。
  • 危うい条件判定の書き方に対する指摘:
    • 意図しない判定ミスの抑止。
  • よくある文法ミスの指摘:
    • 変数定義のイコールの前後にスペースがある場合の指摘など。
  • 危険な書き方の指摘:
    • 破壊的な rm 実行の指摘など。
  • ポータビリティ(実行環境に依存しない書き方)に関する指摘:
    • 一行目のシバンに #!/bin/sh と書いてるのに bash 依存のコマンドを書いた場合など。

これだけでも心強いですが、上記はほんの一例です。

導入手順

1. ShellCheck をインストールする

Mac の場合:

brew install shellcheck

Windows の場合:

# chocolatey 環境の場合:
choco install shellcheck

# winget 環境の場合:
winget install --id koalaman.shellcheck

# scoop 環境の場合:
scoop install shellcheck

Ubuntu (Debian based distros) の場合:

sudo apt install shellcheck

もしくはシステムに直接インストールしたくない場合、Docker イメージとしても提供されています。

インストールが終わったら shellcheck コマンドが使用できるようになったことを確認します。

$ shellcheck --version
ShellCheck - shell script analysis tool
version: 0.10.0
license: GNU General Public License, version 3
website: https://www.shellcheck.net

例えば、./myscript.sh に置かれているスクリプトをチェックしたい場合は以下のように実行します。

$ shellcheck ./myscript.sh

2. エディターと連携させる

毎回コマンドを打つのは面倒なので、エディターに拡張機能を導入して連携させると便利です。

VSCode と連携させる場合は、ShellCheck の拡張機能 をインストールします。

以上で導入完了です。

適当にシェルスクリプトを書いてリアルタイムにチェックされるか確認してみる

下の図は適当にシェルスクリプトを書いてみたときの様子です。

ShellCheck により問題点が指摘されている様子

何やら4行目に波下線が出ています。これは検出された問題のあるコードの箇所を示しています。ウィンドウ下部の PROBLEMS の欄に、具体的な問題の内容が表示されています。

Double quote to prevent globbing and word splitting. shellcheck(SC2086)

一般的に変数を使うときは二重引用符 “” で囲うべき とされているので、その指摘となります。(囲わないと意図しない単語分割やグロブ展開が起きる危険性があります。)

# NG例(ダブルクォートなし)
echo $filename

# OK例(ダブルクォートあり)
echo "$filename"

メッセージの末尾にある SC2086 というのは ShellCheck の各チェック事項に対して割り当てられたコード番号で、このコード番号で調べると問題の詳細を知ることができます。例えば SC2086 の Wiki ページ を見ると直し方のコード例と共に、なぜこの書き方が問題なのか、解説を確認することができます。とても親切ですね。

まとめ

今回は二重引用符で囲うべきという指摘がされる例を挙げましたが、この他にも様々なことをチェックしてくれます。実際に ShellCheck を入れて書いてみるとその安心感が分かるかと思います。

シェルスクリプトには事故を引き起こしかねない危険な書き方のパターンが無数にありますが、すべて把握するのは上級者でも難しいため、このようなチェックツールの導入が現実的です。

ShellCheck が指摘してくれた問題点を直していくだけでベストプラクティスとされる書き方を反映したスクリプトを書くことができますし、上達にも繋がるのでおすすめです。

関連記事

シェルスクリプトで暗記カードを作った話

突然ですが Jan. や Feb. といった月名の略称を見たときにそれが何月かスムーズに思い出せますか?

思い出せる人はとてもえらいです。ちなみに私はさっき Jun. という文字を見て何月か思い出すことが出来ずショックを受けました。June ってなんだか6月感が薄いと思うんですよね。どうしようもない話ですみません…。

何とかするためシェルスクリプトでサクッと暗記マシーン(?)を作ることにしました。

動いてるところ

ランダムに問題を出題し、答えるだけのシンプルなツールです。

simple-tester-sample

CTRL-C キーなどでスクリプトを終了するまで無限に出題され続けるスパルタ仕様になっています。何度かやって無事に覚えることができました🙆

ソースコード

なるべくシェル芸感は薄めて、前に書いた シェルスクリプトを高級言語のような書き味に近づける Tips 集 の内容を取り入れた実装にしています。

#!/usr/bin/env bash
set -Ceuo pipefail

# 出題する問題集を定義
# スペース区切りで問題とその回答を記述しておく
readonly QUESTIONS=$(
  cat <<FIN
Jan 1
Feb 2
Mar 3
Apr 4
May 5
Jun 6
Jul 7
Aug 8
Sep 9
Oct 10
Nov 11
Dec 12
FIN
)

# 色付きの echo 関数
function cecho() {
  local color_name color
  readonly color_name="$1"
  shift
  case $color_name in
    red) color=31 ;;
    green) color=32 ;;
    yellow) color=33 ;;
    *) color=30 ;;
  esac
  printf "\033[${color}m%b\033[m\n" "$*"
}

# 標準入力で渡された全行の中から
# ランダムに一行を取り出すフィルタ関数
function rand_line() {
  cat - | perl -MList::Util=shuffle -E 'say shuffle(<STDIN>);'
}

# 標準入力で渡された一問を出題する関数
function ask_question() {
  # 区切り文字を半角スペースでスプリットし、
  # $question と $answer に代入する
  IFS=" " read -r question answer

  # 出題文を表示する
  echo "[Q] $question"

  # 回答の入力待ちをする
  read -rp "[A] " input </dev/tty

  # 入力された回答を答え合わせ
  if [[ $input == "$answer" ]]; then
    cecho green "正解"
  else
    cecho red "不正解  (正解は「${answer}」でした)"
  fi
  echo
}

# ランダムに一問を出題する関数
function ask_any_question() {
  # 問題集を出力する
  echo "$QUESTIONS" |
    # 全問題の中からランダムに一問を取得する
    rand_line |
    # 取得した一問を出題する
    ask_question
}

function main() {
  echo "表示される月の略称が何月か回答してください!"
  echo

  # 出題を繰り返すメインループ
  while true; do
    ask_any_question
  done
}

main

回答の入力待ちは無くしてランダムに出題と回答表示を繰り返すだけのほうが暗記カードっぽくなるかもしれませんね。

月名に限らず、問題集の定義を書き換えれば英単語の単語カードなどでも使えるとおもいます。出来れば問題データは別ファイルに定義した方がいいですが今回は省略したいとおもいます。