PowerShellの並列処理 ── ForEach-Object -Parallelとジョブの使い分け

· 更新日: · · PowerShell, Windows, 並列処理, 性能改善, 自動化, 運用改善, スクリプト, ジョブ

更新履歴(4件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
並列処理の前提になるランスペースの説明を新設し、$using: が変数の参照を渡すのは同一プロセス内の並列化に限られることを補足しました。あわせてThreadJobモジュールの導入手順、ランスペースプールの動きを示す図、2種類の -ThrottleLimit の違いの表を追加しています。
本文中の関連記事へのリンクの文言が、リンク先の現在のタイトルと食い違っていたのを、実際のタイトルに揃えました。本文の内容は変えていません。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21547437)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「PowerShellの並列処理 ── ForEach-Object -Parallelとジョブの使い分け」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21547437 https://staging.comcomponent.com/blog/powershell-parallel-processing/

DOI(最新版)
10.5281/zenodo.21547437
DOI(この版)
10.5281/zenodo.21733147

「200台のPCにpingを打って死活確認するスクリプトが、1周するのに15分かかる」「共有フォルダー配下10万ファイルのハッシュ計算が終わらない」── PowerShellの自動化がある程度育つと、必ず処理時間の壁にぶつかります。そして、こうした処理の大半は待ち時間が支配的です。CPUは暇なのに、ネットワークの応答やディスクI/Oを1件ずつ順番に待っているせいで遅い。ここが並列化の出番です。

PowerShell 7では ForEach-Object -Parallel が使えるようになり、並列処理のハードルは劇的に下がりました。ただし、雑に並列化すると速くならないどころか遅くなり、しかも結果が壊れるという厄介さもあります。「共有変数を更新したら値が飛んだ」「出力の順序が毎回違う」「呼び出し元で定義した関数が見つからない」は、いずれも並列処理の仕組みを知らないまま使ったときの典型的な症状です。

この記事では、PowerShellで使える並列化の手段を整理し、ForEach-Object -Parallel の使い方と落とし穴、Start-ThreadJob / Start-Job との使い分け、そして「並列化してはいけないケース」までを、実務で判断できる形にまとめます。

1. まず結論

  • ForEach-Object -Parallel はPowerShell 7.0で追加されました。Windows PowerShell 5.1には存在しません。1
  • 各スクリプトブロックは別のランスペース(実行環境)で動きます。呼び出し元の変数・関数はそのままでは見えません。変数は $using: スコープ修飾子で渡します。1
  • -ThrottleLimit の既定値は5です。PowerShell 7.1以降はランスペースプールが再利用され、ThrottleLimitがそのプールサイズになります。毎回新規に作りたいときは -UseNewRunspace です。1
  • $using: で渡すのは「参照」です。読むだけなら安全ですが、更新するなら System.Collections.Concurrent のスレッドセーフな型を使う必要があります。通常のハッシュテーブルやListの同時更新は壊れます。1
  • 並列化は「必ず速くなる」施策ではありません。公式も、些細な処理を並列化すると通常よりずっと遅くなり得ると明記しています。効くのは待ち時間が長い処理と、マルチコアで意味のある計算処理です。1
  • 出力もエラーも順序は不定です。エラーストリームへの書き込み順もランダムで、スクリプトブロック内の終了エラーはそのイテレーションだけを止め、PSTaskException として報告されます。1
  • -AsJob でジョブとして投げられます。ただし -ThrottleLimit はジョブ1つあたりの並列数なので、ジョブを複数作れば同時実行数は掛け算になります。1
  • 軽量な並列ならStart-ThreadJob、隔離が必要ならStart-Jobです。Start-Jobは別プロセスで動くためオーバーヘッドが大きく、結果はシリアライズされてメソッドを失った状態で戻ります。23
  • 複数台のWindowsに同じ処理を流すなら、Invoke-Command の暗黙の並列実行が最短です。こちらは既定で32台まで同時実行します。4

この記事の知識マップ

