2011/12/25

はじめに

PowerShell Advent Calendar 2011の25日目最終日の記事、そしてこれが私の記事では4回目となります。今回もバックグラウンドジョブについての話題です。今回はバックグラウンドジョブを使って並列処理をやってみようという試みです。

これまでの記事は以下になります。

2日目:バックグラウンドジョブの使い方・基本編

13日目:バックグラウンドジョブとの通信

19日目:PowerShell 3.0で追加されるバックグラウンドジョブ関係の新機能

ところでつい2日前、WMF3 CTP2 Windows PowerShell Workflow.pdfというpdfファイルが公開されました。これは19日目に書いたPS workflowについての詳しい説明(英語)です。構文だけでなくPSスクリプトとの違いやWFとの関係などが詳しく書かれています。ぜひ目を通しておくことをお勧めします。23日目のAhfさんの記事と併せて読むと理解が深まると思いますよ!

並列処理スクリプト

C#をご存知の方なら、PowerShellのバックグラウンドジョブ機能はC#4.0から使えるTaskオブジェクトとちょっと似てるかなーと思われるかもしれません。ではC#4.0でコレクションに対して並列処理でループを回すParallel.For()やParallel.Invoke()みたいなことはPowerShellでできないのか、という疑問が出てくるかと思います。

前回述べたようにPowerShell 3.0ならworkflowを使えば並列処理が可能で、for -parallelステートメントやparallelブロックでParallel.For()やParallel.Invoke()みたいなことが可能になります。しかしPowerShell 3.0がリリースされるのはまだ先ですし制限事項も多いので、なんとかPowerShell 2.0で、しかもworkflowのような制限なしで、並列処理のスクリプトは書けないものかと考えてみました。

function ParallelForEach-Object
{    
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true,Position=1)][scriptblock]$process,
        [scriptblock]$begin={},
        [scriptblock]$end={},
        [Parameter(ValueFromPipeline=$true)][psobject]$inputObject
    )
    begin
    {
        &$begin
        $jobs=@()
    }
    
    process
    {    
        $jobs|Receive-Job
        while(@($jobs|?{$_.State -eq "Running"}).Length -ge 5)
        {
            $jobs|Receive-Job
            start-sleep -Milliseconds 100
        }       
        
        $jobs += Start-Job $process -argumentList $inputObject
    }

    end
    {
        while(@($jobs |?{$_.State -eq "Running"}).Length -gt 0)
        {
            $jobs|Receive-Job
            start-sleep -Milliseconds 100
        }
        $jobs|Receive-Job
        $jobs|remove-job
        &$end
    }
}




$watch=new-object System.Diagnostics.Stopwatch

"ForEach-Object 開始"

$watch.Start()
1..10|ForEach-Object {
    "start: " + $_
    Start-Sleep -sec 5
    "end: " + $_
    
}
$watch.Stop()

"ForEach-Objectの場合:" + $watch.Elapsed.TotalSeconds + " sec"

$watch.Reset()

"ParallelForEach-Object 開始"

$watch.Start()
1..10|ParallelForEach-Object {
    "start: " + $args[0]
    Start-Sleep -sec 5
    "end: " + $args[0]
}
$watch.Stop()

"ParallelForEach-Objectの場合:" + $watch.Elapsed.TotalSeconds + " sec"

ParallelForEach-Object関数はパイプラインから渡されたコレクションの各要素について、並列にスクリプトブロックを実行させるものです。同等の処理をForEach-Objectを使って同期的に逐次処理した場合とかかる時間を比較しています。10個の要素があり、各要素につき5秒かかる処理なので、逐次的に処理すると当然50秒以上かかりますが、ParallelForEach-Object関数を使って並列処理させると環境にもよりますが20秒以内に完了します。

この関数では渡されたコレクション1要素に対し1つのジョブを割り当て、同時に5ジョブまで(呼び出し元を含めて同時稼働が6プロセスまで)を並列実行するようにしています。

ただこれはあくまでなんちゃって並列処理なので、並列化することで本当に処理が高速になるかどうかは環境次第かと思います。一応、うちのCore2Duo (2コアCPU)な環境だと、足し算を3万回ほどする処理を10回行う場合、逐次処理とこの関数を使った並列処理では54秒が39秒に短縮され、有意な実行時間差が出ました。

