Windows PowerShellの罠――trap文概論

PowerShell 1.0の頃から例外処理用にtrap文が用意されていて、これがまた興味深い。

trap文の元となったのは、おそらくBourne shellの系統のシェルに用意されている組み込みコマンドtrapだろう。『Windows PowerShellインアクション』1.2.1によれば、PowerShellの設計においてPOSIX shellの文法を研究したらしい。POSIX shellはkshUNIX Korn shell)のサブセットであり、kshBourne shellのスーパーセットだ。当然ながらtrapをサポートしている。

ただ元々trapはUnix環境との結びつきが深いコマンドで、異なるOSであるWindowsに導入するにあたり、その役目を変化させている。Unix環境のtrapはシグナルを捕捉し、PowerShellのtrapは例外を捕捉する。

シグナル

Unix環境にてプロセス間通信を行う方法はいくつか存在する。ソケット*1、パイプ*2、共有メモリ、シグナルだ。

シグナルは文字通り「信号(シグナル)」をOSからプロセスに、又はプロセスからプロセスに送る。シグナルを受信したプロセスは、シグナルの種類に応じて適切な処理を行う。発想としてはハードウェア割り込みの制御に近いだろう。

身近な所で、例えば端末エミュレータで実行中のコマンドを停止させるCtrl-c。あれは実行中のコマンドのプロセスに対してSIGINTというシグナルを送っている。またシャットダウン時にはinitないし代替のデーモンからシステムの他のプロセスにSIGTERMが送られる。kill(1)でプロセスを停止する際、通常はSIGTERMを送ってみるし、それで駄目な場合は最終手段としてSIGKILLを送ったりする。

プロセスは、シグナルを受信した時点で処理が割り込まれる。割り込まれて実行される処理は、シグナルの種類によってデフォルトの内容が決まっている。例えばSIGINTやSIGTERMを受け取った場合のデフォルトの挙動は「その時点で終了」だ。しかしいきなり終了してしまうのは都合が悪いこともある。一時ファイルを作成している場合は終了前に削除しておきたいだろうし、デーモンの場合は一通り必要な終了処理があるだろう。

そこでSIGKILLなどのごくわずかなシグナルを除いて、プロセス側でシグナルを捕捉する仕組みが用意されている。signal(2)やsigaction(2)だ。捕捉したいシグナルとシグナルハンドラ(シグナル受信時に実行したい関数へのポインタ。要はコールバック関数)を設定しておくと、実際にシグナルが届いた時にデフォルトの挙動の代わりに設定しておいた関数が呼び出される。

#ifdef __linux__
#ifndef _POSIX_C_SOURCE
#define _POSIX_C_SOURCE 1
#endif /* ndef _POSIX_C_SOURCE */
#endif /* def __linux__ */

#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>

#include <libgen.h>
#include <unistd.h>

static volatile sig_atomic_t have_to_stop = 0;

void trap(int signum)
{
    (void) signum;
    have_to_stop = 1;
}

int main(int argc, char *argv[])
{
    const char *progname;
    struct sigaction sa;
    unsigned long i;

    (void) argc;

    progname = basename(argv[0]);

    (void) memset(&sa, 0, sizeof(sa));
    sa.sa_handler = trap;
    sa.sa_flags = SA_RESTART;

    errno = 0;
    if (sigaction(SIGINT, &sa, NULL) < 0) {
        perror(progname);
        return 1;
    }
    errno = 0;
    if (sigaction(SIGTERM, &sa, NULL) < 0) {
        perror(progname);
        return 1;
    }

    i = 0;
    while (!have_to_stop) {
        (void) printf("%lu\n", i++);
        (void) sleep(1);
    }
    (void) printf("%s: stopped\n", progname);

    return 0;
}