PowerShellの並列処理はランスペースという実行環境を複数用意して行われ、ForEach-Object -Parallelは各要素を別ランスペースのスレッドで実行し、呼び出し元の変数は$using:で参照として渡す。書き込みを伴う共有には通常のハッシュテーブルではなくConcurrentDictionaryなどスレッドセーフな型が前提になり、呼び出し元で定義した関数を使うには.psm1モジュール化して各ランスペースで読み込む必要がある。同時実行数はThrottleLimitで制御し、待ち時間が支配的な処理には効果がある一方、1件あたりが軽い処理や少件数の処理には向かずMeasure-Commandでの比較が推奨される。プロセス隔離が必要ならStart-Job、軽量な投げっぱなし処理ならStart-ThreadJobが向き、相手が業務サーバーの場合はThrottleLimitを上げすぎると相手側に過負荷をかけることもある。

PowerShellの並列処理の知識マップForEach-Object -Parallelがランスペースとusingスコープ修飾子の仕組みの上に成り立ち、ThrottleLimitでの制御やスレッドセーフなコレクション・.psm1モジュール化が必要になる場面、Start-ThreadJob・Start-Job・Invoke-Commandとの使い分け、並列化が効く場面と効かない場面の関係を示す図利用する利用するで構成できる前提とする前提とする利用する前提とする前提とするで構成できる用いるのは非推奨推奨される対応で確認できる推奨される対応用いるのは非推奨原因になり得る原因になり得るForEach-Object -Parallelランスペース(runspace)$using:スコープ修飾子ThrottleLimitスレッドセーフなコレクション型.psm1スクリプトモジュールStart-ThreadJobStart-JobInvoke-Command -ComputerName1件あたりが軽い・少件数のワークロード待ち時間が支配的な処理Measure-Commandプロセス隔離が必要な処理接続先サーバーへの過負荷

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全16件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

2. 並列化の4つの選択肢

2.1 前提知識 ── ランスペース(runspace)とは

この記事で繰り返し出てくる「ランスペース」を、先に押さえておきます。ランスペースとは、PowerShellがスクリプトを実行するための「実行環境」そのものです。変数、関数、読み込み済みのモジュール、カレントディレクトリといったセッションの状態一式が、このランスペースに属します。

プロセス・スレッドとの関係で言うと、こうなります。

何か この記事での意味
プロセス OSから見た実行単位(pwsh.exe 1つ) 1つのプロセスの中に複数のランスペースを持てる
スレッド 実際にコードを走らせる実行の流れ ランスペースはスレッドの上で動く
ランスペース PowerShellの状態(変数・関数・モジュール)の入れ物 ここが分かれると、変数も関数も共有されない

イメージとしては、「1つのプロセスの中に、変数も関数も別々に持つPowerShellセッションを複数立てられる」と考えると近いです。ForEach-Object -Parallel は、このランスペースを複数用意して、スクリプトブロックをそれぞれ別のスレッドで走らせる仕組みです。1

この記事の面倒ごとは、ほぼすべてここから出てきます。呼び出し元の変数や関数は呼び出し元のランスペースに属しているので、別のランスペースからは見えません。だから変数は $using: で明示的に渡す必要があり、関数はモジュール化して各ランスペースで読み込む必要があります。その一方でプロセスは同じなので、メモリ上のオブジェクトの実体は共有できてしまいます。これが後述のスレッド安全性の問題として跳ね返ってきます。

「見えないのに、共有はされる」── この一見矛盾した性質が、ランスペースを理解しないまま並列化したときに事故になる原因です。

2.2 4つの手段

まず地図を持っておきます。PowerShellで「同時に走らせる」手段は大きく4つです。

手段 実行単位 使えるバージョン 向いている用途
ForEach-Object -Parallel スレッド(ランスペース) PowerShell 7.0+1 コレクションの各要素に同じ処理。第一候補
Start-ThreadJob スレッド(ランスペース) PowerShell 7同梱 / 5.1はGalleryから導入2 少数の処理を投げっぱなしにして後で回収
Start-Job 別プロセス 5.1・7とも標準3 隔離が必要、または別プロセスにしたい処理
Invoke-Command -ComputerName リモートの各PC 5.1・7とも標準4 複数台のWindowsに同じ処理を配る

実務上の使い分けは単純です。同じ処理を大量の対象に適用するなら ForEach-Object -Parallel、対象が「複数のリモートPC」ならリモート実行が先です。後者は指定した複数コンピューターに対して既定で32台まで同時に実行するため、自分で並列化を書く必要がありません。4 リモート実行そのものの設定は「PowerShell Remoting(WinRM)入門」を参照してください。