またジョブを開始するのに新しくプロセスを起動させるので、1ループあたりの実行時間がプロセス起動にかかる時間より短ければ、この関数による並列化で処理時間の短縮は見込めません。

処理の対象が複数のリモートPCである場合などは割と有効なのかなと思います。たとえば複数サーバーから別々のファイルを同時にダウンロードするときなど。

ここではParallel.For()やParallel.ForEach()相当の関数を書きましたが、Parallel.Invoke()のような関数も書けるかと思います。スクリプトブロックの配列をStart-Jobで順に走らせ、Wait-Job, Receive-Jobする感じですね。

あとここではやりませんでしたが、Start-Jobの代わりにInvoke-Commandを使い複数のリモートPCに処理を振り分ければ、なんちゃって分散処理もできるのかなあと思いました。

おわりに

実はこのスクリプトを書いたのはPS Workflowの調査前のことで、Workflowで同様のことが可能になることを知って少々愕然としたのですが、それなりに面白いスクリプトかと思ったので公開することにしました。ともあれ、これからのマルチコア、メニーコアの時代、非同期処理や並列処理はますます重要になるかと思います。管理スクリプトにおいてもこれらの概念を意識しないわけにはいかなくなるでしょう。全4回にわたってPowerShellのバックグラウンド機能を解説してきましたが、これらがあなたの非同期&並列スクリプトライフ(?)の一助になれば幸いです。

さてさて、これでPSアドベントカレンダー2011もおしまいです。楽しんでいただけたでしょうか? 私自身も自分で記事を書いていて楽しかったですし、他の方の記事を読むのも色々な発見があり、とても有意義な25日間でした。記事を書いて参加していただいた方々、そして読者の方々に厚く御礼申し上げます。これからもぜひ、PowerShellを活用し、楽しんでくださいませ。

それでは皆様、良いクリスマスをお過ごしください!

2011/12/13

はじめに

この記事はPowerShell Advent Calendar 2011の13日目、そして私の2回目の記事となります。

今日のテーマは前回の続きで、PowerShellのバックグラウンドジョブの結果を読み取ったり、バックグラウンドジョブに値を与えたりして、ジョブと通信を行う方法を解説します。

ジョブから呼び出し元に値を返却する

ジョブの結果を取得するにはReceive-Jobコマンドレットを使用すれば良いと前回書きましたが、今回はジョブ側から結果を返す実際の方法を示します。

基本的にPowerShellのスクリプトやスクリプトブロックが呼び出し元に返却する値というのは、そのスクリプト(or ブロック)でパイプラインを通じて最終的にデフォルト出力に渡されたすべての値です。複数行に渡って出力されている場合は、呼び出し元にはその配列(object[])として返却されます。

ジョブにおいてもそれは同様で、基本的にStart-Jobなどで生成したスクリプトやスクリプトブロックが出力したすべての値がジョブの出力となり、呼び出し元からはReceive-Jobコマンドレットで受け取ることができます。

以下に現在の日付時刻を出力するサンプルを示します。サンプルなのでジョブなのに同期的な処理になってますがご了承を。

$job=Start-Job {
    Start-Sleep -sec 5
    Get-Date
}
Wait-Job $job|Receive-Job

複数だと以下のようになります。

$job=Start-Job {
    Start-Sleep -sec 1
    "Give me job."
    Get-Date
    1+1
}
Wait-Job $job|Receive-Job

この場合だと文字列、日付時刻、数値の3種類のオブジェクトが出力されますので、結果は長さ3のobject配列になります。そのためこれらの値を個別に取り出す場合は次のようにします。

$job=Start-Job {
    Start-Sleep -sec 1
    "Give me job."
    Get-Date
    1+1
}
$result=Wait-Job $job|Receive-Job
Write-Host $result[0]
Write-Host $result[1].ToString("yyyyMMdd")
Write-Host $result[2]

このように配列のインデックスで各値にアクセスできますが、これだと受け取り側での処理が分かりにくいと思われるかもしれませんね。

そこでお勧めなのが、このように複数値を返却するのではなく、カスタムオブジェクトを1つだけ返却するようにする方法です。

