PowerShell: 複数の異なるタスクを並行稼働させる

目次

要旨

Windows Server から複数のサーバに同時に問い合わせ、応答データを集計して出力したい作業がある。Linux では & でバックグラウンドに回したり、GNU Parallel を使ったりするのだが、似たようなことが PowerShell でもできる。

利用には若干の工夫が必要なためメモしておく。

サンプルスクリプト(変更前)

下記のように複数のサーバに ssh で問い合わせ、結果を表示するようなスクリプトを想定する。

# サーバ A からデータ取得
function Get-ServerDataA {
    param (
        [string]$arg1 = "",
        [string]$arg2 = ""
    )
    $res = & ssh batchuser@serverA.example.internal batch1.sh $arg1 $arg2
    return $res
}

# サーバ B からデータ取得
function Get-ServerDataB {
    param (
        [string]$arg1 = "",
        [string]$arg2 = ""
    )
    $res = & ssh batchuser@serverB.example.internal batch2.sh $arg1 $arg2
    return $res
}

# 順次実行する
$resultA = Get-ServerDataA "ABC" "DEF"
$resultB = Get-ServerDataB "PQR" "XYZ"

# 取得結果を出力
Write-Host $resultA
Write-Host $resultB

Get-ServerDataA と Get-ServerDataB の応答がそれぞれ 30秒, 40秒かかるとしたら、単純計算で 70秒は掛かることになる。PowerShell 側ではただ待っているだけなので、この2つの呼び出しを並列化し、すべて完了したら結果を表示したい。並列化の想定通りであれば、遅い方の時間 40秒+αで終わるはず。

サンプルスクリプト(並列化)

上記スクリプトを並列化する。

## function をスクリプトブロックに変換し変数に入れる
# サーバ A からデータ取得
$function_Get_ServerDataA = {
    param (
        [string]$arg1 = "",
        [string]$arg2 = ""
    )
    $res = & ssh batchuser@serverA.example.internal batch1.sh $arg1 $arg2
    return $res
}

# サーバ B からデータ取得
$function_Get_ServerDataB = {
    param (
        [string]$arg1 = "",
        [string]$arg2 = ""
    )
    $res = & ssh batchuser@serverB.example.internal batch2.sh $arg1 $arg2
    return $res
}

# バックグラウンドジョブで並列実行
$jobA = Start-Job -ScriptBlock $function_Get_ServerDataA -ArgumentList "ABC", "DEF"
$jobB = Start-Job -ScriptBlock $function_Get_ServerDataB -ArgumentList "PQR", "XYZ"

# 全ジョブの終了を待つ
Get-Job | Wait-Job | Out-Null

# 各ジョブの結果を取り出す
$resultA = Receive-Job $jobA
$resultB = Receive-Job $jobB

# 終了しただけ残っているジョブを削除しておく
Get-Job | Remove-Job

# 取得結果を出力
Write-Host $resultA
Write-Host $resultB

ポイントは下記の通り。

  • ジョブ管理系の *-Job コマンドレットを使用する
  • 実行させたい処理単位をスクリプトブロックとして定義し、変数に入れておく
  • バックグラウンド内のジョブは、外部で定義した function やグローバル変数が (普通には) 参照できないため、引数として渡す
  • Start-Job で並列化したい分だけ実行を掛け、Wait-Job で全ジョブ終了を待つ。
  • Recieve-Job で、各 JOB 応答オブジェクトから結果を取り出す。
  • 各ジョブは実行後も消えずに残っているので、後処理として Remove-Job ですべて削除する。

共通の関数を複数のジョブで使いたいときは、function 定義自体をスクリプトブロックとして 変数に入れ、Start-Job に -InitializationScript オプションで渡す等の工夫が必要。

検証

上記のようなコード例は実機で並行稼働できることを実証済みだが、ピンとこない方のために手元で確認できるコードを書いてみた。Windows 10 標準の PowerShell 5.1 および、最新の PowerShell 7 の双方で動作確認してある。

サーバAの処理が10秒、サーバBの処理が5秒と想定し、Sleep で代用する。

順次実行: Test-Sequential.ps1

# サーバ A からデータ取得
function Get-ServerDataA {
    param ([string]$arg1 = "", [string]$arg2 = "")
    Start-Sleep -s 10
    $res = "SERVER A: ${arg1}-${arg2}"
    return $res
}

# サーバ B からデータ取得
function Get-ServerDataB {
    param ([string]$arg1 = "", [string]$arg2 = "")
    Start-Sleep -s 5
    $res = "SERVER B: ${arg1}-${arg2}"
    return $res
}

# 順次実行する
$resultA = Get-ServerDataA "ABC" "DEF"
$resultB = Get-ServerDataB "PQR" "XYZ"

# 取得結果を出力
Write-Host $resultA
Write-Host $resultB

並列実行: Test-Parallel.ps1

# サーバ A からデータ取得
$func_Get_ServerDataA = {
    param ([string]$arg1 = "", [string]$arg2 = "")
    Start-Sleep -s 10
    $res = "SERVER A: ${arg1}-${arg2}"
    return $res
}

# サーバ B からデータ取得
$func_Get_ServerDataB = {
    param ([string]$arg1 = "", [string]$arg2 = "")
    Start-Sleep -s 5
    $res = "SERVER B: ${arg1}-${arg2}"
    return $res
}

# 並列実行+終了待ち
$jobA = Start-Job -ScriptBlock $func_Get_ServerDataA -ArgumentList "ABC","DEF"
$jobB = Start-Job -ScriptBlock $func_Get_ServerDataB -ArgumentList "PQR","XYZ"
Get-Job | Wait-Job | Out-Null

# 結果取得+JOB後片付け
$resultA = Receive-Job $jobA
$resultB = Receive-Job $jobB
Get-Job | Remove-Job

# 取得結果を出力
Write-Host $resultA
Write-Host $resultB

速度比較

実行時間を測定できる Measure-Command コマンドレットを利用し、秒数のみ表示してみる。

(1) 順次実行バージョン

PS C:\> Measure-Command { .\Test-Sequential.ps1 } | Format-List TotalSeconds
SERVER A: ABC-DEF
SERVER B: PQR-XYZ

TotalSeconds : 15.024752

(2) 並列実行化

PS C:\> Measure-Command { .\Test-Parallel.ps1 } | Format-List TotalSeconds
SERVER A: ABC-DEF
SERVER B: PQR-XYZ

TotalSeconds : 10.1969016

想定通り、長く掛かる方のジョブの時間の約 10 秒で完了している。

このコードではエラーハンドリングは行っていない。Wait-Job にはタイムアウトオプションもあるため、複数のジョブのうち1つが失敗したら結果を表示せず、エラー終了する処理を入れるべきだと思う。