👌

PowerShell Job を使ってみる

2023/05/04に公開

TL;DR

  • PowerShell で並列実行しようとすると Workflow と Job、ThreadJob という選択肢がある
  • この記事では気に入ってる ThreadJob について書きつつ、いちおう Job の方も書いておく
  • 並列実行するときは -ThrottleLimit を指定するとよい

Update log

  • 全体的に # (見出し1) から ## (見出し2) に変更 - 2024/01/24
  • ThreadJob のほうがイケてるので順番を入れ替えました - 2024/01/24

はじめに

なんか PowerShell Workflow を調べている中で、Job ってのもあるので使ってみます。
ざっくりいうとこちらも並列実行ができるようですが、いろいろと違いがあります。

こちらの記事は Azure VM として構築した Windows 10 の上で検証しています。
PowerShell 5.1.x 系が使いたかったということで、一時的にこちらの環境を試しています。

> $PSVersionTable

Name                           Value
----                           -----
PSVersion                      5.1.19041.2673
PSEdition                      Desktop
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0...}
BuildVersion                   10.0.19041.2673
CLRVersion                     4.0.30319.42000
WSManStackVersion              3.0
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1

ThreadJob module を使ってみる

普通の PowerShell Job、まぁまぁ遅いんですよね。
複数の Job を走らせつつ Task Manager を見てみると、powershell.exe が複数起動しており、multi process で動いているという感じなんですね。
これに対して、multi thread の方が速いだろうということで利用できるのが ThreadJob module です。

使い方は特に難しくはなくて、Start-JobStart-ThreadJob に置き換えるだけです。
事前作業として、Install-Module -Name ThreadJob -Scope CurrentUser でインストールしておきます。
というわけで、ほぼ差がないように見えますが TheadJob 版がこちらです。

$Svcs = Get-Service | Select-Object -First 3

$ScriptBlock = {
    param(
        [Parameter(Mandatory = $true)] $Svc
    )

    Write-Output "$(Get-Date -Format "o") The status of $($Svc.Name) is $(($Svc.Status).ToString())."
}

# Start Job for each VM
$Jobs = foreach ($Svc in $Svcs) {
    Start-ThreadJob -ScriptBlock $ScriptBlock -ArgumentList $Svc
}

# Wait for all Jobs to complete
$Results = $Jobs | Wait-Job | Receive-Job
$Results

一例でしかないのですが、1 行目を 30 に変えて 30 つの Windows service について情報を出力させると、検証マシンでは以下のような差が出ました。
Start-Job は 84.36 sec、Start-ThreadJob では 1.55 sec でした。
特に Start-Job の方は速くなったり遅くなったりの違いがかなり大きいです。。

ThreadJob-ThrottleLimit で並列実行数を変えられる

Start-ThreadJob-ThrottleLimit を指定すると、並列実行数を変えられます。
特に意味はないですが、Start-Sleep を使った具体的な例を挙げてみます。

まずは ThreadJob を使わずに Start-Job を使った場合です。
結構かかってますね。。

> Measure-Command {1..30 | % {Start-Job {Start-Sleep 1}} | Wait-Job} | Select-Object TotalSeconds

TotalSeconds
------------
  93.7860297

これに対し、-ThrottleLimit を指定しない場合の Start-ThreadJob では以下のとおりです。

> Measure-Command {1..30 | % {Start-ThreadJob {Start-Sleep 1}} | Wait-Job} | Select-Object TotalSeconds

TotalSeconds
------------
   7.1880552

これに対して、-ThrottleLimit 30 を指定して、理論値最速の 30 並列で実行してみます。

> Measure-Command {1..30 | % {Start-ThreadJob -ThrottleLimit 30 {Start-Sleep 1}} | Wait-Job} | Select-Object TotalSeconds

TotalSeconds
------------
   3.0249063

ということで確かに速くなることが確認できています。
この並列化が PowerShell 5.1.x でも動いているのはうれしいです。

Write-(Output|Warning|Verbose) とかの出力系 cmdlet の使い方にちょっと癖がある

以降、気に入ったので ThreadJob を使っていきます。

今までは Write-Output を使っていたのですが、Write-Warning とか Write-Verbose を使う場面もあるかと思うのですが、少し癖があります。
以下の PowerShell script を実行してみます。

$Svcs = Get-Service | Select-Object -First 3

$ScriptBlock = {
    param(
        [Parameter(Mandatory = $true)] $Svc
    )

    Write-Output "$(Get-Date -Format "o") The status of $($Svc.Name) is $(($Svc.Status).ToString())."
    Write-Warning "$(Get-Date -Format "o") The status of $($Svc.Name) is $(($Svc.Status).ToString())."
    Write-Verbose "$(Get-Date -Format "o") The status of $($Svc.Name) is $(($Svc.Status).ToString())."
}

# Start Job for each VM
$Jobs = foreach ($Svc in $Svcs) {
    Start-ThreadJob -ScriptBlock $ScriptBlock -ArgumentList $Svc
}

