[PowerShell] ForEach-Objectの新機能、ForEach-Object -Parallel について

2019.08.30

しばたです。

先日リリースされたPowerShell 7 Preview.3では多くの機能追加がされましたが、その中のひとつにForEach-Objectコマンドレットに処理を並列で行う-Parallelパラメーターの追加があります。

本記事ではこのForEach-Object -Parallelについて解説します。

導入に至る経緯

もともとWindows PowerShellではPowerShell 3.0からワークフローの機能が導入されており、このワークフローの機能のひとつでforeach文に-parallelパラメーターを指定して並列処理を行うことが可能でした。

# Windows PowerShell 3.0から導入されたワークフロー
workflow Invoke-Parallel {
    foreach -parallel ($i in (1..10)) {
        "Time {0:HH:mm:ss} | Input : {1}" -f (Get-Date),$i; Start-Sleep -Seconds 5
    }
}
Invoke-Parallel

ただ、PowerShell Core 6.0からは.NET Coreでワークフローの基盤となるWorkflow Foundationがサポートされないことからこの機能は廃止されました。
今回のForEach-Object -Parallelはこのワークフローでの並列処理を代替する目的で導入されています。

前提条件

ForEach-Object -Parallelを利用するにはPowerShell 7 Preview.3以降の環境が必要です。

この機能は試験的な機能(Experimental Feature)として追加されていますが、PowerShell 7 Preview.3からプレビュー版のPowerShellでは試験的な機能がデフォルトで有効になる仕様変更が入っているため、PowerShell 7 Preview.3インストール後に機能の有効化をする手順は不要です。

試してみる

このForEach-Object -Parallelでは引数に指定されたスクリプトブロックを個別のRunspace(≒スレッド)で並列処理します。
デフォルトは5並列で、-ThrottleLimitパラメーターを指定することで並列度を変更できます。
また、-TimeoutSecondsパラメーターを指定すると処理全体でのタイムアウトを指定することができます。

ここで例として以下の様なコマンドを実行すると下図の様な結果となります。

# ForEach-Object -Parallel を使った並列処理 (5並列)
1..10 | ForEach-Object -Parallel { "Time {0:HH:mm:ss} | Input : {1}" -f (Get-Date),$_; Start-Sleep -Seconds 5 }

ここでは最初の5オブジェクト(15)が並列処理されて5秒間Sleepし、その後残りのオブジェクト(610)が順に処理されていく形となります。
-Parallelを指定しない場合はひとつひとつのオブジェクトが順に処理されるので下図の様な結果となります。

こうしてみるとForEach-Object -Parallelとの違いは一目瞭然でしょう。

通常のForEach-Objectとの違い

この結果だけ見ると常にForEach-Object -Parallelだけ使えば良さそうに見えますが、通常のForEach-Objectでは-Begin-Endパラメーターを使いコマンドレットのBeginブロック、Endブロックをカスタマイズすることができるのに対して、ForEach-Object -Parallelでは-Begin-Endパラメーターは指定できずProcessブロックのみ実行可能となっています。

# 通常の ForEach-Object では Begin / Process / End 各ブロックの処理を記述可能
1..10 | ForEach-Object -Begin { [開始処理] } -Process { [順次処理] } -End { [終了処理] }

# -Parallel パラメーターでは Process ブロックのみ記述可能
1..10 | ForEach-Object -Parallel { [並列処理] }

また、ForEach-Object -Parallelで並列実行されるスクリプトブロックはRunSpaceが分かれますので外部から変数を引き渡す際はusing:スコープを指定してやる必要があります。

# スクリプトブロックは別RunSpaceで実行されるため、外部から変数を引き渡す際はusingスコープを指定してやる必要がある
$value = "Exeternal Value"
1..10 | ForEach-Object -Parallel { Write-Output $using:Value }

このことからForEach-Object -ParallelForEach-Objectの一つのパラメーターではあるものの処理を実行するコンテキストは通常の場合とは完全に異なり、ほぼ別コマンドレットと考えた方が良いでしょう。
状況に応じて2つの処理を使い分けるのが良いと思います。

実装について

続けてForEach-Object -Parallelの実装に触れていきます。
この機能は中の人であるPaul Higinbothamさんによって導入され、この方は以前にThreadJobを導入した人でもあります。

このため並列処理の仕組みはThreadJobの方式に似ている部分が多いです。

PSTask・PSTaskPool

ForEach-Object -Parallelで並列実行される処理はPSTaskと呼ばれる単位で個別のRunSpaceに分かれて実行されます。
PSTaskPoolというクラスが個々のPSTaskをまとめタスクの並列度やタスクに対する終了通知などの役割を担っています。
c#のスレッド処理におけるスレッドプールとTaskの関連をPowerShellのRunSpaceに合わせた形式と考えると分かりやすいでしょう。

ForEach-Object内部のBeginProcessingメソッドでPSTaskPoolの初期化処理を実施、ProcessRecordメソッドで都度PSTaskを生成しPSTaskPoolに追加しタスク(=-Parallelパラメータで指定したスクリプトブロック)を実行、並列度が-ThrottleLimitパラメーターで指定されている値を超える場合はPSTaskPoolへのタスク追加がブロックされる仕様となっています。
このためProcessRecordメソッドで実行される処理は並列ですが、開始順は保証されています。
最後にEndProcessingメソッドでPSTaskPoolの終了処理が行われます。
ちなみにPSTaskから各ストリームへの書き込みはPSTaskDataStreamWriterクラスが調整しています。

概要図

ここまでの説明を図に示すと以下の様になります。

最後に

ざっとこんな感じです。

現在のPowerShell CoreではThreadJobを使うことで同等のことができますが、ForEach-Object -Parallelはより簡易な記法で取り扱うことができます。
これまで並列処理にはからっきし弱かったPowerShellですがこの機能が導入されることでだいぶ使える様になるのではないかと思います。