カテゴリー別アーカイブ: Powershell

PowerShell の起動を高速化する

PowerShell の起動が遅い理由

PowerShell に限らず .NET Framework で構成されたアプリケーションは、OS起動後、初めての起動で大変時間がかかります。これには理由があります。

まず、.NET Framework 以前のアプリケーションは、「マシン語」と呼ばれる、 CPU が直接理解できる言語で書かれています。たとえ C 言語や VisualBasic などの「高級言語」で開発されたとしても、最終的にはマシン語に直さないと CPU はこれを実行できません。したがって、コンパイラやインタプリタが、翻訳の労を担います。

.NET Framework の場合、アセンブリは IL と呼ばれる中間言語で書かれています。中間言語ですから、これは直接実行できません。そのため、初めての起動では、JIT(Just In Time)コンパイラがこれをマシン語に翻訳します。

これが、.NET Framework アプリケーションの起動に時間がかかる理由です。

Ngen.exe

.NET Framework には、ネイティブ イメージ ジェネレータ (Ngen.exe) というツールが付属しています。

参照: MSDN「ネイティブ イメージ ジェネレータ (Ngen.exe)」

これは、IL で書かれたアセンブリをマシン語にコンパイルし、「ネイティブ イメージ キャッシュ」と呼ばれる場所にインストールするツールです。ネイティブイメージキャッシュにインストールされたアセンブリは、直接実行できますので、起動が高速化されるというわけです。

ダウンロード

以下のスクリプトを実行すると、現在ロードされているアセンブリを、すべてネイティブイメージキャッシュにインストールします。

 

追記

  • 2012年12月26日 Powershell は多くの動的ライブラリを読み込むため、ネイティブイメージが作成されていても、ディスクキャッシュの無い初回起動にはそれなりに時間がかかります。現在のパソコンでは処理速度の上昇により掲載当時よりコンパイル時間がかなり短くなりますので、ディスクからの読み取り速度の方が問題になるかもしれません。
  • 2012年12月26日 MSDN の「ネイティブ イメージの生成」によると、新しい .NET Framework では、ネイティブイメージは Ngen.exe を使わなくても自動的に作成されるようです。

メモリリークのない C# の実行

アプリケーションドメイン

前回、「PowerShell で C# の実行」では、メモリ内に動的アセンブリをコンパイルする方法をご紹介しました。しかし、この方法では、PowerShell を終了するまで解放できない動的アセンブリがメモリ内に次々に増えていくことを問題点として挙げました。今回ご紹介するのは、アプリケーションドメインを使ってアセンブリを解放する方法です。アプリケーションドメインについての詳細は、以下をご覧ください。

 

アプリケーションドメインを使う

.NET のプロセスは、1 つ以上のアプリケーションドメインで成り立っていて、アセンブリは、いずれかのアプリケーションドメイン内にロードされます。アセンブリだけを単独で解放する手段はありませんが、アプリケーションドメインを解放することで、そこに読み込まれたアセンブリが解放されます。そこで、新しくアプリケーションドメインを作成し、そこにコンパイル済みのアセンブリを読み込んで実行し、終了後にアプリケーションドメインを解放する、という方法を採ることにします。

 

ダウンロード

以下より、メモリリーク無しで C# を実行するスクリプトをダウンロードできます。

 

用例

前回の用例がそのまま使えますので、ご覧ください。ただし、前回の Invoke-Cs.ps1 では可能であった、「新しい型を作成して使用すること」が、今回の Invoke-CsRemote.ps1 ではできなくなっています。

用例: 新しい型の作成

PS > Get-Content hello.cs
class Program {
    static object Main()
    {
        return new Program();
    }
    public string Hello()
    {
        return "Hello World!";
    }
}
PS > $a = Invoke-Cs hello.cs
PS > $a.Hello()
Hello World!
    ※ Invoke-Cs では成功
