プロセスの基礎再確認シリーズもこれで3つめ。
ここまで見てきた間でも、ターミナルから irb を起動したり、Ruby コードから system
メソッドを呼び出したりすることで子プロセスを扱ってきた(結果的に)けれど、自らの手で意図して子プロセスを作り出す、ということはしてこなかった。
今回は、子プロセスのメリットやその作り方、扱い方を中心に再確認したもののメモ、という形になる。項目的には以下。
fork
で子プロセスを作るfork
が高速なワケfork
で作った子プロセスを待つ- 子プロセスの面倒を見る
fork
で子プロセスを作る
fork(2)
システムコールを使うことで、実行中のプロセスから新しいプロセスを生成することができる。
生成されたプロセスは、元のプロセスの子プロセスとなる。となると当然、fork(2)
システムコールを呼んだ側のプロセスが親プロセス、となる。
そうして生成された子プロセスには、ある特徴がある。それは、元のプロセスの完全なコピーである、というもの。言い換えると、「親プロセスで使われている全てのメモリのコピーを引き継いでいる」、ということでもある。
「親プロセスで使われている全てのメモリのコピーを引き継ぐ」ので、例えば、親プロセスが500MBのメモリを消費していてそこから fork(2)
した場合、その子プロセスも500MBのメモリを新たに消費(※後述の内容も参照)することになる。じゃあ、fork
された瞬間に再度アプリケーションが読み込みなおされているのか?というとそうではない。fork
は、元のプロセスと同様のメモリ空間を持つプロセスを生成してくれる。
子プロセスのために都度アプリケーションを読み込み直す必要がないので、「(並列実行などを目的として)アプリケーションをメモリに読み込んだプロセスを増やす」という目的での fork
は大変有用である、というかんじ。
「子プロセスは親プロセスと同様のメモリ空間を持つ」、このことを、Ruby のコードを実行しながら確認してみる。
下記のような Ruby コードを実行すると、
puts "parents process pid is #{Process.pid}" hoge = "hoge" if fork puts "entered the if block from #{Process.pid}, #{hoge}" else puts "entered the else block from #{Process.pid}, #{hoge}" end
以下のような出力が得られる。
parents process pid is 82338 entered the if block from 82338, hoge entered the else block from 82366, hoge
出力されているプロセスID からもわかるとおり、if 句は親プロセスによって実行され、else句は子プロセスによって実行されている。
fork
メソッドが実行された時点で子プロセスの生成は行われているが、子プロセス側では fork
メソッドは nil
を返す。一方の親プロセス側では、fork
の戻り値は生成した子プロセスの pid となる。このため、上記のような挙動となる(nil
は偽、pid は真)。
また、子プロセスが生成されるより前の変数 hoge
の値の出力が子プロセス側の処理でも出力できている。「子プロセスは親プロセスのメモリ空間のコピーを持っている」ことがわかる。
ちなみに子プロセスは、メモリ空間と同じく、「親プロセスが開いていたファイルディスクリプタ」も同様に引き継ぐ。そのとき、子プロセスのファイルディスクリプタ番号は親プロセスと同じものが割り当てられる。これはつまり、親子2つのプロセスで開いているファイルやソケットなどは共有される、ということになる。なるほど、これは便利だろうけど同時にハマりやすそうでもあるなぁ。
...といったかんじの fork
メソッドだけど、Ruby ではこの fork
はブロックを渡す形で利用することが多い。
fork do # 子プロセスで実行する処理をここに記述する end # 親プロセスで実行する処理をここに記述する
ブロック内に記述された処理は子プロセスのみで実行される。また、ブロック内の処理が終了すると子プロセスも終了する。
ここまでの例では、なんとなく「子プロセスは親プロセスよりも先に終了するもの」といった暗黙の了解みたいなものがあるような感じだけれど、その逆、「子プロセスより先に親プロセスが終了する」といったことも当然起こり得る。
でも、仮に子プロセスより先に親プロセスが終了したとしても、子プロセスには何も起きない(親のあとを追って終了したり、親が子を道連れに終了したりはしない)。そのような状態の子プロセスのことを孤児プロセスと呼ぶらしい。せつない。
「デーモン」と呼ばれるプロセスを見聞きしたことはあると思うけど、「あるプロセスを意図的に孤児化したもの」がまさしくそれ、らしい。
fork
が高速なワケ
親プロセスから子プロセスを fork
する際、親プロセスのメモリ空間全てをコピーすると上述した。しかし、いくらメモリ上での話とはいえ、物理的にすべてのデータをコピーするのはかなりのオーバーヘッドになる。
そのため、最近のUnixシステムではコピーオンライト(Copy on Write / CoW)と呼ばれる仕組みが採用されている。この仕組みは、書き込みが必要になるタイミングまでメモリを実際にコピーするのを遅らせる、というもの。
じゃあ書き込みが必要になるまでの間はどうなってるの、というと、親プロセスと子プロセスはメモリ上の同じデータを物理的に共有している。親、または子プロセスのいずれかでメモリ上の情報を変更する必要が生じたときだけ、メモリをコピーすることで両者のプロセスの独立性を保っている。なるほど賢い。
先程例示として用いた下記の Ruby コードでは、
puts "parents process pid is #{Process.pid}" hoge = "hoge" if fork puts "entered the if block from #{Process.pid}, #{hoge}" else puts "entered the else block from #{Process.pid}, #{hoge}" end
子プロセス側で実行される変数 hoge
の内容は、実際には親プロセスが参照しているメモリ空間と物理的に同じ空間、ということになる。
逆にこのコードが以下のようなものであれば、物理的なコピーが行われることになる。
puts "parents process pid is #{Process.pid}" hoge = "hoge" if fork puts "entered the if block from #{Process.pid}, #{hoge}" else puts "entered the else block from #{Process.pid}, #{hoge}" hoge.gsub!('o', 'i') # この行は変数に対して変更を加えるので、変更を加える前に子プロセス用にコピーが必要になる end
CoW によって、子プロセス生成の際のコストが節約されている。これが、fork(2)
が速い理由。
fork
で作った子プロセスを待つ
fork
すると子プロセスが作られるが、作られた子プロセスはその瞬間から走り出す。ここまで用いてきた、fork
メソッドを使う Ruby のコードの例では、子プロセスを「撃ちっぱなし」、つまり思うがまま走らせ放題にしている。うーん、腕白感。
そのため、子プロセスでの処理内容によっては、子プロセスよりも先に親プロセスが終了する、といったことも起こり得る。
それで問題のないこともあるが、「子プロセスの処理結果に応じて親プロセスでの処理内容を切り替えたい」といったときなどのように、子プロセスを管理するための何らかの仕組みが必要になることもある。また、そうすることで意図せぬ孤児プロセスの発生を抑えたりもできる。
「子プロセスよりも先に親プロセスが終了する」ことを防いだりするために、Ruby では Process.wait
が利用できる。
fork do 5.times do sleep 1 puts "This is child process!" end end Process.wait abort "Parent process died..."
上記のコードの出力結果は以下のようなものになる。
This is child process! This is child process! This is child process! This is child process! This is child process! Parent process died...
全ての This is child process!
の出力が終わるまで Parent process died...
の出力は行われない。つまり Process.wait
により、子プロセスが終了するまでの間、親プロセスをブロックして待つようになる。
ただし、Process.wait
により待ち受けられるのは、子プロセスのうちどれか一つだ。「どれか一つ」の終了しか待ってくれないので、一つ以上の子プロセスを監視する場合には、「どの子プロセスが終了したのか」を知る必要があるんだけど、それには Process.wait
の戻り値を使うことができる。というのも Process.wait
は、終了した子プロセスの pid
を返してくれるからだ。
# 子プロセスを 3 つ生成する。 3.times do fork do # 各プロセス毎に 5 秒未満でランダムにスリープする。 sleep rand(5) end end 3.times do # 子プロセスそれぞれの終了を待ち、返ってきた pid を出力する。 puts Process.wait end # => 75179 # => 75180 # => 75181
ちなみに、子プロセスが存在しない場合に Process.wait
すると、以下のようなエラーになる。
Process.wait # => Errno::ECHILD: No child processes
では、以下のような、「親プロセスが待ち受けるよりも早く子プロセスが終了してしまった」ような場合ではどうなるか。
# 子プロセスを 2 つ生成する 2.times do fork do # いずれもすぐに終了させる abort "Finished!" end end # 親プロセスは最初のプロセスの終了を待ってから、5 秒間スリープする。 # スリープしている間に 2 つめの子プロセスが終了してしまうが、 # その時、親プロセスはスリープ中。 puts Process.wait sleep 5 # 親プロセス側で再び wait を呼び出す。 # 既に2 つめの子プロセスが終了して5秒以上経っているのに、その終了情報はちゃんとここで取得できる puts Process.wait
コード中のコメントにも書いてあるけど、ちゃんと Process.wait
で子プロセスの終了情報を得ることができる。なぜか。
答えは単純で、Process.wait
がダイレクトに子プロセスの終了を待ち受けているわけではなく、カーネルが終了したプロセスの情報を"キュー"に入れておいてくれるため。つまりイメージ的には Process.wait
は、終了プロセスキューが空ならそこに何かがエンキューされるのを待ち、すでに何かしらが入っているのであればそこからひとつだけデキューする、ということをやってくれるメソッドになる。
そう考えるとさきほどの Errno::ECHILD
エラーは、子プロセスを生成していない(現在のプロセスに子プロセスが存在していない)のに Process.wait
しても、そこになにかがエンキューされる可能性はないよ、ということを教えてくれるためのもの。ってかんじかな。
ところで Ruby には Process.wait2
というメソッドもある。こちらは、終了したプロセスの pid
とその終了ステータスの両方を返してくれる。2つの値を返す wait だから wait2
?
# 子プロセスを 5 つ生成する 5.times do fork do # 子プロセスごとにランダムな値を生成する。 # もし偶数なら 111 を、奇数なら 112 を終了コードとして返す。 if rand(5).even? sleep 5 exit 111 else sleep 5 exit 112 end end end # 子プロセスを5つ生成したので、waitも5回行う 5.times do # 生成した子プロセスが終了するのを待つ。status は Process::Status クラスのインスタンス。 pid, status = Process.wait2 # もし終了コードが 111 なら、 # 子プロセス側で生成された値が偶数だとわかる。 if status.exitstatus == 111 puts "#{pid} encoutered an even number!" else puts "#{pid} encoutered an odd number!" end end
これにより、ファイルシステム(ログへのプロセス情報の書き出し)やネットワーク(ソケットによる通信など)を使わずにプロセス間での通信ができたことになる。
さらに Ruby には Process.waitpid
Process.waitpid2
というメソッドも存在する。
これらは、任意のプロセス id を指定して、そのプロセスの終了を待つことができるもの。
favourite = fork do exit 77 end middle_child = fork do abort "I want to be waited on!" end pid, status = Process.waitpid2 favourite puts status.exitstatus
Process.wait
と Process.waitpid
はそれぞれ別の振る舞いをする別の関数に見えるが、実際にはいずれも同じ関数を指している。Process.wait
に任意の pid を渡すことで特定の子プロセスの終了を待つこともできるし、 Process.waitpid
に -1
を渡すことでどれかひとつの子プロセスの終了を待つこともできる。
このように機能的には同じ事ができるものの名前を敢えて区別することで、実装の意図をわかりやすくする効果がある。Ruby の Process.wait
とその一族は、waitpid(2)
システムコールに対応している。
子プロセスの面倒を見る
あるプロセスから生み出された子プロセスの情報はカーネルによってキューに入れられる、ということは前述の通り。キューに入れられているのだから、キューから取り出されるまで( Process.wait
が呼び出されるまで)その情報はキューに残り続けることになる。
ということは、親プロセスが子プロセスの終了ステータスをいつまでも要求しなければ、カーネルはその情報をずっと持ち続けないといけなくなる。これは、カーネルのリソースの無駄遣いになる。
何らかの理由で親プロセスから"見放され"、カーネルのキューに残り続けてしまった子プロセスはどのように見えるのか。以下の Ruby コードで「キューに残っている」状態を再現し、ps
コマンドで確認してみる。
# 1 秒後に終了する子プロセスを生成する。 pid = fork { sleep 1 } # 終了した子プロセスの pid を出力する。 puts pid # => 80776 # 親プロセスを sleep させる。 # sleep している間に゙子プロセスのステータスを調査する sleep 60 # 調査が終わったらちゃんとデキューしておく Process.wait
sleep している間に確認してみた結果は以下。
$ ps -ho pid,state -p 80776 PID STAT 80776 Z+
Z+
と表示されている。これは、そのプロセスがキューに残っている状態を意味している。
「プロセスがキューに残っている状態」を指して、「ゾンビプロセス」と呼ぶらしい。Z
もそこから取っているんだろうか...。
では、ゾンビプロセスとなってカーネルのリソースの無駄遣いを避けるためには Process.wait
して子プロセスの終了を待ち続けるしかないのか、というとそうではなく、子プロセスの終了を待つ必要が(明示的に・ロジックとして)ないのなら、それを上手に処理してくれる「デタッチ」という操作が Ruby には別に用意されている。
message = 'Good Morning' recipient = 'tree@mybackyard.com' pid = fork do StatsCollector.record message, recipient end # fork で生成された子プロセスがゾンビにならないことを Process.detach で保証する。 Process.detach(pid)
Process.detach
が内部的にやってくれていることは、「新しいスレッドを用意して、そこで、pid で指定された子プロセスの終了を待ち受ける」ということ。なので、Process.detach
に相当するシステムコールがあるわけではなく、Process.wait
とスレッドを用いて子プロセスの待ち受けを"よしなにやってくれる"のが Process.detach
、ということだ。
親プロセスに待たれることなく死んでしまった子プロセスは例外なくゾンビプロセスになってしまう、という点には気をつけたい。子プロセスを生成したら、その情報をどのようにカーネルのキューから取り除くのか?ということも常にセットで考えるクセをつける必要がありそうだ。
その終了を意識的に待ち受け、その結果次第で親プロセスでの処理も分岐したりするようなら Process.wait
だし、それがどうでもいいのなら Process.detach
だ。
以上。
以上、少々長かったけれど、子プロセスの作り方と、作った子プロセスとの上手な付き合い方について、だった。
ここまで見てきたような子プロセスの活用は、Unixプログラミングでよく使われるパターンの最たるもので、
- 子守りプロセス
- マスター/ワーカー
- prefork
などと呼ばれる。
このパターンの肝は、用意したひとつのプロセス(親プロセス)から並行処理のために複数の子プロセスを生成して、その後の親プロセスは子プロセスの面倒をみるのに徹する、ということ。子プロセスがちゃんと応答するのかを確かめたり、子プロセスが終了した際にはその後始末をしたりする。まさに「親」!
僕は以前の職場で Unicorn を扱った開発をしていたんだけど、その Unicorn がまさに、このパターンを採用している。この Unicorn を、僕はほんとに "何気なく" 使ってしまっていたな、と改めて思う。
Unicorn は大きく分けると、「preforkサーバ」というカテゴリに分類される。pre
、つまり、Unicorn を起動する際には、子プロセス(ワーカープロセス)がいくつ必要なのかを Unicorn に伝える必要がある、ということ。
Unicorn は起動するとまず、ソケットを初期化したのちにアプリケーションをロード(メモリに展開)して、それから fork(2)
を使ってワーカープロセスを生成する。...CoWの利点が思い出される。
そしてそのワーカープロセス群をマスタープロセスが甲斐甲斐しく死活監視したり検査したり、後始末をしたりする。本書の付録ではその後始末処理についてフォーカスし、これでもかとばかりに詳細に、かつわかりやすく解説してくれている。気になる人はぜひ、買って読んでみてほしいなと思う。
Unicorn、なんとなくのイメージで今まで使ってきていたが、ここですこし、いろんな要素が線で繋がったような思いがしている。改めて、基礎固めの重要性を噛み締める。
Unicorn を初めて触ったタイミングでなぜこのような基礎固めができなかったのか・するべきではなかったのか、という思いも、無くはない。でもまぁそれは、色んなことをひっくるめて「やってこれなかったことは、しょうがない」し、だからこそ今こうやって基礎を振り返る機会が与えられていることに感謝したい。今の僕にできることは、少しずつでも前に進んでいくことだけだ。
本のページ数的には折り返しを過ぎた。次はプロセスとの通信手段について。