2016/01/11
関数内でのリソース解放処理
注:最後に書きましたが、この記事の内容には未解決問題が残っています。
はじめに
前回は、パイプラインを下流の関数内で打ち切る方法について説明しました。
今回は逆に、下流でパイプラインが打ち切られた場合、上流の関数ではどう対応すべきなのか、特にリソース解放処理を焦点にして考察してみます。
パイプラインが打ち切られるケース
前回も説明しましたが、パイプライン内の関数またはコマンドレットで何らかの例外が発生すると、パイプライン処理がその時点で打ち切られます。パイプライン処理が打ち切られると、後続のprocessブロックのみならず、パイプラインに含まれるすべてのendブロックの実行もスキップされてしまいます。
ところでコマンドレットや関数では何かエラーが発生した場合、コマンドレットではWriteErrorメソッド、関数ではWrite-Errorコマンドレットを使ってエラーストリームに、生の例外オブジェクトをラップしたErrorRecordを出力し、呼び出し側のErrorActionの設定にエラー時の処理を委ねるのが基本です。
呼び出し時のErrorAction指定がContinue、SilentlyContinue、Ignoreの場合は、パイプラインが中断することはありませんが、StopやInquireで中断した場合は例外(ActionPreferenceStopException)がthrowされ、パイプラインは打ち切られます。
(ちなみにv3からはStopで中断した場合は、コマンドレットがエラーストリームに書き出したErrorRecordに含まれるExceptionがthrowされる)
また、継続不能エラーが発生(ThrowTerminatingErrorメソッド)したり、.NETの生の例外がそのままthrowされたり(お行儀が悪いですが)、breakステートメント(FlowControlException)が実行されたり、Select-Object -First(StopUpstreamCommandsException)が実行された場合も、同様にパイプラインは打ち切られます。
つまり、下流のコマンドでパイプラインが打ち切られるケースというのは色々あり得るので、endブロックというのは実行が確約されたものでは全くない、ということに留意しておく必要があります。
関数内のリソース解放処理
確実に実行したい後処理というのは色々あると思いますが、特に確保したリソースの解放というのは確実に実行してもらわないと困ります。
しかし、前述のような背景があるので、何らかのリソースを用いる関数を書く際に、beginブロックでリソースを確保し、processブロックでリソースを利用し、endブロックでリソースを解放する、ということは基本はNGということになります。
この点、コマンドレットクラスであれば、IDisposableインターフェースを実装しておけば、コマンドレット終了時にDisposeメソッドをPowerShellが呼んでくれるので、その中にリソース解放処理を記述しておけばOKです。しかし関数ではこの手法が使えないので、代替案を考える必要があります。
パイプライン処理の後始末をしよう - 鷲ノ巣であえとす氏が考案した、beginブロックに後処理用の関数を定義しておく方法では、同じ関数内のprocessブロックで発生したエラーをcatchしてリソース解放処理を走らせることは可能です。が、残念ながら下流で発生した例外をcatchすることはできず、その場合は後処理がスキップされてしまいます。
リソース解放処理を含めた関数
あえとす氏の方法を若干アレンジして、下流で例外が発生した場合も確実にリソース解放の後処理を走らせる方法を考えてみました。以下にコードを示します。
※クラス構文を使ってますが、これは単に、Disposeできるオブジェクトのサンプルだと思ってください。
class SomeResource : IDisposable { [void]Dispose() { # ここで何らかのリソース解放処理を行ったものとする } } function Write-OutputWithResource { [CmdletBinding()] param([Parameter(ValueFromPipeline)][psobject[]]$InputObject) begin { $resource = New-Object SomeResource # 後で解放する必要のある何らかのリソース確保 function Clear-Resource { # リソースの解放処理 $resource.Dispose() } } process { try { $processing = $true Write-Verbose "output_start: $InputObject" $InputObject # パイプライン出力 Write-Verbose "output_end: $InputObject" $processing = $false } catch { # try内で例外が発生した場合はそのまま再スロー throw } finally { if($processing) { Write-Verbose "resource_dispose (on error)" Clear-Resource } Write-Verbose "output_finally: $InputObject" } } end { Write-Verbose "resource_dispose (at end)" Clear-Resource } }
processブロック内のtryブロックでパイプライン下流に値を出力したとき、下流で例外が出てもcatchブロックが実行されないので、代わりに、必ず実行されるfinallyブロックから後処理を呼び出すようにしてみました。
ただし、パイプライン下流で例外が発生しなかった場合には、processブロック内ではリソース解放処理はしたくないので、例外発生の有無を$processingという変数の値を見ることで確認しています。もしパイプライン出力したあと下流で例外が出ていれば、$processingの値は$trueのままになるので判断可能です。
パイプラインが中断することなく、最後まで実行される場合は、endブロック内でリソース解放処理を行います。
同じ関数内で例外が発生したときのリソース解放処理についても、$processing = $trueと$processing = $falseの間に例外が発生する可能性のある処理を記述した上で、catchブロックで再throwすれば、併せて対応できるのではないかと思います。(それ用のtry..catchを記述してもいいですが)
関数の実行例
PS> 1..3 | Write-OutputWithResource -Verbose | Select-Object -First 2 詳細: output_start: 1 1 詳細: output_end: 1 詳細: output_finally: 1 詳細: output_start: 2 2 詳細: resource_dispose (on error) 詳細: output_finally: 2
このように、下流でパイプライン打ち切りがあるとendブロックは実行されませんが、リソース解放処理は、きちんとprocessブロック内のfinallyブロックから呼び出されています。
PS> 1..3 | Write-OutputWithResource -Verbose 詳細: output_start: 1 1 詳細: output_end: 1 詳細: output_finally: 1 詳細: output_start: 2 2 詳細: output_end: 2 詳細: output_finally: 2 詳細: output_start: 3 3 詳細: output_end: 3 詳細: output_finally: 3 詳細: resource_dispose (at end)
もちろんパイプラインが最後まで正常に実行された場合も、ちゃんと最後にendブロックでリソース解放が行えるようになっています。
おわりに
本当に、こうするしかないんですかね…?
PowerShellにもリソース利用のusingステートメント欲しいです、が、begin, process, endにまたがって機能するusingってどういう構文になるんでしょうね。
(14:22追記)と、ここまで書いておいて、この方法では下流で発生した例外には対処できますが、上流で例外が発生した場合はリソース解放が実行されないという問題に気付きました…。どうすればいいんだ…。
2016/01/03
パイプラインの処理を途中で打ち切る方法
PowerShellのパイプライン処理
まず、PowerShellのパイプライン処理について軽くおさらいします。
例えば、@、A、Bをそれぞれ何らかのコマンドとしたとき、
@|A|B
というパイプラインがあったら、処理の流れは、
@begin→Abegin→Bbegin→(@process→Aprocess→Bprocess→)×n→@end→Aend→Bend
の順に実行されます。(processブロックで「1入力に対し1出力する」場合以外は必ずしもこうならないですが)
さて、AかBのprocessブロック実行中に、何らかの条件を満たした時、パイプラインのprocessの後続処理を打ち切りたい場合はどうすれば良いでしょうか。
まずはbreakを使った駄目な例
ネットでよく見かける以下のようなコード、すなわち「パイプラインはbreakで処理を打ち切ることができる」というのは実は正しくないのです。
function Select-WhileTest { [CmdletBinding()] param ( [parameter(ValueFromPipeline=$true)] [psobject[]]$InputObject, [parameter(Position=0)] [scriptblock]$Predicate ) process { if(!(&$Predicate)) { break } $InputObject } }
このコードはv2までではそもそも正しく動作しませんが、v3以降では条件によっては正しく動作しているように見えるのが、誤解の元なのかと思います。(というか私も誤解してました。)
例えば、
$result = "初期値" $result = &{end{foreach($i in (1..5)){$i}}} | Select-WhileTest {$_ -lt 3} Write-Host "`$resultは $result です。"
のようにすると、
$resultは 1 2 です。
のように、想定した通りの結果が得られます。このように、上流のスクリプトブロックのendブロック内にforeachなどループブロックが存在し、そのループブロック内で下流に値を出力している場合はうまくいきます。(ちなみに、スクリプトブロック直下に記述するのとendブロック内に記述するのは等価。)
しかし、上流にループブロックがない場合、例えば
$result = "初期値" $result = 1..5 | Select-WhileTest {$_ -lt 3} Write-Host "`$resultは $result です。"
とすると、コンソールに1と2が改行区切りで表示されますが、ホストに表示されるだけで$resultには値は格納されません。そしてスクリプト化して実行した場合は、Write-Hostが実行されることすらなく、スクリプトが終了してしまいます。
breakだとなぜうまくいかないのか
結局どういうことかというと、パイプライン下流のbreakは、パイプラインを打ち切る処理をするのではなく、単に一つ上流のブロックをbreakする処理に過ぎないのです。
パイプライン上流にループブロックがある場合は、そのループブロックをbreakしますが、それ以外の場合はスクリプトのbegin, process, endのいずれかのブロックがbreakされてしまい、結果としてスクリプトが終了してしまうわけですね。
そして、このSelect-WhileTest関数だと大丈夫ですが、processブロックの中にループブロックを記述し、その中でbreakを書くのは当然ダメです。単にそのループを抜けるだけなので、上流の出力は止まってくれません。
breakではなくcontinueを使う場合も基本は同じ結果です。しかもcontinueは所詮、その名の通り継続処理なので、上流に以下のような無限リストがあると無限ループに陥ってしまいます。
&{begin{$i = 0} process{while($true){$i++; $i}}}|Select-WhileTest {$_ -lt 3}
breakの代わりに、
throw (New-Object System.Management.Automation.PipelineStoppedException)
を実行する方法も見かけますが、これはループブロックがあっても強制的にスクリプトが終了するので余計ダメです。try...catchでエラートラップすればスクリプトの終了は回避できますが、「パイプラインが正常終了せずエラーを出した」扱いであることには代わりないので、やはり出力を変数に格納することができません。
ダミーループを用いる、取りあえずの解決策
前述のbreakを使った方法の問題点のうち、上流にループブロックがないとスクリプトが終了してしまい、出力を変数に代入することもできない問題は、とりあえず解決する方法があります。
以下のように、呼び出す時にパイプライン全体をダミーのループブロックでラップすれば良いのです。
$result = "初期値" $result = do{ 1..5 | Select-WhileTest {$_ -lt 3} }until($true) Write-Host "`$resultは $result です。"
このようにしておけば、breakはパイプラインの外側のdo...untilを抜ける効果になるので、スクリプトが終了する心配も、値を出力しない問題も起こりません。
元々、パイプライン上流にループブロックが存在する場合でも、単にdoループ内の処理が1回走るだけなので、特に問題は起きません。1回だけ処理を実行するダミーループなら、for($i=0; $i -lt 1; $i++){}とかでも良いです。
ただ…この記述を美しいと思う人は多分いないですね。事情を知らないと意味不明ですし。そして、breakを記述する側の関数には、前述の通りのループブロック内では値を出力できないという制限は残ったままになります。
やはりbreakでパイプラインを打ち切るのは、本来想定された動作かと言われるとかなり微妙なところだと思います。(v3で一応動くようになったとはいえ)
この方法についての参考記事:Cancelling a Pipeline - Dreaming in PowerShell - PowerShell.com ? PowerShell Scripts, Tips, Forums, and Resources (ただしv2準拠の内容であることに注意)
ところで、Select-Object -Firstは…
さて、話は変わって、PowerShell 3.0からはSelect-Object -First の処理が変わったことについては、ご存知の方も多いかと思います。
具体的には、v2までは単にパイプライン処理をすべて終了してから、最初のn件を抽出する処理であったn件のパイプライン出力がされた後は、入力をすべてフィルタし出力に流さなくなる動作であったところが、v3からはn件のパイプライン出力がされた時点で、パイプラインの処理を打ち切るようになりました。(1/5修正)
つまり、
$result = 1..5| &{process{Write-Host "Process:$_"; $_}}| Select-Object -First 2 Write-Host "`$resultは $result です。"
というスクリプトは、v2までは
Process:1 Process:2 Process:3 Process:4 Process:5 $resultは 1 2 です。
のようにパイプライン出力は指示通り2件であるものの、上流の処理は結局、全部実行されてしまっています。
一方v3以降では、
Process:1 Process:2 $resultは 1 2 です。
のように、きちんと上流の処理を打ち切ってくれています。
つまり、ここまで述べてきたパイプライン処理の打ち切りは、実はv3以降のSelect-Object -Firstでは実現できているということです。これと同じことを我々も自作関数の中でやりたいわけです。
ではSelect-Object -Firstは具体的にどういう処理をしているかというと、StopUpstreamCommandsExceptionという例外をthrowすることでパイプライン処理の打ち切りを実現しています。この例外はまさに名前の通り、パイプライン上流の処理を中止するための例外です。この例外を自作関数でthrowしてやればうまくいきそうです。
しかし、この例外は非publicな例外クラスであることから、New-Objectコマンドレットなどでインスタンス化することはできません。リフレクションを駆使する必要がでてきます。
参考:PowerShell 3.0からはじめるTakeWhile - めらんこーど地階
(1/5追記)参考2:パイプラインの処理を途中で打ち切る方法のPowerShell版 - tech.guitarrapc.cóm(Add-TypeでC#経由でリフレクションしてます。)
頑張ればできなくはないですが、もっと楽な方法はないものか…と思ってあきらめかけたところ、いい方法を思いついたので紹介します。
Select-Object -Firstを利用する方法
Select-Object -Firstでできることが我々には(簡単には)できない。ならばどうするか。Select-Object -Firstを使えばいいじゃない。という発想です。
function Select-While { [CmdletBinding()] param ( [parameter(ValueFromPipeline=$true)] [psobject[]]$InputObject, [parameter(Position=0)] [scriptblock]$Predicate ) begin { $steppablePipeline = {Select-Object -First 1}.GetSteppablePipeline() $steppablePipeline.Begin($true) } process { if(!(&$Predicate)) { $steppablePipeline.Process($InputObject) } $InputObject } end { $steppablePipeline.End() } }
scriptblockにはGetSteppablePipelineというメソッドが存在し、このメソッドによりSteppablePipelineオブジェクトが取得できます。これは何かというと、要は「スクリプトブロック内のbegin, process, endを個別に実行する」ための機能です。
参考:PowerShell: ◆パイプライン入力・パラメータ入力対応のGridView出力関数を作る(私自身も以前この記事で知りました。)
{Select-Object -First 1}というスクリプトブロックは、1回目に実行するprocessブロック内でStopUpstreamCommandsExceptionを出してくれます。
よって、自作関数のprocessブロック内のパイプライン処理を打ち切りたい箇所で、SteppablePipelineオブジェクトのProcessメソッドを使うことで、{Select-Object -First 1}のprocessブロックの処理を呼び出してやればいいわけです。
このようにして作成したSelect-While関数を以下のように実行してみると、
# 上流にループあり $result1 = &{end{foreach($i in (1..5)){$i}}} | Select-WhileTest {$_ -lt 2} Write-Host "`$result1は $result1 です。" # 上流にループなし $result2 = 1..5 | Select-While {$_ -lt 3} Write-Host "`$result2は $result2 です。" # 上流に無限リスト $result3 = &{begin{$i = 0} process{while($true){$i++; $i}}} | Select-While {$_ -lt 4} Write-Host "`$result3は $result3 です。"
結果は
$result1は 1 です。 $result2は 1 2 です。 $result3は 1 2 3 です。
となり、少なくとも今まで述べてきた諸問題はすべて解消していることが分かると思います。
このSelect-While関数は、スクリプトブロックで指定した条件を満たさなくなったときに、パイプライン処理を打ち切ってくれるものですが、この「Steppable Select -First 方式」を使えば他の自作関数でも、割と気楽に呼べるんじゃないかなと思います。ループブロック内で呼び出すことももちろん可能です。
ただし問題点はある
これはSelect-Object -FirstというかStopUpstreamCommandsExceptionあるいはPowerShellのパイプライン機構の仕様に由来する問題であると思われるので、どうにもならないことではあるんですが、一点だけ注意事項があります。
$result = 1..5| &{ begin { Write-Host "Begin" } process { Write-Host "Process:$_" $_ } end { Write-Host "End" } }| Select-While {$_ -lt 2} Write-Host "`$resultは $result です。"
これの結果は
Begin Process:1 Process:2 $resultは 1 です。
となり、なんとendブロックが実行されていません。Select-While {$_ -lt 2} の代わりに Select-Object -Firstを使っても、同様にendは実行されません。
つまり、StopUpstreamCommandsExceptionというのはパイプライン処理を打ち切って、そこまでの出力値を正しくパイプライン出力として出してくれますが、やってくれるのはそこまでで、最後のendブロック処理はしてくれません。
これは十分注意が必要な点で、自作関数内でbeginブロックで確保したリソースをprocessブロックで利用して、endブロックで解放する…という、いかにも書いてしまいそうなパターンは、実はNGなんですね。何も上のようにマニアックなことをしなくても、単に下流でSelect-Object -Firstを使うだけでアウトです。
じゃあ、リソースの取り回しはどうするのが良いの、って話もありますが、それはまたの機会にしましょう。
(1/5追記)あえとすさんの記事が参考になります。:パイプライン処理の後始末をしよう - 鷲ノ巣 ただ、この方法ではパイプライン下流でthrowされた場合はトラップできないぽいですね。コマンドレットクラスの場合はIDisposable実装で良さそうです。
ここからは私見ですが、StopUpstreamCommandsExceptionが後付けかつ非パブリックなところとか、パイプラインを合法的に脱出するステートメントが今に至るまで用意されていないところとか、パイプラインを何とかして途中で打ち切っても、endは実行されないところとかを見ていると、そもそもPowerShellではパイプライン処理の中断というのは、あまり想定してない操作なのかなぁ、という気がしてきています。
上記のような裏技を使って回避するのも一案ではあるとは思いますが、そもそも「パイプライン処理の中断はイレギュラー」と考えて、そういう処理は避けて、必要に応じて別のアプローチを取ることも考えた方がいいのかもしれません。
Copyright © 2005-2018 Daisuke Mutaguchi All rights reserved
mailto: mutaguchi at roy.hi-ho.ne.jp
プライバシーポリシー