$job = Start-Job {
    Start-Sleep -sec 1
    $ret = New-Object PSObject -property @{
        String = "Give me job.";
        Date = Get-Date;
        Number = 1+1
    }
    $ret
}
$result = Wait-Job $job|Receive-Job
Write-Host $result.String
Write-Host $result.Date.ToString("yyyyMMdd")
Write-Host $result.Number

この方法ではジョブの中でNew-Objectコマンドレットでカスタムオブジェクトを作成し、それを返却しています。返却値は1つのオブジェクトでそのプロパティに値が格納されているのでドット演算子で値を参照できるようになりました。

ただしこの方法にも欠点があって、Receive-Objectで結果を参照するとき、ジョブが終了するまですべての値が参照できません。実はジョブが完了してない段階でも、Receive-Objectを実行するとジョブがそこまで出力した値を逐次取得することができるのです。よって

$job=Start-Job {
    Start-Sleep -sec 3
    "Give me job."
    Start-Sleep -sec 3
    Get-Date
    Start-Sleep -sec 3
    1+1
}

のようにしてジョブを走らせた後、適当な間隔で

$job|Receive-Job

を実行すると、それまでに出力した部分までを取得して書き出します。先程の例のように出力をカスタムオブジェクトでまとめてしまうとこの手法が使えなくなってしまいます。

どちらもメリット、デメリットがあるのでうまく使い分けると良いかと思います。具体的にはジョブの実行途中では結果を取得せず、ジョブ完了後の最終的な結果のみまとめて参照したい場合はカスタムオブジェクトで返却し、それ以外はそのまま随時値を返却するようにすればいいと思います。

さて、ジョブの結果を受け取る際にもう一点注意しなければならないことがあります。それはジョブが返すオブジェクトの型です。PowerShellのジョブ機能はリモーティング機構の上に構築されているというのは前回も書きましたが、その関係上、呼び出し元とジョブとの間でオブジェクトを受け渡しする場合は一度シリアル化され、受け取り側でデシリアライズされます。

オブジェクトのクラスもしくは構造体がシリアライズ可能(Serializable属性がついている)なら、PowerShellによりシリアル化→デシリアライズされたオブジェクトはシリアル化される前のオブジェクトと同一のものです。しかしそうではないオブジェクトの場合だと完全に元と同じオブジェクトには復元されません。

たとえば(Get-Process)[0]をジョブで実行するとSystem.Diagnostics.Processオブジェクトが得られますが、それをジョブの呼び出し元に返却するとDeserialized.System.Diagnostics.Processというカスタムオブジェクトに変換されます。このオブジェクトは各プロパティ値は(シリアル化可能なものだけ)保持しているものの、メソッド定義などは消失しているのでこのオブジェクトのメソッドを実行することはできません。

ちなみにSystem.StringクラスやSystem.Int32やSystem.DateTime構造体はSerializable属性がついているのでジョブの結果として取得しても元のオブジェクトと同一なので、メソッドなどが呼び出し可能です。

ジョブに呼び出し元の値を渡す

今度は逆の場合です。ジョブを走らせるとき、呼び出し元からジョブに値を渡す方法です。

$job = Start-Job {
    param($date,$value)
    Start-Sleep -sec 1
    "${date}の${value}日後の日付は" + $date.AddDays($value).ToString("yyyy/MM/dd") + "です。"
} -argumentList @((Get-Date),1)
Wait-Job $job|Receive-Job

このようにStart-Jobコマンドレットの-argumentListパラメータに、ジョブに渡したい値を指定すればOKです。複数ある場合はこのように配列指定も可能です。

ジョブ側ではparamキーワードで仮引数を指定しておけば、スクリプトブロック内で呼び出し元の値が格納された変数を使用できます。ここではparamを使いましたが、paramを使用しない場合は$argsに実引数が配列として格納されているので、これを利用するのでもOKです。

値を渡す場合でもシリアライズとデシリアライズが行われるので、その点だけは注意が必要です。

ジョブは呼び出し元と別インスタンスなので、呼び出し元に読み込まれた関数を参照することはできません。よってジョブでも呼び出し元で定義した関数を実行したい場合は同様に-argumentListで関数の実体であるスクリプトブロックを送ってやる必要があります。

function Get-Test
{
    "テスト!" + (1+1)
}

