シェルスクリプトの並列処理設計|&とwaitでバックグラウンドジョブを管理する方法

運営:リナックスマスター.JP(代表 宮崎智広)
HOME > Linux技術 リナックスマスター.JP(Linuxマスター.JP) > シェルスクリプト > シェルスクリプトの並列処理設計|&とwaitでバックグラウンドジョブを管理する方法
「10台のサーバーに同じコマンドを実行したい。でも1台ずつやったら時間がかかりすぎる」
「バックアップスクリプトがディレクトリごとに逐次実行しているせいで、朝までに終わらない」

シェルスクリプトで運用を自動化していると、こうした「待ち時間をなんとかしたい」場面に必ず出くわします。逐次実行では対象が増えるほど線形に時間がかかり、10台のサーバーなら10倍のリードタイムが生じます。

この記事では、bashの &(バックグラウンド実行演算子)と wait コマンドを使った並列処理設計を解説します。基本的な仕組みから、複数サーバーへの同時SSH実行・最大同時実行数の制御・失敗ジョブの特定・競合状態の防止まで、実務で使えるパターンをまとめました。RHEL 9.4 / Ubuntu 24.04 LTS の bash 5.x 環境を想定しています。

この記事のポイント

・コマンド末尾に & を付けるとバックグラウンド実行になり、waitで全完了を待てる
・$! で直前の子プロセスのPIDを取得し、個別にwait/killで管理できる
・最大同時実行数は active カウンタと wait -n(bash 4.3以降)の組み合わせで制御できる
・wait -n -p 変数名(bash 5.1以降)で完了PIDを取得し、失敗したジョブを個別に特定できる
・並列書き込みの競合にはflockを、子プロセスのエラー検出には wait $pid の戻り値を使う


「このままじゃマズい」と感じていませんか?
参考書を開く気力もない、同年代に取り残される不安——
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

なぜシェルスクリプトに並列処理が必要なのか

サーバー運用では「複数の対象に同じ処理を行う」タスクが頻繁に発生します。

・5台の構成サーバーにパッケージアップデートをかける
・30個のディレクトリをそれぞれ圧縮してS3にアップロードする
・監視対象20サービスの死活確認を毎分行う

これらを逐次実行すると、1回の処理に5秒かかるコマンドを10台に実行するだけで50秒かかります。並列化すれば理論上は5秒程度で済みます。

bashには並列処理のための組み込み機能が揃っています。外部ツール(GNU parallel等)を使わなくても、& と wait の組み合わせで多くのユースケースに対応できます。

ただし、対象が50台・100台規模になると「全部を一斉に流す」だけでは次のリソース枯渇問題が起きます。
問題 起きること
ネットワーク帯域の飽和 100台へ同時rsyncすると上流スイッチのポートが詰まる
CPU・ディスクI/Oの飽和 コア数を超えるプロセスが起動するとコンテキストスイッチ過多になる
プロセス数の上限 ulimit -u(最大プロセス数)に達するとfork()が失敗する
APIのrate limit 100リクエストを同時に投げてHTTP 429が返ってくる
これを解決するのが、この記事で解説する「最大N個だけ並列実行するワーカープール設計」です。

&とwaitの基本(バックグラウンド実行の仕組み)

1. &でコマンドをバックグラウンドへ送る

コマンドの末尾に & を付けると、そのコマンドはバックグラウンドで起動し、シェルは即座に次の行へ進みます。

#!/bin/bash # 通常の逐次実行(10秒かかる) sleep 5 sleep 5 # バックグラウンド実行(約5秒で終わる) sleep 5 & sleep 5 & wait echo "両方の sleep が完了した"

& でバックグラウンド化したコマンドは、シェルから独立した子プロセスとして動作します。コマンドを起動した直後にシェルはジョブ番号とPIDを表示します。

$ sleep 5 & [1] 14823 $ sleep 5 & [2] 14827 $ jobs [1]- Running sleep 5 & [2]+ Running sleep 5 &

[1] がジョブ番号、14823 がPID(プロセスID)です。

2. waitで子プロセスの完了を待つ

wait(引数なし)は、シェルが起動したすべての子プロセスが終了するまで待機します。並列実行後に「全部終わったら次へ進む」という設計の基本形です。

#!/bin/bash # 3つのコマンドを並列実行して全完了を待つ rsync -a /data/dir1/ backup01:/backup/dir1/ & rsync -a /data/dir2/ backup01:/backup/dir2/ & rsync -a /data/dir3/ backup01:/backup/dir3/ & wait echo "全バックアップ完了: $(date)"