Start-Job が重いのは、バックグラウンドジョブが別プロセスとして起動されるためです。プロセス起動のコストに加えて、結果はシリアライズされて戻るので、受け取ったオブジェクトはメソッドを持たない「デシリアライズされたコピー」になります。3 一方 Start-ThreadJob は同一プロセス内のスレッドで動くため大幅に軽量です。2

3. ForEach-Object -Parallel の基本

最小の形はこうです。$_ が現在の入力オブジェクト、外の変数は $using: を付けて参照します。1

$timeout = 2   # 呼び出し元の変数

$result = $computers | ForEach-Object -Parallel {
    $name = $_
    # 外側の変数は $using: を付けないと見えない
    $ok = Test-Connection -TargetName $name -Count 1 -TimeoutSeconds $using:timeout -Quiet

    # 集計用の共有変数を更新するのではなく、「出力する」のが基本形
    [pscustomobject]@{
        Computer = $name
        Alive    = $ok
        CheckedAt = Get-Date
    }
} -ThrottleLimit 20

$result | Where-Object { -not $_.Alive } | Format-Table

ここで押さえるべき設計原則が一つあります。共有変数を更新するのではなく、各スクリプトブロックが結果を「出力」し、呼び出し元でまとめて受け取る。この形にしておけば、スレッド安全性の問題はそもそも発生しません。並列処理でトラブルになるコードは、たいてい共有状態を書き換えようとしています。

どうしても共有コレクションに集めたい場合は、スレッドセーフな型を使います。1

# 公式が例示しているパターン: ConcurrentDictionaryは複数スレッドから安全に更新できる
$safeDict = [System.Collections.Concurrent.ConcurrentDictionary[string,object]]::new()

Get-Process | ForEach-Object -Parallel {
    $dict = $using:safeDict
    # TryAdd()はbooleanを返す。捨てないと True/False が出力に混ざる
    $null = $dict.TryAdd($_.ProcessName, $_)
}

# 【危険】通常のHashtableやList<T>を$using:で渡して更新するのは安全ではない
# $shared = @{}          # 同時更新で内部構造が壊れ、例外や値の消失が起きる

ここで $using: の意味を正確にしておきます。公式ドキュメントは、ForEach-Object -Parallel について「Using: 修飾子は、コマンドレットを呼び出したスレッドから、実行中の各スクリプトブロックのスレッドへ変数の参照を渡す」と明記しています。渡っているのは同じプロセス内の同じオブジェクトへの参照です。だからこそ、変化しないものを読むだけなら安全で、状態を書き換えるならスレッドセーフな型が要る、という話になります。1

そして、この「参照が渡る」性質は、同じプロセス内で動く並列化に限った話です。Start-Job は別プロセス、Invoke-Command -ComputerName は別マシンで動くため、$using: で渡した値はシリアライズされてコピーが届きます。向こう側で書き換えても呼び出し元には反映されませんし、戻ってくるオブジェクトもメソッドを持たないデシリアライズ済みのコピーです。3 記法は同じ $using: でも意味が違うので、ForEach-Object -Parallel の感覚のまま Start-Job に持ち込まないでください。

4. 5つの落とし穴

(1) 呼び出し元の関数が見えない

並列スクリプトブロックは別のランスペースで実行されるため、呼び出し元で定義した関数はそのままでは呼べません。共通処理はモジュール化してスクリプトブロックの先頭でインポートするのが正攻法です。

$items | ForEach-Object -Parallel {
    Import-Module 'D:\Scripts\Modules\KsOps' -ErrorAction Stop   # 各ランスペースで読み込む
    Convert-KsRecord -Input $_
} -ThrottleLimit 8

ただし各ランスペースでモジュールを読み込むコストが並列数だけ発生します。重いモジュールを使う処理では、並列度を上げても頭打ちになる主因がここになることがあります。モジュール化の作法は「PowerShellの引数設計とモジュール化」にまとめています。

(2) ランスペースが再利用されるため、状態が持ち越されることがある