# Wait for all Jobs to complete
$Results = $Jobs | Wait-Job | Receive-Job
$Results

実行結果は期待どおり以下のような感じです。
細かく見ていくと、Receive-Job 実行時点で Write-Warning の出力が画面に表示され、Write-Output の出力は $Results に格納されているようです。
Write-Verbose の出力はどこにも表示されていません。

> $Results = $Jobs | Wait-Job | Receive-Job
WARNING: 2023-05-04T12:42:43.9376178+00:00 The status of AarSvc_10deec is Stopped.
WARNING: 2023-05-04T12:42:43.9532433+00:00 The status of AdtAgent is Stopped.
WARNING: 2023-05-04T12:42:43.9844960+00:00 The status of AJRouter is Stopped.
> $Results
2023-05-04T12:42:43.9376178+00:00 The status of AarSvc_10deec is Stopped.
2023-05-04T12:42:43.9532433+00:00 The status of AdtAgent is Stopped.
2023-05-04T12:42:43.9844960+00:00 The status of AJRouter is Stopped.

Verbose な output を画面に表示させるためには、$VerbosePreference = 'Continue' を実行しておく必要があります。
のはずなのですが、実はこれでも Write-Verbose の内容は画面に表示されませんでした。
いくつか試したところ、$ScriptBlock の中にも $VerbosePreference = 'Continue' を書いておかないと出力されないようでした。

$Svcs = Get-Service | Select-Object -First 3

$ScriptBlock = {
    param(
        [Parameter(Mandatory = $true)] $Svc
    )

    $VerbosePreference = 'Continue'

    Write-Output "$(Get-Date -Format "o") The status of $($Svc.Name) is $(($Svc.Status).ToString())."
    Write-Warning "$(Get-Date -Format "o") The status of $($Svc.Name) is $(($Svc.Status).ToString())."
    Write-Verbose "$(Get-Date -Format "o") The status of $($Svc.Name) is $(($Svc.Status).ToString())."
}

# Start Job for each VM
$Jobs = foreach ($Svc in $Svcs) {
    Start-ThreadJob -ScriptBlock $ScriptBlock -ArgumentList $Svc
}

# Wait for all Jobs to complete
$Results = $Jobs | Wait-Job | Receive-Job
$Results

こちらの実行結果は以下のとおりです。
ということで、VERBOSE: で始まる行で Write-Verbose の出力が画面に表示されていることがわかります。

> $Results = $Jobs | Wait-Job | Receive-Job
VERBOSE: 2023-05-04T12:48:49.1369121+00:00 The status of AarSvc_10deec is Stopped.
WARNING: 2023-05-04T12:48:49.1369121+00:00 The status of AarSvc_10deec is Stopped.
VERBOSE: 2023-05-04T12:48:49.1525379+00:00 The status of AdtAgent is Stopped.
WARNING: 2023-05-04T12:48:49.1525379+00:00 The status of AdtAgent is Stopped.
VERBOSE: 2023-05-04T12:48:49.1837871+00:00 The status of AJRouter is Stopped.
WARNING: 2023-05-04T12:48:49.1837871+00:00 The status of AJRouter is Stopped.
> $Results
2023-05-04T12:48:49.1369121+00:00 The status of AarSvc_10deec is Stopped.
2023-05-04T12:48:49.1525379+00:00 The status of AdtAgent is Stopped.
2023-05-04T12:48:49.1837871+00:00 The status of AJRouter is Stopped.

あらかじめ $ScriptBlock の中に $VerbosePreference = 'Continue' を書いておけば、それを実行する PowerShell session の設定に応じて出力が変わったようにできるかとは思います。

$ScriptBlock で渡す際に型情報が引き継がれている

以下のような PowerShell script を実行してみます。

$Svcs = Get-Service | Select-Object -First 3

$ScriptBlock = {
    param(
        [Parameter(Mandatory = $true)] $Svc
    )

    Write-Output "$(Get-Date -Format "o") The type of `$Svc is $($Svc.GetType().FullName)."
}

# Start Job for each VM
$Jobs = foreach ($Svc in $Svcs) {
    Start-ThreadJob -ScriptBlock $ScriptBlock -ArgumentList $Svc
}

# Wait for all Jobs to complete
$Results = $Jobs | Wait-Job | Receive-Job
$Results

実行結果は以下のとおりです。
3 行表示する意味はないのですが、いずれにせよ System.ServiceProcess.ServiceController という型情報が引き継がれていることがわかります。
これは別途記事を書いた PowerShell Workflow との大きな違いとなります。

> $Results
2023-05-04T12:51:54.9084622+00:00 The type of $Svc is System.ServiceProcess.ServiceController.
2023-05-04T12:51:54.9397135+00:00 The type of $Svc is System.ServiceProcess.ServiceController.
2023-05-04T12:51:54.9553375+00:00 The type of $Svc is System.ServiceProcess.ServiceController.

型情報が引き継がれていることで、深い property にも問題なくアクセスできるわけで、これは余計なことをしなくてよい、大きなメリットとなります。

