前回までの続き。なるほどUnixプロセス ― Rubyで学ぶUnixの基礎 - 達人出版会をまだ読んでいる。遅読。
今回は、Unixプロセスとシグナルの基礎について再確認していく。
Unixシグナル・事始め
前回、子プロセスの終了を待ち受けるのに用いた Process.wait は、実行するとそこで自身(親プロセス)の処理を止めて子プロセスの終了を待った。これは ブロッキング呼び出し と呼ばれる。
では「親は親で何か別の仕事をしたいとき」はどうするかというと、これから見ていくシグナルを上手に使うと実現できる。
今回はいきなり具体的なコードから見てみる。
child_processes = 3 dead_processes = 0 # 子プロセスを 3 つ生成する child_processes.times do fork do # それぞれ 3 秒間 sleep させる sleep 3 end end # この後、親プロセスは重い計算処理で忙しくなるが、 # 子プロセスの終了は検知したい。 # そこで、:CHLD シグナルを捕捉(trap)する。こうすることで # 子プロセスの終了時にカーネルからの通知を受信できる。 trap(:CHLD) do # 終了した子プロセスの情報を Process.wait で取得すれば、 # 生成した子プロセスのどれが終了したのかがわかる。 puts Process.wait dead_processes += 1 # すべての子プロセスが終了した時点で明示的に親プロセスを終了させる。 exit if dead_processes == child_processes end # 重い計算処理 loop do (Math.sqrt(rand(44)) ** 8).floor sleep 1 end
このコードにおける「Unixシグナル」は、:CHLD がそれ。
シグナル(信号)には色んな種類があって、その中でも :CHLD は「子プロセス(CHILD)の終了時にカーネルから送られるシグナル」と思ってよさそうだ。(参考:http://www.oki-osk.jp/esc/linux/signal-5.html)
ただこの信号は意図的・明示的に捕捉(受信)しようとしなければ受信できない。それを行うのが trap 、ということか。
もう少しコードを見る。「子プロセスが終了した」というシグナルを受けて初めて Process.wait するので、親プロセスの待ち時間を最小にすることができる、ということか。なるほど。
ふと思ったけど、この trap ブロックの実行を行っている間は親プロセスの処理はどうなるんだろう? trap 処理が割り込む形になって、親プロセスの「重い計算処理」は一時的にストップするのかな?
本書の流れからは少し外れることになるけど、ちょっとした検証コードを書いて試してみる。
child_processes = 3 dead_processes = 0 sleep_second = 5 child_processes.times do fork do # それぞれ sleep させる sleep sleep_second end # 子プロセスごとに秒数を伸ばす。 # これにより、trap の処理が終わったら親プロセスの処理に戻る猶予を与えられる。 sleep_second += 5 end trap(:CHLD) do puts "This child process pid is #{Process.wait}" dead_processes += 1 # trapブロックの処理が親プロセスにどのような影響を与えるのかを # わかりやすくするために、3秒間のスリープを挟む sleep 3 end # 親プロセスは1秒ごとにカウントしていく、仮に trap ブロックの処理が優先されたりすると # その間の親プロセスのカウント出力は行われないはず parent_count = 0 loop do puts "Parent Counter : #{parent_count}" parent_count += 1 sleep 1 if dead_processes == child_processes puts "Child processes are all finished!" exit end end
確認したいことはコメントに書いているのでわかってもらえると思う。
このコードを実行した結果は以下。
Parent Counter : 0 Parent Counter : 1 Parent Counter : 2 Parent Counter : 3 Parent Counter : 4 This child process pid is 84810 Parent Counter : 5 Parent Counter : 6 This child process pid is 84811 Parent Counter : 7 Parent Counter : 8 This child process pid is 84812 Child processes are all finished!
全て出力されたあとの結果だけを見てもわかりにくいと思うけど、This child process pid is が表示されてからの3秒間のスリープの間は、Parent Counter の出力は行われなかった。つまり、trap の処理が割り込み、親プロセスでの処理が中断しているってことになる。
また、Child processes are all finished! の前に Parent Counter の出力がないので、つまり、
sleep 1のスリープをしている最中にシグナルを捕捉したのでtrapの処理を行い、- それが終わったらまた
sleep 1のスリープ処理の続きに戻った
という挙動を取っていることがわかる。すっきり。ちなみにここで出てきた trap は、sigaction(2) に大筋で対応している、とのこと。
話を本に戻すと、この「シグナルの配信」、完全に信頼できるものではないんだそうだ。たとえば、もし :CHLD シグナルを処理している間に別の子プロセスが終了した場合、次の :CHLD シグナルを捕捉できるかどうかはその保証がないらしい。
ただこれは同じ種類のシグナルを立て続けに受信した場合(今回のコード例だと、複数の子プロセスが瞬間的に連続して終了した場合)にだけ起こる、と。ただ、少なくとも1回のシグナルの捕捉はできる。イメージ的には、捕捉している間に別のシグナルに来られても、それは trap できないよ、という感じかな。
本書では、このような状況にどう上手に対応するか、についても書かれている。読み進める前に自分の頭の中でも策を考えてみる。ぱっと思いつくのは trap ブロック内でも念のために Process.wait をループさせて予期せぬ子プロセスの終了を待ち受ける、というのがあると思うけど、 Process.wait は「ブロッキング呼び出し」なので、これをしてしまうとシグナルを trap している意味がなくなってしまう。...本に目を戻すと同様のことが書いてあった。
なるほどうーんどうするんかな、と思ってさらに本を読み進めてみると、実は Process.wait には第2引数があってフラグが渡せると。そのフラグによって、「終了を待つ子プロセスがなければブロックしない」ようにカーネルに指示をすることができるんだそうだ。ずるい。(?)
その使い方は↓こんなかんじ。
Process.wait(-1, Process::WNOHANG)
第1引数の -1 は前回学んだとおり、「いずれかひとつの子プロセスを待ち受ける」という意味なので、上記の呼び出しは、いずれかひとつの子プロセスを待ち受けるけど、その時点で終了を待つ子プロセス(キューに入っている子プロセス)がなけければブロックしない という意味になる。なるほどよさそう。
これを踏まえると先ほどのコードはこう↓なる。
child_processes = 3 dead_processes = 0 # 子プロセスを 3 つ生成する child_processes.times do fork do # それぞれ 3 秒間 sleep させる sleep 3 end end # CHLD ハンドラの中で puts の出力をバッファリングしないよう、 # $stdout の出力を同期モードに設定する。 # こうすることで、もし puts を呼び出した後にシグナルハンドラが # 中断された場合には ThreadError 例外が送出されるようになる。 # これはシグナルハンドラで IO を扱う場合の一般的な流儀らしい。 $stdout.sync = true # この後、親プロセスは重い計算処理で忙しくなるが、 # 子プロセスの終了は検知したい。 # そこで、:CHLD シグナルを捕捉(trap)する。こうすることで # 子プロセスの終了時にカーネルからの通知を受信できる。 trap(:CHLD) do # 終了した子プロセスの情報を Process.wait で取得すれば、 # 生成した子プロセスのどれが終了したのかがわかる。 # Process.wait(-1, Process::WNOHANG) をループさせることで、 # 親プロセスの処理をブロックさせることなく子プロセスの終了を見逃さないようにする。 begin while pid = Process.wait(-1, Process::WNOHANG) puts pid dead_processes += 1 end rescue Errno::ECHILD # カーネルの終了キューが空であれば待ち受けのループを終了する end end # 重い計算処理 loop do (Math.sqrt(rand(44)) ** 8).floor sleep 1 end
なるほど。Process.wait(-1, Process::WNOHANG) はブロックはしないけど、キューが空だったときの Errno::ECHILD 例外はちゃんと投げてくれるのね。
Unixシグナルの「いろは」
いきなりシグナルを使った子プロセスの後始末についての実装を見ることになったが、ここまで見てきたとおりシグナルは、ある特定のプロセスから別のプロセスへと送られるものである。そして、2つのプロセスを仲介するのがカーネルだ。
プロセスは、カーネルからシグナルを受けたとき、
- シグナルを無視する
- 特定の処理を行う
- デフォルトの処理を行う
の、いずれかの処理を行う。
これは例えば、CHLD シグナルを trap するコードによるプロセスであれば「特定の処理を行」ったことになる、ってことでいいのかな。
ここまでは CHLD シグナルしか扱ってこなかったが、「Unixシステム上で一般的サポートされているシグナル」というものがある。それが以下の表。
| シグナル | 番号 | アクション | 説明 |
|---|---|---|---|
| SIGHUP | 1 | Term | 制御端末のハングアップ、または制御しているプロセスの死の検出 |
| SIGINT | 2 | Term | キーボードからの割り込み |
| SIGQUIT | 3 | Core | キーボードによる中止 |
| SIGILL | 4 | Core | 不正な命令 |
| SIGABRT | 6 | Core | abort(3) からの中断シグナル |
| SIGFPE | 8 | Core | 浮動小数点例外 |
| SIGKILL | 9 | Term | Kill シグナル |
| SIGSEGV | 11 | Core | 不正なメモリ参照 |
| SIGPIPE | 13 | Term | パイプ破壊: 読み手の無いパイプへの書き出し |
| SIGALRM | 14 | Term | alarm(2) からのタイマーシグナル |
| SIGTERM | 15 | Term | 終了シグナル |
| SIGUSR1 | 30,10,16 | Term | ユーザ定義シグナル 1 |
| SIGUSR2 | 31,12,17 | Term | ユーザ定義シグナル 2 |
| SIGCHLD | 20,17,18 | Ign | 子プロセスの一時停止または終了 |
| SIGCONT | 19,18,25 | Cont | 一時停止からの再開 |
| SIGSTOP | 17,19,23 | Stop | プロセスの一時停止 |
| SIGTSTP | 18,20,24 | Stop | 端末より入力された一時停止 |
| SIGTTIN | 21,21,26 | Stop | バックグランドプロセスの端末入力 |
| SIGTTOU | 22,22,27 | Stop | バックグランドプロセスの端末出力 |
この表中の「アクション」列の内容が、そのプロセスの「デフォルトの処理」らしい。具体的な処理内容は以下の通り。
| アクション名 | 処理内容 |
|---|---|
| Term | プロセスをすぐに終了させる |
| Core | プロセスをすぐに終了させ、コアをダンプする |
| Ign | プロセスはシグナルを無視する |
| Stop | プロセスを停止させる |
| Cont | プロセスを復帰させる |
コアをダンプする とは、その後の調査だとかのために、その時点の使用中メモリの内容をそのままテキストファイルなどに出力すること。
これらのシグナルを Ruby のコードで送信するにはどうするかというと、Process.kill(<シグナルシンボル>, <送りたいプロセスのpid>) とすれば良い。なので例えば、 pid 12345 のプロセスを kill したい、といったときには Process.kill(:KILL, 12345) とする。
Process.kill は、kill(2) に対応している。今までの経験でも、あるプロセスを殺したいときには kill -9 12345 なんてやっていたけど、なるほど、この kill というコマンドはプロセスを殺すためのものではなくて、「特定のシグナルを送る」というのが本来の使い方なわけだ(手作業でプロセスにシグナルを送るようなときは大体、そのプロセスを殺したいときなわけだけど...)。
ちなみにシグナルを指定するとき、シグナル名の「SIG」の部分は必ずしも指定しなくてもいいんだそうだ。
シグナルを再定義する
さきほどの表に SIGUSR1 と SIGUSR2 という、ユーサー定義シグナル というシグナルがある。この2つのシグナルは、ユーザーが自由に用途を決めて用いる(ユーザーが再定義する)ために用意されているものらしい。
じゃあその再定義はどうするのかというと、既にここまでに知り得た方法でできる。trap だ。
シグナルの再定義を行うというのはつまり、「そのプロセスで特定のシグナルを受けたときにどう振る舞わせるか」ということにほかならない。
たとえば↓このように SIGUSR1 を trap してやると、
trap(:USR1) { print "元気があれば何でも出来る" }
たちまち、このプロセスにとって SIGUSR1 というシグナルはド根性メッセージを表示させるためのシグナルと変わる。
実は、trap で再定義できるのはユーザー定義シグナルだけではなかったりする。trap(:USR1) を trap(:INT) とすれば、SIGINT に対しても同様の挙動を取らせることができるようになる。
じゃあ全てのシグナルについて再定義できるのかというと、SIGKILL と SIGSTOP だけはできない...というか、そもそも捕捉( trap )できないようになっている。どんなにそのプロセス(のためのコード)を魔改造し、ありとあらゆるシグナルについて再定義していたとして・またそのプロセスが暴走したとしても、これらのシグナルだけは最後の砦として残っているので、いざとなればこのシグナルを送ることでプロセスを終了させることができる、というかんじか。
ちなみに、こう↓書くと、そのシグナルを無視できるようになる。
trap(:INT, "IGNORE")
これもひとつの「シグナルの再定義」だと思う。もちろん SIGKILL と SIGSTOP にはこの無視をさせるような再定義も行うことはできない。
シグナルハンドリングの注意点
trap のような、シグナルを捕捉し何らかの処理を行うコードのことを シグナルハンドラ と呼ぶ。シグナルハンドラを trap で書くときの注意点は、その定義はそのプロセスにおいてグローバルなものである、ということ。
trap でのシグナルハンドラは trap(<シグナル>) { ... } という書き方になるので、あるシグナルについて有効なシグナルハンドラは、そのプロセスにおいて2つ以上は存在し得ない(どちらかがどちらかを上書きする)、ということになる。
では例えば、
- 実行しようとする Ruby コードのどこかで
INTのシグナルハンドラが定義されているが、 - そこでの既存の処理を踏みにじることなく、新たな処理を付け加えたい
ようなときはどうすればいいか。本の中での回答が以下。
# これを既存の INT シグナルハンドラとみなす trap(:INT) { puts 'This is the first signal handler' } # 既存のハンドラの処理を踏みにじることなく、新たな処理を付け加える old_handler = trap(:INT) { old_handler.call puts 'This is the second handler' exit }
なるほど。ちょっと違和感を覚えなくもないコードだけれど、これでいいらしい。
ただ、だからといって下記↓のようなコードで「デフォルトのアクション」にひと味加えることができるか、というと、それはできない。
system_handler = trap(:INT) { puts 'about to exit!' system_handler.call }
Ruby のコードで変数に捕まえられるシグナルハンドラは、あくまで Ruby コード上で定義されたシグナルハンドラだけ、ということらしい。
以上
ここまで見てきたように、対象にしたいプロセスの pid と kill とを組み合わせれば、Unixシステム上のどんなプロセスともシグナルで通信することができる。プロセスはいつでもシグナルを受信できるからだ。
本書では、人間がシグナルを送る相手として一般的なのはもっぱら、サーバやデーモンといった長時間動き続けるプロセスが多いとしている。たしかに。
シグナルを使って Unix プロセスとの「対話」をしなくちゃいけなくなったときのために、自分が扱うサーバやデーモンが「どのシグナルを受けるとどうアクションするのか」ということを知っておく、というのは大事そうだなと思った。
例えば Unicorn であれば SIGNALS というファイルに「サポートしているシグナル」と「それに対応する処理」のリストがあるらしい。これを見れば、シグナルを使って Unicorn プロセスと会話することが可能になるわけだ。