PowerShell 7.0では反復ごとに新しいランスペースが作られていましたが、7.1以降は既定でランスペースプールから再利用されます1 性能上は大きな改善ですが、あるイテレーションで設定した環境設定変数やカレントディレクトリの変更、読み込んだモジュールの状態が、同じランスペースを使う後続のイテレーションから見えることを意味します。イテレーション間の完全な独立が必要なら -UseNewRunspace を指定します(その分は遅くなります)。1

(3) 出力もエラーも順序が保証されない

並列実行の順序は非決定的です。エラーストリームに書かれる順もランダムで、警告・詳細・情報ストリームも同様です。1

1..5 | ForEach-Object -Parallel {
    if ($_ -eq 3) { throw "Terminating Error: $_" }   # このイテレーションだけが止まる
    "Output: $_"
}
# Output: 1 / Output: 4 / Output: 2 / Output: 5 (順不同)、Output: 3 は出ない

スクリプトブロック内の終了エラーはそのイテレーションだけを終了させ、他の並列実行は続行します。エラーは FullyQualifiedErrorIdPSTaskException のErrorRecordとしてエラーストリームに書かれます。「1件でも失敗したら全部止める」という挙動ではないので、全件の成否を自分で集計する必要があります。1

$results = $files | ForEach-Object -Parallel {
    # catchブロックの中では $_ がErrorRecordに変わるため、
    # 入力オブジェクトは必ず別の変数に退避してから使う
    $file = $_

    try {
        $hash = Get-FileHash -Path $file.FullName -Algorithm SHA256 -ErrorAction Stop
        [pscustomobject]@{ Path = $file.FullName; Hash = $hash.Hash; Error = $null }
    }
    catch {
        # 失敗も「出力」として返し、呼び出し元で仕分ける
        [pscustomobject]@{ Path = $file.FullName; Hash = $null; Error = $_.Exception.Message }
    }
} -ThrottleLimit 8

$failed = $results | Where-Object Error
if ($failed) { Write-Warning "$($failed.Count)件が失敗しました" }

(4) PipelineVariableが使えない

共通パラメーターの -PipelineVariable は、$using: を付けても並列シナリオではサポートされません。1 逐次処理から移植するときに引っかかるポイントです。

(5) 進捗表示とログが混ざる

複数スレッドから同時に Write-Progress や独自ログを書くと、行が混ざったりファイルが競合したりします。ログはスクリプトブロック内で直接ファイルに追記するのではなく、結果オブジェクトとして返して呼び出し元で1か所から書くのが安全です。出力ストリームの設計は「PowerShellの出力ストリームとログ設計」で扱っています。

5. ThrottleLimitの決め方

-ThrottleLimit は同時に走るスクリプトブロックの数で、既定は5です。1 7.1以降はこの値がランスペースプールのサイズそのものになります。1 動きを図にすると次のとおりです。

1件終わるたびに同じランスペースを使い回すランスペースプール ThrottleLimit = 5ランスペース 1ランスペース 2ランスペース 3ランスペース 4ランスペース 5入力 200件待ち行列空きが出るまで待つ出力順序は不定

入力200件に対してランスペースが200個作られるのではなく、-ThrottleLimit の数だけ用意されたランスペースを使い回す、というのが要点です。ここから2つのことが導けます。ひとつは、-ThrottleLimit を上げるほどメモリと初期化コストが増えること。もうひとつは、使い回されるがゆえに前のイテレーションの状態が残りうること(4章(2))です。

決め方の目安は処理の性質で分けます。

処理の性質 目安 理由
ネットワーク待ち(疎通確認、API呼び出し) コア数より大きくてよい(20〜50程度から試す) CPUはほぼ遊んでいる。制約は相手側
ディスクI/O待ち 8〜16程度から試す 上げすぎるとランダムアクセスが増えて逆効果。SSD/HDDで差が大きい
CPU計算(ハッシュ、圧縮、変換) 論理コア数程度 それ以上はコンテキストスイッチの無駄
相手が業務サーバー・API 相手の許容量が上限 レート制限や同時接続上限を超えると障害の加害者になる

最後の行が実務では最重要です。自分のスクリプトを速くするために業務サーバーを落としては本末転倒なので、社内APIやファイルサーバーを相手にするときは、こちらの並列度を「相手が耐えられる範囲」で決めます。APIのレート制限への対処は「PowerShellでREST APIと連携する」を参照してください。