このコードはsigaction(2)を使ってSIGINTとSIGTERMの受信時に関数trap()を実行するようにしている。通常はmain()のwhileループを実行し続けるが、SIGINTかSIGTERMを受信すると処理が一時的に割り込まれ、trap()が実行される。trap()は変数have_to_stopの値を変更し、終了する。処理が再開された時、変数have_to_stopの値の変化によりwhileループを抜けることになる(又は、whileループに入る前にtrap()が実行された場合はループに入らないだろう)。

実装上の問題として、何でもかんでもシグナルハンドラで実行するのは正しくない。シグナルは並行プログラミング機能の一種で、プログラム本体とシグナルハンドラの中とでは実行コンテクストが異なる。マルチスレッド・プログラミングにおける異なるスレッド同士に似た関係だ(ただし厳密にはマルチスレッドとは幾分と異なる。シグナルハンドラの中身を実行している時、プログラム本体側は停止しているようだ)。シグナルハンドラ内で安全に実行できる処理は、シグナルハンドラ中の自動変数の読み書き、volatile sig_atomic_t型のグローバル変数の読み書き、async-signal-safeな関数の呼び出しのみだ。

基本的には、シグナルハンドラ内の処理は短時間で済ますべきだ。なのでデーモンの場合は、例えばシグナルハンドラ内でフラグを設定するようにしておき、プログラム本体側ではフラグの変化を捕捉して必要な処理を行う、という構成になるだろう。フラグにはvolatile sig_atomic_t型の変数かPOSIXセマフォを使用することになる。

デーモンを実装する際はsigaction(2)などで一部シグナルの挙動を変更しておくのが大半だろう。SIGINTやSIGTERMが届いた際には必要な終了処理を呼び出してから終了するようにしておく。SIGHUPが届いたら設定ファイルを読み直すようなデーモンも少なくない。

シェルスクリプトのtrap

Unix Cプログラミングのシグナルの仕組みをシェルスクリプトでも使う方法、それがtrapだ。sigaction(2)などと同様に、捕捉したいシグナルと受信時に実行するコマンドを設定しておくと、シグナル受信時に設定したコマンドが実行される。

あらかじめ設定しておくと、条件を満たした時に発動する。trapとは言い得て妙だ。

trapを使うよくあるパターンといえば、一時ファイル等の削除だろう。

#!/bin/sh

readonly PROGNAME=`basename "$0"`

trap 'rm -f "$tmpfile"; exit 1' 1 2 15

tmpfile=`mktemp "$PROGNAME.$$.XXXXXXXXXX"`
if [ $? -ne 0 ]; then
    echo "$PROGNAME: cannot create temporary file" 1>&2
    exit 1
fi

while : ; do
    date | tee -a "$tmpfile"
    sleep 10
done

このスクリプトは無限ループで一時ファイルにテキストを追記し続けるが、trapによってSIGHUP、SIGINT、SIGTERMを捕捉して一時ファイルを削除し、スクリプトを終了する。可能性としてはwhileによる無限ループ中にシグナルが届くことが多いだろうから、trapで仕掛けたコマンドは大抵dateやtee、sleep、: の構文のあたりで実行されることになる。bashの場合は実行中のコマンドが終了してからtrapで仕掛けたコマンドが実行されるようだけど*3、この辺りはシェルの実装によって差異があるのかもしれない。

少し調べた範囲では、sigaction(2)などと異なりtrapでは実行コンテクストの違い云々の問題は無いようだ。まあその手の問題がシェルスクリプトで起こるなんて考えにくいものではある。

trapの第一引数に設定したコマンドの文字列は、実際にシグナルを受信した際にも評価される。なので上記スクリプトではmktemp(1)で一時ファイルを作成する前にtrapを仕掛けているものの、一時ファイル作成後にシグナルを受信した際には$tmpfileは正しく一時ファイル名に展開される。なおtrapを仕掛けてから一時ファイルを作成するまでの間にシグナルが届いた場合は$tmpfileは空文字となるので、対策としてrm(1)をオプション-f付きで実行するようにしている。