PS > $a = Invoke-CsRemote hello.cs
"5" 個の引数を指定して "InvokeMember" を呼び出し中に例外が発生しました: "呼び出しのターゲットが例外をスローしました。" 発生場所 Invoke-CsRemote.ps1:70 文字:57 +         $result = [Runtime.Remoting.ObjectHandle].InvokeMember( <<<< ‘Unwrap’, ‘InvokeMethod’, $null, $objectHandle, $null)
    ※ Invoke-CsRemote では失敗

これはなぜかと言うと、新しい型を作成したアセンブリが別のアプリケーションドメインにあるために、型情報を読み込むことができないからです。

Invoke-Cs と Invoke-CsRemote の使い分け

Invoke-Cs は、 PowerShell を終了するまでアセンブリを保持し続けます。これはメモリリークの原因となりますので、できる限り Invoke-CsRemote を使うのが良いでしょう。ただし、新しい型を作る必要がある場合には Invoke-Cs のような方法を使わざるをえません。その場合でも、作ったアセンブリをその度に使い捨てにするのではなく、一度作成した型を何度も再利用することで、メモリリークを防ぐことができます。また、頻繁に同じ C# ソースを実行するようであれば、恒常的なアセンブリにコンパイルすることを検討すべきかもしれません。

PowerShell で C# の実行

なぜ C# なのか?

PowerShell は、プログラミング言語としての側面と、手軽なシェルとしての側面を持っています。このバランスは奇跡的ともいえるくらい絶妙なものですが、やはり本業はシェルということで、C# や VisualBasic.NET、JScript.NET などの本格的プログラミング言語に比べると、いくらか省略された機能もあります。

それらの機能が欲しいときには、本格的プログラミング言語に頼るのが最も簡単です。中でも C# は、.NET Framework のために生まれた言語であり、PowerShell の使う .NET Framework と高い親和性を持っています。

なぜすべて C# でやらないのか?

単に C# ソースをコンパイルして実行ファイルを作り、それを実行したのでは、コンパイルの工程が増えます。また、そのプログラムとは文字列しかやり取りすることができません。

インタプリタのごとく、C# のソースを与えれば即座に動き、また、文字列以外のオブジェクトをもやり取りすることができる、そんなことが PowerShell では可能です。

ダウンロード

以下より、C# ソースを実行するスクリプトがダウンロードできます。

用例

用例1: Hello World

PS> Invoke-Cs -Source @"
>> class Program
>> {
>>   static string Main()
>>   {
>>     return "Hello World!";
>>   }
>> }
>> "@
>> Hello World!

用例2: 引数

PS> Get-Content Add.cs
class Program {
    static int Main(int arg1, int arg2)
    {
        return arg1 + arg2;
    }
}
PS> Invoke-CS Add.cs -Argument (5, 15)
20

メモリリークの問題

スクリプト中で他の言語を使うために、よく紹介されているのが、メモリ内に動的アセンブリを作る方法です。今回のスクリプトでも、その方法を使っています。

しかし、これにはひとつ問題があります。変数と違い、動的アセンブリは、ガベージコレクションされません。それどころか、一度作られた動的アセンブリは、PowerShell を終了するまで解放する手段がありません。つまり、そういうスクリプトは何度も呼び出すことによって、その度にメモリリークしていくのです。

以下のコマンドは、現在ロードされているアセンブリのリストを出力します。

PS> [AppDomain]::CurrentDomain.GetAssemblies()

これで調べてみると、 Invoke-Cs.ps1 を実行するたびに無名のアセンブリが追加されていることがわかります。もちろん、PowerShell を終了すれば解放されるので、通常の使い方では問題ないはずですが、常に例外と安全を考慮する、そして「使用上の注意」を減らすのが、正しい開発者の道でしょう。次回は、用途は多少制限されますが、メモリリークのないスクリプトをご紹介する予定です。

Set-Location を取り替える