-AsJob を使う場合の注意も公式に明記されています。ThrottleLimitは ForEach-Object -Parallel 1回あたりの制限であり、ジョブを10個作れば「10 × ThrottleLimit」が同時に走ります。1

もう一点、同じ -ThrottleLimit という名前でも、コマンドによって数えているものが違います。混同すると並列度の見積もりを間違えるので、この記事で出てくる2つを並べておきます。

コマンド -ThrottleLimit が数えるもの 既定値
ForEach-Object -Parallel 同時に走るスクリプトブロックの数(= ランスペースプールのサイズ) 51
Invoke-Command -ComputerName 同時に接続するコンピューターの台数 324

つまり Invoke-Command -ThrottleLimit 32 は「32台に同時接続する」であって、「1台あたり32並列で処理する」ではありません。両方を組み合わせる(リモート先で ForEach-Object -Parallel を走らせる)場合は、台数 × 並列度が相手側の総負荷になる点にも注意してください。

6. 並列化しないほうが速いケース

公式ドキュメントは踏み込んだ書き方をしています ── 新しいランスペースは逐次処理に比べて相当なオーバーヘッドがあり、並列スクリプトが些細な処理だと通常よりずっと遅くなり得る、実際に試して効果がある場所を見つけよ、と。1 公式の例にも「これは並列化の非効率な使用例」と明記されたサンプルがあるほどです。

判断基準はシンプルです。

  • 1件あたりの処理時間が短い(ミリ秒オーダー) → 並列化しない。文字列処理やハッシュテーブル参照の類は逐次のほうが速い
  • 件数が少ない(数十件) → 並列化しない。オーバーヘッドが相対的に大きい
  • 1件あたり数百ミリ秒以上の待ちがある → 並列化の価値がある
  • CPUを長時間使う計算がある → マルチコアで効く

そして必ず測ってから決めることです。Measure-Command で逐次版と並列版を比べれば数十秒で答えが出ます。測定の作法とPowerShellスクリプトの高速化全般は「PowerShellスクリプトが遅いときに見るところ」にまとめました。

# 逐次と並列を同じ入力で比較する(何度か実行して安定した値を見る)
# 並列側は別ランスペースで動くため、呼び出し元で定義した関数は見えない。
# 比較にならないので、モジュールを読み込むか処理を直接書く
$seq = Measure-Command {
    $items | ForEach-Object { Invoke-Work $_ }
}
$par = Measure-Command {
    $items | ForEach-Object -Parallel {
        Import-Module 'D:\Scripts\Modules\KsOps'   # ← これがないとコマンド未検出になる
        Invoke-Work $_
    } -ThrottleLimit 16
}
'{0:N1}秒 → {1:N1}秒' -f $seq.TotalSeconds, $par.TotalSeconds

7. Start-ThreadJobとStart-Jobの使い分け

ForEach-Object -Parallel は「同じ処理を多数の入力に適用する」形にはまりますが、性質の違う処理を同時に走らせて後で回収したいときはジョブが向きます。

# 別々の処理を同時に走らせ、まとめて待つ(スレッドジョブ = 軽量)
# スレッドジョブも別ランスペースで動くため、呼び出し元の関数は見えない。
# 各ジョブの先頭でモジュールを読み込む(または自己完結したスクリプトブロックにする)
$jobs = @(
    Start-ThreadJob -Name 'ADユーザー棚卸' -ScriptBlock {
        Import-Module 'D:\Scripts\Modules\KsOps'; Get-KsAdUserReport
    }
    Start-ThreadJob -Name 'ファイルサーバー容量' -ScriptBlock {
        Import-Module 'D:\Scripts\Modules\KsOps'; Get-KsShareUsage
    }
    Start-ThreadJob -Name 'ライセンス集計' -ScriptBlock {
        Import-Module 'D:\Scripts\Modules\KsOps'; Get-KsLicenseSummary
    }
)

# 完了を待って結果を回収する。エラーは -ErrorVariable で必ず受け取る
$reports = $jobs | Receive-Job -Wait -ErrorAction SilentlyContinue -ErrorVariable jobErrors