ところでtrapに設定したコマンドにてexitを呼んでいるのだけど、exitが無かったらどうなるか? 一時ファイルを削除したら、スクリプトの実行を中断箇所から再開する。

なので、例えば次のスクリプトは実行中にCtrl-cでSIGINTを送っても「CANNOT STOP !」と表示するだけで、即座には終了しない。

#!/bin/sh

trap 'echo "CANNOT STOP !"' 2

for i in `seq 1 15`; do
    echo $i
    sleep 1
done

単純にCtrl-cを無効にしたい場合には、上記スクリプトのtrapに仕掛けるコマンド文字列を「' '」のような空白文字列にすればよい。

PowerShellのtrap

PowerShellのtrap文はシェルスクリプトのtrapによく似ている。しかしUnix環境とは異なり、Windowsはシグナルをサポートしていないようだ。

一応、WindowsのCRTにもsignal(3)は存在するものの、利用できるシグナル値は限られている。何より、

SIGINT は、すべての Win32 アプリケーションではサポートされません。 Ctrl + C 割り込みが発生すると、 Win32 オペレーティング システムは、その割り込みを処理するための新しいスレッドを生成します。 これにより、 UNIX に 1 個のようなシングル スレッドのアプリケーションは、予期しない動作によって、マルチスレッドになります。

http://msdn.microsoft.com/ja-jp/library/vstudio/xdkz3x12.aspx

――このような記述を見るに、「Windows自体はUnix風のシグナルをサポートせず、CRT側でエミュレートしている」という解釈が正しいのではないかと思う。*4

シグナルが存在しないのならば、PowerShellのtrap文はどのような条件を満たした時に発動するのか? 「例外が発生したとき」だ。

Set-StrictMode -version Latest

trap { 'trap!'; break }

'foo'
[int]'bar'
'baz'

「[int]'bar'」でわざと例外を発生させている。この時、trap文で設定した処理が実行される。このスクリプトでは、まず「foo」と画面表示され、その次の行で例外が発生してtrap文が実行され、「trap!」と画面表示されてから例外のメッセージが出力され、スクリプトが終了する。

Unix環境のtrapは、シグナルという全くの外部要因に対応するための仕組みだった。つまりスクリプト上ではシグナルが発生する箇所は分からなかった。なので予めtrapを使用してシグナル発生時の処理を設定しておく、という手法をとっていた。

PowerShellのtrapはシグナルではなく例外を捕捉するものへと役割が変化しているが、しかしその使い方については概ねUnix環境のtrapのそれを踏襲している。なので、例外ならばスクリプト上で発生する可能性がある場所を特定可能である(なので他の言語では、try - catchのような例外が発生する箇所を囲む構文を採用している*5)にも関わらず、「予め例外発生時の処理を設定しておく」というスタイルをとっている。

Unix環境のtrapとPowerShellのtrap文には他にも差異がある。Unixのtrapが(シェル組み込みコマンドではあるものの)あくまでコマンドであるのに対して、PowerShellでは言語の構文の一部となっている(つまりコマンドレットの類ではない)。

なので、先のスクリプトで使用しているような「break」や「continue」という構文を併用できる。breakの場合、trapを抜ける時に例外が再スローされ、結果としてスクリプトが終了する。「continue」の場合、trapを抜けた後に、実行されたtrap文が存在するブロックにおける例外発生箇所の次の文から実行が再開される。「break」も「continue」も記述しなかった場合の挙動は「continue」とほぼ同じだが、エラーストリームへの書き込みが発生する(continueでは書き込まれない)。

trap文が言語の一部であることの影響は他にもある。次のスクリプトにて例外が発生した時、trap文の中身は実行されるだろうか?

Set-StrictMode -version Latest

'foo'
[int]'bar'
'baz'

trap { 'trap!'; break }

試してみれば分かるが、例外発生時にtrap文の中身が実行される。例外発生箇所よりも後にあるにも関わらず、だ。どういうことだろうか?