$job = Start-Job {
    param($sb)
    &([scriptblock]::Create($sb))
} -argumentList (Get-Item Function:\Get-Test).ScriptBlock

Wait-Job $job|Receive-Job

-argumentListでスクリプトブロックを渡すとStringにキャストされてしまうので、ジョブ内でそれをCreateメソッドでスクリプトブロックに戻してから実行演算子&で実行するという回りくどいことになってしまいました。関数にこだわらなければ呼び出し側でスクリプトブロックを作って変数に入れ、それを-argumentListに入れてやると少しだけ記述がシンプルになりますが、ジョブ内でスクリプトブロックを復元しなければならないのは同様です。

いずれにせよあんまり美しくないのでお勧めしません。こんなことをやるくらいならジョブの中あるいは -InitializationScriptパラメータの中で関数やスクリプトブロックを定義してやるか、関数を別スクリプトファイルに切り出して、そのスクリプトファイルをジョブ内で読み込むほうが良いかと思います。前者の場合だと呼び出し元とジョブ内で関数を共有することはできませんが、後者の方法だとファイルとしては分割してしまいますが可能です。

おわりに

今回はジョブと通信する方法として、ジョブから結果を出力したり、ジョブに値を渡したりする方法をまとめました。意外と落とし穴が多いので注意してください。

このシリーズはあと1回だけ続く予定です。お楽しみに。

2011/12/02

はじめに

このたび、技術系アドベントカレンダーイベントの1つとして、PowerShell Advent Calendar 2011を企画しました。この記事はその2日目の記事となります。アドベントカレンダーについてはリンク先を参照してください。

今日のテーマはPowerShellのバックグラウンドジョブ機能の使い方についてのまとめです。

バックグラウンドジョブとは

バックグラウンドジョブ機能はその名の通り、ジョブ(具体的にはスクリプト)をバックグラウンドで非同期に実行するものです。PowerShell v2で追加された機能の一つです。インタラクティブシェルでStart-Jobコマンドレットを使用してバックグラウンドジョブ(以下、単に「ジョブ」と表記)を実行すると、新しくpowershell.exeのプロセスが起動しそのままシェルに制御が戻りユーザーは後続の処理を行うことができます。もちろんスクリプトからジョブを実行することも可能です。時間のかかる処理をバックグラウンドで走らせたり、数多くの処理を並列で実行したりするのに重宝します。

起動されたジョブは操作中のpowershell.exeとは別のジョブ用のプロセスで実行され、処理が完了すると呼び出し元でその結果をReceive-Jobコマンドレットを使って受け取ることができます。ジョブは並列して何個も同時に実行できます。なおPowerShellのジョブは1ジョブ=1プロセスです。スレッドではないので注意。

PowerShellのジョブシステムはリモート処理インフラストラクチャの上に構築されているので、たとえローカルPCでもジョブ実行するにはローカルPCをリモート用構成にしておく必要があります。詳しくはabout_Remote_Requirementsを参照のこと。

ジョブはローカルでもリモートでも走らせることができます。以下に具体的な方法を述べていきます。

ローカルコンピュータでのジョブ実行

ローカルコンピュータ上に新しくジョブを作成して開始するにはStart-Jobコマンドレットを用います。

Start-Job {ジョブとして実行したいコマンド、スクリプト}

とするとジョブを実行します。

$job=Start-Job {..}

のようにするとJobオブジェクト(System.Management.Automation.PSRemotingJob)を変数に格納してあとで利用できます。変数で受けない場合はJobオブジェクトの内容が表示されます。

存在するジョブを取得するにはGet-Jobコマンドレットを用います。

Get-Job

で現在実行中のジョブ一覧を表示します。以下に出力例を示します。

Id              Name            State      HasMoreData     Location             Command
--              ----            -----      -----------     --------             -------
1               Job1            Completed  True            localhost            "test"
3               Job3            Running    True            localhost            start-sleep -sec 120;"...

以下の表は各項目の意味です。

Id ジョブID番号
Name ジョブの名前
State

Running=実行中のジョブ

Stopped=停止したジョブ

Complete=完了したジョブ

Failed=エラーが出たジョブ

HasMoreData 返却されたデータがあるかどうか
Location ジョブが実行されているコンピュータ名
Command ジョブで実行されているコマンド、スクリプト