# 状態が Failed になるのは、スクリプトブロックが「終了エラー」で落ちたときだけ。
# Write-Error のような非終了エラーで終わったジョブは Completed のままなので、
# 状態だけを見ていると「エラーが出ていたのに成功扱い」になる
$failed = @($jobs | Where-Object { $_.State -eq 'Failed' })
$jobs | Remove-Job -Force

if ($failed.Count -gt 0 -or @($jobErrors).Count -gt 0) {
    throw "並列実行でエラーが発生しました: $(($jobErrors | ForEach-Object { $_.Exception.Message }) -join ' / ')"
}

-AutoRemoveJob をここで使わないのは、回収した直後にジョブが消えてしまい、どれが失敗したのかを確認できなくなるからです。Receive-Job のエラーは既定で非終了エラーなので、何も書かなければ「一部が失敗したのに、成功したぶんだけ返ってきて完了したように見える」という、いちばん困る形になります。棚卸しやレポートの自動化では、この取りこぼしが数字の間違いとして静かに残ります。

Start-Job(プロセス分離型)を選ぶのは、次のような場合です。3

  • 呼び出し元プロセスを巻き込みたくない処理(クラッシュしうるネイティブDLLを叩くなど)
  • 別のプロセス環境が必要な処理(異なるカルチャ設定や環境変数で動かしたい)
  • 32bit / 64bitなど、実行環境そのものを分けたい処理

逆にこれらに当てはまらないなら、オーバーヘッドとシリアライズの制約の分だけ Start-Job は不利です。デシリアライズされたオブジェクトはメソッドを持たない点も、後段の処理で効いてきます。3

8. Windows PowerShell 5.1しかない環境では

5.1で -Parallel は使えません。現実的な選択肢は3つです。

  1. Start-ThreadJob(PowerShell Galleryの ThreadJob モジュールを導入)── 5.1でも軽量な並列が使えます。社内配布の作法は「PowerShellモジュールの社内配布と更新」を参照2
  2. Invoke-Command -ComputerName ── 対象が複数台のWindowsなら、これだけで並列になります(既定32台同時)4
  3. PowerShell 7を導入する ── 5.1と7は共存できるので、重い処理だけ7で動かす選択も現実的です

1番の導入は次のとおりです。素の5.1でそのまま Install-Module すると失敗することがあるので、つまずきやすい2点も併記します。

# 【1】PowerShell Gallery は TLS 1.2 以降を要求する。
#      Windows PowerShell 5.1 の既定では有効になっておらず、
#      「基になる接続が閉じられました」等で失敗することがある。
#      このセッションだけ TLS 1.2 を有効にしてから実行する
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# 【2】導入。CurrentUser なら管理者権限は不要
Install-Module ThreadJob -Scope CurrentUser

# 【3】確認
Import-Module ThreadJob
Get-Command -Module ThreadJob     # Start-ThreadJob が出れば導入できている

つまずきやすいのは次の2点です。

  • TLS 1.2 ── PowerShell Galleryは2020年4月以降、TLS 1.2以降での接続を前提としています。5.1では上の1行が必要になることがあります5
  • リポジトリの信頼と実行ポリシー ── PSGalleryは既定で「信頼されていないリポジトリ」として扱われるため、Install-Module で確認を求められます。無人で導入するなら -Force、常用するなら Set-PSRepository -Name PSGallery -InstallationPolicy Trusted で一度信頼済みにしておきます。また実行ポリシーが Restricted(Windowsクライアントの既定)だとスクリプトの読み込みで止まるため、Set-ExecutionPolicy -Scope CurrentUser RemoteSigned 相当の設定が必要です。恒久的に Unrestricted にするのは避けてください6

3番目が結局いちばん安上がりになることが多く、当社でも「並列化のために凝った5.1コードを書く」より「その処理だけ7で動かす」を推奨しています。共存と移行の考え方は「Windows PowerShell 5.1とPowerShell 7の違い」にまとめてあります。

9. 実務の定石(判断表)