Move-Location.ps1 は、標準コマンドレットの Set-Location の代替えとなるスクリプトです。私は、profile.ps1 中で Remove-Item Alias:cd; Set-Alias cd Move-Location として、「cd」コマンドで起動するようにしています。
このコマンドには、以下の特徴があります。

  1. ロケーションが変わるたびに以前の位置を pushd する。
  2. 引数や入力を何も与えずに起動すると、直前の位置を popd し、現在の位置を pushd する。
  3. 引数 -b (-Browse) と伴に起動すると、GUI によるフォルダ選択ができる。

用例:

C:> Move-Location 'Documents and Settings'
C:Documents and Settings> 'デスクトップ' | Move-Location
C:Documents and Settingsデスクトップ> popd
C:Documents and Settings> popd
C:> Move-Location -b
C:Documents and SettingsflameworkMy Documents> Move-Location
C:> Move-Location
C:Documents and SettingsflameworkMy Documents> Move-Location
C:>

Which command will be invoked?

Get-Which.ps1 は、ユーザーがコマンドを入力したときに、実際に起動するコマンドを検索するスクリプトです。

用例1: sort と sort.exe

PS> Get-Which sort
CommandType Name Definition
———– —- ———-
Alias sort Sort-Object
Cmdlet Sort-Object Sort-Object [[-Property] <Obje…
PS> Get-Which sort.exe
CommandType Name Definition
———– —- ———-
Application sort.exe C:Windowssystem32sort.exe

用例2: C:

PS> Get-Which C:
CommandType Name Definition
———– —- ———-
Function C: Set-Location C:

実装:
Get-Which.ps1 は、現行の PowerShell がコマンドを検索している規則に従ってコマンドを検索し、表示しています。したがって、PowerShell のバージョンアップに伴って検索規則が変わった場合、正しく検索されない可能性があります。
 

クリップボードを簡単に使うスクリプト

Use-Clipboard.ps1 は、クリップボードの内容を取得・設定するスクリプトです。(Invoke-StaThread.ps1New-Delegate.ps1 が必要) 用例1: 文字列「Hello World!」をクリップボードにコピーする

PS> ‘Hello World!’ | Use-Clipboard

用例2: クリップボードの内容を取得する

PS> Use-Clipboard
Hello World!

用例3: ビットマップを作成してクリップボードにコピーし、ペイントを起動してそれを貼り付ける

PS> $bitmap = New-Object Drawing.Bitmap(100, 20)
PS> $graphics = [Drawing.Graphics]::FromImage($bitmap)
PS> $whiteBrush = New-Object Drawing.SolidBrush('White')
PS> $graphics.FillRectangle($whiteBrush, 0, 0, 100, 20)
PS> $font = New-Object Drawing.Font('MS ゴシック', 10)
PS> $blackBrush = New-Object Drawing.SolidBrush('Black')
PS> $point = New-Object Drawing.PointF(5, 5)
PS> $graphics.DrawString('Hello World!', $font, $blackBrush, $point)
PS> $bitmap | Use-Clipboard
PS> $process = [Diagnostics.Process]::Start('mspaint.exe'); [void]$process.WaitForInputIdle(); Start-Sleep 1; [Windows.Forms.SendKeys]::SendWait('^v')

シングル スレッド アパートメント

クリップボードを使うにはどうすればよいでしょう?

PS> [void][Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')
PS> [Windows.Forms.Clipboard]::SetText('Hello World!')
"1" 個の引数を指定して "SetText" を呼び出し中に例外が発生しました: "OLE が呼び出される前に、現在のスレッドが Single Thread Apartment (STA) モードに設定されていなければなりません。Main 関数に STAThreadAttribute が設定されていることを確認してください。" 発生場所 行:1 文字:35 + [Windows.Forms.Clipboard]::SetText( <<<< ‘Hello World!’)

うまくいきません。これは、System.Windows.Forms.Clipboard がシングルスレッドアパートメント(STA)を必要とするのに対し、PowerShell がマルチスレッドアパートメント(MTA)で動いているからです。

したがって、STA のスレッドを新たに開始し、その中でスクリプトを実行することで解決できます。Invoke-StaThread.ps1 は、STA スレッドでスクリプトブロックを実行し、そこに引数や入力を与え、出力を受け取り、主スレッドに出力します。内部で New-Delegate.ps1 を呼び出しますので、こちらも用意してください。

 用例1: クリップボードに「Hello World!」をコピーし、それを貼り付け

PS> [void][Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')
PS> Invoke-StaThread {[Windows.Forms.Clipboard]::SetText('Hello World!')}
PS> Invoke-StaThread {[Windows.Forms.Clipboard]::GetText()}
Hello World!

用例2: 引数の使用

PS> [void][Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')
PS> Invoke-StaThread {[Windows.Forms.Clipboard]::SetText($args[0])} 'Hello'
PS> Invoke-StaThread {[Windows.Forms.Clipboard]::GetText()}
Hello

用例3: パイプラインの使用

PS> [void][Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')
PS> 'World' | Invoke-StaThread {process{[Windows.Forms.Clipboard]::SetText($_)}}
PS> Invoke-StaThread {[Windows.Forms.Clipboard]::GetText()}
World

実装:

Invoke-StaThread は次のような手順で動作します。ハッシュ(連想配列)を用意し、それに引数、パイプラインからの入力を格納します。 New-Delegate を使って新しいスレッドを作成し、STA に設定し、スタートさせます。新スレッド内でハッシュから引数、パイプラインからの入力を取り出し、スクリプトを実行し、その出力をハッシュに格納します。新スレッドが終了するのを待ち、ハッシュ内の出力を取り出します。

Windows PowerShell で delegate

New-Delegate.ps1 は、スクリプトブロックからデリゲートを作るスクリプトです。

用例1: 3 秒後に音が鳴ります。

PS> $delegate = New-Delegate Threading.ThreadStart {Start-Sleep 3; [Media.SystemSounds]::Beep.Play()}
PS> $thread = New-Object Threading.Thread ($delegate)
PS> $thread.Start()

用例2: System.Collections.Generic.List<int> を逆順にソートします。

PS> $list = New-Object Collections.Generic.List``1[System.Int32]
PS> $list.AddRange([int[]](1..3))
PS> $list
1
2
3
PS> $delegate = New-Delegate Comparison``1[System.Int32] {$args[1] - $args[0]}
PS> $list.Sort($delegate)
PS> $list
3
2
1

ご覧のように、第一引数にデリゲート型を、第二引数にスクリプトブロックを指定して呼び出すと、その型のデリゲートを作成し、デリゲートが呼び出された際にスクリプトブロックを実行します。

用例2 のように、デリゲートのパラメータは $args に代入され、スクリプトブロックの出力はデリゲートの戻り値となります。

実装

ソース中で IL を出力しているために、大変読みにくくなっていますが、原理は単純です。

ArrayList に、実行環境となる Runspace と、実行すべき ScriptBlock を格納し、そのArrayList に System.Reflection.Emit.DynamicMethod による新たな動的メソッドを作成してバインドします。

動的メソッド内部では、与えられたパラメータを指定してスクリプトブロックを実行し、その出力をキャストして戻り値に代入しています。

PowerScript は、手軽にコマンドを実行できるシェルであると同時に、非常に強力なスクリプト環境であることは異論がないでしょう。 .NET Framework のオブジェクトをそのまま使用できることが、その強力なパワーの源となっています。 .NET Framework を使えば、プログラムからコンパイルもできます。アセンブルもできます。

つまり、PowerShell にデリゲートを作る手段が無ければ、その手段を作ってしまえば良いのです。

追記

2008年4月17日追記:
このスクリプトは、Windows PowerShell Get-Enjoy コンテストにて選考委員特別賞を受賞しました。