ジョブの終了を待つにはWait-Jobコマンドレットを用います。

Get-Job|Wait-Job

とすると実行中のジョブすべてが完了するまで待ちます。-timeoutパラメータを使うと最大待ち時間(秒)を指定できます。

Get-Job|Wait-Job -any

とすると実行中のいずれかのジョブが完了するまで待ちます。正確には「対象のジョブが一つ以上完了するまで待つ」という効果なので、完了済みのジョブが1つ以上ある場合に新たにジョブを追加した場合などは想定の動作になりません。あらかじめRemove-Jobで完了済みのジョブを削除するか、Where-ObjectコマンドレットでRunningのみ対象にするようフィルタをかけるかしてください。

ジョブを中止するにはStop-Jobコマンドレットを用います。

Get-Job -id 1|Stop-Job

とするとジョブIDが1のジョブを中止します。

$jobにJobオブジェクトが格納されている場合は

$job|Stop-Job

でもOKです。

ジョブを削除するにはRemove-Jobコマンドレットを用います。

Get-Job|where {$_.state -eq "Completed" -or $_.state -eq "Stopped"}|Remove-Job

とすると完了済みと中止したジョブを削除します。実行中のジョブは削除できませんが-forceパラメータを使って強制削除することは可能です。

ジョブの実行結果データを取得するにはReceive-Jobコマンドレットを用います。

Get-Job|Receive-Job

とすると完了済みのジョブのうち、結果を返却しているもの(HasMoreDataがTrueのジョブ)があればその結果を表示します。-keepパラメータをつければ結果データを保持しますが付けてない場合は参照後破棄します。

*-Job系のコマンドレットの多くはJobオブジェクトを返却するので、パイプラインでどんどん繋げていけます。

Get-Job|Wait-Job -timeout 10|Receive-Job

のように。

ジョブの基本的な使い方に関して詳しくはabout_jobsを参照してください。

イベントサブスクライブ

PowerShell 2.0では.NET Frameworkのオブジェクトのイベントをサブスクライブすることができます。すなわちイベントハンドラを記述することができます。このイベントサブスクライブ機能もジョブ機能を元に構築されています。

たとえばTimerオブジェクトのElapsedイベントをサブスクライブし、タイマーの実行間隔(ここでは1秒)ごとにtest.txtファイルに乱数を追記していくサンプルは次のようになります。

$timer=new-object System.Timers.Timer
$timer.Interval=1000
Register-ObjectEvent -EventName Elapsed -SourceIdentifier test -Action {get-random|add-content c:\users\daisuke\test.txt} -InputObject $timer
$timer.Enabled=$true

Register-ObjectEventの結果、新しくジョブが生成しそのJobオブジェクトが返却されます。このジョブは-EventNameパラメータで指定したイベントが発生するたび、-Actionパラメータで指定したスクリプトブロックを実行します。

なお、イベントサブスクライブを解除するには

Unregister-Event test

のように-SourceIdentifierパラメータで指定した値を指定してUnregister-Eventコマンドレットを実行することで可能です。サブスクライブを解除してもジョブ自体は削除されない(StateがStoppedになるだけ)ので、必要であればRemove-Jobで削除します。

なお.NETオブジェクトの他にPowerShellスクリプトのカスタムイベント(Register-EngineEvent)、WMIオブジェクトのイベント(Register-WmiEvent)をサブスクライブすることもできます。これらのコマンドレットも同様にイベント発生時の処理をジョブとして登録します。詳しくは各コマンドレットのヘルプを参照してください。

リモートコンピュータでのジョブ実行

最初に述べたとおりPowerShellのジョブ機能はリモートインフラストラクチャの上に構築されています。よってローカルのみならずリモートコンピュータに対してジョブを実行することができます。もちろんリモートコンピュータにもリモート構成されていることが条件です。

基本はInvoke-Commandコマンドレットを用い、

$job=Invoke-Command -ComputerName リモートコンピュータ名 {リモートで実行するコマンド、スクリプト} -asjob

となります。これで{}内の処理がリモートコンピュータ上のPowerShellインスタンスで実行されます。-asJobパラメータをつけることでジョブとして(ローカルPCから見て)非同期に処理できますが、-asJobパラメータを省略すると同期的に実行されます。この場合ジョブは作成されず、リモートでの処理が終了するまでローカル側は待機することになります。