やりたいこと 選択 補足
多数の対象に同じ処理(疎通確認、ハッシュ計算、API呼び出し) ForEach-Object -Parallel PowerShell 7専用。結果は出力で返す設計に1
複数台のWindowsで同じ処理 Invoke-Command -ComputerName 既定32台同時。自前の並列化は不要4
性質の違う処理を同時実行 Start-ThreadJob 軽量。Receive-Job -Wait で回収2
プロセス分離が必要 Start-Job 重い。結果はデシリアライズされる3
結果を1か所に集めたい 出力で返す / ConcurrentDictionary 通常のHashtable・Listの同時更新は不可1
1件が軽い・件数が少ない 並列化しない オーバーヘッドで逆に遅くなる1
相手が業務サーバー・API ThrottleLimitを相手基準で決める レート制限・同時接続上限が事実上の上限
イテレーション間の独立が必須 -UseNewRunspace 再利用による状態の持ち越しを避ける(遅くなる)1

10. まとめ

  • ForEach-Object -Parallel はPowerShell 7.0以降の機能で、各スクリプトブロックを別ランスペースで実行します。呼び出し元の変数は $using:、関数はモジュール化して読み込むのが基本形です。
  • 共有変数を更新するのではなく、各イテレーションが結果を出力して呼び出し元で集約する設計にすれば、スレッド安全性の問題はほぼ回避できます。どうしても共有するならConcurrent系の型を使います。
  • 既定のThrottleLimitは5。待ち時間が支配的な処理は大きく、CPU処理はコア数程度、相手がある処理は相手の許容量が上限です。
  • 出力・エラーの順序は不定で、終了エラーはそのイテレーションだけを止めます。全件の成否は自分で集計してください。
  • 並列化は万能ではありません。軽い処理や少件数ではオーバーヘッドで遅くなります。必ず Measure-Command で逐次版と比較してから採用してください。
  • 5.1環境では Start-ThreadJob、リモートなら Invoke-Command、それでも足りなければPowerShell 7の導入を検討するのが現実的です。

サンプルコードのダウンロード

この記事で扱ったコードは、そのまま動かせる形にまとめて配布しています。ForEach-Object -Parallel / Start-ThreadJob の実務向け定型が入っています。

サンプルコードをダウンロード(zip)

この記事のサンプルは、PowerShell 7.6 で実際に実行して検証しています(Pester 8件)。zipに含まれる Invoke-SampleTests.ps1 を実行すれば、お手元でも同じ検証を再現できます。

# 構文解析 + 静的解析 + Pesterテスト
./Invoke-SampleTests.ps1

設定値(パス、サーバー名、テナントIDなど)は例です。そのまま本番環境で実行せず、自社の環境に合わせて読み替えてください。

関連記事

関連する相談領域

合同会社小村ソフトでは、時間のかかる運用スクリプトの高速化、並列処理を含む自動化設計のレビュー、「並列化したら結果がおかしくなった」といった不具合の調査を扱っています。

参考リンク

  1. Microsoft Learn, ForEach-Object. PowerShell 7.0で-Parallelパラメーターセットが追加されたこと、各スクリプトブロックが新しいランスペースで実行されること、$using:スコープ修飾子による変数の受け渡し、-ThrottleLimitの既定値が5でランスペースプールのサイズになること、7.1以降はランスペースが再利用され-UseNewRunspaceで新規作成できること、-AsJobと-TimeoutSecondsの動作、$using:で渡した参照の更新にはSystem.Collections.Concurrentのようなスレッドセーフな型が必要なこと、非終了エラーの出力順が不定であること、終了エラーが個々の並列インスタンスのみを終了させFullyQualifiedErrorIdがPSTaskExceptionになること、PipelineVariableが並列シナリオで未サポートであること、新規ランスペースのオーバーヘッドが大きく些細な処理では逆に遅くなり得ること、-AsJob使用時はThrottleLimitがジョブごとの制限であることについて。  2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26

  2. Microsoft Learn, Start-ThreadJob. Start-ThreadJobが別プロセスではなく同一プロセス内の独立したスレッドでスクリプトブロックを実行し、Start-Jobより軽量であること、標準のジョブコマンドレット(Receive-Job等)で扱えること、-ThrottleLimitによる同時実行数の制御について。  2 3 4 5

  3. Microsoft Learn, about_Jobs. バックグラウンドジョブがコマンドを新しいプロセスで非同期に実行すること、Start-Job・Get-Job・Receive-Job・Wait-Jobによるジョブ操作、ジョブの結果がシリアライズを経て返されることについて。  2 3 4 5 6 7

  4. Microsoft Learn, Invoke-Command. -ComputerNameに複数のコンピューターを指定して同じコマンドを実行できること、-ThrottleLimitが同時接続数を制限し既定値が32であること、-AsJobによるバックグラウンド実行について。  2 3 4 5 6

  5. Microsoft PowerShell Team Blog, PowerShell Gallery TLS Support. 2020年4月以降PowerShell GalleryがTLS 1.2を既定とし、クライアント側もTLS 1.2以降で接続する必要があること、Windows PowerShell 5.1では[Net.ServicePointManager]::SecurityProtocolにTls12を設定してから接続する回避策が案内されていることについて。 

  6. Microsoft Learn, about_Execution_Policies. 実行ポリシーがスクリプトの読み込み条件を制御する仕組みであること、すべてのスコープで未定義の場合の実効ポリシーがWindowsクライアントではRestricted、Windows ServerではRemoteSignedになること、Restrictedでは.ps1・.psm1を含むスクリプトファイルが実行できないこと、-Scopeを指定して現在のユーザーやプロセスにだけ適用できることについて。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

