えいのうにっき

a-knowの日記です

プロセスの適切な扱い方を再確認した

プロセスの基礎再確認シリーズもこれで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.waitProcess.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の利点が思い出される。

そしてそのワーカープロセス群をマスタープロセスが甲斐甲斐しく死活監視したり検査したり、後始末をしたりする。本書の付録ではその後始末処理についてフォーカスし、これでもかとばかりに詳細に、かつわかりやすく解説してくれている。気になる人はぜひ、買って読んでみてほしいなと思う。

tatsu-zine.com

Unicorn、なんとなくのイメージで今まで使ってきていたが、ここですこし、いろんな要素が線で繋がったような思いがしている。改めて、基礎固めの重要性を噛み締める。

Unicorn を初めて触ったタイミングでなぜこのような基礎固めができなかったのか・するべきではなかったのか、という思いも、無くはない。でもまぁそれは、色んなことをひっくるめて「やってこれなかったことは、しょうがない」し、だからこそ今こうやって基礎を振り返る機会が与えられていることに感謝したい。今の僕にできることは、少しずつでも前に進んでいくことだけだ。

本のページ数的には折り返しを過ぎた。次はプロセスとの通信手段について。