なお、wait(引数なし)には次の3つの限界があります。

・どのジョブが先に終わったか分からない
・失敗した(終了ステータス非0)ジョブを個別に拾えない(最後に終了したジョブのステータスしか返さない)
・全部終わるまでブロックするため、途中でジョブを追加投入できない

これらを解決するのが、後述の $! による個別管理と wait -n によるワーカープール設計です。

3. $!でPIDを取得して個別管理する

$! は直前にバックグラウンドへ送ったコマンドのPIDを保持するシェル変数です。PIDを配列に保存しておくことで、子プロセスを個別に管理できます。

#!/bin/bash PIDS=() rsync -a /data/dir1/ backup01:/backup/dir1/ & PIDS+=($!) # PIDをPIDS配列に追加 rsync -a /data/dir2/ backup01:/backup/dir2/ & PIDS+=($!) rsync -a /data/dir3/ backup01:/backup/dir3/ & PIDS+=($!) # 個別に終了ステータスを取得する for PID in "${PIDS[@]}"; do wait "$PID" echo "PID $PID: 終了ステータス $?" done

wait $PID は指定した子プロセスの終了ステータスを返します。逐次実行の $? と同じ感覚で使えるため、「どの子プロセスが失敗したか」を個別に確認できます。

実務で使う並列処理パターン

1. 複数サーバーへのSSH同時実行

複数台への一斉展開やヘルスチェックは、並列処理の最も典型的なユースケースです。

#!/bin/bash set -euo pipefail SERVERS=(web01.example.com web02.example.com db01.example.com) PIDS=() echo "=== 並列ヘルスチェック開始: $(date) ===" for SERVER in "${SERVERS[@]}"; do ssh -o ConnectTimeout=10 "${SERVER}" "hostname && uptime" & PIDS+=($!) done # 全サーバーの完了を待つ echo "--- 出力を待機中 ---" wait echo "=== 全サーバーの確認完了 ==="

実際に実行すると、出力は到着順に表示されます。ネットワーク遅延の少ないサーバーが先に出力を返すため、ホスト名が起動順とは異なる場合があります。

=== 並列ヘルスチェック開始: Thu Jul 31 10:14:22 JST 2026 === --- 出力を待機中 --- web02.example.com 10:14:23 up 42 days, 3:15, 1 user, load average: 0.12, 0.08, 0.05 web01.example.com 10:14:23 up 156 days, 12:33, 2 users, load average: 0.05, 0.03, 0.01 db01.example.com 10:14:24 up 89 days, 7:41, 1 user, load average: 0.33, 0.28, 0.25 === 全サーバーの確認完了 ===

逐次実行では3台 × 約1秒 = 3秒かかるところが、並列実行では最も遅いサーバーの応答時間(約1秒)で完了します。

2. ファイルを並列処理するループ設計

大量のファイルを処理するバッチスクリプトは、forループ内で & を付けるだけで並列化できます。

#!/bin/bash # ログファイルを並列で圧縮してアーカイブディレクトリへ移動する LOGDIR="/var/log/app" ARCHDIR="/backup/logs/$(date +%Y%m)" mkdir -p "${ARCHDIR}" PIDS=() for LOGFILE in "${LOGDIR}"/*.log; do [[ -f "${LOGFILE}" ]] || continue ( gzip -c "${LOGFILE}" > "${ARCHDIR}/$(basename "${LOGFILE}").gz" && rm -f "${LOGFILE}" ) & PIDS+=($!) done # 全ファイルの処理完了を待ち、失敗をカウントする FAILED=0 for PID in "${PIDS[@]}"; do wait "${PID}" || FAILED=$((FAILED + 1)) done if [[ $FAILED -gt 0 ]]; then echo "ERROR: ${FAILED}件のファイルで圧縮に失敗しました" >&2 exit 1 fi echo "全ログファイルの圧縮完了: ${ARCHDIR}"

サブシェル ( ... ) でコマンドをグループ化し、複数コマンドを1つのバックグラウンドジョブとして扱っている点がポイントです。wait "${PID}" || FAILED=$((...)) で個別の失敗を集計しています。

3. 最大同時実行数を制御する設計(ワーカープール)

対象が100個・1,000個になると、すべてを一度にバックグラウンド実行するのは危険です。ロードアベレージの急上昇やメモリ枯渇を招きます。「常に最大N個だけ実行する」ワーカープールパターンを実装します。