ForEach-Object -ParallelはWindows PowerShell 5.1でも使えますか?
使えません。-ParallelはPowerShell 7.0で追加されたパラメーターセットで、5.1にはそもそも存在しません。5.1で並列化するなら、PowerShell Galleryから導入できるThreadJobモジュールのStart-ThreadJob、標準のStart-Job(プロセス分離型で重い)、あるいはRunspacePoolを自分で組む方法になります。社内スクリプトの高速化が目的なら、5.1で凝った並列処理を書くより、PowerShell 7を導入して-Parallelを使うほうが保守性の面でも有利です。
並列化したのに、かえって遅くなりました。なぜですか?
並列実行のオーバーヘッドが処理そのものより大きいためです。ForEach-Object -Parallelは各スクリプトブロックを別のランスペースで実行するので、逐次処理に比べて相当のオーバーヘッドがあり、公式ドキュメントも「並列スクリプトが些細な処理だと通常よりずっと遅くなり得る」と明記しています。1件あたりの処理が数ミリ秒で終わる、あるいは件数が数十件しかないといった場合は、並列化しないほうが速いのが普通です。効果が出るのは、ネットワーク待ちやファイルI/O待ちが長い処理や、マルチコアで意味のある計算処理です。
$using:で渡した変数に、並列スクリプトブロックの中から値を書き込めますか?
参照している値を読むのは安全ですが、書き込みは対象がスレッドセーフな型でない限り安全ではありません。$using:は呼び出し元スレッドから各スクリプトブロックのスレッドへ変数の参照を渡す仕組みで、複数スレッドが同時に触るため、通常のハッシュテーブルやListを更新すると壊れます。集計結果を集めたい場合は、System.Collections.Concurrent名前空間のConcurrentDictionaryやConcurrentBagのようなスレッドセーフな型を使うか、そもそも各スクリプトブロックが値を出力して呼び出し元で受け取る形に設計してください。
ThrottleLimitはいくつにするのが正解ですか?
処理の性質で変わります。既定値は5です。ネットワーク待ちやファイルI/O待ちが支配的な処理(サーバーへの疎通確認、API呼び出しなど)は、CPUコア数より大きい値でも効果が出ます。一方、CPUを使い切る計算処理では、コア数程度を超えると競合で遅くなるだけです。相手が業務サーバーやAPIの場合は、こちらの都合だけでなく相手側の同時接続上限やレート制限も上限になります。まずは既定の5で測り、倍にして効果があるかを確認する、という進め方が安全です。
並列スクリプトブロックの中から、自分で書いた関数が呼べません。
並列スクリプトブロックは呼び出し元とは別のランスペースで実行されるため、呼び出し元のスコープで定義した関数や変数はそのままでは見えません。対処は2つで、共通処理をモジュール(.psm1)にしてスクリプトブロックの先頭でImport-Moduleする、または関数定義をテキストとして$using:で渡してスクリプトブロック内で再定義する方法です。保守性の観点では前者を推奨します。ただし各ランスペースでモジュールを読み込むコストがかかるので、モジュールが重い場合は並列度を上げても頭打ちになる点に注意してください。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る