シグナルはユーザプロセスの任意の時点で処理される可能性がある。そのため、シグナルハンドラの中で行えることは、再入可能な関数の呼び出しなどのような、状態の一貫性に影響しないものに制限される。
インタプリタのユーザ言語でシグナルハンドラも書けるようにする場合は、割込まれたときに直接実行せず、インタプリタが安全に処理できる時点まで遅延させる方法がとられる。
シグナルが発生すると、実装言語のシグナルハンドラではシグナルが送られたことの記録だけを行う。一方、インタプリタは適当なタイミングでそれをチェックしていて、割込みが記録されていればユーザ言語で書かれたハンドラのコードを実行する。
UNIXのシグナル処理
プロセスにシグナルが送られると、カーネルはプロセス表のビットを立ててシグナルの発生を記録する。
シグナルの処理はカーネルからユーザプロセスに処理が移るタイミングでのみ行われる。すなわち、ユーザプロセスがシステムコールから戻るときや、タイムスライスの切り替えによってユーザプロセスの実行を再開するときだ。ユーザプロセスの実行中に割込みが発生してカーネルに制御が移る場合は、カーネルは割込みを処理した後でユーザプロセスに戻る前にシグナルを処理し、それからユーザプロセスに戻る。
シグナルハンドラを実行する間もカーネルにとっては通常のユーザプロセスの実行状態にすぎないので、さらにシグナルが割込む可能性も当然ある。ただしBSDやPOSIX.1では、カーネルがシグナルハンドラを起動するときに原因シグナルを自動的にマスクするため、同じシグナルによって再入することはない(マスクされている間に発生したシグナルはブロックされ、マスクが解除されるまで処理が遅延される)。
シグナルハンドラを実行させる仕組み
プロセスにはカーネルから制御を戻すときのレジスタコンテクストが記録されている。これには次にどのアドレスから実行を再開するかも含まれる。 カーネルはこれを変更して、ユーザプロセスがシグナルハンドラから実行を再開するように仕向ける。ただしそれだけではまずいので、シグナルハンドラからリターンすると本来の実行再開アドレスに戻るように、ユーザプロセスのスタックに擬似的なスタックフレームを積んでおく。
このように操作してからユーザプロセスを再開すると、カーネルにとってはユーザプロセスがそのまま実行を再開したのと同じだが、ユーザプロセスではシグナルハンドラを実行することになる。シグナルハンドラの実行が終わるとretで本来の再開アドレスに飛ぶため、プロセスはシステムコールからの復帰したり、プロセス切り替えから再開したように見える。
実際は、スタックポインタやプログラムカウンタだけでなく作業レジスタやシグナルマスクも含めて完全に元のコンテクストを復元する必要があるため、シグナルハンドラから単に再開アドレスに復帰するだけでは不十分で、もう少し仕組みを凝らす必要がある。
そこで、シグナルハンドラに指定された関数を呼び出し、そこから復帰したらコンテクストを完全に復元するというコード(動作の様子から、これをシグナル・トランポリンと呼ぶ)を予め用意しておく。カーネルがユーザプロセスのコンテクストをいじる際は直接シグナルハンドラから再開するのでなく、このトランポリン・コードから再開するようにする。このときトランポリンにはシグナルハンドラのアドレスと復帰すべきコンテクストが情報として渡される。
ユーザプロセスのスタックの変化:
| | +--------------+ |シグナルハンド| | | |ラのフレーム | | | +--------------+ +--------------+ +--------------+ |トランポリン | |トランポリン | |トランポリン | |用のフレーム | |用のフレーム | |用のフレーム | | | +--------------+ +--------------+ +--------------+ +--------------+ |割込み直前の | |割込み直前の | |割込み直前の | |割込み直前の | |ユーザスタック| |ユーザスタック| |ユーザスタック| |ユーザスタック| | |→| |→| |→| | ユーザプロセス シグナルハンドラ シグナルハンドラ コンテクスト復元 再開 呼び出し 終了 (トランポリン)
FreeBSD/amd64のシグナル・トランポリン。シグナルハンドラをcallした後、sigreturn(2)によりコンテクストを復元する。復元したコンテクストで実行が再開されるから、このシステムコールからは戻ってこない。 カーネルがプロセスを作成するときはいつもスタックの隅っこにこのコードをコピーしておく。
sigcode: call *SIGF_HANDLER(%rsp) /* call signal handler */ lea SIGF_UC(%rsp),%rdi /* get ucontext_t */ pushq $0 /* junk to fake return addr. */ movq $SYS_sigreturn,%rax syscall /* enter kernel with args */ 0: hlt /* trap priviliged instruction */ jmp 0b
SCM
SCMでは仮想マシンの状態を操作する部分(かなりの部分がそうである)をDEFER_INTS
とALLOW_INTS
で囲み、この間はフラグints_disabled
が真になる。
C言語レベルのシグナルハンドラscmable_signals
ではこのフラグが立っているとフラグSIG_deferred
のシグナルに対応するビットを立て、変数deferred_proc
に関数process_signals
をセットする。
ALLOW_INTS
ではこのdeferred_proc
を検査し、セットされていれば実行してシグナルを処理する。
フラグints_disabled
が偽の場合はシグナルハンドラが仮想マシンの状態を変更しても安全なので、設定されたコードを直ぐに実行できる。
ベルのシグナルハンドラにはもう一つerr_signal
がある。これは呼ばれると通常のScheme実行中のエラーと同様にしてエラーを発生させる。longjmp()
が呼ばれ、トップレベルに脱出する。
Gauche
ユーザ定義のシグナルハンドラがあると、Cレベルのシグナルハンドラとしてsig_handle()
が登録される。この関数は仮想マシンにシグナルを記録し、遅延された処理が存在することを示すフラグを立てる。
Gaucheは仮想マシンループの先頭で毎回このフラグをチェックし、フラグが立っていれば遅延された処理を行うprocess_queued_requests()
を呼ぶ。この中で呼ばれるScm_SigCheck()
が、シグナルを順に調べて発生していれば対応するコードを実行する。
Gaucheではシステムコールを呼ぶ関数を実行した後にもScm_SigCheck()
を呼ぶ。前述の通り、システムコールから戻るときにシグナルが記録されていれば、これによって素早くそれを処理することが期待できる。また、scm_sigsuspend()
でシグナル待ちに入る前にもScm_SigCheck()
を呼んで、発生済シグナルを片付けている。
Ruby
Rubyでも基本的には同じであり、シグナルはフラグを立てるだけで遅延させる。
C言語レベルのシグナルハンドラは関数sighandler()
である。これはシグナル処理の遅延を示すフラグrb_trap_pending
と、どのシグナルかを示すtrap_pending_list[sig]
をセットする。
Rubyではインタプリタの各所でCHECK_INTS
を実行していて、このマクロがフラグを調べて遅延されたシグナル処理を行う。
Rubyレベルでも処理をインターリーブさせると不都合な場合にはrb_prohibit_interrupt
を真にしてこれを禁止することができる。このフラグの操作はDEFER_INTS
, ALLOW_INTS
, ENABLE_INTS
マクロが行う。
またRubyでは、Rubyレベルのハンドラがなく、ブロックしていたシステムコール中にシグナルが発生した場合は、シグナルハンドラの中で直ちに処理する(いくつかのシグナルではRubyレベルのエラーを投げる。それ以外のシグナルでは何もしない)。
このためにRubyインタプリタの状態を安全にした上でTRAP_BEG
, TRAP_END
というマクロでシステムコールを囲んでいる。これらのマクロがフラグrb_trap_immediate
を操作し、sighandler
はそれを参照する。
TRAP_END
はCHECK_INTS
を実行するため、Gaucheの場合と同様、システムコール中に発生したシグナルがあればシステムコールから戻ると遅延した処理が素早く行われる。
尚、Rubyでは自前でユーザレベルスレッドの切り替えを行っていて、シグナルを処理するのはその中のメインスレッドと決めている。このため、エラーを投げる際やシグナルハンドラのコードを実行する際にコンテクストの切り替えが行われる。RubyのスレッドについてはRubyソースコード完全解説 第19章 スレッドに詳しい。
シグナルを考慮から除外できるか
それでは、シグナルハンドラをユーザ言語で書くことをサポートしない場合はシグナルの存在を考慮しなくても済むだろうか。残念ながらそうとも言い切れない。
他のソフトウェアへの組込みなど、インタプリタに無関係にプログラムの別の場所でシグナルハンドラが設定され、そのハンドラがlongjmp
するのであれば、やはりインタプリタの状態が一貫性を崩しているタイミングで割込まれると危険である。
そのような場合はシグナルハンドラに手を加えて安全な状態になるまで処理を遅延するか、またはインタプリタの状態を操作するの前にシグナルをマスクすることが必要となる。