[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 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 集

感想

for で並列実行するパターンは説明的なスクリプトを書きやすいところがメリットだと思います。

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

また GNU parallel を使う方法もありますが、こちらは別途インストールが必要なので割愛。

VSCode 上でシェルスクリプトのまずい書き方を検出してみよう (with ShellCheck on Docker)

UNIX/Linux での作業の効率化に欠かせないシェルスクリプトですが、手軽に書ける反面、様々な罠があることでも知られています。

そんな罠にはまらないよう、シェルスクリプトの解析ツール「ShellCheck」を使ってチェックしましょう、というお話です。

ShellCheck とは?

静的解析によりシェルスクリプトの問題点を分析し、より良い書き方を提案してくれる CLI の Linter ツールです。

構文の問題点やバグだけでなく、推奨されない書き方も教えてくれます。

シェルスクリプトの初心者・上級者に関わらず、入れておくだけで安心してスクリプトを書けるようになります。

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

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

  • クォーティング処理に関する指摘:
    • ダブルクォーテーションで囲わずに変数展開している場合など。
  • 条件判定の書き方に関する指摘:
    • 意図しない判定ミスが起きそうな書き方など。
  • 初心者がやりがちなミスの指摘:
    • 変数定義のイコールの前後にスペースがある場合など。
  • 危険な書き方の指摘:
    • 破壊的な rm 実行など。
  • ポータビリティ(実行環境に依存しない書き方)に関する指摘:
    • 一行目のシバンに #!/bin/sh と書いてるのに bash 依存のコマンドを書いている場合など。

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

ShellCheck を入れてみよう

この記事では Visual Studio CodeShellCheck 連携プラグイン により ShellCheck を呼び出し、コーディングの際にリアルタイムにチェックできる環境の作り方を紹介します。

尚、ShellCheck の導入は Docker イメージとして提供されているものを使います。そのため事前に Docker のインストール が必要です。

なぜ ShellCheck を直接インストールせずに Docker イメージを使うの?

ShellCheck は brew や yum や apt-get などのパッケージ管理ツールでシステムに直接インストールすることもできます。

しかし、環境によってビルドにめちゃくちゃ時間がかかります。macOS では1時間ほど要することもあるようです。Docker イメージならビルドなしですぐ使えます。

もし時間がかかってもシステムに直接インストールしたい場合は 公式のインストール方法 を参照してみてください。

VSCode に ShellCheck を導入する手順

1. ShellCheck の Docker イメージを取得する

docker pull koalaman/shellcheck:v0.7.1

これだけでコマンドラインで ShellCheck を使えるようになります。

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

cd /path/to
docker run --rm -v "$PWD:/mnt" koalaman/shellcheck:v0.7.1 myscript.sh

2. VSCode から実行するためのキッカースクリプトを作る

VSCode 上で shellcheck を使うには docker run コマンドなしで実行できるようにする必要があります。

そのため、公式で説明されているように以下のようなキッカースクリプトを用意しておきます。

#!/bin/bash

exec docker run --rm -i -v "$PWD:/mnt:ro" \
  koalaman/shellcheck:v0.7.1 "$@"

作成したスクリプトは chmod +x コマンドで実行権限のパーミッションを付与しておきます。スクリプトはどこに置いてもいいですが、ファイルパスは控えておいてください。

3. VSCode に拡張機能をインストールする

次に VSCode に ShellCheck を動かすための拡張機能「vscode-shellcheck」をインストールします。

インストールできたら VSCode の設定を開き、shellcheck.executablePath の設定に先ほど作成したキッカースクリプトのパスをフルパスで指定します。(システムに ShellCheck をインストールした場合はこの設定は不要です。)

VSCode 上の設定例

以上で導入完了です。

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

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

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

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

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

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

メッセージの末尾にある 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

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

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