えいのうにっき

むかしのじぶんのために書いています

Unixプロセスとシグナルの基礎をRubyで再確認した

前回までの続き。なるほどUnixプロセス ― Rubyで学ぶUnixの基礎 - 達人出版会をまだ読んでいる。遅読。

今回は、Unixプロセスとシグナルの基礎について再確認していく。

  • 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」の部分は必ずしも指定しなくてもいいんだそうだ。

シグナルを再定義する

さきほどの表に SIGUSR1SIGUSR2 という、ユーサー定義シグナル というシグナルがある。この2つのシグナルは、ユーザーが自由に用途を決めて用いる(ユーザーが再定義する)ために用意されているものらしい。

じゃあその再定義はどうするのかというと、既にここまでに知り得た方法でできる。trap だ。 シグナルの再定義を行うというのはつまり、「そのプロセスで特定のシグナルを受けたときにどう振る舞わせるか」ということにほかならない。

たとえば↓このように SIGUSR1trap してやると、

trap(:USR1) { print "元気があれば何でも出来る" }

たちまち、このプロセスにとって SIGUSR1 というシグナルはド根性メッセージを表示させるためのシグナルと変わる。

実は、trap で再定義できるのはユーザー定義シグナルだけではなかったりする。trap(:USR1)trap(:INT) とすれば、SIGINT に対しても同様の挙動を取らせることができるようになる。

じゃあ全てのシグナルについて再定義できるのかというと、SIGKILLSIGSTOP だけはできない…というか、そもそも捕捉( trap )できないようになっている。どんなにそのプロセス(のためのコード)を魔改造し、ありとあらゆるシグナルについて再定義していたとして・またそのプロセスが暴走したとしても、これらのシグナルだけは最後の砦として残っているので、いざとなればこのシグナルを送ることでプロセスを終了させることができる、というかんじか。

ちなみに、こう↓書くと、そのシグナルを無視できるようになる。

trap(:INT, "IGNORE")

これもひとつの「シグナルの再定義」だと思う。もちろん SIGKILLSIGSTOP にはこの無視をさせるような再定義も行うことはできない。

シグナルハンドリングの注意点

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 プロセスと会話することが可能になるわけだ。



follow us in feedly