Unix環境のtrapはシェル組み込みコマンドだが、あくまでもコマンドだ。コマンドは、スクリプトに記述した順番に実行される。なのでtrapでシグナル受信時の挙動を設定する前にシグナルが届いた場合、古い設定の挙動(全くtrapを使用していないのならデフォルトの挙動)が実行される。

一方でPowerShellのtrap文は言語の一部だ。スクリプト実行時、おそらくPowerShellの処理系はスクリプトをCIL(MSIL)にコンパイルしてCLRで実行しているはずだ。言語の一部であるtrap文はCILへのコンパイル時に全てピックアップされているのだろう。例外がスローされるのはCLRでの実行時(コンパイルの後)なので、例外発生時には既にtrap文の存在とその中身が明らかになっているのだ。だから、例外発生時にtrap文の中身を実行できる。

逆に言えば、コンパイル時に発生する例外をtrap文で捕捉することはできない。

Set-StrictMode -version Latest

trap { 'trap!'; break }

1 / 0

このスクリプトでは、例外発生時にtrap文が実行されることはない。

しかし以下のように書き換えれば、例外発生時にtrap文が実行される。

Set-StrictMode -version Latest

trap { 'trap!'; break }

1 / $Null

PowerShellの処理系では定数式のたたき込みを行っていて、定数式はコンパイル時に1度だけ評価される。式「1 / 0」は定数式なのでコンパイル時に評価され、ゼロ除算の例外が発生する。しかしtrap文はあくまで実行時の例外を捕捉するものだ。なのでコンパイル時に発生するこのゼロ除算は捕捉できない。

一方で式「1 / $Null」は定数式ではないので、コンパイル時ではなく実行時に評価される。実行時のゼロ除算なのでtrap文で捕捉される。

まとめ

ここまで、PowerShellのtrap文の原型である(と思われる)Unix環境のtrapと、PowerShellでの変更点について簡単に見てきた。

元々のtrapはシグナルを捕捉するための仕組みであり、シグナルの「いつ、どこから届くか分からない」という特徴に即した「予め罠をしかけておくと、発動する」という使い方をするものだった。

PowerShellでは捕捉対象がシグナルから例外に変化したが、「予め罠をしかけておく」というスタイルはそのまま受け継がれた。そのため「例外が発生する可能性がある場所に網を張る」というtry - catch的なアプローチとは異なるものになっている。

その一方で、trap文がコマンド(コマンドレット)ではなく言語の構文の一部になったことで、Unix環境のtrapとは異なる特徴を備えるようになった。

trap文は興味深い機能だが……PowerShell 2.0以降のtry - catchのほうが使いやすい、という人の方が多いかもしれない。個人的には「エラーが起きたらゴミ掃除して終了」程度で済むようなスクリプトならtrap文で十分だが、本格的にコードを書く際にはtry - catchのほうが扱いやすいと思う。

*1:UNIXドメインソケットとTCP/UDPソケット。移植性のあるTCPソケットを使うことが多いように思う(APIは異なるけどWinsockでも模倣できる)。

*2:pipe(2)でカーネルパイプを使う方法とmkfifo(3)で名前つきパイプを使う方法がある。

*3:Linuxのjmanより。ただし組み込みコマンドのwaitだけは事情が異なるようだ。あとUbuntuのsleep(1)はSIGINTが届いたら即座に復帰した。多分sleep(3)の仕様の影響だろうけど、他の環境ではどうなのだろうか?

*4:よく考えれば、Win32 アプリもWindowsサービスも内部にメッセージループ的なものを抱えていて、取得したメッセージに応じて処理を行うスタイルな訳で、その意味では「(少なくともOSがプロセスに何か通知する場合は)メッセージループを使えばいいのでは?」的な発想が自然なのかもしれない。

*5:PowerShell 2.0以降では、try - catchスタイルの例外処理も可能。