リモートコンピュータに接続するための資格情報を別途入力する必要がある場合は-credentialパラメータを使用します。

Invoke-Command -ComputerName リモートコンピュータ名 {リモートで実行するコマンド、スクリプト} -asjob -credential ユーザー名

とするとパスワードを入力するダイアログが表示されます。なお、スクリプトで動かすときなどあらかじめ入力したパスワードを指定したい場合の方法は以前書きました

同じコマンドを複数のリモートPCで同時実行することも可能で、その場合は-computerNameパラメータにリモートコンピュータ名の配列を指定します(「,」区切り)。この場合ローカルPCで見えるジョブとしては1つですが、そのジョブにリモートコンピュータの数だけ子ジョブ(ChildJobs)が作成されています。

このように子ジョブが複数ある場合にReceive-Jobするときは

$job|Receive-Job -location リモートコンピュータ名

あるいは

$job.ChildJobs

として表示される子ジョブの名前(Name)を調べ、

Receive-Job -name 子ジョブの名前

とすることでリモートコンピュータごとに結果を取得できます。

すべての結果をまとめて取得するなら

Receive-Job $job

とします。

$job|Receive-Jobはなぜか駄目なようです。

固定セッションを用いたリモーティング

同じリモートPCに対して何度もコマンドを実行させたい場合、毎回リモートコンピュータ名を指定してセッションを張るのは非効率的なので、リモートセッションを確立したあとその固定セッションを何度も使用する方法が用意されています。新しく固定セッションを確立するにはNew-PSSessionコマンドレットを用い、

$session=New-PSSession リモートコンピュータ名

とすると固定セッションが確立され、$session変数にそのセッションオブジェクトが格納されます。あとは

Invoke-Command $session {リモートで実行するコマンド、スクリプト} -asjob

とすればそのたびにそのセッションを用いてリモートでコマンドを実行できるようになります。

ここまでの説明はリモートコンピュータでしてきましたが、ローカルコンピュータに対して固定セッションを張ることも可能です。

さらに、Enter-PSSessionコマンドレットを用いると作成したセッションに入ってリモートコンピュータ上のPowerShellを対話実行することも可能です。

Enter-PSSession $session

とすると、プロンプトが

PS カレントディレクトリ>

から

[リモートコンピュータ名]: PS カレントディレクトリ> 

に変化し、以降リモートのPowerShellをローカルPCから対話実行できます。

なおこの状態から抜けるにはexitもしくはExit-PSSessionと入力して実行します。

ジョブ実行できるそのほかのコマンドレット

これまで述べたコマンドレット以外にも、いくつかのコマンドレットはジョブ実行(ローカルorリモート)することができます。ジョブ実行するには-asJobパラメータを使用します。以下にv2の段階で-asJobパラメータが定義されているそのほかのコマンドレットを示します。

これらのコマンドレットはコマンドレット自体にジョブ実行機能がついているので、単独で実行するだけならStart-JobやInvoke-Commandを用いる必要がありません。v2ではWMIを扱うコマンドレットにのみ-asJobパラメータが存在するようです(ここに挙げたコマンドレットはすべてWMIの機能を呼び出すもの)。なお、-asJobパラメータが使用できるコマンドレットの一覧を取得するのに、fsugiyamaさんの1日目の記事の問15のスクリプトを使用させていただきました。

おわりに

PowerShell Advent Calendar 2011二日目は、PowerShellのバックグラウンドジョブ機能概要についてまとめてみました。実はバックグランドジョブ機能のTipsを書こうと思ってその前ふりとして書き始めたのですが、これだけでかなりの量になってしまったので概要だけ一記事としてまとめることにしました。おそらくPSアドベントカレンダーに私はあと何回か登場することになりそうですので、Tips編はその際に書こうと思います。

さて、明日三日目は@jsakamotoさんのご登場ですね。よろしくお願いします!

そして参加者はまだまだ募集中ですよ!→PowerShell Advent Calendar 2011



Copyright © 2005-2018 Daisuke Mutaguchi All rights reserved
mailto: mutaguchi at roy.hi-ho.ne.jp
プライバシーポリシー

Books

Twitter