並列化したいと考えているエンジニアの方は、make -j で並列ビルドするのと同じ思想でシェルスクリプトを設計できます。

#!/bin/bash # bash 4.3以降(RHEL 8=bash 4.4、RHEL 9=bash 5.1、Ubuntu 24.04=bash 5.2 で利用可能) set -uo pipefail MAX_PARALLEL=5 # 最大同時実行数 active=0 # 現在実行中のジョブ数 process_file() { local FILE="$1" # ここにファイルの処理を記述する gzip -k "${FILE}" echo "処理完了: ${FILE}" } for FILE in /data/files/*.csv; do [[ -f "${FILE}" ]] || continue # 実行中のジョブ数がMAX_PARALLELに達したら1つ完了するまで待つ if [[ $active -ge $MAX_PARALLEL ]]; then wait -n # bash 4.3以降: 任意の1子プロセスが終わるまでブロック (( active-- )) fi process_file "${FILE}" & (( active++ )) done # 残りの全ジョブを待つ wait echo "全ファイルの処理完了"

wait -n(bash 4.3以降)は任意の子プロセスが1つ終わるまで待つコマンドです。sleepポーリングが不要なため、空きができた瞬間に次のジョブを投入でき、常にMAX_PARALLELに近い並列数を維持できます。RHEL 8以降・Ubuntu 20.04以降では標準で使用できます。

古い環境(RHEL 7 / bash 4.2)では wait -n が使えないため、後述のトラブルシュートセクションで代替手段を解説します。

$ bash --version GNU bash, バージョン 5.1.8(1)-release (x86_64-redhat-linux-gnu) # wait -n が使えるかどうかの確認方法 $ bash -c 'sleep 0 & wait -n; echo $?' 0

4. wait -n -p(bash 5.1以降)で失敗ジョブを特定する

bash 5.1で追加された wait -n -p 変数名 は、完了したジョブのPIDを指定した変数に格納します。PIDとジョブ名をあらかじめマッピングしておけば、失敗したジョブを名前付きで特定できます。

#!/bin/bash # bash 5.1以降(RHEL 9、Ubuntu 22.04以降で利用可能) set -uo pipefail MAX_PARALLEL=5 SERVERS=(sv01 sv02 sv03 sv04 sv05 sv06 sv07 sv08 sv09 sv10) declare -A PID_TO_SERVER # PID -> サーバー名のマッピング FAILED_SERVERS=() active=0 log() { echo "[$(date '+%H:%M:%S')] $*"; } logerr() { echo "[$(date '+%H:%M:%S')] $*" >&2; } wait_one() { local finished_pid status wait -n -p finished_pid # 完了PIDをfinished_pidに格納(bash 5.1以降) status=$? if [[ -n "${PID_TO_SERVER[$finished_pid]+x}" ]]; then local server="${PID_TO_SERVER[$finished_pid]}" if [[ $status -ne 0 ]]; then FAILED_SERVERS+=("") logerr "FAIL ${server} (pid=${finished_pid}, exit=${status})" else log "OK ${server} (pid=${finished_pid})" fi unset "PID_TO_SERVER[$finished_pid]" fi (( active-- )) } for server in "${SERVERS[@]}"; do if [[ $active -ge $MAX_PARALLEL ]]; then wait_one fi rsync -az /etc/app.conf "deploy@${server}:/etc/app.conf" & pid=$! PID_TO_SERVER[$pid]="$server" log "START ${server} (pid=${pid})" (( active++ )) done while [[ $active -gt 0 ]]; do wait_one done if [[ ${#FAILED_SERVERS[@]} -gt 0 ]]; then logerr "失敗サーバー(${#FAILED_SERVERS[@]}台): ${FAILED_SERVERS[*]}" exit 1 fi log "全台配布完了(計 ${#SERVERS[@]} 台)"

sv03が接続拒否した場合の実行例(RHEL 9.4で確認):

[14:22:01] START sv01 (pid=18421) [14:22:01] START sv02 (pid=18422) [14:22:01] START sv03 (pid=18423) [14:22:01] START sv04 (pid=18424) [14:22:01] START sv05 (pid=18425) [14:22:04] OK sv01 (pid=18421) [14:22:04] START sv06 (pid=18430) [14:22:05] FAIL sv03 (pid=18423, exit=255) [14:22:05] START sv07 (pid=18431) ... [14:22:11] 失敗サーバー(1台): sv03

並列処理のトラブルと対処法

1. 競合状態(レースコンディション)をflockで防ぐ