まとめ

Install-Module が必要ではありますが非管理者でも可能かとは思いますし、PowerShell 5.1.x 系におけるタスク並列化としては ThreadJob 一択でいいんじゃないかという印象です。
もう少し大き目のタスクについてもこれから流し込んで試してみます。

参考

  • ジョブについて - PowerShell

https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_jobs?view=powershell-5.1

  • スクリプト ブロックについて - PowerShell

https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_script_blocks?view=powershell-5.1

  • スレッド ジョブについて - PowerShell

https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_thread_jobs?view=powershell-5.1

  • 出力ストリームについて - PowerShell
    Write-Output とか Write-Verbose についてはこちらに一覧があります

https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_output_streams?view=powershell-5.1

  • 基本設定変数について - PowerShell
    $VerbosePreference = 'Continue' とか、それ以外にもいろいろあるのですが、こちらに一覧があります

https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_preference_variables?view=powershell-5.1

  • PowerShell Workflow についてはこちらに記事を書きました

https://zenn.dev/skmkzyk/articles/powershell-51-workflow


PowerShell Job の基本的な使い方

まずは簡単なものを試してみます。

$Svcs = Get-Service | Select-Object -First 3

# Start Job for each VM
$Jobs = foreach ($Svc in $Svcs) {
    Start-Job -ScriptBlock {
        Write-Output "$(Get-Date -Format "o") The status of $($Using:Svc.Name) is $(($Using:Svc.Status).ToString())."
    }
}

# Wait for all Jobs to complete
$Results = $Jobs | Wait-Job | Receive-Job
$Results

少し時間がかかるかもしれませんが、$Result に以下のような結果が入っているはずです。
細かいサービスの内容についてはここでは関係ないので触れません。

> $Results
2023-05-04T11:56:35.6122188+00:00 The status of AarSvc_10deec is Stopped.
2023-05-04T11:56:35.6747190+00:00 The status of AdtAgent is Stopped.
2023-05-04T11:56:35.5809681+00:00 The status of AJRouter is Stopped.

んで、$ScriptBlock という変数を定義することで、以下のように書き換えることができます。

$Svcs = Get-Service | Select-Object -First 3

$ScriptBlock = {
    Write-Output "$(Get-Date -Format "o") The status of $($Using:Svc.Name) is $(($Using:Svc.Status).ToString())."
}

# Start Job for each VM
$Jobs = foreach ($Svc in $Svcs) {
    Start-Job -ScriptBlock $ScriptBlock
}

# Wait for all Jobs to complete
$Results = $Jobs | Wait-Job | Receive-Job
$Results

また、$Using というのは少し慣れないな、という場合には以下のようにも書くことができます。
こちらのほうが、$ScriptBlock の部分を関数のように書くことができ、[Parameter(Mandatory = $true)] などと引数に対する属性指定などもできるため便利かと思います。

$Svcs = Get-Service | Select-Object -First 3

$ScriptBlock = {
    param(
        [Parameter(Mandatory = $true)] $Svc
    )

    Write-Output "$(Get-Date -Format "o") The status of $($Svc.Name) is $(($Svc.Status).ToString())."
}

# Start Job for each VM
$Jobs = foreach ($Svc in $Svcs) {
    Start-Job -ScriptBlock $ScriptBlock -ArgumentList $Svc
}

# Wait for all Jobs to complete
$Results = $Jobs | Wait-Job | Receive-Job
$Results
`Start-Job` のよくわからん動作

これ、よくわからないんですけど、Get-NetAdapter で取得したオブジェクトを使うと動きません。。
Wait-Job の部分で止まってしまい、結果が返ってきません。。

$Nics = Get-NetAdapter

$ScriptBlock = {
    param(
        [Parameter(Mandatory = $true)] $Nic
    )

    Write-Output "$(Get-Date -Format "o") The status of $($Nic.Name) is $(($Nic.Status).ToString())."
}

# Start Job for each VM
$Jobs = foreach ($Nic in $Nics) {
    Start-Job -ScriptBlock $ScriptBlock -ArgumentList $Nic
}

# Wait for all Jobs to complete
$Results = $Jobs | Wait-Job | Receive-Job
$Results

原因はわかっていないですが、-ScriptBlock の部分を直書きすると動きます。。

$Nics = Get-NetAdapter

# Start Job for each VM
$Jobs = foreach ($Nic in $Nics) {
    Start-Job -ScriptBlock {
        Write-Output "$(Get-Date -Format "o") The status of $($Using:Nic.Name) is $(($Using:Nic.Status).ToString())."
    }
}

# Wait for all Jobs to complete
$Results = $Jobs | Wait-Job | Receive-Job
$Results

明らかではありますが、実行結果は以下のような感じです。

> $Results
2023-05-04T12:07:10.8600213+00:00 The status of Ethernet is Up.

よくわかってないのですが、後述の ThreadJob が気に入ってしまったのでこちらは放置します。。

Microsoft (有志)

Discussion