複数のバックグラウンドプロセスが同じファイルに書き込むと、出力が混在して壊れることがあります。競合状態(レースコンディション)と呼ばれるこの問題は、flock コマンドでロックを取得してから書き込むことで防げます。

#!/bin/bash LOGFILE="/var/log/parallel-result.log" LOCKFILE="/var/lock/parallel-result.lock" write_result() { local SERVER="$1" local RESULT="$2" # flockでロックを取得してから書き込む(タイムアウト: 10秒) flock --timeout 10 "${LOCKFILE}" bash -c "echo "[$(date '+%Y-%m-%d %H:%M:%S')] ${SERVER}: ${RESULT}" >> "${LOGFILE}"" } SERVERS=(web01 web02 web03 app01 app02) for SERVER in "${SERVERS[@]}"; do ( RESULT=$(ssh "${SERVER}" "uptime" 2>&1) write_result "${SERVER}" "${RESULT}" ) & done wait

2. 子プロセスの終了ステータスを正確に取得する

wait(引数なし)は全子プロセスを待ちますが、終了ステータスは最後に終了した子プロセスのものだけを返します。どのサーバーで失敗したかを特定するには、PIDを配列に保存して個別に wait $PID を呼ぶ設計が必須です。

#!/bin/bash SERVERS=(web01.example.com web02.example.com db01.example.com app01.example.com) declare -A SERVER_PIDS # 連想配列でサーバー名とPIDを対応させる for SERVER in "${SERVERS[@]}"; do ssh -o ConnectTimeout=10 "${SERVER}" "systemctl is-active nginx" & SERVER_PIDS["${SERVER}"]=$! done FAILED_SERVERS=() for SERVER in "${SERVERS[@]}"; do PID="${SERVER_PIDS[$SERVER]}" if wait "${PID}"; then echo "OK: ${SERVER} - nginx 起動中" else echo "ERROR: ${SERVER} - nginx が停止または応答なし" >&2 FAILED_SERVERS+=("${SERVER}") fi done if [[ ${#FAILED_SERVERS[@]} -gt 0 ]]; then echo "要対応サーバー: ${FAILED_SERVERS[*]}" >&2 exit 1 fi echo "全サーバーの nginx 正常稼働を確認"

実行結果の例です。

OK: web01.example.com - nginx 起動中 OK: web02.example.com - nginx 起動中 ERROR: db01.example.com - nginx が停止または応答なし OK: app01.example.com - nginx 起動中 要対応サーバー: db01.example.com

3. bash 4.2(RHEL 7)で最大同時実行数を制御する代替手段

wait -n が使えない bash 4.2 環境では、jobs -p で実行中のジョブ数を数えながらポーリングする方法が代替になります。

#!/bin/bash MAX_PARALLEL=5 # 実行中のジョブ数を返す関数 count_jobs() { jobs -p | wc -l } for FILE in /data/files/*.csv; do [[ -f "${FILE}" ]] || continue # 実行中ジョブ数がMAXに達したら100msごとに確認する while [[ $(count_jobs) -ge $MAX_PARALLEL ]]; do sleep 0.1 done gzip -k "${FILE}" & done wait echo "全処理完了"

jobs -p は現在のシェルのバックグラウンドジョブのPIDを1行ずつ出力します。wc -l で行数を数えることで実行中のジョブ数を把握できます。wait -n と比べてsleepを挟むポーリングになるため効率は落ちますが、互換性が高い方法です。

4. タイムアウト付き並列実行

SSH実行でリモートホストが無応答になった場合、wait が永遠にブロックするリスクがあります。-o ConnectTimeout オプションをSSHに付けるのが最も確実な対策です。コマンド全体にタイムアウトを設けるには timeout コマンドと組み合わせます。

#!/bin/bash SERVERS=(web01.example.com web02.example.com db01.example.com) PIDS=() for SERVER in "${SERVERS[@]}"; do # timeout: コマンド全体に30秒の上限を設ける # SSH: 接続確立に最大10秒 timeout 30 ssh -o ConnectTimeout=10 -o BatchMode=yes "${SERVER}" "df -h /" & PIDS+=($!) done FAILED=0 for i in "${!PIDS[@]}"; do PID="${PIDS[$i]}" SERVER="${SERVERS[$i]}" wait "${PID}" STATUS=$? if [[ $STATUS -eq 124 ]]; then echo "TIMEOUT: ${SERVER} - 30秒で応答なし" >&2 FAILED=$((FAILED + 1)) elif [[ $STATUS -ne 0 ]]; then echo "ERROR: ${SERVER} - 終了ステータス ${STATUS}" >&2 FAILED=$((FAILED + 1)) fi done [[ $FAILED -eq 0 ]] && echo "全サーバー正常" || exit 1

timeout コマンドは制限時間を超えた場合に終了ステータス 124 を返します。これを wait $PID の戻り値で確認することで、タイムアウトと通常のエラーを区別できます。

5. set -eとwait -nの干渉を避ける

set -e(errexit)が有効な状態でワーカープールを動かすと、wait -n が失敗ジョブの終了ステータス(非0)を受け取った瞬間にスクリプト全体が停止することがあります。active カウンタの更新漏れも起きやすくなります。

ワーカープール設計では set -e を外して、終了ステータスは wait -n の戻り値を明示的に確認する設計を推奨します。

# NG: set -e と wait -n を組み合わせると失敗ジョブ検出時にスクリプトが停止する #!/bin/bash set -euo pipefail # OK: ワーカープールでは -e を外して終了ステータスを明示的に確認する #!/bin/bash set -uo pipefail # -e は使わない FAILED=0 active=0 for FILE in /data/files/*.csv; do [[ -f "${FILE}" ]] || continue if [[ $active -ge $MAX_PARALLEL ]]; then wait -n status=$? (( active-- )) [[ $status -ne 0 ]] && FAILED=$((FAILED + 1)) fi gzip -k "${FILE}" & (( active++ )) done wait

6. wait -nが機能しない:サブシェルとジョブ管理の境界

wait -n はそのシェルの子プロセスしか待てません。サブシェル(( ... ))の中で起動したバックグラウンドジョブは、親シェルから wait で待てないことがあります。ジョブの投入と wait -n の呼び出しは必ず同じシェル層で行ってください。

# NG: サブシェル内でバックグラウンド実行して親から wait -n しようとしている ( rsync -a /data/ backup:/ & ) wait -n # サブシェルの子プロセスは親から待てない # OK: 投入と wait -n を同じシェル層で行う rsync -a /data/ backup:/ & pid=$! wait -n # 同じシェルで投入したジョブを正しく待てる

シェルスクリプトの並列処理設計をさらに体系的に学びたい方は、シェルスクリプト実践講座(Linux Master Pro)もご覧ください。現場で即使える設計パターンを実機ハンズオンで身につけられます。

本記事のまとめ

やりたいこと 設計パターン
コマンドを並列で起動する コマンド & PIDS+=($!)
全子プロセスの完了を待つ wait
子プロセスを個別に待ち終了ステータスを得る wait $PID
最大同時実行数を制御する(bash 4.3以降) wait -n と active カウンタを組み合わせる
完了PIDを取得して失敗ジョブを特定する(bash 5.1以降) wait -n -p 変数名
最大同時実行数を制御する(bash 4.2以前) jobs -p | wc -l でポーリングして制御する
並列書き込みの競合を防ぐ flock --timeout N ロックファイル bash -c "書き込みコマンド"
SSH無応答によるブロックを防ぐ timeout 秒数 ssh -o ConnectTimeout=N ホスト コマンド &
タイムアウト終了を判定する wait $PID の戻り値 124 はタイムアウトを示す
ワーカープールでset -eの干渉を避ける set -e を外して終了ステータスを明示的に確認する
現場で通用する安全なLinuxサーバー構築の「型」を体系的に身につけたい方へ、20年以上の運用経験を持つ現役エンジニアが基礎から教えます。
シェルスクリプト講座を見る >>

<PR>手元に置いて学びを深める1冊

マスタリングLinuxシェルスクリプト 第2版

シェルスクリプトの並列処理・waitコマンドの設計パターンを体系的に学べる、現場エンジニア必携の一冊。

無料メルマガで学習を続ける

Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。

登録無料・いつでも解除できます

暗記不要・1時間後にはサーバーが動く

3,100名以上が実践した「型」を無料で公開中

プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。

姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

Linux無料マニュアル(図解60P) 名前とメールで30秒登録

運営

リナックスマスター.JP(代表 宮崎智広)

株式会社イーネットマーキュリーが運営するLinux教育サイトです。代表の宮崎智広は現役のLinuxサーバー管理者として20年以上の実務経験を持ち、累計3,100名以上のエンジニアを指導してきました。実践型のLinuxセミナーや無料マニュアルの配布を通じてLinux人材の育成に取り組んでいます。